From 61cd9457057bb4d159e70750cb24115d4fe4e50b Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sat, 12 Sep 2026 16:24:13 +0200 Subject: [PATCH] tell direct-remote users to redeploy when a stack backup can't find its files --- src/lib/server/backups/backup-script.ts | 13 ++---- src/lib/server/backups/index.ts | 3 +- src/lib/server/backups/stackdir-plan.ts | 31 +++++++++++++- tests/backups/backup-script.test.ts | 23 +++++++++-- tests/backups/stackdir-plan.test.ts | 55 +++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/lib/server/backups/backup-script.ts b/src/lib/server/backups/backup-script.ts index ff05061..04d2b82 100644 --- a/src/lib/server/backups/backup-script.ts +++ b/src/lib/server/backups/backup-script.ts @@ -41,7 +41,7 @@ export interface MetadataFile { * @param resticArgs the full restic argv (starting with 'backup') — already * tag/exclude/flag-assembled by the caller. */ -import type { StackDirProbeHint } from './stackdir-plan'; +import { stackDirProbeFixHint, type StackDirProbeHint } from './stackdir-plan'; export function buildBackupScript(resticArgs: string[], stackDirProbe?: { volumeKey: string; composeFileName: string; label?: string; hostPath?: string; hint?: StackDirProbeHint }): string { // restic runs backgrounded so the SIGINT-forwarding trap can reach it on cancel @@ -63,19 +63,14 @@ export function buildBackupScript(resticArgs: string[], stackDirProbe?: { volume if (stackDirProbe) { const composePath = shellQuote(`/volumes/${stackDirProbe.volumeKey}/${stackDirProbe.composeFileName}`); const label = stackDirProbe.label ? ` ${stackDirProbe.label}` : ''; - // The compose is missing under the host dir we bind-mounted. For a remote env (hawser or a - // user-set path) tell the operator to point "Remote stack path (for backup)" at the real host - // path; for a local stack a redeploy stages the files. + // The compose is missing under the host dir we bind-mounted. The fix depends on WHY + // (path wrong vs not-deployed-there-yet vs local) - stackDirProbeFixHint decides. const hint = stackDirProbe.hint; const remote = hint?.kind === 'hawser-defaulted' || hint?.kind === 'user-set'; const where = remote ? hint.hostPath : stackDirProbe.hostPath; // "on " when we know the environment name, else a plain "on the host". const on = remote && hint.envName ? ` on ${hint.envName}` : ` on the host`; - // Name the EXACT settings location so the operator can go straight there. - const settingsLoc = remote && hint.envName ? `Settings > Environments > ${hint.envName}` : `Settings > Environments`; - const fix = remote - ? ` Set "Remote stack path (for backup)" in ${settingsLoc} to the real host path where this stack's files live.` - : ` Redeploy the stack to stage its files there.`; + const fix = stackDirProbeFixHint(hint, where); const at = where ? ` in ${where}` : ''; // Build the whole message as ONE string and shell-quote it so the operator-facing text // (which contains quotes, parens, and `>` - e.g. `"Remote stack path (for backup)"` and diff --git a/src/lib/server/backups/index.ts b/src/lib/server/backups/index.ts index 15bb6cb..7f9272e 100644 --- a/src/lib/server/backups/index.ts +++ b/src/lib/server/backups/index.ts @@ -311,7 +311,8 @@ async function planStackDirVolume( // HOST-side "Remote stack path (for backup)" - NOT to redeploy. const probeHint: StackDirProbeHint = isHawser && remoteStacksDirDefaulted ? { kind: 'hawser-defaulted', hostPath, envName } - : (isHawser || directRemote) && remoteStacksDir ? { kind: 'user-set', hostPath, envName } + : (isHawser || directRemote) && remoteStacksDir + ? { kind: 'user-set', transport: isHawser ? 'hawser' : 'direct', hostPath, envName } : { kind: 'local' }; return { diff --git a/src/lib/server/backups/stackdir-plan.ts b/src/lib/server/backups/stackdir-plan.ts index f591b69..4b29140 100644 --- a/src/lib/server/backups/stackdir-plan.ts +++ b/src/lib/server/backups/stackdir-plan.ts @@ -211,9 +211,38 @@ export function isLocalDaemon(connectionType: string | null, envTcpHost: string // wrong. `local`: a socket/local-daemon stack -> redeploy staged its files. export type StackDirProbeHint = | { kind: 'hawser-defaulted'; hostPath: string; envName: string | null } - | { kind: 'user-set'; hostPath: string; envName: string | null } + // `transport` distinguishes how the files reach the host: a hawser agent keeps them + // under its own STACKS_DIR (the path just needs to be the right HOST mapping), while a + // direct-remote env has Dockhand COPY them there on `up` - so a direct env whose path is + // set but empty needs a redeploy, not a path change. + | { kind: 'user-set'; transport: 'hawser' | 'direct'; hostPath: string; envName: string | null } | { kind: 'local' }; +/** + * The operator-facing fix line for a STACKDIR PROBE FAILED, tailored to why the compose + * is missing under the mounted host dir. Pure so it can be unit-tested (the message is the + * only actionable thing the user sees when a stack backup can't find its files). + */ +export function stackDirProbeFixHint(hint: StackDirProbeHint | undefined, hostPath: string | undefined): string { + const envName = hint && 'envName' in hint ? hint.envName : null; + const settingsLoc = envName ? `Settings > Environments > ${envName}` : `Settings > Environments`; + const at = hostPath ? ` at ${hostPath}` : ''; + // A direct-remote env stages the files on deploy: if the path is set but the folder is + // empty, the stack simply hasn't been deployed there yet - a redeploy stages it. Naming a + // path change here (as the other branches do) is the wrong advice and sends the user in + // circles when the path is already correct. + if (hint?.kind === 'user-set' && hint.transport === 'direct') { + return ` The Remote stack path is set, but this stack's files aren't on ${envName ?? 'the host'} yet - redeploy the stack so Dockhand stages them${at}. (down/start/restart don't copy; it must be a deploy.)`; + } + // Hawser (defaulted or user-set): the agent keeps stacks under its own dir, so an empty + // probe means the configured HOST path doesn't map to where the agent actually writes. + if (hint?.kind === 'hawser-defaulted' || hint?.kind === 'user-set') { + return ` Set "Remote stack path (for backup)" in ${settingsLoc} to the real host path where this stack's files live.`; + } + // Local stack: a redeploy stages the files into the managed stack dir. + return ` Redeploy the stack to stage its files there.`; +} + /** Result of resolving the candidate host stack folder (before the runtime probe). */ export type HostStackDirResolution = | { kind: 'candidate'; hostPath: string; composeFile: string; source: string } diff --git a/tests/backups/backup-script.test.ts b/tests/backups/backup-script.test.ts index d6d9122..560f91e 100644 --- a/tests/backups/backup-script.test.ts +++ b/tests/backups/backup-script.test.ts @@ -160,17 +160,33 @@ describe('buildBackupScript', () => { expect(script).toContain('compose dupa.yaml not found'); }); - it('user-set hint: same clear "set the real host path" instruction', () => { + it('user-set hint (hawser): same clear "set the real host path" instruction', () => { const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], { volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml', hostPath: '/opt/agent/stacks/gitlab', - hint: { kind: 'user-set', hostPath: '/opt/agent/stacks/gitlab', envName: 'remote-1' } + hint: { kind: 'user-set', transport: 'hawser', hostPath: '/opt/agent/stacks/gitlab', envName: 'remote-1' } }); expect(script).toContain('not found in /opt/agent/stacks/gitlab on remote-1'); expect(script).toContain('Set "Remote stack path (for backup)" in Settings > Environments > remote-1'); expect(script).not.toContain('Redeploy the stack'); }); + it('user-set hint (direct-remote): the path is set, so tell them to REDEPLOY (files are staged on up), not to change the path', () => { + const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], { + volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml', + hostPath: '/home/ubuntu/Docker/dockhand/cf-tunnel', + hint: { kind: 'user-set', transport: 'direct', hostPath: '/home/ubuntu/Docker/dockhand/cf-tunnel', envName: 'DOOBER' } + }); + // Apostrophes in the message are shell-escaped in the generated script, so match on + // apostrophe-free fragments here; the exact wording is asserted in stackdir-plan.test.ts + // against the pure builder. + expect(script).toContain('not found in /home/ubuntu/Docker/dockhand/cf-tunnel on DOOBER'); + expect(script).toContain('The Remote stack path is set'); + expect(script).toContain('redeploy the stack so Dockhand stages them'); + // It must NOT tell them to set the path (they already did) - that was the confusing advice. + expect(script).not.toContain('Set "Remote stack path (for backup)"'); + }); + it('falls back to "on the host" when the env name is unknown (never prints null)', () => { const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], { volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml', @@ -221,7 +237,8 @@ describe('buildBackupScript', () => { // generated script through sh for each hint kind to guarantee it stays valid. for (const hint of [ { kind: 'hawser-defaulted', hostPath: '/data/stacks/x', envName: "prod's-env (eu)" } as const, - { kind: 'user-set', hostPath: '/opt/agent/stacks/x', envName: 'remote-1' } as const, + { kind: 'user-set', transport: 'hawser', hostPath: '/opt/agent/stacks/x', envName: 'remote-1' } as const, + { kind: 'user-set', transport: 'direct', hostPath: '/opt/agent/stacks/x', envName: "prod's-env (eu)" } as const, { kind: 'local' } as const, ]) { it(`round-trip: generated probe script is valid sh for hint kind=${hint.kind}`, () => { diff --git a/tests/backups/stackdir-plan.test.ts b/tests/backups/stackdir-plan.test.ts index f6d7cc3..d937368 100644 --- a/tests/backups/stackdir-plan.test.ts +++ b/tests/backups/stackdir-plan.test.ts @@ -17,6 +17,7 @@ import { STACKDIR_VOLUME_KEY, isReservedVolumeKey, stackDirSource, + stackDirProbeFixHint, type HostStackDirInput, } from '../../src/lib/server/backups/stackdir-plan'; @@ -316,3 +317,57 @@ describe('tagCapturedEntries — mark stack-dir entries that are also a bind', ( expect(out[0].capturedAs).toBeUndefined(); }); }); + +describe('stackDirProbeFixHint', () => { + test('direct-remote user-set: tells the user to REDEPLOY (path is set, files staged on up), never to change the path', () => { + const msg = stackDirProbeFixHint( + { kind: 'user-set', transport: 'direct', hostPath: '/home/ubuntu/Docker/dockhand/cf-tunnel', envName: 'DOOBER' }, + '/home/ubuntu/Docker/dockhand/cf-tunnel', + ); + expect(msg).toContain("The Remote stack path is set, but this stack's files aren't on DOOBER yet"); + expect(msg).toContain('redeploy the stack so Dockhand stages them at /home/ubuntu/Docker/dockhand/cf-tunnel'); + expect(msg).toContain("down/start/restart don't copy; it must be a deploy"); + // The confusing advice must be gone - the user already set the path. + expect(msg).not.toContain('Set "Remote stack path (for backup)"'); + }); + + test('hawser user-set: keeps the "set the real host path" advice (agent keeps files under its own dir)', () => { + const msg = stackDirProbeFixHint( + { kind: 'user-set', transport: 'hawser', hostPath: '/opt/agent/stacks/gitlab', envName: 'remote-1' }, + '/opt/agent/stacks/gitlab', + ); + expect(msg).toContain('Set "Remote stack path (for backup)" in Settings > Environments > remote-1'); + expect(msg).not.toContain('redeploy'); + }); + + test('hawser-defaulted: set the real host path', () => { + const msg = stackDirProbeFixHint( + { kind: 'hawser-defaulted', hostPath: '/data/stacks/x', envName: 'prod' }, + '/data/stacks/x', + ); + expect(msg).toContain('Set "Remote stack path (for backup)" in Settings > Environments > prod'); + }); + + test('no env name: falls back to plain "Settings > Environments" (never prints null)', () => { + const msg = stackDirProbeFixHint( + { kind: 'hawser-defaulted', hostPath: '/data/stacks/x', envName: null }, + '/data/stacks/x', + ); + expect(msg).toContain('Settings > Environments to the real host path'); + expect(msg).not.toContain('null'); + }); + + test('direct-remote user-set with no env name: still says redeploy, no null', () => { + const msg = stackDirProbeFixHint( + { kind: 'user-set', transport: 'direct', hostPath: '/x', envName: null }, + '/x', + ); + expect(msg).toContain("this stack's files aren't on the host yet"); + expect(msg).toContain('redeploy the stack'); + expect(msg).not.toContain('null'); + }); + + test('local: redeploy stages the files', () => { + expect(stackDirProbeFixHint({ kind: 'local' }, '/srv/stacks/app')).toContain('Redeploy the stack to stage its files there'); + }); +});