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
@@ -13,6 +13,7 @@ import {
} from '@/lib/dossierMarkdown';
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
import { buildNetworkExposureSummary, type NetworkExposureSummary } from '@/lib/networkExposureSummary';
import { buildStorageSummary, type StorageSummary } from '@/lib/storageSummary';
import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift';
import { RollbackReadinessSection } from './RollbackReadinessSection';
import { useNodes } from '@/context/NodeContext';
@@ -152,8 +153,9 @@ function DocDriftWarnings({ findings }: { findings: DocDriftFinding[] }) {
}
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
const { activeNode } = useNodes();
const { activeNode, hasCapability } = useNodes();
const nodeId = activeNode?.id;
const storageEnabled = hasCapability('compose-storage');
// Identifies the dossier currently in view. Doc-drift renders only once the
// load for *this* key has succeeded (see loadedKey), so a switch-in-flight or
// a failed load never diffs new anatomy against the prior stack's fields.
@@ -219,6 +221,19 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
}
};
// Fetched only on export, and only when the node advertises the capability.
// Fail-soft: an unavailable inventory simply omits the storage section.
const loadStorageSummary = async (): Promise<StorageSummary | null> => {
if (!storageEnabled) return null;
try {
const res = await apiFetch(`/stacks/${stackName}/storage`);
return res.ok ? buildStorageSummary(await res.json()) : null;
} catch (err) {
console.warn(`[Dossier] storage summary load failed for "${stackName}":`, err);
return null;
}
};
const dirty = useMemo(
() => FIELD_KEYS.some(k => fields[k] !== serverFields[k]),
[fields, serverFields],
@@ -265,8 +280,8 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
const handleCopy = async () => {
if (!anatomy) return;
try {
const networking = await loadNetworkingSummary();
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields, networking));
const [networking, storage] = await Promise.all([loadNetworkingSummary(), loadStorageSummary()]);
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields, networking, storage));
toast.success('Stack dossier copied as Markdown.');
} catch {
toast.error('Failed to copy to clipboard.');
@@ -276,11 +291,11 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
const handleDownload = async () => {
if (!anatomy) return;
try {
const networking = await loadNetworkingSummary();
const [networking, storage] = await Promise.all([loadNetworkingSummary(), loadStorageSummary()]);
// Stack names are already constrained, but sanitize defensively so the
// file always has a coherent, safe name ending in .md.
const base = stackName.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'stack';
downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields, networking));
downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields, networking, storage));
} catch {
toast.error('Failed to download the dossier.');
}