Files
sencho/backend/src/services/storage/probeHostPath.ts
T
Anso 9ea2864d60 feat: per-stack storage inventory and portability guardrails (#1399)
* feat: per-stack storage inventory and portability guardrails

Add a Storage tab to the stack Anatomy panel that derives a per-stack mount
inventory (bind mounts, named/anonymous volumes, tmpfs, docker socket;
read-only vs read-write; host-path existence, type, and owner) from the
effective Compose model, and classifies the stack as Portable, Partially
portable, Node-bound, or Unknown with the reasons behind it.

- New GET /api/stacks/:stackName/storage route (stack:read, Community), served
  by an on-demand, non-persisted service that renders the effective model,
  probes within-stack bind sources (symlink-escape aware), and runs the
  deterministic portability classifier.
- Extend the effective-model parser additively with a full per-mount inventory
  and service-level tmpfs, leaving the rule-facing binds/namedVolumes
  byte-identical for the existing preflight rules.
- New anonymous-volume preflight finding.
- Admin-visible "no recent snapshot" warning that reuses the existing hub-local
  snapshot-coverage endpoint, plus a static note distinguishing config
  snapshots from application-data backups.
- Surface storage assumptions in the Stack Dossier markdown export.
- Gate the tab behind a new compose-storage capability on both sides.

* docs: phrase the Storage tab availability as current behavior

Replace the "older Sencho version / until it is updated" wording in the
Storage feature page with present-tense, capability-based phrasing.
2026-06-20 15:06:26 -04:00

85 lines
3.0 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { isPathWithinBase } from '../../utils/validation';
import type { HostPathKind, HostPathProbe } from './types';
function kindOf(st: fs.Stats): HostPathKind {
if (st.isSymbolicLink()) return 'symlink';
if (st.isDirectory()) return 'directory';
if (st.isFile()) return 'file';
if (st.isSocket()) return 'socket';
return 'unknown';
}
const UNVERIFIED: HostPathProbe = {
lexicalWithinStackDir: false, withinStackDir: false, exists: false,
kind: 'unknown', escapes: false, uid: null, gid: null, mode: null,
};
/**
* Probe a bind-mount host source for the storage inventory. Existence, type, and
* ownership are resolved ONLY for sources lexically inside the stack's own
* directory (relative binds); absolute external host paths are outside Sencho's
* filesystem view and are left unverified. A within-stack symlink whose target
* escapes the stack dir, resolved or (when the link is broken) via its readlink
* target, is flagged `escapes` so the classifier treats it as node-bound. Never
* reads the path's content.
*/
export async function probeHostPath(source: string, stackDir: string): Promise<HostPathProbe> {
const resolvedStackDir = path.resolve(stackDir);
const resolvedSource = path.resolve(source);
if (!isPathWithinBase(resolvedSource, resolvedStackDir)) {
return { ...UNVERIFIED };
}
let lst: fs.Stats;
try {
lst = await fs.promises.lstat(resolvedSource);
} catch {
return {
lexicalWithinStackDir: true, withinStackDir: true, exists: false,
kind: 'missing', escapes: false, uid: null, gid: null, mode: null,
};
}
const kind = kindOf(lst);
let withinStackDir = true;
let escapes = false;
if (kind === 'symlink') {
const target = await resolveSymlinkTarget(resolvedSource);
if (target !== null) {
withinStackDir = isPathWithinBase(target, resolvedStackDir);
escapes = !withinStackDir;
}
// An unreadable link is left as within-stack (conservative; nothing proves an escape).
}
const posix = process.platform !== 'win32';
const uid = posix && typeof lst.uid === 'number' ? lst.uid : null;
const gid = posix && typeof lst.gid === 'number' ? lst.gid : null;
const mode = posix && typeof lst.mode === 'number' ? (lst.mode & 0o777).toString(8).padStart(3, '0') : null;
return { lexicalWithinStackDir: true, withinStackDir, exists: true, kind, escapes, uid, gid, mode };
}
/**
* Resolve a symlink's absolute target. Prefers `realpath` (follows the whole
* chain); on a broken link falls back to `readlink` and resolves its target
* lexically so an escape is still detectable. Returns null when neither works.
*/
async function resolveSymlinkTarget(linkPath: string): Promise<string | null> {
try {
return await fs.promises.realpath(linkPath);
} catch {
try {
const link = await fs.promises.readlink(linkPath);
return path.isAbsolute(link) ? path.resolve(link) : path.resolve(path.dirname(linkPath), link);
} catch {
return null;
}
}
}