/** * Unit tests for resolveHostStackDir — the ONE decision point for WHICH host folder is * captured as a stack's directory. ONE FLOW: the helper always bind-mounts the host stack * folder. The candidate path comes from the compose `working_dir` label ALONE (ground truth * for where compose ran on the target daemon's host - staged, matching-path, or local); a * runtime probe (not tested here) then proves it's reachable. */ import { describe, test, expect } from 'bun:test'; import { resolveHostStackDir, hostStackDirFromBind, deriveStackDirFromBinds, trustBindDerivedForEnv, parseProbeListing, tagCapturedEntries, isLocalDaemon, STACKDIR_VOLUME_KEY, isReservedVolumeKey, stackDirSource, stackDirProbeFixHint, type HostStackDirInput, } from '../../src/lib/server/backups/stackdir-plan'; const base = (over: Partial = {}): HostStackDirInput => ({ composeFileName: 'docker-compose.yml', bindDerivedHostPath: null, dataDirHostPath: null, mountHostPath: null, workingDirLabel: null, ...over, }); describe('resolveHostStackDir — host path in priority order (bind-derived first, label last)', () => { test('bindDerivedHostPath wins over EVERYTHING (daemon-authoritative)', () => { const r = resolveHostStackDir(base({ bindDerivedHostPath: '/docker/data/dockhand/stacks/anton/pppppp', dataDirHostPath: '/somewhere/else', workingDirLabel: '/app/data/stacks/anton/pppppp', })); expect(r.kind).toBe('candidate'); if (r.kind === 'candidate') { expect(r.hostPath).toBe('/docker/data/dockhand/stacks/anton/pppppp'); expect(r.source).toContain('bind'); } }); test('remoteStacksDirHostPath (direct-remote, user-configured) wins over data/mount/label', () => { // A direct-remote env with remote_stacks_dir set: the deploy staged the files at this // explicit host path, so it beats the (untrustworthy for direct-remote) label and the // local-only translations. const r = resolveHostStackDir(base({ remoteStacksDirHostPath: '/opt/dockhand/stacks/myapp', dataDirHostPath: '/docker/data/dockhand/stacks/myapp', workingDirLabel: '/app/data/stacks/myapp', })); expect(r.kind).toBe('candidate'); if (r.kind === 'candidate') { expect(r.hostPath).toBe('/opt/dockhand/stacks/myapp'); expect(r.source).toContain('remote_stacks_dir'); } }); test('a relative bind still pins the dir more exactly than remote_stacks_dir', () => { const r = resolveHostStackDir(base({ bindDerivedHostPath: '/opt/dockhand/stacks/myapp', remoteStacksDirHostPath: '/opt/dockhand/stacks/myapp', })); expect(r.kind).toBe('candidate'); if (r.kind === 'candidate') expect(r.source).toContain('bind'); }); test('SOCKET/LOCAL: DATA_DIR->HOST_DATA_DIR translation wins over the working_dir label', () => { // The regression from the label-only resolver: on a socket env Dockhand deploys under // /app/data (its CONTAINER view). The helper on the host needs /docker/data/dockhand/... // The working_dir label is the container path and must NOT be used when the DATA_DIR // translation is available. const r = resolveHostStackDir(base({ dataDirHostPath: '/docker/data/dockhand/stacks/anton/pppppp', workingDirLabel: '/app/data/stacks/anton/pppppp', // container view - the wrong one })); expect(r.kind).toBe('candidate'); if (r.kind === 'candidate') { expect(r.hostPath).toBe('/docker/data/dockhand/stacks/anton/pppppp'); expect(r.hostPath).not.toBe('/app/data/stacks/anton/pppppp'); // never the container path expect(r.source).toContain('DATA_DIR'); } }); test('ADOPTED/EXTERNAL: mount translation used when not under DATA_DIR', () => { const r = resolveHostStackDir(base({ dataDirHostPath: null, mountHostPath: '/opt/stacks/blog', workingDirLabel: '/app/data/x' })); expect(r.kind === 'candidate' && r.hostPath).toBe('/opt/stacks/blog'); expect((r as any).source).toContain('mount'); }); test('HAWSER / matching-paths: working_dir label used when no translation applies', () => { // A hawser agent ran compose on the remote host, so the label IS the host path. const r = resolveHostStackDir(base({ dataDirHostPath: null, mountHostPath: null, workingDirLabel: '/data/stacks/immich' })); expect(r.kind === 'candidate' && r.hostPath).toBe('/data/stacks/immich'); expect((r as any).source).toContain('working_dir'); }); test('composeFile taken from composeFileName (authoritative), NOT config_files', () => { const r = resolveHostStackDir(base({ composeFileName: 'immich.yaml', dataDirHostPath: '/h/x' })); expect(r.kind === 'candidate' && r.composeFile).toBe('immich.yaml'); }); test("composeFileName '-' (stdin deploy) falls back to docker-compose.yml, never probes for '-'", () => { // Dockhand deploys via `-f -` (stdin) so config_files is `-`. composeFileName is `-` too // when derived from that; the resolver must NOT build a probe path ending in `/-`. const r = resolveHostStackDir(base({ composeFileName: '-', dataDirHostPath: '/h/x' })); expect(r.kind === 'candidate' && r.composeFile).toBe('docker-compose.yml'); }); test('null composeFileName falls back to docker-compose.yml', () => { const r = resolveHostStackDir(base({ composeFileName: null, dataDirHostPath: '/h/x' })); expect(r.kind === 'candidate' && r.composeFile).toBe('docker-compose.yml'); }); test('trailing slashes on the chosen host path are normalized', () => { expect((resolveHostStackDir(base({ dataDirHostPath: '/h/x/' })) as any).hostPath).toBe('/h/x'); expect((resolveHostStackDir(base({ mountHostPath: '/m/y///' })) as any).hostPath).toBe('/m/y'); }); test('NO BINDS: falls back to DATA_DIR translation (socket stack with only named volumes)', () => { // A stack with no bind mounts (only named volumes, or none) can't be bind-derived, so // bindDerivedHostPath is null. On socket the DATA_DIR translation still resolves it. const r = resolveHostStackDir(base({ bindDerivedHostPath: null, dataDirHostPath: '/docker/data/dockhand/stacks/x' })); expect(r.kind === 'candidate' && r.hostPath).toBe('/docker/data/dockhand/stacks/x'); }); test('NO BINDS + no translation + no label -> unknown (conscious hard-fail, never a silent skip)', () => { // The remaining gap the user worried about: no bind to derive from AND no translation // (self-inspect failed / HOST_DATA_DIR unknown) AND no label. This is a CONSCIOUS // unknown -> the caller hard-fails the stack backup rather than capturing an empty dir. expect(resolveHostStackDir(base({ bindDerivedHostPath: null, dataDirHostPath: null, mountHostPath: null, workingDirLabel: null })).kind).toBe('unknown'); }); }); describe('hostStackDirFromBind — derive the stack dir from a relative bind + daemon source', () => { test('the anton case: ./html on host /docker/data/dockhand/stacks/anton/pppppp/html', () => { expect(hostStackDirFromBind('./html', '/docker/data/dockhand/stacks/anton/pppppp/html')) .toBe('/docker/data/dockhand/stacks/anton/pppppp'); }); test('nested relative dir ./conf/nginx strips the whole tail', () => { expect(hostStackDirFromBind('./conf/nginx', '/srv/stacks/blog/conf/nginx')).toBe('/srv/stacks/blog'); }); test('whole-dir bind ./ (or .) -> the host source IS the stack dir', () => { expect(hostStackDirFromBind('./', '/srv/stacks/blog')).toBe('/srv/stacks/blog'); expect(hostStackDirFromBind('.', '/srv/stacks/blog')).toBe('/srv/stacks/blog'); }); test('trailing slash on the host source is normalized before stripping', () => { expect(hostStackDirFromBind('./html', '/srv/blog/html/')).toBe('/srv/blog'); }); test('ABSOLUTE bind source -> null (not relative, cannot derive)', () => { expect(hostStackDirFromBind('/opt/data', '/opt/data')).toBeNull(); }); test('../ source (above the stack dir) -> null', () => { expect(hostStackDirFromBind('../shared', '/srv/shared')).toBeNull(); }); test('host source that does NOT end with the relative tail -> null (inconsistent, do not guess)', () => { expect(hostStackDirFromBind('./html', '/srv/blog/public')).toBeNull(); }); test('non-absolute host source -> null', () => { expect(hostStackDirFromBind('./html', 'relative/html')).toBeNull(); }); }); describe('deriveStackDirFromBinds — match compose relative dirs to discovered bind sources', () => { test('finds the stack dir from the matching bind source', () => { expect(deriveStackDirFromBinds(['html'], ['/docker/data/dockhand/stacks/anton/pppppp/html'])) .toBe('/docker/data/dockhand/stacks/anton/pppppp'); }); test('picks the first relative dir that has a consistent source', () => { // data has no matching source; html does. expect(deriveStackDirFromBinds(['data', 'html'], ['/srv/blog/html'])).toBe('/srv/blog'); }); test('no matching source -> null (caller falls back to translation/label)', () => { expect(deriveStackDirFromBinds(['html'], ['/unrelated/path'])).toBeNull(); }); test('no relative dirs -> null', () => { expect(deriveStackDirFromBinds([], ['/srv/blog/html'])).toBeNull(); }); }); describe('trustBindDerivedForEnv — distrust the phantom bind path on direct-remote stdin deploys', () => { const P = '/app/data/stacks/test-direct/myapp'; // a phantom Dockhand-container path test('direct-remote + NO remote_stacks_dir -> null (falls through to tar; the #4921 regression)', () => { // The stdin deploy made the remote daemon mkdir a phantom empty dir at Dockhand's own // container path; trusting it made the probe mount an empty dir and HARD-FAIL the backup. expect(trustBindDerivedForEnv(P, { directRemote: true, hasRemoteStacksDir: false })).toBeNull(); }); test('direct-remote WITH remote_stacks_dir -> kept (deploy staged the files, bind pins the real path)', () => { expect(trustBindDerivedForEnv(P, { directRemote: true, hasRemoteStacksDir: true })).toBe(P); }); test('NOT direct-remote (socket / direct-local / adopted) -> kept (daemon shares Dockhand\'s host)', () => { expect(trustBindDerivedForEnv(P, { directRemote: false, hasRemoteStacksDir: false })).toBe(P); }); test('hawser (directRemote=false) -> kept (agent ran compose on its own host, mount.Source is real)', () => { expect(trustBindDerivedForEnv('/opt/stacks/immich', { directRemote: false, hasRemoteStacksDir: false })) .toBe('/opt/stacks/immich'); }); test('already-null bind-derived stays null regardless of env', () => { expect(trustBindDerivedForEnv(null, { directRemote: false, hasRemoteStacksDir: false })).toBeNull(); expect(trustBindDerivedForEnv(null, { directRemote: true, hasRemoteStacksDir: true })).toBeNull(); }); }); describe('isLocalDaemon — DATA_DIR/mount translation is only valid on Dockhand\'s own host', () => { test('socket / null -> local', () => { expect(isLocalDaemon('socket', null, null)).toBe(true); expect(isLocalDaemon(null, null, null)).toBe(true); }); test('hawser -> never local (the rambo case: remote host, translation would be wrong)', () => { expect(isLocalDaemon('hawser-standard', null, 'tcp://x:2375')).toBe(false); expect(isLocalDaemon('hawser-edge', null, null)).toBe(false); }); test('direct -> local ONLY when the env tcp host == Dockhand\'s own docker host', () => { expect(isLocalDaemon('direct', 'tcp://h:2375', 'tcp://h:2375')).toBe(true); expect(isLocalDaemon('direct', 'tcp://rambo:2375', 'tcp://anton:2375')).toBe(false); expect(isLocalDaemon('direct', 'tcp://h:2375', null)).toBe(false); }); }); describe('stack-dir reserved-key helpers (unchanged)', () => { test('the reserved key is recognized', () => { expect(isReservedVolumeKey(STACKDIR_VOLUME_KEY)).toBe(true); expect(isReservedVolumeKey('my_data')).toBe(false); }); test('stackDirSource points at the single /volumes location restore reads', () => { expect(stackDirSource().include).toBe(`/volumes/${STACKDIR_VOLUME_KEY}`); expect(stackDirSource().extractSub).toBe(`volumes/${STACKDIR_VOLUME_KEY}`); }); }); describe('parseProbeListing — the host probe `find -printf` listing', () => { test('parses dirs and files with sizes, dirs first then alpha', () => { const out = parseProbeListing('f\t155\tcompose.yaml\nd\t4096\tmedia\nd\t4096\tconfig\nf\t9\t.env'); expect(out).toEqual([ { name: 'config', type: 'dir', size: 4096 }, { name: 'media', type: 'dir', size: 4096 }, { name: '.env', type: 'file', size: 9 }, { name: 'compose.yaml', type: 'file', size: 155 }, ]); }); test('names with spaces survive (tab-delimited, not space)', () => { const out = parseProbeListing('f\t10\tmy config.yaml'); expect(out).toEqual([{ name: 'my config.yaml', type: 'file', size: 10 }]); }); test('non-d type is treated as file', () => { const out = parseProbeListing('l\t0\tsymlink'); expect(out[0]).toEqual({ name: 'symlink', type: 'file', size: 0 }); }); test('empty stdout -> empty list', () => { expect(parseProbeListing('')).toEqual([]); expect(parseProbeListing('\n\n')).toEqual([]); }); test('malformed lines (missing tabs) are skipped', () => { const out = parseProbeListing('garbage line\nf\t5\tgood.txt'); expect(out).toEqual([{ name: 'good.txt', type: 'file', size: 5 }]); }); test('non-numeric size falls back to 0', () => { expect(parseProbeListing('f\tNaN\tx')[0].size).toBe(0); }); }); describe('tagCapturedEntries — mark stack-dir entries that are also a bind', () => { const entries = [ { name: 'config', type: 'dir' as const, size: 4096 }, { name: 'uploads', type: 'dir' as const, size: 4096 }, { name: 'compose.yaml', type: 'file' as const, size: 100 }, { name: 'README.txt', type: 'file' as const, size: 20 } ]; test('bind-dir entries get capturedAs:bind; loose files stay untagged', () => { const out = tagCapturedEntries(entries, '/opt/immich', ['/opt/immich/config', '/opt/immich/uploads']); expect(out.find((e) => e.name === 'config')?.capturedAs).toBe('bind'); expect(out.find((e) => e.name === 'uploads')?.capturedAs).toBe('bind'); expect(out.find((e) => e.name === 'compose.yaml')?.capturedAs).toBeUndefined(); expect(out.find((e) => e.name === 'README.txt')?.capturedAs).toBeUndefined(); // the DATA_OLD case }); test('trailing slash on hostPath or source is tolerated', () => { const out = tagCapturedEntries([{ name: 'data', type: 'dir', size: 1 }], '/opt/app/', ['/opt/app/data/']); expect(out[0].capturedAs).toBe('bind'); }); test('a bind OUTSIDE the stack dir does not match an equally-named entry', () => { // entry 'media' under /opt/app, but the only bind source is /mnt/media (absolute, elsewhere) const out = tagCapturedEntries([{ name: 'media', type: 'dir', size: 1 }], '/opt/app', ['/mnt/media']); expect(out[0].capturedAs).toBeUndefined(); }); test('no bind sources -> nothing tagged', () => { const out = tagCapturedEntries(entries, '/opt/immich', []); expect(out.every((e) => e.capturedAs === undefined)).toBe(true); }); // STOPPED stack: the daemon reports no bind sources (containers aren't running), so the // compose bind-dir names are what tag `./memos` as captured-by-bind — without this, a stopped // stack's bind dir wrongly shows as a plain, deselectable stack file. test('compose bind-dir names tag entries even when runtime bindSources is empty', () => { const es = [ { name: 'memos', type: 'dir' as const, size: 4096 }, { name: 'compose.yaml', type: 'file' as const, size: 100 } ]; const out = tagCapturedEntries(es, '/docker/data/stacks/x', [], ['memos']); expect(out.find((e) => e.name === 'memos')?.capturedAs).toBe('bind'); expect(out.find((e) => e.name === 'compose.yaml')?.capturedAs).toBeUndefined(); }); test('bindSources and bindDirNames are unioned (either tags an entry)', () => { const es = [ { name: 'data', type: 'dir' as const, size: 1 }, // via bindSources { name: 'memos', type: 'dir' as const, size: 1 } // via bindDirNames only ]; const out = tagCapturedEntries(es, '/opt/app', ['/opt/app/data'], ['memos']); expect(out.find((e) => e.name === 'data')?.capturedAs).toBe('bind'); expect(out.find((e) => e.name === 'memos')?.capturedAs).toBe('bind'); }); test('a name NOT in bindDirNames and NOT a bind source stays untagged', () => { const out = tagCapturedEntries([{ name: 'readme.txt', type: 'file', size: 10 }], '/opt/app', [], ['memos']); 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'); }); });