diff --git a/backend/src/__tests__/compose-network-inspector.test.ts b/backend/src/__tests__/compose-network-inspector.test.ts index 32802070..aa5a37b6 100644 --- a/backend/src/__tests__/compose-network-inspector.test.ts +++ b/backend/src/__tests__/compose-network-inspector.test.ts @@ -15,7 +15,7 @@ import { assembleStackNetworkFacts } from '../services/network/composeNetworkIns function effSvc(over: Partial = {}): 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, }; diff --git a/backend/src/__tests__/preflight-effective-model.test.ts b/backend/src/__tests__/preflight-effective-model.test.ts index 772cf724..a0792c42 100644 --- a/backend/src/__tests__/preflight-effective-model.test.ts +++ b/backend/src/__tests__/preflight-effective-model.test.ts @@ -159,3 +159,77 @@ describe('parseEffectiveModel', () => { expect(empty.networks).toEqual({}); }); }); + +describe('parseStorageMounts (storage inventory field)', () => { + it('captures every long-form mount type with the read-only flag', () => { + const m = parseEffectiveModel({ + services: { + app: { + volumes: [ + { type: 'bind', source: '/srv/data', target: '/data', read_only: true }, + { type: 'volume', source: 'cache', target: '/var/cache' }, + { type: 'volume', target: '/anon' }, // anonymous: a volume with no source + { type: 'tmpfs', target: '/run' }, + ], + }, + }, + }, 'p'); + expect(m.services[0].storageMounts).toEqual([ + { type: 'bind', source: '/srv/data', target: '/data', readOnly: true }, + { type: 'named', source: 'cache', target: '/var/cache', readOnly: false }, + { type: 'anonymous', target: '/anon', readOnly: false }, + { type: 'tmpfs', target: '/run', readOnly: false }, + ]); + }); + + it('captures the service-level tmpfs field in string and array form', () => { + const single = parseEffectiveModel({ services: { s: { tmpfs: '/tmp' } } }, 'p'); + expect(single.services[0].storageMounts).toEqual([{ type: 'tmpfs', target: '/tmp', readOnly: false }]); + const many = parseEffectiveModel({ services: { s: { tmpfs: ['/tmp', '/run'] } } }, 'p'); + expect(many.services[0].storageMounts).toEqual([ + { type: 'tmpfs', target: '/tmp', readOnly: false }, + { type: 'tmpfs', target: '/run', readOnly: false }, + ]); + }); + + it('parses short-form binds, named volumes, and read-only / comma options', () => { + const m = parseEffectiveModel({ + services: { s: { volumes: ['/host:/data:ro', './rel:/rel', 'vol:/v:rw,Z'] } }, + }, 'p'); + expect(m.services[0].storageMounts).toEqual([ + { type: 'bind', source: '/host', target: '/data', readOnly: true }, + { type: 'bind', source: './rel', target: '/rel', readOnly: false }, + { type: 'named', source: 'vol', target: '/v', readOnly: false }, + ]); + }); + + it('treats a single-token short volume as an anonymous mount at that container path', () => { + const m = parseEffectiveModel({ services: { s: { volumes: ['/data'] } } }, 'p'); + expect(m.services[0].storageMounts).toEqual([{ type: 'anonymous', target: '/data', readOnly: false }]); + }); + + it('drops an unparseable single-token short volume rather than inventing a mount', () => { + const m = parseEffectiveModel({ services: { s: { volumes: ['notapath'] } } }, 'p'); + expect(m.services[0].storageMounts).toEqual([]); + }); + + it('splits a Windows-drive short-form source without the drive colon breaking it', () => { + const m = parseEffectiveModel({ services: { s: { volumes: ['C:\\data:/data:ro'] } } }, 'p'); + expect(m.services[0].storageMounts).toEqual([{ type: 'bind', source: 'C:\\data', target: '/data', readOnly: true }]); + }); + + it('records a named volume by its compose key even when the top-level name differs', () => { + const m = parseEffectiveModel({ + services: { s: { volumes: [{ type: 'volume', source: 'cache', target: '/c' }] } }, + volumes: { cache: { name: 'myapp_cache' } }, + }, 'p'); + expect(m.services[0].storageMounts).toEqual([{ type: 'named', source: 'cache', target: '/c', readOnly: false }]); + expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false }); + }); + + it('leaves the rule-facing binds and namedVolumes byte-identical', () => { + const m = parseEffectiveModel(render(), 'fallback'); + expect(m.services[0].binds).toEqual([{ source: '/srv/data', target: '/data' }]); + expect(m.services[0].namedVolumes).toEqual(['cache']); + }); +}); diff --git a/backend/src/__tests__/preflight-rules.test.ts b/backend/src/__tests__/preflight-rules.test.ts index 89b34201..23918782 100644 --- a/backend/src/__tests__/preflight-rules.test.ts +++ b/backend/src/__tests__/preflight-rules.test.ts @@ -12,7 +12,7 @@ import type { PreflightContext, PreflightFinding } from '../services/preflight/t function svc(over: Partial = {}): 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', diff --git a/backend/src/__tests__/storage-inventory.test.ts b/backend/src/__tests__/storage-inventory.test.ts new file mode 100644 index 00000000..7b5ea118 --- /dev/null +++ b/backend/src/__tests__/storage-inventory.test.ts @@ -0,0 +1,186 @@ +/** + * Storage inventory core: the pure mount builder, the portability classifier + * (every verdict and its edge cases), and the assembler's renderable/stateful + * handling. These are pure functions over fixtures, so no filesystem or docker + * is touched here (the live probe is covered by storage-probe-host-path.test.ts). + */ +import { describe, it, expect } from 'vitest'; +import { buildMounts, classifyPortability, assembleStorageInventory } from '../services/storage/inventory'; +import type { HostPathProbe, StorageMount } from '../services/storage/types'; +import type { EffectiveModel } from '../services/preflight/effectiveModel'; + +function probe(over: Partial = {}): HostPathProbe { + return { + lexicalWithinStackDir: true, withinStackDir: true, exists: true, kind: 'directory', + escapes: false, uid: null, gid: null, mode: null, ...over, + }; +} + +/** A bind whose source resolves inside the stack directory. */ +function withinBind(over: Partial = {}): StorageMount { + return { type: 'bind', source: '/app/stack/data', target: '/data', readOnly: false, service: 'app', probe: probe(), externalNamed: false, ...over }; +} +/** A bind whose source is an external absolute host path (unprobed view). */ +function externalBind(over: Partial = {}): StorageMount { + return { + type: 'bind', source: '/mnt/media', target: '/media', readOnly: false, service: 'app', + probe: probe({ lexicalWithinStackDir: false, withinStackDir: false, exists: false, kind: 'unknown' }), + externalNamed: false, ...over, + }; +} +function socketBind(over: Partial = {}): StorageMount { + return { + type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false, service: 'app', + probe: probe({ lexicalWithinStackDir: false, withinStackDir: false, exists: false, kind: 'unknown' }), + externalNamed: false, ...over, + }; +} +function namedVol(over: Partial = {}): StorageMount { + return { type: 'named', source: 'db', target: '/db', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over }; +} +function anonVol(over: Partial = {}): StorageMount { + return { type: 'anonymous', target: '/anon', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over }; +} +function tmpfsMount(over: Partial = {}): StorageMount { + return { type: 'tmpfs', target: '/run', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over }; +} + +const verdict = (mounts: StorageMount[]) => classifyPortability(mounts, true); +const status = (mounts: StorageMount[]) => verdict(mounts).status; + +describe('classifyPortability', () => { + it('is node-bound when the Docker socket is mounted', () => { + const v = verdict([socketBind()]); + expect(v.status).toBe('node-bound'); + expect(v.reasons.some(r => r.includes('Docker socket'))).toBe(true); + }); + + it('is node-bound for an external bind, read-only or not', () => { + expect(status([externalBind()])).toBe('node-bound'); + expect(status([externalBind({ readOnly: true })])).toBe('node-bound'); + expect(verdict([externalBind()]).reasons.some(r => r.includes('outside the stack directory'))).toBe(true); + }); + + it('detects a Docker socket mounted by target alone and reports it once', () => { + const v = verdict([socketBind({ source: '/host/custom', target: '/var/run/docker.sock' })]); + expect(v.status).toBe('node-bound'); + expect(v.reasons.filter(r => r.includes('Docker socket'))).toHaveLength(1); + expect(v.reasons.some(r => r.includes('outside the stack directory'))).toBe(false); + }); + + it('accumulates every node-bound reason (socket and external bind together)', () => { + const v = verdict([socketBind(), externalBind()]); + expect(v.status).toBe('node-bound'); + expect(v.reasons.some(r => r.includes('Docker socket'))).toBe(true); + expect(v.reasons.some(r => r.includes('outside the stack directory'))).toBe(true); + }); + + it('is node-bound for a within-stack symlink that resolves outside the stack dir', () => { + const escaping = withinBind({ probe: probe({ kind: 'symlink', escapes: true, withinStackDir: false }) }); + const v = verdict([escaping]); + expect(v.status).toBe('node-bound'); + expect(v.reasons.some(r => r.includes('symlink'))).toBe(true); + }); + + it('treats a broken symlink that escapes as node-bound, but one that stays inside as portable', () => { + const brokenEscape = withinBind({ probe: probe({ kind: 'symlink', exists: true, escapes: true, withinStackDir: false }) }); + expect(status([brokenEscape])).toBe('node-bound'); + const brokenInside = withinBind({ probe: probe({ kind: 'symlink', exists: true, escapes: false, withinStackDir: true }) }); + expect(status([brokenInside])).toBe('portable'); + }); + + it('is portable for within-stack binds only, including a bind that is the stack dir itself', () => { + expect(status([withinBind()])).toBe('portable'); + expect(status([withinBind({ source: '/app/stack', target: '/app' })])).toBe('portable'); + }); + + it('is partially portable for named or anonymous volumes', () => { + expect(status([namedVol()])).toBe('partially-portable'); + expect(status([anonVol()])).toBe('partially-portable'); + }); + + it('adds a distinct reason for an external named volume but stays partially portable', () => { + const v = verdict([namedVol({ externalNamed: true })]); + expect(v.status).toBe('partially-portable'); + expect(v.reasons.some(r => r.includes('pre-existing'))).toBe(true); + }); + + it('is portable for tmpfs-only and for no mounts at all', () => { + expect(status([tmpfsMount()])).toBe('portable'); + expect(status([])).toBe('portable'); + }); + + it('is partially portable for a mix of within-stack bind and named volume', () => { + expect(status([withinBind(), namedVol()])).toBe('partially-portable'); + }); + + it('is unknown when the model is unrenderable', () => { + const v = classifyPortability([], false); + expect(v.status).toBe('unknown'); + expect(v.reasons[0]).toContain('could not render'); + }); +}); + +describe('buildMounts', () => { + function model(): EffectiveModel { + return { + projectName: 'app', + services: [ + { + name: 'web', image: 'nginx', ports: [], binds: [], namedVolumes: [], + storageMounts: [ + { type: 'bind', source: '/app/stack/conf', target: '/conf', readOnly: true }, + { type: 'named', source: 'shared', target: '/s', readOnly: false }, + ], + privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [], + }, + ], + networks: {}, + volumes: { shared: { name: 'shared_vol', external: true, internal: false } }, + }; + } + + it('flattens mounts with their service, attaches bind probes, and marks external named volumes', () => { + const probes = new Map([['/app/stack/conf', probe({ kind: 'directory' })]]); + const mounts = buildMounts(model(), probes); + expect(mounts).toHaveLength(2); + expect(mounts[0]).toMatchObject({ service: 'web', type: 'bind', probe: { kind: 'directory' } }); + expect(mounts[1]).toMatchObject({ service: 'web', type: 'named', probe: null, externalNamed: true }); + }); +}); + +describe('assembleStorageInventory', () => { + it('returns an unrenderable, stateless, unknown inventory when the model is null', () => { + const inv = assembleStorageInventory('web', null, 'boom', new Map()); + expect(inv).toMatchObject({ renderable: false, renderError: 'boom', stateful: false, mounts: [] }); + expect(inv.portability.status).toBe('unknown'); + }); + + it('marks a stack with persistent storage stateful and a tmpfs-only stack stateless', () => { + const stateful: EffectiveModel = { + projectName: 'a', services: [{ + name: 'app', ports: [], binds: [], namedVolumes: [], + storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }], + privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [], + }], networks: {}, volumes: {}, + }; + expect(assembleStorageInventory('a', stateful, null, new Map()).stateful).toBe(true); + + const ephemeral: EffectiveModel = { + ...stateful, + services: [{ ...stateful.services[0], storageMounts: [{ type: 'tmpfs', target: '/run', readOnly: false }] }], + }; + expect(assembleStorageInventory('a', ephemeral, null, new Map()).stateful).toBe(false); + }); + + it('does not mark a docker-socket-only stack as stateful (the socket holds no data)', () => { + const socketOnly: EffectiveModel = { + projectName: 'a', services: [{ + name: 'app', ports: [], binds: [], namedVolumes: [], + storageMounts: [{ type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false }], + privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [], + }], networks: {}, volumes: {}, + }; + expect(assembleStorageInventory('a', socketOnly, null, new Map()).stateful).toBe(false); + }); +}); diff --git a/backend/src/__tests__/storage-probe-host-path.test.ts b/backend/src/__tests__/storage-probe-host-path.test.ts new file mode 100644 index 00000000..88f604cf --- /dev/null +++ b/backend/src/__tests__/storage-probe-host-path.test.ts @@ -0,0 +1,95 @@ +/** + * probeHostPath: the lstat/readlink/realpath logic behind the storage + * inventory's bind-source classification. fs is mocked (real symlinks need + * privileges on Windows and are flaky), so these tests pin the kind taxonomy, + * the within-stack gate, and symlink-escape detection for both resolvable and + * broken links. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; + +const m = vi.hoisted(() => ({ lstat: vi.fn(), realpath: vi.fn(), readlink: vi.fn() })); +vi.mock('fs', () => ({ + default: { promises: { lstat: m.lstat, realpath: m.realpath, readlink: m.readlink } }, +})); + +import { probeHostPath } from '../services/storage/probeHostPath'; + +const STACK = path.resolve('/app/compose/mystack'); +const enoent = () => Object.assign(new Error('not found'), { code: 'ENOENT' }); + +function stat(kind: 'dir' | 'file' | 'socket' | 'symlink', extra: { uid?: number; gid?: number; mode?: number } = {}) { + return { + isSymbolicLink: () => kind === 'symlink', + isDirectory: () => kind === 'dir', + isFile: () => kind === 'file', + isSocket: () => kind === 'socket', + uid: extra.uid, + gid: extra.gid, + mode: extra.mode, + }; +} + +beforeEach(() => { + m.lstat.mockReset(); + m.realpath.mockReset(); + m.readlink.mockReset(); +}); + +describe('probeHostPath', () => { + it('classifies a within-stack directory, file, and socket', async () => { + m.lstat.mockResolvedValueOnce(stat('dir')); + expect(await probeHostPath(path.join(STACK, 'data'), STACK)).toMatchObject({ exists: true, kind: 'directory', withinStackDir: true }); + m.lstat.mockResolvedValueOnce(stat('file')); + expect((await probeHostPath(path.join(STACK, 'cfg'), STACK)).kind).toBe('file'); + m.lstat.mockResolvedValueOnce(stat('socket')); + expect((await probeHostPath(path.join(STACK, 'sock'), STACK)).kind).toBe('socket'); + }); + + it('reports a within-stack path that does not exist as missing', async () => { + m.lstat.mockRejectedValue(enoent()); + const p = await probeHostPath(path.join(STACK, 'gone'), STACK); + expect(p).toMatchObject({ exists: false, kind: 'missing', withinStackDir: true, lexicalWithinStackDir: true }); + }); + + it('never probes an external absolute path', async () => { + const p = await probeHostPath(path.resolve('/mnt/media'), STACK); + expect(p).toMatchObject({ lexicalWithinStackDir: false, withinStackDir: false, exists: false, kind: 'unknown' }); + expect(m.lstat).not.toHaveBeenCalled(); + }); + + it('flags a resolvable symlink that escapes the stack dir', async () => { + m.lstat.mockResolvedValue(stat('symlink')); + m.realpath.mockResolvedValue(path.resolve('/mnt/data')); + const p = await probeHostPath(path.join(STACK, 'link'), STACK); + expect(p).toMatchObject({ kind: 'symlink', exists: true, escapes: true, withinStackDir: false }); + }); + + it('flags a broken symlink whose readlink target escapes the stack dir', async () => { + m.lstat.mockResolvedValue(stat('symlink')); + m.realpath.mockRejectedValue(enoent()); + m.readlink.mockResolvedValue('/mnt/data'); + const p = await probeHostPath(path.join(STACK, 'broken'), STACK); + expect(p).toMatchObject({ kind: 'symlink', escapes: true, withinStackDir: false }); + }); + + it('keeps a broken symlink whose target stays inside the stack dir within-stack', async () => { + m.lstat.mockResolvedValue(stat('symlink')); + m.realpath.mockRejectedValue(enoent()); + m.readlink.mockResolvedValue('./sub'); + const p = await probeHostPath(path.join(STACK, 'broken'), STACK); + expect(p).toMatchObject({ kind: 'symlink', escapes: false, withinStackDir: true }); + }); + + it('populates uid/gid/mode from stat on POSIX', async () => { + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + m.lstat.mockResolvedValue(stat('dir', { uid: 1000, gid: 1000, mode: 0o40755 })); + const p = await probeHostPath(path.join(STACK, 'data'), STACK); + expect(p).toMatchObject({ uid: 1000, gid: 1000, mode: '755' }); + } finally { + Object.defineProperty(process, 'platform', { value: orig, configurable: true }); + } + }); +}); diff --git a/backend/src/__tests__/storage-route.test.ts b/backend/src/__tests__/storage-route.test.ts new file mode 100644 index 00000000..7951aeb9 --- /dev/null +++ b/backend/src/__tests__/storage-route.test.ts @@ -0,0 +1,113 @@ +/** + * GET /api/stacks/:stackName/storage: returns the per-stack storage inventory + + * portability verdict. Requires stack:read, rejects unauthenticated and + * missing-stack requests, degrades to an unknown verdict when the model is + * unrenderable, and never leaks raw docker stderr. Docker render is mocked. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { ComposeService } from '../services/ComposeService'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let stackDir: string; + +const STACK = 'storageroute'; + +function stubRender(result: { rendered: string | null; stderr?: string }) { + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ rendered: result.rendered, stderr: result.stderr ?? '', code: 0, timedOut: false }), + } as unknown as ComposeService); +} + +/** A rendered model with a named volume and an external bind (so the verdict is node-bound). */ +function renderedModel(): string { + return JSON.stringify({ + name: STACK, + services: { + app: { + image: 'nginx:1.27', + volumes: [ + { type: 'volume', source: 'data', target: '/data' }, + { type: 'bind', source: '/mnt/media', target: '/media' }, + ], + }, + }, + networks: {}, + volumes: { data: { name: `${STACK}_data` } }, + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('storage route', () => { + beforeEach(() => { + stackDir = path.join(process.env.COMPOSE_DIR as string, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx:1.27\n'); + }); + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(stackDir, { recursive: true, force: true }); + }); + + it('returns the inventory, mounts, and a portability verdict', async () => { + stubRender({ rendered: renderedModel() }); + const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(true); + expect(res.body.stateful).toBe(true); + expect(res.body.mounts.map((mnt: { type: string }) => mnt.type).sort()).toEqual(['bind', 'named']); + expect(res.body.mounts.every((mnt: { service: string }) => mnt.service === 'app')).toBe(true); + expect(res.body.portability.status).toBe('node-bound'); + }); + + it('degrades to an unknown verdict when the model cannot be rendered', async () => { + stubRender({ rendered: null, stderr: 'required variable "FOO" is missing' }); + const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(false); + expect(res.body.portability.status).toBe('unknown'); + }); + + it('never leaks raw docker stderr into the response', async () => { + const secret = 'super-secret-env-value-9f3a'; + stubRender({ rendered: null, stderr: `boom DB_PASSWORD=${secret}` }); + const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain(secret); + }); + + it('degrades to unknown when docker compose cannot be started (spawn failure)', async () => { + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockRejectedValue(new Error('spawn docker ENOENT')), + } as unknown as ComposeService); + const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(false); + expect(res.body.portability.status).toBe('unknown'); + expect(res.body.mounts).toEqual([]); + }); + + it('rejects an unauthenticated request', async () => { + const res = await request(app).get(`/api/stacks/${STACK}/storage`); + expect(res.status).toBe(401); + }); + + it('returns 404 for a stack that does not exist', async () => { + stubRender({ rendered: renderedModel() }); + const res = await request(app).get('/api/stacks/nope-not-here/storage').set('Authorization', authHeader); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index a6a1a826..e58ce3d3 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -17,6 +17,7 @@ import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } f import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService'; import { ComposeDoctorService } from '../services/ComposeDoctorService'; import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { buildStorageInventory } from '../services/storage/inventory'; import { buildEffectiveAnatomy } from '../services/effectiveAnatomy'; import { buildEnvInventory } from '../services/EnvInventoryService'; import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types'; @@ -1094,6 +1095,24 @@ stacksRouter.get('/:stackName/networking', async (req: Request, res: Response) = } }); +// Storage inventory: per-stack mount inventory (binds, named/anonymous volumes, +// tmpfs, docker socket; read-only vs read-write; host-path existence/type/owner) +// and a portability verdict derived from the effective model + within-stack +// host-path probes. Read-only and advisory; auto-proxies to the active node. +// Never returns raw render stderr or any environment value. +stacksRouter.get('/:stackName/storage', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + res.json(await buildStorageInventory(req.nodeId, stackName)); + } catch (error) { + console.error('[Stacks] Failed to build storage inventory for %s:', sanitizeForLog(stackName), + sanitizeForLog(inspect(error, { depth: 4 }))); + res.status(500).json({ error: 'Failed to build storage inventory' }); + } +}); + // Effective Stack Anatomy: structural facts (services, ports, volumes, networks, // restart) from the fully-merged effective model, so a multi-file Git source's // dossier and doc-drift reflect every override file, not just the root compose. diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 6794beae..00c5bea5 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -39,6 +39,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'compose-storage', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/backend/src/services/preflight/effectiveModel.ts b/backend/src/services/preflight/effectiveModel.ts index fcb03468..926fa043 100644 --- a/backend/src/services/preflight/effectiveModel.ts +++ b/backend/src/services/preflight/effectiveModel.ts @@ -21,6 +21,21 @@ export interface EffBind { target: string; } +/** + * One mount declared by a service, classified for the storage inventory. Unlike + * `EffBind`/`namedVolumes` (which the preflight rules consume), this captures the + * full mount taxonomy the Storage tab needs: every type, the read-only flag, and + * anonymous/tmpfs mounts the rule-facing fields deliberately omit. + */ +export interface EffStorageMount { + /** bind = host path; named = top-level volume key; anonymous = unnamed volume; tmpfs = ephemeral RAM mount. */ + type: 'bind' | 'named' | 'anonymous' | 'tmpfs'; + /** Host path (bind) or volume key (named); absent for anonymous and tmpfs mounts. */ + source?: string; + target: string; + readOnly: boolean; +} + /** A service's membership in one top-level network, keyed by the network KEY * (not the resolved docker name) so it lines up with the `networks` map and * with the authored `DeclaredService.networks`. */ @@ -35,6 +50,8 @@ export interface EffService { ports: EffPortSpec[]; binds: EffBind[]; namedVolumes: string[]; + /** Full per-mount inventory (every type, read-only flag, anonymous/tmpfs). Consumed by the storage feature, not the preflight rules. */ + storageMounts: EffStorageMount[]; privileged: boolean; networkMode?: string; restart?: string; @@ -135,6 +152,90 @@ function parseVolumes(volumes: unknown): { binds: EffBind[]; named: string[] } { return { binds, named }; } +/** Leading Windows drive (`C:\` or `C:/`), whose own colon must not split a short-form source. */ +const WIN_DRIVE = /^[A-Za-z]:[\\/]/; + +/** A short-form source is a bind when it looks like a host path, otherwise a named volume. */ +function isHostPathSource(source: string): boolean { + return source.startsWith('/') || source.startsWith('.') || source.startsWith('~') || WIN_DRIVE.test(source); +} + +/** + * Parse one short-form `volumes:` entry (`[SOURCE:]TARGET[:OPTIONS]`) into a + * storage mount. Windows-drive aware (the drive's colon does not split the + * source) and tolerant of comma-joined options (`ro,Z`). A single token is an + * anonymous volume mounted at that container path. `docker compose config` + * normalizes to long form, so this is a defensive fallback. + */ +function parseShortVolume(s: string): EffStorageMount | null { + if (!s) return null; + let source: string; + let target: string; + let optionTokens: string[]; + + if (WIN_DRIVE.test(s)) { + const parts = s.split(':'); + source = `${parts[0]}:${parts[1]}`; // rejoin the drive (e.g. C:\data) + const rest = parts.slice(2); + if (rest.length === 0) return null; // a drive path with no container target is not a classifiable mount + target = rest[0]; + optionTokens = rest.slice(1); + } else { + const parts = s.split(':'); + if (parts.length < 2) { + // A bare token is an anonymous volume only when it is a container path; + // anything else is unparseable, so drop it rather than invent a mount that + // would skew the deterministic portability verdict. + return parts[0].startsWith('/') ? { type: 'anonymous', target: parts[0], readOnly: false } : null; + } + source = parts[0]; + target = parts[1]; + optionTokens = parts.slice(2); + } + + const readOnly = optionTokens.flatMap(o => o.split(',')).includes('ro'); + return { type: isHostPathSource(source) ? 'bind' : 'named', source, target, readOnly }; +} + +/** + * Build the full per-mount storage inventory for a service from its `volumes:` + * list and the separate service-level `tmpfs:` field (a string or string[], + * distinct from a `volumes:` tmpfs mount). Captures every mount type plus the + * read-only flag; never reads a mount's content. + */ +function parseStorageMounts(volumes: unknown, tmpfs: unknown): EffStorageMount[] { + const mounts: EffStorageMount[] = []; + if (Array.isArray(volumes)) { + for (const v of volumes) { + if (v && typeof v === 'object') { + const o = v as Record; + const type = str(o.type); + const source = str(o.source); + const target = str(o.target) ?? ''; + const readOnly = o.read_only === true; + if (type === 'bind' && source) mounts.push({ type: 'bind', source, target, readOnly }); + else if (type === 'volume' && source) mounts.push({ type: 'named', source, target, readOnly }); + else if (type === 'volume') mounts.push({ type: 'anonymous', target, readOnly }); + else if (type === 'tmpfs') mounts.push({ type: 'tmpfs', target, readOnly: false }); + continue; + } + const s = str(v); + if (!s) continue; + const m = parseShortVolume(s); + if (m) mounts.push(m); + } + } + if (typeof tmpfs === 'string') { + mounts.push({ type: 'tmpfs', target: tmpfs, readOnly: false }); + } else if (Array.isArray(tmpfs)) { + for (const t of tmpfs) { + const tt = str(t); + if (tt) mounts.push({ type: 'tmpfs', target: tt, readOnly: false }); + } + } + return mounts; +} + /** Environment KEY names only. Never returns a value. */ function envKeysOf(env: unknown): string[] { if (Array.isArray(env)) { @@ -219,6 +320,7 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string ? svc.ports.map(parsePortSpec).filter((p): p is EffPortSpec => p !== null) : []; const { binds, named } = parseVolumes(svc.volumes); + const storageMounts = parseStorageMounts(svc.volumes, svc.tmpfs); const healthcheck = svc.healthcheck; const hasHealthcheck = !!healthcheck && typeof healthcheck === 'object' @@ -229,6 +331,7 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string ports, binds, namedVolumes: named, + storageMounts, privileged: svc.privileged === true, networkMode: str(svc.network_mode), restart: str(svc.restart), diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index a197216b..c318108a 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -470,6 +470,29 @@ const newVolume: PreflightRule = { }, }; +const anonymousVolume: PreflightRule = { + id: 'anonymous-volume', + run(ctx) { + if (!ctx.model) return []; + const findings: PreflightFinding[] = []; + for (const svc of ctx.model.services) { + const anon = (svc.storageMounts ?? []).filter(m => m.type === 'anonymous'); + if (anon.length === 0) continue; + const targets = anon.map(m => m.target).filter(Boolean); + findings.push({ + ruleId: 'anonymous-volume', + severity: 'info', + title: 'Anonymous volume in use', + message: `Service "${svc.name}" mounts ${anon.length > 1 ? `${anon.length} anonymous volumes` : 'an anonymous volume'}${targets.length ? ` at ${targets.join(', ')}` : ''}. Anonymous volumes have no name, so they are easy to miss when backing up and are orphaned when the container is recreated.`, + sourcePath: svc.name, + service: svc.name, + remediation: 'Give the volume a name so it can be referenced, backed up, and reattached.', + }); + } + return findings; + }, +}; + const containerNameInternalDup: PreflightRule = { id: 'container-name-internal-dup', run(ctx) { @@ -704,6 +727,7 @@ export const PREFLIGHT_RULES: PreflightRule[] = [ externalVolumeMissing, newNetwork, newVolume, + anonymousVolume, containerNameInternalDup, containerNameCollision, exposureInternalPublished, diff --git a/backend/src/services/storage/inventory.ts b/backend/src/services/storage/inventory.ts new file mode 100644 index 00000000..15df687c --- /dev/null +++ b/backend/src/services/storage/inventory.ts @@ -0,0 +1,178 @@ +/** + * Storage Inventory: renders a stack's effective Compose model, probes each + * within-stack bind source, and classifies the stack's storage portability. + * Advisory and read-only; it never mutates a path, reads mount content, or + * returns raw docker stderr or any environment value. Node-scoped: it runs on + * whichever node owns the stack (the route auto-proxies there). + */ +import path from 'path'; + +import { ComposeService } from '../ComposeService'; +import { FileSystemService } from '../FileSystemService'; +import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel'; +import { parseMissingRequiredVars } from '../../helpers/envVarParse'; +import { probeHostPath } from './probeHostPath'; +import { isDockerSocketMount } from './types'; +import type { HostPathProbe, PortabilityVerdict, StorageInventory, StorageMount } from './types'; + +import { getErrorMessage } from '../../utils/errors'; +import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog'; + +const MAX_RENDER_ERROR = 600; + +const UNRENDERABLE_REASON = + 'Sencho could not render the effective Compose model, so storage portability cannot be determined.'; + +/** Flatten the model into per-service mounts, attaching each bind's probe and external-named status. */ +export function buildMounts(model: EffectiveModel, probes: Map): StorageMount[] { + const mounts: StorageMount[] = []; + for (const svc of model.services) { + for (const m of svc.storageMounts ?? []) { + const probe = m.type === 'bind' && m.source ? (probes.get(m.source) ?? null) : null; + const externalNamed = m.type === 'named' && m.source ? (model.volumes[m.source]?.external ?? false) : false; + mounts.push({ ...m, service: svc.name, probe, externalNamed }); + } + } + return mounts; +} + +/** A bind is node-bound when its source resolves outside the stack dir, escapes via a symlink, or is unverified. */ +function isExternalBind(m: StorageMount): boolean { + if (m.type !== 'bind') return false; + if (!m.probe) return true; + return m.probe.escapes || !m.probe.withinStackDir; +} + +/** + * A stack carries node-local state when it has any data-bearing mount: a bind + * (other than the Docker socket, which holds no application data), or a named or + * anonymous volume. tmpfs is ephemeral and never counts. + */ +function isStateful(mounts: StorageMount[]): boolean { + return mounts.some(m => + (m.type === 'bind' && !isDockerSocketMount(m)) || m.type === 'named' || m.type === 'anonymous'); +} + +/** + * Deterministic portability verdict. Status is the single highest verdict + * (node-bound > partially-portable > portable > unknown); `reasons` accumulates + * every contributing factor so the UI can show the full picture. + */ +export function classifyPortability(mounts: StorageMount[], renderable: boolean): PortabilityVerdict { + if (!renderable) return { status: 'unknown', reasons: [UNRENDERABLE_REASON] }; + + const reasons: string[] = []; + const socketMounts = mounts.filter(isDockerSocketMount); + // A socket bind is node-bound, but it is reported via its own reason, so it is + // excluded here to avoid a duplicate reason for the one mount. + const externalBinds = mounts.filter(m => isExternalBind(m) && !isDockerSocketMount(m)); + const withinStackBinds = mounts.filter(m => m.type === 'bind' && !isExternalBind(m)); + const dataVolumes = mounts.filter(m => m.type === 'named' || m.type === 'anonymous'); + + for (const s of socketMounts) { + reasons.push(`Service "${s.service}" mounts the Docker socket, tying the stack to this host's Docker engine.`); + } + for (const b of externalBinds) { + reasons.push(b.probe?.escapes + ? `"${b.source}" in service "${b.service}" is a symlink to a path outside the stack directory.` + : `Service "${b.service}" binds "${b.source}", a host path outside the stack directory that must exist on every node you move this stack to.`); + } + + if (socketMounts.length > 0 || externalBinds.length > 0) { + return { status: 'node-bound', reasons }; + } + + if (dataVolumes.length > 0) { + for (const v of dataVolumes) { + const which = v.type === 'anonymous' ? 'an anonymous volume' : `named volume "${v.source}"`; + reasons.push(v.externalNamed + ? `Service "${v.service}" uses ${which}, which expects a pre-existing volume on the node; its data does not move with the files.` + : `Service "${v.service}" uses ${which}; its data lives on this node and is not carried by moving the files.`); + } + if (withinStackBinds.length > 0) { + reasons.push('Bind mounts inside the stack directory move with the stack files.'); + } + return { status: 'partially-portable', reasons }; + } + + reasons.push(withinStackBinds.length > 0 + ? 'All mounts are bind paths inside the stack directory, so they move with the stack files.' + : 'This stack declares no persistent storage, so nothing is tied to this node.'); + return { status: 'portable', reasons }; +} + +/** Pure assembler: turns a rendered model + probe map into the inventory payload. */ +export function assembleStorageInventory( + stackName: string, + model: EffectiveModel | null, + renderError: string | null, + probes: Map, +): StorageInventory { + if (!model) { + return { + stack: stackName, renderable: false, renderError, stateful: false, mounts: [], + portability: classifyPortability([], false), + }; + } + const mounts = buildMounts(model, probes); + return { + stack: stackName, + renderable: true, + renderError: null, + stateful: isStateful(mounts), + mounts, + portability: classifyPortability(mounts, true), + }; +} + +/** Probe each unique bind source once. */ +async function probeBindSources(model: EffectiveModel, stackDir: string): Promise> { + const sources = new Set(); + for (const svc of model.services) { + for (const m of svc.storageMounts ?? []) { + if (m.type === 'bind' && m.source) sources.add(m.source); + } + } + const probes = new Map(); + for (const source of sources) probes.set(source, await probeHostPath(source, stackDir)); + return probes; +} + +/** Render the model on the owning node, probe its binds, and assemble the inventory. */ +export async function buildStorageInventory(nodeId: number, stackName: string): Promise { + const stackDir = path.join(FileSystemService.getInstance(nodeId).getBaseDir(), stackName); + + let model: EffectiveModel | null = null; + let renderError: string | null = null; + try { + const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); + if (result.rendered !== null) { + try { + model = parseEffectiveModel(JSON.parse(result.rendered), stackName); + } catch (parseErr) { + // JSON.parse errors carry no file content, so the message is safe to log. + console.warn('[StorageInventory] Effective model parse failed for %s:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown'))); + renderError = 'Sencho could not parse the rendered Compose model.'; + } + } else if (result.timedOut) { + // A timeout is a Sencho-generated condition, not a Compose error; name it so + // the operator does not hunt for a syntax error that is not there. + renderError = 'Rendering the effective Compose model timed out on this node.'; + } else { + // Raw stderr can echo file content/secrets and is never surfaced; only the + // names of any missing required variables, otherwise a generic nudge. + const missing = parseMissingRequiredVars(result.stderr); + renderError = missing.length + ? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.` + : 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.'; + } + } catch (err) { + // Spawn failure (docker unavailable). Redact defensively. + renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim() + || 'Sencho could not run docker compose on this node.'; + } + + const probes = model ? await probeBindSources(model, stackDir) : new Map(); + return assembleStorageInventory(stackName, model, renderError, probes); +} diff --git a/backend/src/services/storage/probeHostPath.ts b/backend/src/services/storage/probeHostPath.ts new file mode 100644 index 00000000..3d85d0c5 --- /dev/null +++ b/backend/src/services/storage/probeHostPath.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; + +import { isPathWithinBase } from '../../utils/validation'; +import type { HostPathKind, HostPathProbe } from './types'; + +function kindOf(st: fs.Stats): HostPathKind { + if (st.isSymbolicLink()) return 'symlink'; + if (st.isDirectory()) return 'directory'; + if (st.isFile()) return 'file'; + if (st.isSocket()) return 'socket'; + return 'unknown'; +} + +const UNVERIFIED: HostPathProbe = { + lexicalWithinStackDir: false, withinStackDir: false, exists: false, + kind: 'unknown', escapes: false, uid: null, gid: null, mode: null, +}; + +/** + * Probe a bind-mount host source for the storage inventory. Existence, type, and + * ownership are resolved ONLY for sources lexically inside the stack's own + * directory (relative binds); absolute external host paths are outside Sencho's + * filesystem view and are left unverified. A within-stack symlink whose target + * escapes the stack dir, resolved or (when the link is broken) via its readlink + * target, is flagged `escapes` so the classifier treats it as node-bound. Never + * reads the path's content. + */ +export async function probeHostPath(source: string, stackDir: string): Promise { + const resolvedStackDir = path.resolve(stackDir); + const resolvedSource = path.resolve(source); + + if (!isPathWithinBase(resolvedSource, resolvedStackDir)) { + return { ...UNVERIFIED }; + } + + let lst: fs.Stats; + try { + lst = await fs.promises.lstat(resolvedSource); + } catch { + return { + lexicalWithinStackDir: true, withinStackDir: true, exists: false, + kind: 'missing', escapes: false, uid: null, gid: null, mode: null, + }; + } + + const kind = kindOf(lst); + let withinStackDir = true; + let escapes = false; + + if (kind === 'symlink') { + const target = await resolveSymlinkTarget(resolvedSource); + if (target !== null) { + withinStackDir = isPathWithinBase(target, resolvedStackDir); + escapes = !withinStackDir; + } + // An unreadable link is left as within-stack (conservative; nothing proves an escape). + } + + const posix = process.platform !== 'win32'; + const uid = posix && typeof lst.uid === 'number' ? lst.uid : null; + const gid = posix && typeof lst.gid === 'number' ? lst.gid : null; + const mode = posix && typeof lst.mode === 'number' ? (lst.mode & 0o777).toString(8).padStart(3, '0') : null; + + return { lexicalWithinStackDir: true, withinStackDir, exists: true, kind, escapes, uid, gid, mode }; +} + +/** + * Resolve a symlink's absolute target. Prefers `realpath` (follows the whole + * chain); on a broken link falls back to `readlink` and resolves its target + * lexically so an escape is still detectable. Returns null when neither works. + */ +async function resolveSymlinkTarget(linkPath: string): Promise { + try { + return await fs.promises.realpath(linkPath); + } catch { + try { + const link = await fs.promises.readlink(linkPath); + return path.isAbsolute(link) ? path.resolve(link) : path.resolve(path.dirname(linkPath), link); + } catch { + return null; + } + } +} diff --git a/backend/src/services/storage/types.ts b/backend/src/services/storage/types.ts new file mode 100644 index 00000000..ea47e813 --- /dev/null +++ b/backend/src/services/storage/types.ts @@ -0,0 +1,60 @@ +import type { EffStorageMount } from '../preflight/effectiveModel'; + +/** Resolved type of a host path behind a bind mount. */ +export type HostPathKind = 'file' | 'directory' | 'socket' | 'symlink' | 'missing' | 'unknown'; + +/** + * Existence/type/ownership of a bind-mount host source. Resolved ONLY for + * sources lexically inside the stack's own directory; absolute external paths + * are outside Sencho's filesystem view and are left unverified. Never reads the + * path's content. + */ +export interface HostPathProbe { + /** True when the source path lexically resolves inside the stack's own directory. */ + lexicalWithinStackDir: boolean; + /** True when the resolved (symlink-followed) target still sits inside the stack dir. */ + withinStackDir: boolean; + /** Existence is only probed for within-stack-dir sources; external paths stay false (unverifiable). */ + exists: boolean; + kind: HostPathKind; + /** True when a within-stack symlink points (resolved, or for a broken link its readlink target) outside the stack dir. */ + escapes: boolean; + /** POSIX owner uid/gid and octal mode when statted; null on Windows or for unverified paths. */ + uid: number | null; + gid: number | null; + mode: string | null; +} + +/** One mount in the storage inventory: the parsed mount, its service, and (binds only) the host-path probe. */ +export interface StorageMount extends EffStorageMount { + service: string; + /** Probe for bind sources; null for named/anonymous/tmpfs mounts. */ + probe: HostPathProbe | null; + /** True when this named mount references a top-level `external: true` volume. */ + externalNamed: boolean; +} + +export type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown'; + +export interface PortabilityVerdict { + status: PortabilityStatus; + /** Every reason that applies, so the UI can list all contributing factors. */ + reasons: string[]; +} + +/** Per-stack storage inventory returned by GET /api/stacks/:stackName/storage. */ +export interface StorageInventory { + stack: string; + renderable: boolean; + /** Redacted, structural render error when `renderable` is false; never raw docker stderr. */ + renderError: string | null; + /** True when the stack has any bind/named/anonymous mount (tmpfs-only and no-mounts are stateless). */ + stateful: boolean; + mounts: StorageMount[]; + portability: PortabilityVerdict; +} + +/** A bind/target referencing the Docker socket grants root-equivalent host control and is node-bound. */ +export function isDockerSocketMount(m: Pick): boolean { + return (m.source?.includes('docker.sock') ?? false) || m.target.includes('docker.sock'); +} diff --git a/docs/docs.json b/docs/docs.json index 906bb2c9..5b66799c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -108,6 +108,7 @@ "features/compose-doctor", "features/compose-networking", "features/environment-guardrails", + "features/compose-storage", "features/stack-labels", "features/sidebar" ] diff --git a/docs/features/compose-storage.mdx b/docs/features/compose-storage.mdx new file mode 100644 index 00000000..3d979fe5 --- /dev/null +++ b/docs/features/compose-storage.mdx @@ -0,0 +1,60 @@ +--- +title: Storage Portability +description: See every mount a stack depends on, learn whether it is portable or tied to this node, and find out what will break if you move or restore it, all before you deploy. +--- + +The **Storage** tab in the right-hand **Anatomy** panel answers a Compose-first question: *what storage does this stack depend on, and what happens to it if I move or restore the stack?* It renders the effective Compose model, the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied, lists every mount, and gives the stack a single portability verdict. + +The view is read-only with respect to the stack and the host: it never changes a mount, and it never reads, moves, or changes the ownership of any file. It inspects structure only, mount type, source and target paths, the read-only flag, and a host path's existence, type, and owner, so nothing inside a volume or bind mount is ever opened. + +## Storage inventory + +Each service lists its mounts, grouped by service: + +- **bind** mounts a host path into the container. For a path inside the stack directory, Sencho also shows whether it exists, whether it is a file, directory, socket, or symlink, and its owner on Linux hosts. +- **named** uses a Docker-managed volume. An **external** chip marks a volume the stack expects to already exist on the node. +- **anonymous** is an unnamed volume. Because it has no name, it is easy to miss when backing up and is orphaned when the container is recreated. +- **tmpfs** is an in-memory mount that holds nothing on disk. +- A **socket** chip marks a mount of the Docker socket. + +Every mount also shows whether it is **read-only** or **read-write**. + +## Portability + +The top of the tab gives the stack one verdict, with the reasons behind it: + +| Status | Meaning | +|--------|---------| +| **Portable** | Every mount is inside the stack directory, or there is no persistent storage, so the stack moves cleanly with its files. | +| **Partially portable** | The Compose structure moves cleanly, but named or anonymous volume data lives on this node and is not carried by moving the files. Back that data up separately. | +| **Node-bound** | The stack binds host paths outside its directory, or mounts the Docker socket, so it is tied to this specific host. Those paths must exist on any node you move it to. | +| **Unknown** | The effective model could not be rendered, so portability cannot be determined. | + +A read-only bind to an outside path is still node-bound: read-only lowers the risk of a change, not the need for the path to exist on the target. A bind that is a symlink pointing outside the stack directory is treated as node-bound as well. + +## Snapshot coverage + +A stack with persistent storage is only as safe as its backups. Sencho shows administrators whether a recent fleet snapshot covers the stack, and warns when a stack that holds data has not been snapshotted in the last seven days. + +Fleet snapshots capture Compose and env files, not the data inside named volumes or bind mounts. The tab states this plainly so a snapshot is never mistaken for an application-data backup. Back up volume data separately before moving or restoring a stack. + +## Findings in Doctor + +Storage risk findings live in the **Doctor** tab, which reads the same model: a missing bind-mount path, a mount whose ownership is likely to mismatch the container user, a mounted Docker socket, a required external volume that does not exist, and an anonymous volume whose data has no name to back up by. + +## Troubleshooting + + + + The Storage tab appears on nodes that support storage portability. A node that does not advertise the capability does not show the tab. + + + Existence, type, and owner are resolved only for paths inside the stack directory. A host path elsewhere on the machine is outside Sencho's view, so it is listed but left unverified, and it counts toward a node-bound verdict. + + + `docker compose config` could not produce an effective model, usually a YAML error, an unresolved include or merge, or a required variable with no value. Fix the reported problem and reopen the tab. + + + Snapshot coverage is shown to administrators. The warning and the last-snapshot line appear for a stack that holds persistent data once a snapshot has run on its node. + + diff --git a/frontend/src/components/StackAnatomyPanel.doctor.test.tsx b/frontend/src/components/StackAnatomyPanel.doctor.test.tsx index b36f4e74..9001e2ef 100644 --- a/frontend/src/components/StackAnatomyPanel.doctor.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.doctor.test.tsx @@ -76,4 +76,10 @@ describe('StackAnatomyPanel Doctor tab (capability on)', () => { render(panel()); expect(await screen.findByTestId('networking-tab')).toBeInTheDocument(); }); + + it('renders the Storage tab when the capability is present', async () => { + badgeSeverity = 'warning'; + render(panel()); + expect(await screen.findByTestId('storage-tab')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index de5c4ea9..f4e0c99f 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -417,11 +417,12 @@ describe('StackAnatomyPanel effective dossier (multi-file Git)', () => { }); describe('StackAnatomyPanel capability gating (capability off)', () => { - it('hides the Networking and Doctor tabs when the capabilities are absent', async () => { + it('hides the Networking, Doctor, and Storage tabs when the capabilities are absent', async () => { render(panel(false)); // The always-on Anatomy tab confirms the panel mounted. expect(await screen.findByRole('tab', { name: 'Anatomy' })).toBeInTheDocument(); expect(screen.queryByTestId('networking-tab')).not.toBeInTheDocument(); expect(screen.queryByTestId('doctor-tab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('storage-tab')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index eef7b39f..b6b26f32 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -12,6 +12,7 @@ import { StackActivityTimeline } from './stack/StackActivityTimeline'; import StackDossierPanel from './stack/StackDossierPanel'; import DriftPanel from './stack/DriftPanel'; import PreflightPanel from './stack/PreflightPanel'; +import StoragePanel from './stack/StoragePanel'; import EnvironmentPanel from './stack/EnvironmentPanel'; import StackNetworkingPanel from './stack/StackNetworkingPanel'; import { useNodes } from '@/context/NodeContext'; @@ -93,6 +94,7 @@ export default function StackAnatomyPanel({ const { hasCapability, activeNode } = useNodes(); const doctorEnabled = hasCapability('compose-doctor'); const networkingEnabled = hasCapability('compose-networking'); + const storageEnabled = hasCapability('compose-storage'); const envInventoryEnabled = hasCapability('env-inventory'); const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null); @@ -370,6 +372,9 @@ export default function StackAnatomyPanel({ )} + {storageEnabled && ( + Storage + )}
@@ -608,6 +613,11 @@ export default function StackAnatomyPanel({ )} + {storageEnabled && ( + + + + )}
); diff --git a/frontend/src/components/stack/StackDossierPanel.test.tsx b/frontend/src/components/stack/StackDossierPanel.test.tsx index 4a939e73..2383f25a 100644 --- a/frontend/src/components/stack/StackDossierPanel.test.tsx +++ b/frontend/src/components/stack/StackDossierPanel.test.tsx @@ -8,11 +8,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +const caps = vi.hoisted(() => ({ enabled: new Set() })); vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); -// hasCapability false keeps the rollback readiness section (tested in its own -// file) out of these dossier-focused tests. -vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => false }) })); +// No capabilities by default, which keeps the rollback readiness section (tested +// in its own file) out of these dossier-focused tests; individual tests enable +// the capability for the gated storage export. +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: (c: string) => caps.enabled.has(c) }) })); vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); vi.mock('@/lib/download', () => ({ downloadTextFile: vi.fn() })); @@ -35,6 +37,7 @@ function jsonRes(body: unknown, ok = true) { beforeEach(() => { vi.clearAllMocks(); + caps.enabled.clear(); }); describe('StackDossierPanel', () => { @@ -179,6 +182,49 @@ describe('StackDossierPanel', () => { expect(section).toHaveTextContent(':9001'); }); + it('does not fetch the storage inventory on export when compose-storage is absent', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS })); + render(); + await screen.findByTestId('dossier-panel'); + fireEvent.click(screen.getByTestId('dossier-copy-btn')); + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); + expect(urls.some(u => u.includes('/storage'))).toBe(false); + expect(vi.mocked(copyToClipboard).mock.calls[0][0]).not.toContain('## Storage portability'); + }); + + it('includes the storage section when the inventory loads, and omits it when the fetch fails', async () => { + caps.enabled.add('compose-storage'); + const inventory = { + renderable: true, stateful: true, + mounts: [{ service: 'web', type: 'bind', source: '/srv/data', target: '/data', readOnly: false }], + portability: { status: 'partially-portable', reasons: ['data lives on this node'] }, + }; + vi.mocked(apiFetch).mockImplementation(async (input: string) => { + const url = String(input); + if (url.includes('/storage')) return jsonRes(inventory); + if (url.includes('/dossier')) return jsonRes({ ...EMPTY_DOSSIER_FIELDS }); + return jsonRes(null, false); // networking + exposure: omitted + }); + render(); + await screen.findByTestId('dossier-panel'); + fireEvent.click(screen.getByTestId('dossier-copy-btn')); + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain('## Storage portability'); + + // Now the inventory fetch fails: the section must be omitted, not error the export. + vi.mocked(copyToClipboard).mockClear(); + vi.mocked(apiFetch).mockImplementation(async (input: string) => { + const url = String(input); + if (url.includes('/storage')) return jsonRes(null, false); + if (url.includes('/dossier')) return jsonRes({ ...EMPTY_DOSSIER_FIELDS }); + return jsonRes(null, false); + }); + fireEvent.click(screen.getByTestId('dossier-copy-btn')); + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + expect(vi.mocked(copyToClipboard).mock.calls[0][0]).not.toContain('## Storage portability'); + }); + it('suppresses the warning when a reload fails, never showing the previous stack stale', async () => { vi.mocked(apiFetch) .mockResolvedValueOnce(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' })) diff --git a/frontend/src/components/stack/StackDossierPanel.tsx b/frontend/src/components/stack/StackDossierPanel.tsx index 5f036158..99105d81 100644 --- a/frontend/src/components/stack/StackDossierPanel.tsx +++ b/frontend/src/components/stack/StackDossierPanel.tsx @@ -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 => { + 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.'); } diff --git a/frontend/src/components/stack/StoragePanel.test.tsx b/frontend/src/components/stack/StoragePanel.test.tsx new file mode 100644 index 00000000..cf138912 --- /dev/null +++ b/frontend/src/components/stack/StoragePanel.test.tsx @@ -0,0 +1,109 @@ +/** + * Covers the Storage panel: the portability verdict + per-service mounts, the + * unrenderable banner, a load-failure retry, the static snapshot caveat, and the + * admin-only snapshot coverage merge (the warning, the recent-snapshot line, the + * hub-local `/fleet/snapshots/coverage` path, and that non-admins never fetch it). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +const auth = vi.hoisted(() => ({ isAdmin: false })); +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) })); +vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: auth.isAdmin }) })); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import StoragePanel from './StoragePanel'; + +interface Mount { service: string; type: string; source?: string; target: string; readOnly: boolean; probe: unknown; externalNamed: boolean } +function mount(over: Partial = {}): Mount { + return { service: 'app', type: 'bind', target: '/data', readOnly: false, probe: null, externalNamed: false, ...over }; +} +function inventory(over: Record = {}) { + return { stack: 'app', renderable: true, renderError: null, stateful: true, mounts: [], portability: { status: 'portable', reasons: [] }, ...over }; +} +function jsonRes(body: unknown, ok = true) { + return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response; +} + +/** Route apiFetch by URL: /storage vs /fleet/snapshots/coverage. */ +function route(opts: { storage?: Response; coverage?: Response }) { + vi.mocked(apiFetch).mockImplementation(((url: string) => { + if (String(url).includes('/snapshots/coverage')) return Promise.resolve(opts.coverage ?? jsonRes({ latestAt: null })); + return Promise.resolve(opts.storage ?? jsonRes(inventory())); + }) as unknown as typeof apiFetch); +} + +beforeEach(() => { vi.clearAllMocks(); auth.isAdmin = false; }); + +describe('StoragePanel', () => { + it('renders the portability verdict and mounts grouped by service', async () => { + route({ storage: jsonRes(inventory({ + portability: { status: 'node-bound', reasons: ['Binds host paths outside the stack directory.'] }, + mounts: [mount({ service: 'web', source: '/mnt/media', target: '/media' }), mount({ service: 'db', type: 'named', source: 'data', target: '/var/lib' })], + })) }); + render(); + const verdict = await screen.findByTestId('storage-portability'); + expect(verdict).toHaveAttribute('data-status', 'node-bound'); + expect(verdict).toHaveTextContent(/node-bound/i); + expect(screen.getByTestId('storage-service-web')).toBeInTheDocument(); + expect(screen.getByTestId('storage-service-db')).toBeInTheDocument(); + expect(screen.getByText(/\/var\/lib/)).toBeInTheDocument(); + }); + + it('shows the cannot-render banner with the render error', async () => { + route({ storage: jsonRes(inventory({ renderable: false, renderError: 'bad compose', stateful: false, portability: { status: 'unknown', reasons: [] } })) }); + render(); + expect(await screen.findByText(/cannot render/i)).toBeInTheDocument(); + expect(screen.getByText(/bad compose/)).toBeInTheDocument(); + }); + + it('shows a retry state and toasts when the load fails', async () => { + route({ storage: jsonRes(null, false) }); + render(); + expect(await screen.findByText(/Could not load the storage inventory/i)).toBeInTheDocument(); + expect(toast.error).toHaveBeenCalled(); + expect(screen.getByTestId('storage-retry-btn')).toBeInTheDocument(); + }); + + it('always shows the snapshots-cover-config caveat', async () => { + route({ storage: jsonRes(inventory()) }); + render(); + expect(await screen.findByText(/capture Compose and env files, not the data/i)).toBeInTheDocument(); + }); + + it('does not fetch snapshot coverage for a non-admin', async () => { + route({ storage: jsonRes(inventory()) }); + render(); + await screen.findByTestId('storage-portability'); + await waitFor(() => { + const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); + expect(urls.some(u => u.includes('/storage'))).toBe(true); + expect(urls.some(u => u.includes('/snapshots/coverage'))).toBe(false); + }); + expect(screen.queryByTestId('storage-snapshot-warning')).not.toBeInTheDocument(); + }); + + it('fetches coverage from the hub-local /fleet path and warns when an admin stack has no recent snapshot', async () => { + auth.isAdmin = true; + route({ storage: jsonRes(inventory({ stateful: true })), coverage: jsonRes({ latestAt: null }) }); + render(); + expect(await screen.findByTestId('storage-snapshot-warning')).toBeInTheDocument(); + const coverageCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/snapshots/coverage')); + expect(coverageCall).toBeDefined(); + // apiFetch prepends /api, so the call must NOT already carry it. + expect(String(coverageCall![0]).startsWith('/fleet/snapshots/coverage')).toBe(true); + expect(String(coverageCall![0]).startsWith('/api/')).toBe(false); + expect((coverageCall![1] as { localOnly?: boolean }).localOnly).toBe(true); + }); + + it('hides the warning and shows the last-snapshot line when a recent snapshot exists', async () => { + auth.isAdmin = true; + route({ storage: jsonRes(inventory({ stateful: true })), coverage: jsonRes({ latestAt: Date.now() - 1000 }) }); + render(); + expect(await screen.findByText(/Last fleet snapshot/i)).toBeInTheDocument(); + expect(screen.queryByTestId('storage-snapshot-warning')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/stack/StoragePanel.tsx b/frontend/src/components/stack/StoragePanel.tsx new file mode 100644 index 00000000..cca9bef7 --- /dev/null +++ b/frontend/src/components/stack/StoragePanel.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from 'react'; +import { + Check, TriangleAlert, Info, MapPin, HelpCircle, HardDrive, type LucideIcon, +} from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { toast } from '@/components/ui/toast-store'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; + +// Mirrors the backend /storage payload (the frontend never imports backend). +type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown'; +type MountType = 'bind' | 'named' | 'anonymous' | 'tmpfs'; +type HostPathKind = 'file' | 'directory' | 'socket' | 'symlink' | 'missing' | 'unknown'; + +interface HostPathProbe { + lexicalWithinStackDir: boolean; + withinStackDir: boolean; + exists: boolean; + kind: HostPathKind; + escapes: boolean; + uid: number | null; + gid: number | null; + mode: string | null; +} +interface StorageMount { + service: string; + type: MountType; + source?: string; + target: string; + readOnly: boolean; + probe: HostPathProbe | null; + externalNamed: boolean; +} +interface StorageInventory { + stack: string; + renderable: boolean; + renderError: string | null; + stateful: boolean; + mounts: StorageMount[]; + portability: { status: PortabilityStatus; reasons: string[] }; +} + +const RECENT_SNAPSHOT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; +const CARD_CLASS = 'rounded-lg border px-3 py-2.5'; +const CHIP_CLASS = 'rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide'; + +const STATUS_META: Record = { + 'portable': { label: 'portable', tone: 'border-success/40 bg-success/[0.06] text-success', icon: Check }, + 'partially-portable': { label: 'partially portable', tone: 'border-info/40 bg-info/[0.06] text-info', icon: Info }, + 'node-bound': { label: 'node-bound', tone: 'border-warning/40 bg-warning/[0.06] text-warning', icon: MapPin }, + 'unknown': { label: 'unknown', tone: 'border-muted bg-card/40 text-stat-subtitle', icon: HelpCircle }, +}; + +const isSocketMount = (m: StorageMount): boolean => + (m.source?.includes('docker.sock') ?? false) || m.target.includes('docker.sock'); + +function mountTypeLabel(m: StorageMount): string { + if (isSocketMount(m)) return 'socket'; + return m.type; +} + +/** A short host-path status for a bind, or null for non-bind mounts. */ +function bindStatus(m: StorageMount): string | null { + if (m.type !== 'bind' || !m.probe) return null; + const p = m.probe; + if (!p.lexicalWithinStackDir) return 'external'; + if (p.escapes) return 'symlink escapes'; + if (!p.exists) return 'missing'; + return p.kind; +} + +function MountRow({ mount }: { mount: StorageMount }) { + const status = bindStatus(mount); + const owner = mount.probe && mount.probe.uid !== null + ? `uid ${mount.probe.uid}${mount.probe.gid !== null ? `:${mount.probe.gid}` : ''}` + : null; + return ( +
+
+ {mountTypeLabel(mount)} + + {mount.readOnly ? 'ro' : 'rw'} + + {mount.externalNamed && external} + {status && {status}} + {owner && · {owner}} +
+
+ {mount.source && {mount.source} → } + {mount.target} +
+
+ ); +} + +export default function StoragePanel({ stackName }: { stackName: string }) { + const { activeNode } = useNodes(); + const { isAdmin } = useAuth(); + const nodeId = activeNode?.id; + + const [inventory, setInventory] = useState(null); + const [loadError, setLoadError] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + // Recency is computed in the effect (impure `Date.now` belongs there, not in render). + const [snapshot, setSnapshot] = useState<{ at: number | null; recent: boolean }>({ at: null, recent: false }); + + // Load the inventory when the stack or active node changes. Read-only. + useEffect(() => { + let cancelled = false; + const run = async () => { + setLoadError(false); + try { + const res = await apiFetch(`/stacks/${stackName}/storage`); + if (cancelled) return; + if (!res.ok) { + setLoadError(true); + toast.error('Failed to load the storage inventory.'); + return; + } + setInventory((await res.json()) as StorageInventory); + setLoadError(false); + } catch { + if (!cancelled) { + setLoadError(true); + toast.error('Failed to load the storage inventory.'); + } + } + }; + void run(); + return () => { cancelled = true; }; + }, [stackName, nodeId, reloadKey]); + + // Snapshot coverage lives only in the hub database (admin-scoped), so it is + // fetched with localOnly and merged client-side. Non-admins skip it and see + // the static caveat only. + useEffect(() => { + if (!isAdmin || nodeId === undefined || nodeId === null) return; + const controller = new AbortController(); + void (async () => { + try { + const res = await apiFetch( + `/fleet/snapshots/coverage?nodeId=${nodeId}&stackName=${encodeURIComponent(stackName)}`, + { localOnly: true, signal: controller.signal }, + ); + if (!res.ok) return; + const data = await res.json(); + const at = typeof data?.latestAt === 'number' ? data.latestAt : null; + setSnapshot({ at, recent: at !== null && Date.now() - at < RECENT_SNAPSHOT_WINDOW_MS }); + } catch { + // Coverage is advisory; a failure simply leaves the warning unshown. + } + })(); + return () => controller.abort(); + }, [stackName, nodeId, isAdmin, reloadKey]); + + const showSnapshotWarning = isAdmin && inventory?.stateful === true && !snapshot.recent; + + const services = inventory ? [...new Set(inventory.mounts.map(m => m.service))] : []; + + return ( +
+ storage portability + + {loadError ? ( +
+ Could not load the storage inventory. + +
+ ) : !inventory ? ( +
Loading storage…
+ ) : !inventory.renderable ? ( +
+
+ + cannot render +
+
+ {inventory.renderError ?? 'Sencho could not render the effective Compose model.'} +
+
+ ) : ( + <> + + + {inventory.mounts.length === 0 ? ( +
+ + This stack declares no mounts. +
+ ) : ( + services.map(service => ( +
+
{service}
+
+ {inventory.mounts.filter(m => m.service === service).map((m, i) => ( + + ))} +
+
+ )) + )} + +
+
snapshot coverage
+ {showSnapshotWarning && ( +
+
+ + + This stack has persistent storage but no fleet snapshot in the last 7 days. + +
+
+ )} + {isAdmin && inventory.stateful && snapshot.recent && snapshot.at && ( +
+ Last fleet snapshot {formatTimeAgo(snapshot.at)}. +
+ )} +
+ + + Fleet snapshots capture Compose and env files, not the data inside named volumes or bind mounts. Back up volume data separately before moving or restoring. + +
+
+ + )} +
+ ); +} + +function PortabilityCard({ portability }: { portability: StorageInventory['portability'] }) { + const meta = STATUS_META[portability.status] ?? STATUS_META.unknown; + const Icon = meta.icon; + return ( +
+
+ + {meta.label} +
+ {portability.reasons.length > 0 && ( +
    + {portability.reasons.map((r, i) => ( +
  • · {r}
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index b36fc191..1c6d58af 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -27,6 +27,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'compose-storage', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/frontend/src/lib/dossierMarkdown.ts b/frontend/src/lib/dossierMarkdown.ts index c0f51eec..19effbdd 100644 --- a/frontend/src/lib/dossierMarkdown.ts +++ b/frontend/src/lib/dossierMarkdown.ts @@ -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'); diff --git a/frontend/src/lib/storageSummary.test.ts b/frontend/src/lib/storageSummary.test.ts new file mode 100644 index 00000000..ee220c5a --- /dev/null +++ b/frontend/src/lib/storageSummary.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/storageSummary.ts b/frontend/src/lib/storageSummary.ts new file mode 100644 index 00000000..4aca08e9 --- /dev/null +++ b/frontend/src/lib/storageSummary.ts @@ -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 = { + '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(['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> & 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'); +}