mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Storage Inventory: renders a stack's effective Compose model, probes each
|
||||
* within-stack bind source, and classifies the stack's storage portability.
|
||||
* Advisory and read-only; it never mutates a path, reads mount content, or
|
||||
* returns raw docker stderr or any environment value. Node-scoped: it runs on
|
||||
* whichever node owns the stack (the route auto-proxies there).
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { ComposeService } from '../ComposeService';
|
||||
import { FileSystemService } from '../FileSystemService';
|
||||
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
|
||||
import { parseMissingRequiredVars } from '../../helpers/envVarParse';
|
||||
import { probeHostPath } from './probeHostPath';
|
||||
import { isDockerSocketMount } from './types';
|
||||
import type { HostPathProbe, PortabilityVerdict, StorageInventory, StorageMount } from './types';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog';
|
||||
|
||||
const MAX_RENDER_ERROR = 600;
|
||||
|
||||
const UNRENDERABLE_REASON =
|
||||
'Sencho could not render the effective Compose model, so storage portability cannot be determined.';
|
||||
|
||||
/** Flatten the model into per-service mounts, attaching each bind's probe and external-named status. */
|
||||
export function buildMounts(model: EffectiveModel, probes: Map<string, HostPathProbe>): StorageMount[] {
|
||||
const mounts: StorageMount[] = [];
|
||||
for (const svc of model.services) {
|
||||
for (const m of svc.storageMounts ?? []) {
|
||||
const probe = m.type === 'bind' && m.source ? (probes.get(m.source) ?? null) : null;
|
||||
const externalNamed = m.type === 'named' && m.source ? (model.volumes[m.source]?.external ?? false) : false;
|
||||
mounts.push({ ...m, service: svc.name, probe, externalNamed });
|
||||
}
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
||||
/** A bind is node-bound when its source resolves outside the stack dir, escapes via a symlink, or is unverified. */
|
||||
function isExternalBind(m: StorageMount): boolean {
|
||||
if (m.type !== 'bind') return false;
|
||||
if (!m.probe) return true;
|
||||
return m.probe.escapes || !m.probe.withinStackDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stack carries node-local state when it has any data-bearing mount: a bind
|
||||
* (other than the Docker socket, which holds no application data), or a named or
|
||||
* anonymous volume. tmpfs is ephemeral and never counts.
|
||||
*/
|
||||
function isStateful(mounts: StorageMount[]): boolean {
|
||||
return mounts.some(m =>
|
||||
(m.type === 'bind' && !isDockerSocketMount(m)) || m.type === 'named' || m.type === 'anonymous');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic portability verdict. Status is the single highest verdict
|
||||
* (node-bound > partially-portable > portable > unknown); `reasons` accumulates
|
||||
* every contributing factor so the UI can show the full picture.
|
||||
*/
|
||||
export function classifyPortability(mounts: StorageMount[], renderable: boolean): PortabilityVerdict {
|
||||
if (!renderable) return { status: 'unknown', reasons: [UNRENDERABLE_REASON] };
|
||||
|
||||
const reasons: string[] = [];
|
||||
const socketMounts = mounts.filter(isDockerSocketMount);
|
||||
// A socket bind is node-bound, but it is reported via its own reason, so it is
|
||||
// excluded here to avoid a duplicate reason for the one mount.
|
||||
const externalBinds = mounts.filter(m => isExternalBind(m) && !isDockerSocketMount(m));
|
||||
const withinStackBinds = mounts.filter(m => m.type === 'bind' && !isExternalBind(m));
|
||||
const dataVolumes = mounts.filter(m => m.type === 'named' || m.type === 'anonymous');
|
||||
|
||||
for (const s of socketMounts) {
|
||||
reasons.push(`Service "${s.service}" mounts the Docker socket, tying the stack to this host's Docker engine.`);
|
||||
}
|
||||
for (const b of externalBinds) {
|
||||
reasons.push(b.probe?.escapes
|
||||
? `"${b.source}" in service "${b.service}" is a symlink to a path outside the stack directory.`
|
||||
: `Service "${b.service}" binds "${b.source}", a host path outside the stack directory that must exist on every node you move this stack to.`);
|
||||
}
|
||||
|
||||
if (socketMounts.length > 0 || externalBinds.length > 0) {
|
||||
return { status: 'node-bound', reasons };
|
||||
}
|
||||
|
||||
if (dataVolumes.length > 0) {
|
||||
for (const v of dataVolumes) {
|
||||
const which = v.type === 'anonymous' ? 'an anonymous volume' : `named volume "${v.source}"`;
|
||||
reasons.push(v.externalNamed
|
||||
? `Service "${v.service}" uses ${which}, which expects a pre-existing volume on the node; its data does not move with the files.`
|
||||
: `Service "${v.service}" uses ${which}; its data lives on this node and is not carried by moving the files.`);
|
||||
}
|
||||
if (withinStackBinds.length > 0) {
|
||||
reasons.push('Bind mounts inside the stack directory move with the stack files.');
|
||||
}
|
||||
return { status: 'partially-portable', reasons };
|
||||
}
|
||||
|
||||
reasons.push(withinStackBinds.length > 0
|
||||
? 'All mounts are bind paths inside the stack directory, so they move with the stack files.'
|
||||
: 'This stack declares no persistent storage, so nothing is tied to this node.');
|
||||
return { status: 'portable', reasons };
|
||||
}
|
||||
|
||||
/** Pure assembler: turns a rendered model + probe map into the inventory payload. */
|
||||
export function assembleStorageInventory(
|
||||
stackName: string,
|
||||
model: EffectiveModel | null,
|
||||
renderError: string | null,
|
||||
probes: Map<string, HostPathProbe>,
|
||||
): StorageInventory {
|
||||
if (!model) {
|
||||
return {
|
||||
stack: stackName, renderable: false, renderError, stateful: false, mounts: [],
|
||||
portability: classifyPortability([], false),
|
||||
};
|
||||
}
|
||||
const mounts = buildMounts(model, probes);
|
||||
return {
|
||||
stack: stackName,
|
||||
renderable: true,
|
||||
renderError: null,
|
||||
stateful: isStateful(mounts),
|
||||
mounts,
|
||||
portability: classifyPortability(mounts, true),
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe each unique bind source once. */
|
||||
async function probeBindSources(model: EffectiveModel, stackDir: string): Promise<Map<string, HostPathProbe>> {
|
||||
const sources = new Set<string>();
|
||||
for (const svc of model.services) {
|
||||
for (const m of svc.storageMounts ?? []) {
|
||||
if (m.type === 'bind' && m.source) sources.add(m.source);
|
||||
}
|
||||
}
|
||||
const probes = new Map<string, HostPathProbe>();
|
||||
for (const source of sources) probes.set(source, await probeHostPath(source, stackDir));
|
||||
return probes;
|
||||
}
|
||||
|
||||
/** Render the model on the owning node, probe its binds, and assemble the inventory. */
|
||||
export async function buildStorageInventory(nodeId: number, stackName: string): Promise<StorageInventory> {
|
||||
const stackDir = path.join(FileSystemService.getInstance(nodeId).getBaseDir(), stackName);
|
||||
|
||||
let model: EffectiveModel | null = null;
|
||||
let renderError: string | null = null;
|
||||
try {
|
||||
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (result.rendered !== null) {
|
||||
try {
|
||||
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
} catch (parseErr) {
|
||||
// JSON.parse errors carry no file content, so the message is safe to log.
|
||||
console.warn('[StorageInventory] Effective model parse failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
|
||||
renderError = 'Sencho could not parse the rendered Compose model.';
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
// A timeout is a Sencho-generated condition, not a Compose error; name it so
|
||||
// the operator does not hunt for a syntax error that is not there.
|
||||
renderError = 'Rendering the effective Compose model timed out on this node.';
|
||||
} else {
|
||||
// Raw stderr can echo file content/secrets and is never surfaced; only the
|
||||
// names of any missing required variables, otherwise a generic nudge.
|
||||
const missing = parseMissingRequiredVars(result.stderr);
|
||||
renderError = missing.length
|
||||
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
|
||||
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.';
|
||||
}
|
||||
} catch (err) {
|
||||
// Spawn failure (docker unavailable). Redact defensively.
|
||||
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|
||||
|| 'Sencho could not run docker compose on this node.';
|
||||
}
|
||||
|
||||
const probes = model ? await probeBindSources(model, stackDir) : new Map<string, HostPathProbe>();
|
||||
return assembleStorageInventory(stackName, model, renderError, probes);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { EffStorageMount } from '../preflight/effectiveModel';
|
||||
|
||||
/** Resolved type of a host path behind a bind mount. */
|
||||
export type HostPathKind = 'file' | 'directory' | 'socket' | 'symlink' | 'missing' | 'unknown';
|
||||
|
||||
/**
|
||||
* Existence/type/ownership of a bind-mount host source. Resolved ONLY for
|
||||
* sources lexically inside the stack's own directory; absolute external paths
|
||||
* are outside Sencho's filesystem view and are left unverified. Never reads the
|
||||
* path's content.
|
||||
*/
|
||||
export interface HostPathProbe {
|
||||
/** True when the source path lexically resolves inside the stack's own directory. */
|
||||
lexicalWithinStackDir: boolean;
|
||||
/** True when the resolved (symlink-followed) target still sits inside the stack dir. */
|
||||
withinStackDir: boolean;
|
||||
/** Existence is only probed for within-stack-dir sources; external paths stay false (unverifiable). */
|
||||
exists: boolean;
|
||||
kind: HostPathKind;
|
||||
/** True when a within-stack symlink points (resolved, or for a broken link its readlink target) outside the stack dir. */
|
||||
escapes: boolean;
|
||||
/** POSIX owner uid/gid and octal mode when statted; null on Windows or for unverified paths. */
|
||||
uid: number | null;
|
||||
gid: number | null;
|
||||
mode: string | null;
|
||||
}
|
||||
|
||||
/** One mount in the storage inventory: the parsed mount, its service, and (binds only) the host-path probe. */
|
||||
export interface StorageMount extends EffStorageMount {
|
||||
service: string;
|
||||
/** Probe for bind sources; null for named/anonymous/tmpfs mounts. */
|
||||
probe: HostPathProbe | null;
|
||||
/** True when this named mount references a top-level `external: true` volume. */
|
||||
externalNamed: boolean;
|
||||
}
|
||||
|
||||
export type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown';
|
||||
|
||||
export interface PortabilityVerdict {
|
||||
status: PortabilityStatus;
|
||||
/** Every reason that applies, so the UI can list all contributing factors. */
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/** Per-stack storage inventory returned by GET /api/stacks/:stackName/storage. */
|
||||
export interface StorageInventory {
|
||||
stack: string;
|
||||
renderable: boolean;
|
||||
/** Redacted, structural render error when `renderable` is false; never raw docker stderr. */
|
||||
renderError: string | null;
|
||||
/** True when the stack has any bind/named/anonymous mount (tmpfs-only and no-mounts are stateless). */
|
||||
stateful: boolean;
|
||||
mounts: StorageMount[];
|
||||
portability: PortabilityVerdict;
|
||||
}
|
||||
|
||||
/** A bind/target referencing the Docker socket grants root-equivalent host control and is node-bound. */
|
||||
export function isDockerSocketMount(m: Pick<EffStorageMount, 'source' | 'target'>): boolean {
|
||||
return (m.source?.includes('docker.sock') ?? false) || m.target.includes('docker.sock');
|
||||
}
|
||||
Reference in New Issue
Block a user