deploy a 1:1-mounted local stack by file so its compose path is preserved
This commit is contained in:
4 files changed
+102
-18
No files matched your search
+14
-6
@@ -21,6 +21,7 @@ import { sendEventNotification } from './notifications';
|
||||
import { buildBasicAuthHeader } from './git-auth';
|
||||
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath, repoBaseEnvPath } from './git-url-safety';
|
||||
import { assertSafeRepoTarget } from './git-branch-lookup';
|
||||
import { shouldDeployGitStack, shouldForceRecreateGitStack } from './git-deploy-policy';
|
||||
import { resolveStackBranch } from '../git-stack-branch';
|
||||
import {
|
||||
parseManifest,
|
||||
@@ -1348,10 +1349,14 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
|
||||
}
|
||||
|
||||
// Check if there are changes - skip redeploy if no changes and not forced
|
||||
// Note: For new stacks (first deploy), syncResult.updated will be true
|
||||
// forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs
|
||||
const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated;
|
||||
// Check if there are changes - skip redeploy if no changes and not forced.
|
||||
// For new stacks (first deploy), syncResult.updated is true. The forceRedeploy
|
||||
// setting overrides the skip logic for webhooks/scheduled syncs.
|
||||
const shouldDeploy = shouldDeployGitStack({
|
||||
force,
|
||||
forceRedeploy: gitStack.forceRedeploy === true,
|
||||
gitUpdated: syncResult.updated === true
|
||||
});
|
||||
if (!shouldDeploy) {
|
||||
console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`);
|
||||
const skippedResult = {
|
||||
@@ -1363,8 +1368,11 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
return skippedResult;
|
||||
}
|
||||
|
||||
const forceRecreate = syncResult.updated;
|
||||
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
|
||||
const forceRecreate = shouldForceRecreateGitStack({
|
||||
forceRedeploy: gitStack.forceRedeploy === true,
|
||||
gitUpdated: syncResult.updated === true
|
||||
});
|
||||
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated}, forceRedeploy=${gitStack.forceRedeploy})`);
|
||||
console.log(`${logPrefix} Build on deploy:`, gitStack.buildOnDeploy);
|
||||
console.log(`${logPrefix} Re-pull images:`, gitStack.repullImages);
|
||||
console.log(`${logPrefix} Force redeploy setting:`, gitStack.forceRedeploy);
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
setStackInjectedSecretKeys
|
||||
} from './db';
|
||||
import { getProvider } from './secretproviders';
|
||||
import { stripSurroundingQuotes } from './secretproviders/shared';
|
||||
import { resolveComposeDockerHost, buildComposeBaseArgs } from './compose-docker-args';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { sendEventNotification } from './notifications';
|
||||
@@ -803,7 +804,7 @@ export async function saveStackComposeFile(
|
||||
oldEnvPath?: string; // Old env file path for renaming
|
||||
secretProviderId?: number | null; // secret provider binding (undefined = unchanged)
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
): Promise<{ success: boolean; error?: string; composePath?: string }> {
|
||||
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
|
||||
// Must also start with a letter or number
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
||||
@@ -1002,7 +1003,10 @@ export async function saveStackComposeFile(
|
||||
|
||||
try {
|
||||
writeFileSync(composeFile, content);
|
||||
return { success: true };
|
||||
// Return the path actually written so the caller can persist it even when it
|
||||
// supplied no explicit composePath (else the stored path is null while the file
|
||||
// exists at the default location - #1515).
|
||||
return { success: true, composePath: composeFile };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to ${create ? 'create' : 'save'} compose file: ${err.message}` };
|
||||
}
|
||||
@@ -3055,9 +3059,19 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
if (source?.composePath) {
|
||||
workingDir = dirname(source.composePath);
|
||||
actualComposePath = source.composePath;
|
||||
// envPath: a real path is used as-is; null/undefined (unset) falls back to
|
||||
// the .env beside the compose file so its content (e.g. a bulk secret
|
||||
// selector) still reaches resolveProviderEnvVars - same as the default-path
|
||||
// branch below. An empty string means the user chose NO env file, so honor
|
||||
// that and read none. Without the fallback, an internal stack whose
|
||||
// composePath is now stored (#1515) but has an unset envPath would skip the
|
||||
// .env entirely.
|
||||
if (source.envPath) {
|
||||
actualEnvPath = source.envPath;
|
||||
} else if (source.envPath == null) {
|
||||
actualEnvPath = join(workingDir, '.env');
|
||||
}
|
||||
// source.envPath === '' -> leave actualEnvPath undefined (no env file)
|
||||
console.log(`${logPrefix} Using custom path from DB:`, workingDir);
|
||||
} else {
|
||||
// Default: compose file should already exist (written by saveStackComposeFile)
|
||||
@@ -3545,20 +3559,24 @@ async function resolveProviderEnvVars(
|
||||
// Only providers that support inline references detect any here.
|
||||
const isRef = (value: unknown): value is string =>
|
||||
provider?.supportsReferences ? provider.isReference(value) : false;
|
||||
// The canonical reference string for lookup: surrounding quotes stripped so a value
|
||||
// pasted straight from 1Password's "Copy Secret Reference" (which includes quotes)
|
||||
// resolves the same as the bare op://... form (#1521). The STORED value is untouched.
|
||||
const normalizeRef = (value: string): string => stripSurroundingQuotes(value);
|
||||
|
||||
const envFileRefs = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(envFileVars)) {
|
||||
if (isRef(value)) {
|
||||
envFileRefs.set(key, value.trim());
|
||||
envFileRefs.set(key, normalizeRef(value));
|
||||
}
|
||||
}
|
||||
|
||||
const refs = new Set<string>();
|
||||
for (const value of Object.values(dbNonSecretVars)) {
|
||||
if (isRef(value)) refs.add(value.trim());
|
||||
if (isRef(value)) refs.add(normalizeRef(value));
|
||||
}
|
||||
for (const value of Object.values(secretVars)) {
|
||||
if (isRef(value)) refs.add(value.trim());
|
||||
if (isRef(value)) refs.add(normalizeRef(value));
|
||||
}
|
||||
for (const ref of envFileRefs.values()) refs.add(ref);
|
||||
|
||||
@@ -3584,7 +3602,7 @@ async function resolveProviderEnvVars(
|
||||
let promotedFromDb = 0;
|
||||
for (const [key, value] of Object.entries(dbNonSecretVars)) {
|
||||
if (isRef(value)) {
|
||||
const resolved = refMap.get(value.trim());
|
||||
const resolved = refMap.get(normalizeRef(value));
|
||||
if (resolved !== undefined) {
|
||||
delete dbNonSecretVars[key];
|
||||
secretVars[key] = resolved;
|
||||
@@ -3594,7 +3612,7 @@ async function resolveProviderEnvVars(
|
||||
}
|
||||
for (const [key, value] of Object.entries(secretVars)) {
|
||||
if (isRef(value)) {
|
||||
const resolved = refMap.get(value.trim());
|
||||
const resolved = refMap.get(normalizeRef(value));
|
||||
if (resolved !== undefined) {
|
||||
secretVars[key] = resolved;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user