updating a compose file over the API now saves it, not just deploys it

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent 16ee5d82b9
commit cc0d50913a
2 files changed
+40 -17

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
/**
* The ONE formula for a stack's folder under a base dir on a host: `<base>/<stack>`.
* Deploy staging (write) and backup (read) share it so they can't diverge (#1383 / #1240).
* Pure + dependency-free, so both sides can import it.
*/
/** Trim + drop trailing slashes: `/data/stacks/` -> `/data/stacks`. */
export function normalizeBaseDir(base: string): string {
return base.trim().replace(/\/+$/, '');
}
/** `<normalized base>/<stackName>`. Name is used verbatim - do NOT slugify (both sides use it raw). */
export function stackDirIn(base: string, stackName: string): string {
return `${normalizeBaseDir(base)}/${stackName}`;
}
+25 -17
View File
@@ -70,22 +70,36 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
}
// Binding a secret provider resolves its secrets into the container at deploy;
// require the secrets permission so a stacks-only user can't exfiltrate a
// provider's secrets by binding it and reading the container env.
if (
typeof secretProviderId === 'number' &&
auth.authEnabled &&
!(await auth.can('secrets', 'view', envIdNum))
) {
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// Build options object for custom paths, move operation, file renames, and secret provider binding
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined)
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId }
: undefined;
// Persist the submitted content on EVERY accepted PUT, whether or not path fields came
// along. Gating this on pathOptions left Dockhand's stored copy stale while restart:true
// deployed the new content - a later GET then served the old copy, silently reverting the
// change on the next read/edit/deploy round-trip (#1383). saveStackComposeFile handles a
// possibly-undefined pathOptions fine (the non-restart branch already relied on that).
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 500 });
}
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
// Save paths first if provided
if (pathOptions) {
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 500 });
}
}
// Get authoritative paths from DB/filesystem for deploy
// Deploy with docker compose up -d --force-recreate.
// Force recreate ensures env var changes are applied.
// Get authoritative paths from DB/filesystem for deploy (now reflects the saved content).
const composeInfo = await getStackComposeFile(name, envIdNum);
// Deploy via SSE to keep connection alive during long operations
@@ -112,13 +126,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
}, request);
}
// Just save the file without restarting (update operation, not create)
const result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
if (!result.success) {
return json({ error: result.error }, { status: 500 });
}
// No restart: the content is already persisted above.
return json({ success: true });
} catch (error: any) {
console.error(`Error updating compose file for stack ${name}:`, error);