298 lines
17 KiB
TypeScript
298 lines
17 KiB
TypeScript
|
|
/**
|
|
* stackdir-plan.ts — the ONE place that decides WHICH host folder is captured as a
|
|
* stack's directory. Pure and import-light (no db/docker/fs), so it is unit-testable
|
|
* directly and can't drift into scattered `connectionType` branches.
|
|
*
|
|
* MAIN FLOW: the backup helper bind-mounts the stack folder from the TARGET env's HOST at
|
|
* /volumes/__dockhand_stackdir__:ro, and restic reads it from disk - the helper is a sibling
|
|
* container on the target daemon, so a host path on that daemon is mountable. The snapshot
|
|
* reflects the stack folder AS IT IS ON THE HOST (what the user actually edits).
|
|
*
|
|
* ONE EXCEPTION - a direct-REMOTE env with NO remote_stacks_dir: nothing is staged on the host
|
|
* (deploy went via stdin), so there is no host folder to mount. The caller then tars the
|
|
* compose/config from Dockhand's OWN local copy into the SAME snapshot location instead of
|
|
* hard-failing. This is the only case that doesn't reflect the host 1:1 - because the host has
|
|
* nothing to reflect.
|
|
*
|
|
* This module resolves the CANDIDATE host path (see resolveHostStackDir). A runtime probe
|
|
* (helper bind + `test -f <composeFile>`) then proves the path is real on the target daemon
|
|
* before capture; if it isn't (e.g. a direct env whose working_dir is Dockhand's own
|
|
* /app/data path, not the host's), capture HARD-FAILS to data-only rather than silently
|
|
* backing up an empty auto-created dir.
|
|
*/
|
|
|
|
/** The reserved /volumes key the stack dir is captured under. Long and namespaced so a real
|
|
* user volume can't realistically collide with it. */
|
|
export const STACKDIR_VOLUME_KEY = '__dockhand_stackdir__';
|
|
|
|
/**
|
|
* True for a reserved /volumes key that is NOT a user data volume — currently just the
|
|
* stack-dir capture. Restore uses it to keep this key OUT of everything that treats a
|
|
* /volumes/ entry as restorable container data (the volume picker, "restore all", the
|
|
* in-place swap). The stack dir is restored ONLY via the redeploy/materialise path.
|
|
*/
|
|
export function isReservedVolumeKey(key: string): boolean {
|
|
return key === STACKDIR_VOLUME_KEY;
|
|
}
|
|
|
|
/**
|
|
* Where a snapshot's captured stack dir lives, for restore to read it back: ALWAYS
|
|
* /volumes/__dockhand_stackdir__. `include` is for `restic restore --include`; `extractSub`
|
|
* is the on-disk subpath the restore lands under its --target.
|
|
*/
|
|
export function stackDirSource(): { include: string; extractSub: string } {
|
|
return { include: `/volumes/${STACKDIR_VOLUME_KEY}`, extractSub: `volumes/${STACKDIR_VOLUME_KEY}` };
|
|
}
|
|
|
|
/**
|
|
* Derive the host stack dir from a RELATIVE compose bind and the host path the daemon reports
|
|
* for it. This is the MOST AUTHORITATIVE source: the Docker daemon tells us exactly where the
|
|
* bind lives on its host (`mount.Source`), and compose resolves a relative source (`./html`)
|
|
* against the stack dir - so `hostStackDir = <mount.Source> with the relative tail removed`.
|
|
*
|
|
* Example: compose `./html:/x` on host `/docker/data/dockhand/stacks/anton/pppppp` gives the
|
|
* daemon `mount.Source = /docker/data/dockhand/stacks/anton/pppppp/html`; with relSource
|
|
* `./html` we strip `/html` and get the stack dir. Works on socket, direct-local, adopted, and
|
|
* hawser alike (the daemon always knows its own bind sources), and needs no DATA_DIR/HOST_DATA_DIR
|
|
* config. Returns null when the source isn't relative or the tail doesn't match (absolute bind,
|
|
* `${VAR}` source, etc.) - the caller then falls back to translation/label.
|
|
*
|
|
* @param relSource the compose bind source as written (e.g. `./html`, `./`, `../x`)
|
|
* @param hostSource the absolute host path the daemon reports for that bind (`mount.Source`)
|
|
*/
|
|
export function hostStackDirFromBind(relSource: string, hostSource: string): string | null {
|
|
if (typeof relSource !== 'string' || typeof hostSource !== 'string') return null;
|
|
const rs = relSource.trim();
|
|
const hs = hostSource.trim().replace(/\/+$/, '');
|
|
if (!hs.startsWith('/')) return null; // host source must be absolute
|
|
if (!/^\.\.?\//.test(rs) && rs !== '.' && rs !== './') return null; // only relative sources
|
|
|
|
// The relative tail below the stack dir: `./html` -> `html`, `./` or `.` -> '' (bind IS the
|
|
// stack dir). Reject `../` sources: the bind is ABOVE the stack dir, so we can't derive it.
|
|
let tail = rs.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
if (tail === '.' || tail === '') {
|
|
return hs; // the whole stack dir is bind-mounted (`./:/app`) -> host source IS the stack dir
|
|
}
|
|
if (tail.startsWith('../') || tail === '..') return null;
|
|
|
|
// Strip the `/tail` suffix from the host source. Must actually end with it, or the mapping
|
|
// is inconsistent (don't guess).
|
|
const suffix = '/' + tail;
|
|
if (!hs.endsWith(suffix)) return null;
|
|
const dir = hs.slice(0, hs.length - suffix.length);
|
|
return dir === '' ? '/' : dir;
|
|
}
|
|
|
|
/**
|
|
* Derive the host stack dir by matching the compose's relative bind DIRS against the daemon's
|
|
* reported bind sources. For each relative dir the compose declares (`html`, from `./html:/x`),
|
|
* find a discovered bind whose host source ENDS WITH `/html` and strip that tail - the remainder
|
|
* is the stack dir. Returns the first consistent match, or null if none. Pure + unit-testable.
|
|
*
|
|
* `relBindDirs`: relative dir names from relativeBindDirsFromCompose (e.g. ['html','data']).
|
|
* `bindSources`: the daemon-reported host source paths of the stack's bind mounts (mount.Source).
|
|
*/
|
|
export function deriveStackDirFromBinds(relBindDirs: string[], bindSources: string[]): string | null {
|
|
for (const rel of relBindDirs) {
|
|
const tail = '/' + rel.replace(/^\/+|\/+$/g, '');
|
|
for (const src of bindSources) {
|
|
const derived = hostStackDirFromBind('./' + rel.replace(/^\/+/, ''), src);
|
|
if (derived) return derived;
|
|
// Also handle a nested relative dir (`./conf/nginx`) whose source ends with the tail.
|
|
const hs = typeof src === 'string' ? src.trim().replace(/\/+$/, '') : '';
|
|
if (hs.endsWith(tail)) { const d = hs.slice(0, hs.length - tail.length); return d === '' ? '/' : d; }
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether a bind-derived host path can be TRUSTED for this env. bind-derived comes from the
|
|
* target daemon's reported mount.Source, which is a REAL host path for socket / direct-LOCAL /
|
|
* adopted / hawser (the daemon shares its host with the stack). But for a direct-REMOTE env with
|
|
* NO remote_stacks_dir, the stack was deployed VIA STDIN with working_dir = Dockhand's own
|
|
* container path; `docker compose` then asks the remote daemon to bind `./data`, and the daemon
|
|
* mkdir's a PHANTOM EMPTY dir at that Dockhand-container path on ITS host. Stripping the tail
|
|
* yields a dir that exists remotely but holds NO compose (never staged) -> the probe HARD-FAILS.
|
|
* So distrust bind-derived in exactly that case, letting the caller fall through to kind:'tar'
|
|
* (capture the compose/config from Dockhand's own local copy). When remote_stacks_dir IS set the
|
|
* deploy staged the files there and bind-derived pins the real path, so keep it. Pure + testable.
|
|
*/
|
|
export function trustBindDerivedForEnv(
|
|
bindDerivedHostPath: string | null,
|
|
opts: { directRemote: boolean; hasRemoteStacksDir: boolean }
|
|
): string | null {
|
|
if (opts.directRemote && !opts.hasRemoteStacksDir) return null;
|
|
return bindDerivedHostPath;
|
|
}
|
|
|
|
export type StackDirEntry = { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' };
|
|
|
|
/**
|
|
* Tag each stack-dir entry that is ALSO captured as its own volume/bind channel, so the picker
|
|
* can show it as non-deselectable ("captured as a bind mount below") - the user controls it in
|
|
* the Volumes section, not here. An entry is a bind when EITHER `<hostPath>/<name>` matches a
|
|
* daemon-reported bind source (runtime) OR its name is a compose relative-bind dir. The compose
|
|
* source is load-bearing: `bindSources` comes from inspecting the RUNNING containers, so a STOPPED
|
|
* stack reports none and a bind dir (e.g. `./memos`) would wrongly show as a plain, deselectable
|
|
* stack file. `bindDirNames` (from the compose, always available) fixes that. Pure + unit-testable.
|
|
*/
|
|
export function tagCapturedEntries(entries: StackDirEntry[], hostPath: string, bindSources: string[], bindDirNames: string[] = []): StackDirEntry[] {
|
|
const base = hostPath.replace(/\/+$/, '');
|
|
const bindSet = new Set(bindSources.map((s) => (typeof s === 'string' ? s.replace(/\/+$/, '') : '')));
|
|
const nameSet = new Set(bindDirNames);
|
|
return entries.map((e) =>
|
|
(bindSet.has(`${base}/${e.name}`) || nameSet.has(e.name)) ? { ...e, capturedAs: 'bind' as const } : e
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse the probe container's directory listing. The probe runs
|
|
* `find /probe -maxdepth 1 -mindepth 1 -printf '%y\t%s\t%f\n'`, so each line is
|
|
* `<type>\t<size>\t<name>` where type is 'd' (dir) / 'f' (file) / other. Pure + unit-testable.
|
|
* Names may contain spaces but not tabs or newlines (find's %f), so tab-splitting is safe.
|
|
* Dirs sort before files, then alphabetical.
|
|
*/
|
|
export function parseProbeListing(stdout: string): StackDirEntry[] {
|
|
const out: StackDirEntry[] = [];
|
|
for (const line of stdout.split('\n')) {
|
|
if (!line) continue;
|
|
const tab1 = line.indexOf('\t');
|
|
const tab2 = line.indexOf('\t', tab1 + 1);
|
|
if (tab1 < 0 || tab2 < 0) continue;
|
|
const t = line.slice(0, tab1);
|
|
const size = parseInt(line.slice(tab1 + 1, tab2), 10);
|
|
const name = line.slice(tab2 + 1);
|
|
if (!name) continue;
|
|
out.push({ name, type: t === 'd' ? 'dir' : 'file', size: Number.isFinite(size) ? size : 0 });
|
|
}
|
|
return out.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
}
|
|
|
|
/** Sentinel the probe list-script prints when the target path does NOT exist, so the classifier can
|
|
* tell "helper ran, dir missing" apart from "helper ran, dir empty" (both otherwise list nothing). */
|
|
export const PROBE_MISSING_SENTINEL = '__PROBE_MISSING__';
|
|
|
|
/** Data-presence verdict for a probed host path / volume. `helper-failed` (the container could not
|
|
* run at all) is decided by the CALLER catching the run error - the classifier only sees stdout of
|
|
* a helper that DID run, so it returns has-data / empty / missing. */
|
|
export type ProbeDataKind = 'has-data' | 'empty' | 'missing' | 'helper-failed';
|
|
|
|
/** Classify a probe helper's stdout (pure, unit-testable). The list-script prints the missing
|
|
* sentinel when the path doesn't exist, one `<type>\t<size>\t<name>` line per entry otherwise. */
|
|
export function classifyProbeListing(stdout: string): 'has-data' | 'empty' | 'missing' {
|
|
if (stdout.includes(PROBE_MISSING_SENTINEL)) return 'missing';
|
|
return parseProbeListing(stdout).length > 0 ? 'has-data' : 'empty';
|
|
}
|
|
|
|
/**
|
|
* Is the TARGET daemon Dockhand's OWN host? Only then are the DATA_DIR / container-mount
|
|
* translations valid: they map a container path to DOCKHAND'S host (via HOST_DATA_DIR /
|
|
* Dockhand's own mounts), which is the WRONG host for a remote daemon. For a remote env the
|
|
* stack lives on a DIFFERENT machine, so those translations would hand the helper a path that
|
|
* only exists on Dockhand's host, not the remote one. Pure + unit-testable.
|
|
* - socket / null: local (Dockhand shares the daemon's host).
|
|
* - direct: local ONLY when the env's tcp host == Dockhand's own docker host (#1203 proof).
|
|
* - hawser: never local.
|
|
*/
|
|
export function isLocalDaemon(connectionType: string | null, envTcpHost: string | null, ownDockerHost: string | null): boolean {
|
|
if (connectionType && connectionType.startsWith('hawser')) return false;
|
|
if (connectionType === 'socket' || connectionType == null) return true;
|
|
if (connectionType === 'direct') return !!(ownDockerHost && envTcpHost && ownDockerHost === envTcpHost);
|
|
return false;
|
|
}
|
|
|
|
// Context the in-helper probe-fail message needs to give the operator an ACTIONABLE next step
|
|
// (the fix differs by wiring). `hawser-defaulted`: a hawser env with no remote_stacks_dir fell
|
|
// back to /data/stacks and it came up empty -> the agent stores stacks under a DIFFERENT host dir
|
|
// (e.g. a containerized agent whose /data/stacks is mounted from another host path), so set the
|
|
// HOST-side path. `user-set`: an explicitly-configured host path came up empty -> the path is
|
|
// 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 }
|
|
| { kind: 'local' };
|
|
|
|
/** Result of resolving the candidate host stack folder (before the runtime probe). */
|
|
export type HostStackDirResolution =
|
|
| { kind: 'candidate'; hostPath: string; composeFile: string; source: string }
|
|
| { kind: 'unknown'; reason: string };
|
|
|
|
/** Inputs for the pure host-path resolution. All the I/O (compose lookup, DATA_DIR/mount
|
|
* translation, label read) is done by the CALLER and injected here, so this stays pure and
|
|
* unit-testable. The caller passes whichever candidates it could compute; this picks the
|
|
* right one in priority order. */
|
|
export interface HostStackDirInput {
|
|
/** basename of Dockhand's compose file for the stack (immich.yaml / docker-compose.yml).
|
|
* The AUTHORITATIVE compose name - NOT the config_files label, which is `-` when Dockhand
|
|
* deploys via stdin (`-f -`). */
|
|
composeFileName: string | null;
|
|
/** For a direct-REMOTE env with remote_stacks_dir set: the explicit, user-declared host
|
|
* path (<remote_stacks_dir>/<stack>) where the deploy staged the files. Highest priority
|
|
* after a bind pins the dir more exactly. Null for socket/hawser/local. */
|
|
remoteStacksDirHostPath?: string | null;
|
|
/** Host stack dir DERIVED from a relative compose bind's daemon-reported source
|
|
* (hostStackDirFromBind). The MOST AUTHORITATIVE source - the daemon knows exactly where its
|
|
* bind lives on the host, so this needs no DATA_DIR/HOST_DATA_DIR config and works on socket,
|
|
* direct-local, adopted, and hawser. Null when the stack has no usable relative bind. */
|
|
bindDerivedHostPath: string | null;
|
|
/** Dockhand's stack dir translated to the host via DATA_DIR -> HOST_DATA_DIR. Non-null
|
|
* (and != the container path) when the stack lives under Dockhand's DATA_DIR and the host
|
|
* data dir is known - the SOCKET/LOCAL Dockhand-deployed case. Fallback when no bind. */
|
|
dataDirHostPath: string | null;
|
|
/** Dockhand's stack dir translated to the host via a container bind mount, for an
|
|
* adopted/external stack that lives OUTSIDE DATA_DIR but on a mounted host path. */
|
|
mountHostPath: string | null;
|
|
/** com.docker.compose.project.working_dir label. For a HAWSER agent (which ran compose on
|
|
* the remote host) or a matching-paths direct env, this IS the real host path. Used only
|
|
* when neither translation applies (the daemon is not Dockhand's own host). */
|
|
workingDirLabel: string | null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the CANDIDATE host stack folder. ONE FLOW still: the helper always bind-mounts this
|
|
* path from the target daemon's host. The subtlety is that "where is the stack folder ON THAT
|
|
* DAEMON'S HOST" differs by wiring, and the compose `working_dir` label is NOT reliable on its
|
|
* own (it is the container-view path for a Dockhand-deployed stack, and `config_files` is `-`
|
|
* for stdin deploys). So the caller computes the candidates and we pick in priority order:
|
|
* 1. `bindDerivedHostPath` - derived from a relative bind's daemon-reported source. The daemon
|
|
* knows exactly where its bind lives on the host, so this is authoritative and needs no
|
|
* DATA_DIR config. Works on socket / direct-local / adopted / hawser.
|
|
* 2. `dataDirHostPath` - Dockhand deployed the stack under DATA_DIR and we know the host data
|
|
* dir (SOCKET/LOCAL). The label `/app/data/...` would be the CONTAINER path; the helper on
|
|
* the host needs `/docker/data/dockhand/...`.
|
|
* 3. `mountHostPath` - adopted/external stack outside DATA_DIR but on a mounted host path.
|
|
* 4. `workingDirLabel` - HAWSER (agent ran compose on the remote host, label = host path) or
|
|
* matching-paths direct. Only when nothing above applied.
|
|
* 5. none -> unknown (caller HARD-FAILS a stack backup; option A).
|
|
* A runtime probe (`test -f <composeFile>` in the helper) then CONFIRMS reachability before
|
|
* capture. Pure + unit-testable. */
|
|
export function resolveHostStackDir(input: HostStackDirInput): HostStackDirResolution {
|
|
// The probe's `test -f` needs a real compose name. `config_files` is `-` for stdin deploys,
|
|
// so we take the name from Dockhand's own compose file (composeFileName), falling back to the
|
|
// conventional name.
|
|
const composeFile = input.composeFileName && input.composeFileName !== '-'
|
|
? input.composeFileName
|
|
: 'docker-compose.yml';
|
|
|
|
const norm = (p: string | null | undefined): string => (typeof p === 'string' ? p.trim().replace(/\/+$/, '') : '');
|
|
|
|
const viaBind = norm(input.bindDerivedHostPath);
|
|
if (viaBind) return { kind: 'candidate', hostPath: viaBind, composeFile, source: 'derived from a relative compose bind (daemon-reported host source)' };
|
|
|
|
const viaRemoteDir = norm(input.remoteStacksDirHostPath);
|
|
if (viaRemoteDir) return { kind: 'candidate', hostPath: viaRemoteDir, composeFile, source: 'declared host stack path (direct-remote remote_stacks_dir / hawser agent STACKS_DIR)' };
|
|
|
|
const viaData = norm(input.dataDirHostPath);
|
|
if (viaData) return { kind: 'candidate', hostPath: viaData, composeFile, source: 'DATA_DIR -> HOST_DATA_DIR translation (Dockhand-deployed, local)' };
|
|
|
|
const viaMount = norm(input.mountHostPath);
|
|
if (viaMount) return { kind: 'candidate', hostPath: viaMount, composeFile, source: 'container mount translation (adopted/external stack)' };
|
|
|
|
const wd = norm(input.workingDirLabel);
|
|
if (wd) return { kind: 'candidate', hostPath: wd, composeFile, source: 'compose working_dir label (hawser agent / matching paths)' };
|
|
|
|
return { kind: 'unknown', reason: 'could not locate the stack folder on the host (no DATA_DIR/mount translation and no working_dir label)' };
|
|
}
|