mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user