mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +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:
@@ -15,7 +15,7 @@ import { assembleStackNetworkFacts } from '../services/network/composeNetworkIns
|
||||
|
||||
function effSvc(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,
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): StorageMount {
|
||||
return { type: 'named', source: 'db', target: '/db', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over };
|
||||
}
|
||||
function anonVol(over: Partial<StorageMount> = {}): StorageMount {
|
||||
return { type: 'anonymous', target: '/anon', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over };
|
||||
}
|
||||
function tmpfsMount(over: Partial<StorageMount> = {}): 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<string, HostPathProbe>([['/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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -39,6 +39,7 @@ export const CAPABILITIES = [
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
'compose-storage',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, HostPathProbe>): 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<string, HostPathProbe>,
|
||||
): 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<Map<string, HostPathProbe>> {
|
||||
const sources = new Set<string>();
|
||||
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<string, HostPathProbe>();
|
||||
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<StorageInventory> {
|
||||
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<string, HostPathProbe>();
|
||||
return assembleStorageInventory(stackName, model, renderError, probes);
|
||||
}
|
||||
@@ -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<HostPathProbe> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EffStorageMount, 'source' | 'target'>): boolean {
|
||||
return (m.source?.includes('docker.sock') ?? false) || m.target.includes('docker.sock');
|
||||
}
|
||||
Reference in New Issue
Block a user