mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
9ea2864d60
* 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.
96 lines
4.1 KiB
TypeScript
96 lines
4.1 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
});
|
|
});
|