tell direct-remote users to redeploy when a stack backup can't find its files
This commit is contained in:
5 files changed
+111
-14
No files matched your search
@@ -41,7 +41,7 @@ export interface MetadataFile {
|
|||||||
* @param resticArgs the full restic argv (starting with 'backup') — already
|
* @param resticArgs the full restic argv (starting with 'backup') — already
|
||||||
* tag/exclude/flag-assembled by the caller.
|
* 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 {
|
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
|
// 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) {
|
if (stackDirProbe) {
|
||||||
const composePath = shellQuote(`/volumes/${stackDirProbe.volumeKey}/${stackDirProbe.composeFileName}`);
|
const composePath = shellQuote(`/volumes/${stackDirProbe.volumeKey}/${stackDirProbe.composeFileName}`);
|
||||||
const label = stackDirProbe.label ? ` ${stackDirProbe.label}` : '';
|
const label = stackDirProbe.label ? ` ${stackDirProbe.label}` : '';
|
||||||
// The compose is missing under the host dir we bind-mounted. For a remote env (hawser or a
|
// The compose is missing under the host dir we bind-mounted. The fix depends on WHY
|
||||||
// user-set path) tell the operator to point "Remote stack path (for backup)" at the real host
|
// (path wrong vs not-deployed-there-yet vs local) - stackDirProbeFixHint decides.
|
||||||
// path; for a local stack a redeploy stages the files.
|
|
||||||
const hint = stackDirProbe.hint;
|
const hint = stackDirProbe.hint;
|
||||||
const remote = hint?.kind === 'hawser-defaulted' || hint?.kind === 'user-set';
|
const remote = hint?.kind === 'hawser-defaulted' || hint?.kind === 'user-set';
|
||||||
const where = remote ? hint.hostPath : stackDirProbe.hostPath;
|
const where = remote ? hint.hostPath : stackDirProbe.hostPath;
|
||||||
// "on <env>" when we know the environment name, else a plain "on the host".
|
// "on <env>" when we know the environment name, else a plain "on the host".
|
||||||
const on = remote && hint.envName ? ` on ${hint.envName}` : ` 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 fix = stackDirProbeFixHint(hint, where);
|
||||||
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 at = where ? ` in ${where}` : '';
|
const at = where ? ` in ${where}` : '';
|
||||||
// Build the whole message as ONE string and shell-quote it so the operator-facing text
|
// 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
|
// (which contains quotes, parens, and `>` - e.g. `"Remote stack path (for backup)"` and
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ async function planStackDirVolume(
|
|||||||
// HOST-side "Remote stack path (for backup)" - NOT to redeploy.
|
// HOST-side "Remote stack path (for backup)" - NOT to redeploy.
|
||||||
const probeHint: StackDirProbeHint =
|
const probeHint: StackDirProbeHint =
|
||||||
isHawser && remoteStacksDirDefaulted ? { kind: 'hawser-defaulted', hostPath, envName }
|
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' };
|
: { kind: 'local' };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -211,9 +211,38 @@ export function isLocalDaemon(connectionType: string | null, envTcpHost: string
|
|||||||
// wrong. `local`: a socket/local-daemon stack -> redeploy staged its files.
|
// wrong. `local`: a socket/local-daemon stack -> redeploy staged its files.
|
||||||
export type StackDirProbeHint =
|
export type StackDirProbeHint =
|
||||||
| { kind: 'hawser-defaulted'; hostPath: string; envName: string | null }
|
| { 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' };
|
| { 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). */
|
/** Result of resolving the candidate host stack folder (before the runtime probe). */
|
||||||
export type HostStackDirResolution =
|
export type HostStackDirResolution =
|
||||||
| { kind: 'candidate'; hostPath: string; composeFile: string; source: string }
|
| { kind: 'candidate'; hostPath: string; composeFile: string; source: string }
|
||||||
|
|||||||
@@ -160,17 +160,33 @@ describe('buildBackupScript', () => {
|
|||||||
expect(script).toContain('compose dupa.yaml not found');
|
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/'], {
|
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
|
||||||
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
|
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
|
||||||
hostPath: '/opt/agent/stacks/gitlab',
|
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('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).toContain('Set "Remote stack path (for backup)" in Settings > Environments > remote-1');
|
||||||
expect(script).not.toContain('Redeploy the stack');
|
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)', () => {
|
it('falls back to "on the host" when the env name is unknown (never prints null)', () => {
|
||||||
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
|
const script = buildBackupScript(['backup', '/volumes/', '/metadata/'], {
|
||||||
volumeKey: '__dockhand_stackdir__', composeFileName: 'compose.yaml',
|
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.
|
// generated script through sh for each hint kind to guarantee it stays valid.
|
||||||
for (const hint of [
|
for (const hint of [
|
||||||
{ kind: 'hawser-defaulted', hostPath: '/data/stacks/x', envName: "prod's-env (eu)" } as const,
|
{ 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,
|
{ kind: 'local' } as const,
|
||||||
]) {
|
]) {
|
||||||
it(`round-trip: generated probe script is valid sh for hint kind=${hint.kind}`, () => {
|
it(`round-trip: generated probe script is valid sh for hint kind=${hint.kind}`, () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
STACKDIR_VOLUME_KEY,
|
STACKDIR_VOLUME_KEY,
|
||||||
isReservedVolumeKey,
|
isReservedVolumeKey,
|
||||||
stackDirSource,
|
stackDirSource,
|
||||||
|
stackDirProbeFixHint,
|
||||||
type HostStackDirInput,
|
type HostStackDirInput,
|
||||||
} from '../../src/lib/server/backups/stackdir-plan';
|
} 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();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user