mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 13:48:03 +00:00
feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It shows a read-only summary auto-derived from the stack's Compose anatomy (services, ports, volumes, network, restart policy, env file, source) plus an editable form for the context Sencho cannot infer: purpose, owner, access URLs, static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and custom notes. Notes persist per stack and per node in a new stack_dossiers table, reached transparently through the remote-node proxy so a remote stack's dossier round-trips to the node that owns it. Reading a dossier needs stack read permission; saving needs stack edit. The tab exports a single Markdown document combining the generated facts and the operator notes, with copy-to-clipboard and download actions; env values are never exported, only variable names and counts. Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is removed since the Dossier export supersedes it. * fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete A dossier endpoint validated the stack name but not that the stack exists, so an editor could PUT a dossier for a name with no stack, leaving an orphan row that a later stack of the same name would inherit. Require the stack to exist (existing requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise. Also clear a node's stack_dossiers rows when the node is deleted, alongside the other node-scoped cleanup, so removing a node leaves no orphan dossiers. Docs: scope the "no secret exported" statement to the generated facts (which only ever carry variable names and counts) and clarify that operator notes are exported exactly as written.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Integration tests for the per-stack Dossier endpoints: blank-on-first-read,
|
||||
* upsert persistence, full-document save semantics, field validation, RBAC, and
|
||||
* per (node, stack) scoping at the DAO layer.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
|
||||
const FIELD_KEYS = [
|
||||
'purpose', 'owner', 'access_urls', 'static_ip', 'vlan',
|
||||
'firewall_notes', 'reverse_proxy_notes', 'backup_notes',
|
||||
'upgrade_notes', 'recovery_notes', 'custom_notes',
|
||||
] as const;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
// The dossier routes require the stack to exist on disk, so seed a compose
|
||||
// file for every stack name these tests drive through the HTTP API.
|
||||
const composeDir = process.env.COMPOSE_DIR as string;
|
||||
for (const stack of ['web', 'upd', 'clr']) {
|
||||
fs.mkdirSync(path.join(composeDir, stack), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, stack, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||
}
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'viewer', password: 'viewerpass' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('GET /api/stacks/:stackName/dossier', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/stacks/web/dossier');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns a blank dossier (200, all fields empty) when none has been saved', async () => {
|
||||
const res = await request(app).get('/api/stacks/web/dossier').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stack_name).toBe('web');
|
||||
for (const k of FIELD_KEYS) expect(res.body[k]).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/stacks/:stackName/dossier', () => {
|
||||
it('persists operator fields and returns the saved row', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/web/dossier')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ purpose: 'Reverse proxy', owner: 'ops', static_ip: '10.0.20.5', vlan: '20', firewall_notes: '443 open' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.purpose).toBe('Reverse proxy');
|
||||
expect(res.body.static_ip).toBe('10.0.20.5');
|
||||
|
||||
const get = await request(app).get('/api/stacks/web/dossier').set('Cookie', adminCookie);
|
||||
expect(get.body.owner).toBe('ops');
|
||||
expect(get.body.firewall_notes).toBe('443 open');
|
||||
});
|
||||
|
||||
it('updates in place: a second PUT keeps a single row and preserves created_at', async () => {
|
||||
const first = await request(app).put('/api/stacks/upd/dossier').set('Cookie', adminCookie).send({ purpose: 'one' });
|
||||
const createdAt = first.body.created_at as number;
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
const second = await request(app).put('/api/stacks/upd/dossier').set('Cookie', adminCookie).send({ purpose: 'two' });
|
||||
expect(second.body.purpose).toBe('two');
|
||||
expect(second.body.created_at).toBe(createdAt);
|
||||
expect(second.body.updated_at).toBeGreaterThanOrEqual(createdAt);
|
||||
|
||||
const row = DatabaseService.getInstance().getDb()
|
||||
.prepare("SELECT COUNT(*) AS n FROM stack_dossiers WHERE stack_name = 'upd'").get() as { n: number };
|
||||
expect(row.n).toBe(1);
|
||||
});
|
||||
|
||||
it('treats a save as a full document: omitted short and block fields are cleared', async () => {
|
||||
await request(app).put('/api/stacks/clr/dossier').set('Cookie', adminCookie)
|
||||
.send({ purpose: 'p', owner: 'o', firewall_notes: '443 open' });
|
||||
const res = await request(app).put('/api/stacks/clr/dossier').set('Cookie', adminCookie)
|
||||
.send({ purpose: 'only purpose' });
|
||||
expect(res.body.purpose).toBe('only purpose');
|
||||
expect(res.body.owner).toBe('');
|
||||
expect(res.body.firewall_notes).toBe('');
|
||||
});
|
||||
|
||||
it('rejects an over-long field with 400', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/web/dossier')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ static_ip: 'x'.repeat(300) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dossier requires the stack to exist', () => {
|
||||
it('returns 404 for a GET on a stack that does not exist', async () => {
|
||||
const res = await request(app).get('/api/stacks/ghost/dossier').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 for a PUT on a missing stack and creates no orphan row', async () => {
|
||||
const res = await request(app).put('/api/stacks/ghost/dossier').set('Cookie', adminCookie).send({ purpose: 'orphan' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(DatabaseService.getInstance().getStackDossier(1, 'ghost')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dossier RBAC', () => {
|
||||
it('lets a viewer read the dossier', async () => {
|
||||
const res = await request(app).get('/api/stacks/web/dossier').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('forbids a viewer from saving the dossier (403)', async () => {
|
||||
const res = await request(app).put('/api/stacks/web/dossier').set('Cookie', viewerCookie).send({ purpose: 'nope' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dossier scoping (DAO)', () => {
|
||||
it('keeps dossiers isolated per (node, stack)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const fields = (purpose: string) => ({
|
||||
purpose, owner: '', access_urls: '', static_ip: '', vlan: '',
|
||||
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '',
|
||||
upgrade_notes: '', recovery_notes: '', custom_notes: '',
|
||||
});
|
||||
db.upsertStackDossier(1, 'shared', fields('node-1'));
|
||||
db.upsertStackDossier(2, 'shared', fields('node-2'));
|
||||
expect(db.getStackDossier(1, 'shared')?.purpose).toBe('node-1');
|
||||
expect(db.getStackDossier(2, 'shared')?.purpose).toBe('node-2');
|
||||
|
||||
db.deleteStackDossier(1, 'shared');
|
||||
expect(db.getStackDossier(1, 'shared')).toBeUndefined();
|
||||
expect(db.getStackDossier(2, 'shared')?.purpose).toBe('node-2');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import multer from 'multer';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
@@ -651,6 +652,58 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Stack Dossier: operator-authored documentation persisted per (node, stack).
|
||||
// All fields default to '' so a PUT is a full-document save (an omitted field
|
||||
// clears it) and a GET for a stack with no dossier yet returns a clean blank.
|
||||
const dossierField = (max: number) => z.string().max(max).default('');
|
||||
const StackDossierUpdateSchema = z.object({
|
||||
purpose: dossierField(1000),
|
||||
owner: dossierField(1000),
|
||||
access_urls: dossierField(2000),
|
||||
static_ip: dossierField(255),
|
||||
vlan: dossierField(255),
|
||||
firewall_notes: dossierField(8000),
|
||||
reverse_proxy_notes: dossierField(8000),
|
||||
backup_notes: dossierField(8000),
|
||||
upgrade_notes: dossierField(8000),
|
||||
recovery_notes: dossierField(8000),
|
||||
custom_notes: dossierField(8000),
|
||||
});
|
||||
const emptyDossierFields = (): StackDossierFields => StackDossierUpdateSchema.parse({});
|
||||
|
||||
stacksRouter.get('/:stackName/dossier', 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 {
|
||||
const row = DatabaseService.getInstance().getStackDossier(req.nodeId, stackName);
|
||||
// No dossier yet: answer 200 with a blank document so the editor loads clean
|
||||
// rather than forcing the client to special-case a 404.
|
||||
res.json(row ?? { node_id: req.nodeId, stack_name: stackName, ...emptyDossierFields(), created_at: 0, updated_at: 0 });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to read dossier:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to read dossier' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName/dossier', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
const parsed = StackDossierUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const row = DatabaseService.getInstance().upsertStackDossier(req.nodeId, stackName, parsed.data);
|
||||
res.json(row);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to save dossier:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to save dossier' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
try {
|
||||
@@ -884,6 +937,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
|
||||
DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName);
|
||||
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
|
||||
} catch (dbErr) {
|
||||
console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr);
|
||||
|
||||
@@ -65,6 +65,30 @@ export interface AutoHealHistoryEntry {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Operator-authored fields of a stack dossier (everything the user types). */
|
||||
export interface StackDossierFields {
|
||||
purpose: string;
|
||||
owner: string;
|
||||
access_urls: string;
|
||||
static_ip: string;
|
||||
vlan: string;
|
||||
firewall_notes: string;
|
||||
reverse_proxy_notes: string;
|
||||
backup_notes: string;
|
||||
upgrade_notes: string;
|
||||
recovery_notes: string;
|
||||
custom_notes: string;
|
||||
}
|
||||
|
||||
/** A persisted stack dossier row: operator fields plus identity and timestamps. */
|
||||
export interface StackDossier extends StackDossierFields {
|
||||
id?: number;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -1131,6 +1155,26 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_auto_heal_history_policy_ts
|
||||
ON auto_heal_history(policy_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_dossiers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT '',
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
access_urls TEXT NOT NULL DEFAULT '',
|
||||
static_ip TEXT NOT NULL DEFAULT '',
|
||||
vlan TEXT NOT NULL DEFAULT '',
|
||||
firewall_notes TEXT NOT NULL DEFAULT '',
|
||||
reverse_proxy_notes TEXT NOT NULL DEFAULT '',
|
||||
backup_notes TEXT NOT NULL DEFAULT '',
|
||||
upgrade_notes TEXT NOT NULL DEFAULT '',
|
||||
recovery_notes TEXT NOT NULL DEFAULT '',
|
||||
custom_notes TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(node_id, stack_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -2014,6 +2058,57 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE auto_heal_policies SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, Date.now(), policyId);
|
||||
}
|
||||
|
||||
// --- Stack Dossiers ---
|
||||
|
||||
public getStackDossier(nodeId: number, stackName: string): StackDossier | undefined {
|
||||
return this.db.prepare('SELECT * FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName) as StackDossier | undefined;
|
||||
}
|
||||
|
||||
public upsertStackDossier(nodeId: number, stackName: string, fields: StackDossierFields): StackDossier {
|
||||
const now = Date.now();
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_dossiers (
|
||||
node_id, stack_name, purpose, owner, access_urls, static_ip, vlan,
|
||||
firewall_notes, reverse_proxy_notes, backup_notes, upgrade_notes,
|
||||
recovery_notes, custom_notes, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name) DO UPDATE SET
|
||||
purpose = excluded.purpose,
|
||||
owner = excluded.owner,
|
||||
access_urls = excluded.access_urls,
|
||||
static_ip = excluded.static_ip,
|
||||
vlan = excluded.vlan,
|
||||
firewall_notes = excluded.firewall_notes,
|
||||
reverse_proxy_notes = excluded.reverse_proxy_notes,
|
||||
backup_notes = excluded.backup_notes,
|
||||
upgrade_notes = excluded.upgrade_notes,
|
||||
recovery_notes = excluded.recovery_notes,
|
||||
custom_notes = excluded.custom_notes,
|
||||
updated_at = excluded.updated_at`
|
||||
).run(
|
||||
nodeId,
|
||||
stackName,
|
||||
fields.purpose,
|
||||
fields.owner,
|
||||
fields.access_urls,
|
||||
fields.static_ip,
|
||||
fields.vlan,
|
||||
fields.firewall_notes,
|
||||
fields.reverse_proxy_notes,
|
||||
fields.backup_notes,
|
||||
fields.upgrade_notes,
|
||||
fields.recovery_notes,
|
||||
fields.custom_notes,
|
||||
now,
|
||||
now
|
||||
);
|
||||
return this.getStackDossier(nodeId, stackName) as StackDossier;
|
||||
}
|
||||
|
||||
public deleteStackDossier(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Notification History ---
|
||||
|
||||
private mapNotificationRow(row: any): NotificationHistory {
|
||||
@@ -2350,6 +2445,7 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id);
|
||||
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
|
||||
this.deleteRoleAssignmentsByResource('node', String(id));
|
||||
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
|
||||
|
||||
Reference in New Issue
Block a user