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:
Anso
2026-06-20 15:06:26 -04:00
committed by GitHub
parent 57a0856ffc
commit 9ea2864d60
26 changed files with 1591 additions and 12 deletions
+24
View File
@@ -470,6 +470,29 @@ const newVolume: PreflightRule = {
},
};
const anonymousVolume: PreflightRule = {
id: 'anonymous-volume',
run(ctx) {
if (!ctx.model) return [];
const findings: PreflightFinding[] = [];
for (const svc of ctx.model.services) {
const anon = (svc.storageMounts ?? []).filter(m => m.type === 'anonymous');
if (anon.length === 0) continue;
const targets = anon.map(m => m.target).filter(Boolean);
findings.push({
ruleId: 'anonymous-volume',
severity: 'info',
title: 'Anonymous volume in use',
message: `Service "${svc.name}" mounts ${anon.length > 1 ? `${anon.length} anonymous volumes` : 'an anonymous volume'}${targets.length ? ` at ${targets.join(', ')}` : ''}. Anonymous volumes have no name, so they are easy to miss when backing up and are orphaned when the container is recreated.`,
sourcePath: svc.name,
service: svc.name,
remediation: 'Give the volume a name so it can be referenced, backed up, and reattached.',
});
}
return findings;
},
};
const containerNameInternalDup: PreflightRule = {
id: 'container-name-internal-dup',
run(ctx) {
@@ -704,6 +727,7 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
externalVolumeMissing,
newNetwork,
newVolume,
anonymousVolume,
containerNameInternalDup,
containerNameCollision,
exposureInternalPublished,