mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 13:18:08 +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,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');
|
||||
}
|
||||
Reference in New Issue
Block a user