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
+1
View File
@@ -27,6 +27,7 @@ export const CAPABILITIES = [
'update-guard',
'compose-networking',
'env-inventory',
'compose-storage',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
+3
View File
@@ -12,6 +12,7 @@
import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown';
import { networkExposureSection, type NetworkExposureSummary } from './networkExposureSummary';
import { storageSection, type StorageSummary } from './storageSummary';
/**
* Operator-authored dossier fields. Mirrors the backend `StackDossierFields`
@@ -86,10 +87,12 @@ export function buildStackDossierMarkdown(
anatomy: AnatomyMarkdownInput,
dossier: StackDossierFields,
networking?: NetworkExposureSummary | null,
storage?: StorageSummary | null,
): string {
const sections = [
buildStackAnatomyMarkdown(anatomy),
networkExposureSection(networking ?? null),
storageSection(storage ?? null),
operatorNotesSection(dossier),
].filter((s): s is string => s !== null && s !== '');
return sections.join('\n\n');
+50
View File
@@ -0,0 +1,50 @@
/**
* buildStorageSummary + storageSection: the pure dossier-export helpers. They
* omit unrenderable or mount-less stacks, coerce an unknown status, and render a
* Markdown section that always carries the config-vs-data snapshot caveat.
*/
import { describe, it, expect } from 'vitest';
import { buildStorageSummary, storageSection } from './storageSummary';
describe('buildStorageSummary', () => {
it('returns null when the model is unrenderable or there are no mounts', () => {
expect(buildStorageSummary(null)).toBeNull();
expect(buildStorageSummary({ renderable: false })).toBeNull();
expect(buildStorageSummary({ renderable: true, mounts: [] })).toBeNull();
});
it('keeps only well-formed mounts and coerces an unknown status', () => {
const summary = buildStorageSummary({
renderable: true,
stateful: true,
portability: { status: 'not-a-status', reasons: ['r'] },
mounts: [
{ service: 'web', type: 'bind', source: '/srv', target: '/data', readOnly: true },
{ service: 'web', type: 'nonsense', target: '/x' }, // dropped: bad type
{ type: 'named', target: '/y' }, // dropped: no service
],
});
expect(summary).not.toBeNull();
expect(summary!.status).toBe('unknown');
expect(summary!.mounts).toEqual([{ service: 'web', type: 'bind', source: '/srv', target: '/data', readOnly: true }]);
});
});
describe('storageSection', () => {
it('returns null for a null summary', () => {
expect(storageSection(null)).toBeNull();
});
it('renders the status, mounts, and the config-vs-data caveat', () => {
const md = storageSection({
status: 'node-bound',
reasons: ['Binds /mnt/media outside the stack directory.'],
stateful: true,
mounts: [{ service: 'web', type: 'bind', source: '/mnt/media', target: '/media', readOnly: false }],
});
expect(md).toContain('## Storage portability');
expect(md).toContain('**Status:** Node-bound');
expect(md).toContain('/mnt/media → /media');
expect(md).toContain('Snapshots capture Compose and env files, not the data');
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Storage portability summary for the Stack Dossier export, derived from the
* /storage inventory. It carries only mount structure (type, source, target,
* read-only) and the portability verdict; never a mount's content, so nothing
* sensitive reaches the exported text. Pure and side-effect free.
*/
export type StoragePortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown';
export interface StorageSummaryMount {
service: string;
type: 'bind' | 'named' | 'anonymous' | 'tmpfs';
source?: string;
target: string;
readOnly: boolean;
}
export interface StorageSummary {
status: StoragePortabilityStatus;
reasons: string[];
stateful: boolean;
mounts: StorageSummaryMount[];
}
// Loose input shape: the builder reads the raw parsed /storage JSON, so it stays
// decoupled from the panel's local interfaces.
interface InventoryMountInput { service?: string; type?: string; source?: string; target?: string; readOnly?: boolean }
export interface StorageInventoryInput {
renderable?: boolean;
stateful?: boolean;
mounts?: InventoryMountInput[];
portability?: { status?: string; reasons?: string[] };
}
const STATUS_LABEL: Record<StoragePortabilityStatus, string> = {
'portable': 'Portable',
'partially-portable': 'Partially portable',
'node-bound': 'Node-bound',
'unknown': 'Unknown',
};
const MOUNT_TYPES = new Set(['bind', 'named', 'anonymous', 'tmpfs']);
const STATUSES = new Set<StoragePortabilityStatus>(['portable', 'partially-portable', 'node-bound', 'unknown']);
/** Assemble the summary, or null when there is no mount worth documenting. */
export function buildStorageSummary(inv: StorageInventoryInput | null): StorageSummary | null {
if (!inv || inv.renderable === false) return null;
const mounts: StorageSummaryMount[] = (inv.mounts ?? [])
.filter((m): m is Required<Pick<InventoryMountInput, 'service' | 'type' | 'target'>> & InventoryMountInput =>
typeof m.service === 'string' && typeof m.target === 'string' && MOUNT_TYPES.has(m.type ?? ''))
.map(m => ({ service: m.service, type: m.type as StorageSummaryMount['type'], source: m.source, target: m.target, readOnly: m.readOnly === true }));
if (mounts.length === 0) return null;
const rawStatus = inv.portability?.status;
const status: StoragePortabilityStatus = STATUSES.has(rawStatus as StoragePortabilityStatus) ? rawStatus as StoragePortabilityStatus : 'unknown';
return { status, reasons: inv.portability?.reasons ?? [], stateful: inv.stateful === true, mounts };
}
/** Render the summary as a Markdown section, or null when there is nothing to show. */
export function storageSection(summary: StorageSummary | null): string | null {
if (!summary) return null;
const parts = [`## Storage portability`, `- **Status:** ${STATUS_LABEL[summary.status]}`];
if (summary.reasons.length > 0) parts.push(summary.reasons.map(r => `- ${r}`).join('\n'));
parts.push('### Mounts', summary.mounts.map(m => {
const src = m.source ? `${m.source}` : '';
const ro = m.readOnly ? ' (read-only)' : '';
return `- **${m.service}** · ${m.type}: ${src}${m.target}${ro}`;
}).join('\n'));
parts.push('> Snapshots capture Compose and env files, not the data inside named volumes or bind mounts. Back up volume data separately before moving or restoring.');
return parts.join('\n\n');
}