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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
|
||||
import { EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
|
||||
import { upsertStackSource, getStackSources } from '$lib/server/db';
|
||||
import { upsertStackSource, getStackSources, secretProviderExists } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
@@ -149,6 +149,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
|
||||
}
|
||||
|
||||
// A stale provider id (provider deleted/recreated while the editor held the old
|
||||
// list) would otherwise hit a raw foreign-key error on save (#1522).
|
||||
if (typeof secretProviderId === 'number' && !(await secretProviderExists(secretProviderId))) {
|
||||
return json({ error: 'The selected secret provider no longer exists. Reopen the stack and pick a current provider.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// If start is false, only create the compose file without deploying
|
||||
if (start === false) {
|
||||
const result = await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
@@ -176,12 +182,13 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
// Persist the path the file was actually written to (the default location
|
||||
// when the caller omitted composePath), not null (#1515).
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
composePath: composePath || result.composePath || undefined,
|
||||
envPath: envPath || undefined,
|
||||
secretProviderId,
|
||||
});
|
||||
@@ -218,12 +225,13 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Record the stack in DB before deploying - ensures it exists even if deploy fails
|
||||
// Record the stack in DB before deploying - ensures it exists even if deploy fails.
|
||||
// Persist the actual written path (default location when composePath omitted), not null (#1515).
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
composePath: composePath || saveResult.composePath || undefined,
|
||||
envPath: envPath || undefined,
|
||||
secretProviderId
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { rewriteComposeVolumePaths, __setCachedMountsForTest } from '../src/lib/server/host-path';
|
||||
|
||||
afterEach(() => __setCachedMountsForTest(null));
|
||||
|
||||
const compose = (bind: string) => `services:\n app:\n image: x\n volumes:\n - ${bind}\n`;
|
||||
|
||||
describe('rewriteComposeVolumePaths', () => {
|
||||
test('1:1 mount (host path == container path) leaves the relative bind untouched (#1514)', () => {
|
||||
// TerraMaster/1:1 case: the compose dir is mounted at the identical path, so
|
||||
// translating ./data_db to the same absolute path is pointless and would force
|
||||
// a stdin deploy that loses com.docker.compose.project.config_files.
|
||||
__setCachedMountsForTest([
|
||||
{ source: '/Volume1/Development/Docker', destination: '/Volume1/Development/Docker' }
|
||||
]);
|
||||
const r = rewriteComposeVolumePaths(compose('./data_db:/var/lib/postgresql/data'), '/Volume1/Development/Docker/authentik');
|
||||
expect(r.modified).toBe(false);
|
||||
expect(r.content).toContain('- ./data_db:/var/lib/postgresql/data');
|
||||
expect(r.changes.length).toBe(0);
|
||||
});
|
||||
|
||||
test('non-1:1 mount (container path differs from host) still translates (no regression)', () => {
|
||||
// The Dockhand-in-Docker case: /app/data mounted from /docker/data on the host.
|
||||
__setCachedMountsForTest([{ source: '/docker/data', destination: '/app/data' }]);
|
||||
const r = rewriteComposeVolumePaths(compose('./sub:/x'), '/app/data/stacks/myapp');
|
||||
expect(r.modified).toBe(true);
|
||||
expect(r.content).toContain('- /docker/data/stacks/myapp/sub:/x');
|
||||
});
|
||||
|
||||
test('untranslatable relative bind (no matching mount) is left unchanged', () => {
|
||||
__setCachedMountsForTest([{ source: '/docker/data', destination: '/app/data' }]);
|
||||
const r = rewriteComposeVolumePaths(compose('./x:/y'), '/somewhere/else');
|
||||
expect(r.modified).toBe(false);
|
||||
expect(r.content).toContain('- ./x:/y');
|
||||
});
|
||||
|
||||
test('no mounts known: nothing is translated', () => {
|
||||
__setCachedMountsForTest([]);
|
||||
const r = rewriteComposeVolumePaths(compose('./x:/y'), '/app/data/s');
|
||||
expect(r.modified).toBe(false);
|
||||
});
|
||||
|
||||
test('named volumes and absolute binds are never touched', () => {
|
||||
__setCachedMountsForTest([{ source: '/docker/data', destination: '/app/data' }]);
|
||||
const named = 'services:\n app:\n volumes:\n - dbdata:/var/lib\n - /abs/host:/x\n';
|
||||
const r = rewriteComposeVolumePaths(named, '/app/data/s');
|
||||
expect(r.modified).toBe(false);
|
||||
expect(r.content).toBe(named);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user