mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
9ea2864d60
* 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.
85 lines
3.0 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|