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
+10 -2
View File
@@ -12,7 +12,7 @@ import type { PreflightContext, PreflightFinding } from '../services/preflight/t
function svc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [], ...over,
};
@@ -210,6 +210,14 @@ describe('network / volume rules', () => {
expect(ids(f, 'new-network')[0].severity).toBe('info');
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
});
it('flags an anonymous volume as info and stays silent without one', () => {
const anon = model([svc({ storageMounts: [{ type: 'anonymous', target: '/data', readOnly: false }] })]);
const f = runRules(ctx({ model: anon }));
expect(ids(f, 'anonymous-volume')[0].severity).toBe('info');
expect(ids(f, 'anonymous-volume')[0].message).toContain('/data');
const named = model([svc({ storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }] })]);
expect(ids(runRules(ctx({ model: named })), 'anonymous-volume')).toHaveLength(0);
});
});
describe('container_name rules', () => {
@@ -341,7 +349,7 @@ describe('rule registry completeness', () => {
'render-failed', 'env-unset', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', 'anonymous-volume',
'container-name-internal-dup', 'container-name-collision',
'exposure-internal-published', 'sensitive-service-broad-exposure', 'exposure-unclassified',
'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded',