mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user