feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)

* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+)

Centralized, encrypted-at-rest secret bundles that can be pushed to labeled
nodes' stacks. Each save bumps a monotonic version; each push records a
per-node-per-version row in `secret_pushes` plus an entry in `audit_log`.
Conflict detection shows added/changed/unchanged/removed (informational)
diffs before write. Overlay merge preserves keys missing from the bundle.

- Adds `secrets`, `secret_versions`, `secret_pushes` tables.
- New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService
  for selectors, and direct fetch + Bearer for outbound calls to remote nodes.
- New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by
  `requirePaid`. Mounted after the auth gate.
- Audit summary patterns added for the new routes.
- New Fleet › Secrets tab with bundle list, editor sheet (key=value rows,
  versions tab), and push wizard (selector, target stack, env file picker,
  per-node diff preview, results pills).
- Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry.
- 26 Vitest cases cover parser, encryption, versioning, push aggregation,
  tier gating.

* fix(fleet): use const for rawValue in env parser

ESLint prefer-const flagged the let declaration as a CI-blocking error;
the variable is never reassigned.
This commit is contained in:
Anso
2026-05-07 06:03:39 -04:00
committed by GitHub
parent 52b46753af
commit 0f0b22c51a
14 changed files with 2550 additions and 21 deletions
+401
View File
@@ -0,0 +1,401 @@
/**
* Tests for Fleet Secrets v1 MVP:
* - parseEnv / serializeEnv round-trip
* - applyOverlay / computeDiff
* - encrypt round-trip via CryptoService
* - DatabaseService secret + version + push CRUD
* - SecretsService versioning, importFromStack, executePush aggregation
* - Route guards (requirePaid 403, push lock 409)
* - getAuditSummary patterns for /secrets routes
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { getAuditSummary } from '../utils/audit-summaries';
import { parseEnv, serializeEnv, applyOverlay, computeDiff } from '../services/SecretsService';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let SecretsService: typeof import('../services/SecretsService').SecretsService;
let CryptoService: typeof import('../services/CryptoService').CryptoService;
function authToken(username: string, role: string = 'admin', tv?: number): string {
const payload: Record<string, unknown> = { username, role };
if (tv !== undefined) payload.tv = tv;
return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' });
}
function adminToken(): string {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(TEST_USERNAME)!;
return authToken(TEST_USERNAME, 'admin', user.token_version);
}
function clearSecretsTables(): void {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM secret_pushes').run();
db.prepare('DELETE FROM secret_versions').run();
db.prepare('DELETE FROM secrets').run();
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ SecretsService } = await import('../services/SecretsService'));
({ CryptoService } = await import('../services/CryptoService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
clearSecretsTables();
});
// ---- Pure functions ----
describe('parseEnv / serializeEnv', () => {
it('parses simple key=value lines', () => {
const kv = parseEnv('FOO=bar\nBAZ=qux\n');
expect(kv).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
it('drops blank lines and # comments', () => {
const kv = parseEnv('# comment\n\nFOO=bar\n# another\n');
expect(kv).toEqual({ FOO: 'bar' });
});
it('strips matching outer double quotes and decodes escapes', () => {
const kv = parseEnv('FOO="hello world"\nBAR="multi\\nline"\n');
expect(kv.FOO).toBe('hello world');
expect(kv.BAR).toBe('multi\nline');
});
it('strips matching outer single quotes literally', () => {
const kv = parseEnv("FOO='raw \\n value'\n");
expect(kv.FOO).toBe('raw \\n value');
});
it('handles empty values', () => {
const kv = parseEnv('EMPTY=\nNON_EMPTY=x\n');
expect(kv.EMPTY).toBe('');
expect(kv.NON_EMPTY).toBe('x');
});
it('strips trailing inline comments on bare values', () => {
const kv = parseEnv('FOO=bar # trailing\n');
expect(kv.FOO).toBe('bar');
});
it('drops malformed keys', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const kv = parseEnv('1BAD=oops\nGOOD=ok\n');
expect(kv).toEqual({ GOOD: 'ok' });
warnSpy.mockRestore();
});
it('round-trips serialize → parse', () => {
const original = { FOO: 'simple', SPACED: 'two words', QUOTED: 'has "quote"', EMPTY: '' };
const serialized = serializeEnv(original);
const parsed = parseEnv(serialized);
expect(parsed).toEqual(original);
});
it('returns empty string for empty map', () => {
expect(serializeEnv({})).toBe('');
});
});
describe('applyOverlay / computeDiff', () => {
it('preserves keys absent from overlay (overlay merge)', () => {
const merged = applyOverlay({ KEEP: 'x', REPLACE: 'old' }, { REPLACE: 'new', NEW: 'fresh' });
expect(merged).toEqual({ KEEP: 'x', REPLACE: 'new', NEW: 'fresh' });
});
it('classifies all four diff statuses', () => {
const diff = computeDiff(
{ KEEP: 'same', REPLACE: 'old', GONE: 'orphan' },
{ KEEP: 'same', REPLACE: 'new', NEW: 'fresh' },
);
const byKey = Object.fromEntries(diff.map(e => [e.key, e]));
expect(byKey.KEEP.status).toBe('unchanged');
expect(byKey.REPLACE.status).toBe('changed');
expect(byKey.REPLACE.before).toBe('old');
expect(byKey.REPLACE.after).toBe('new');
expect(byKey.NEW.status).toBe('added');
expect(byKey.GONE.status).toBe('removed');
expect(byKey.GONE.before).toBe('orphan');
});
});
// ---- Encryption round-trip ----
describe('encryption via CryptoService', () => {
it('round-trips a JSON-encoded KV map', () => {
const kv = { DB_URL: 'postgres://localhost/app', API_KEY: 'abc123' };
const cipher = CryptoService.getInstance().encrypt(JSON.stringify(kv));
expect(cipher.startsWith('enc:')).toBe(true);
const plain = CryptoService.getInstance().decrypt(cipher);
expect(JSON.parse(plain)).toEqual(kv);
});
});
// ---- DatabaseService secret accessors ----
describe('DatabaseService secret accessors', () => {
it('creates a secret with v1 and bumps to v2 on update', () => {
const db = DatabaseService.getInstance();
const created = db.createSecretWithVersion({
name: 'creds',
description: 'app creds',
encryptedPayload: 'enc:dummy',
keyCount: 2,
createdBy: TEST_USERNAME,
note: '',
});
expect(created.version).toBe(1);
const row = db.getSecret(created.id)!;
expect(row.current_version).toBe(1);
const updated = db.updateSecretWithVersion({
secretId: created.id,
description: 'updated',
encryptedPayload: 'enc:dummy2',
keyCount: 3,
createdBy: TEST_USERNAME,
note: 'added DEBUG',
});
expect(updated.version).toBe(2);
const refreshed = db.getSecret(created.id)!;
expect(refreshed.current_version).toBe(2);
expect(refreshed.description).toBe('updated');
const versions = db.listSecretVersions(created.id);
expect(versions.map(v => v.version)).toEqual([2, 1]);
});
it('cascades versions and pushes on delete', () => {
const db = DatabaseService.getInstance();
const created = db.createSecretWithVersion({
name: 'to-delete',
description: '',
encryptedPayload: 'enc:x',
keyCount: 1,
createdBy: TEST_USERNAME,
note: '',
});
db.insertSecretPushes([{
secret_id: created.id,
version: 1,
push_id: 'push-1',
node_id: 1,
stack_name: 'demo',
env_file_basename: '.env',
status: 'ok',
error: '',
added_count: 1,
changed_count: 0,
unchanged_count: 0,
pushed_by: TEST_USERNAME,
pushed_at: Date.now(),
}]);
expect(db.deleteSecret(created.id)).toBe(true);
expect(db.getSecret(created.id)).toBeUndefined();
expect(db.listSecretVersions(created.id)).toEqual([]);
expect(db.listSecretPushes(created.id)).toEqual([]);
});
});
// ---- SecretsService versioning + decryption ----
describe('SecretsService', () => {
it('encrypts on create and decrypts on read', () => {
const svc = SecretsService.getInstance();
const { id } = svc.create({
name: 'app-secrets',
description: 'app',
kv: { DB_URL: 'postgres://x', API_KEY: 'abc' },
user: TEST_USERNAME,
});
const kv = svc.getDecryptedKv(id);
expect(kv).toEqual({ DB_URL: 'postgres://x', API_KEY: 'abc' });
// Stored payload must be encrypted, not plaintext.
const versionRow = DatabaseService.getInstance().getCurrentSecretVersion(id)!;
expect(versionRow.encrypted_payload.startsWith('enc:')).toBe(true);
expect(versionRow.encrypted_payload).not.toContain('postgres://x');
});
it('rejects invalid env keys at create', () => {
const svc = SecretsService.getInstance();
expect(() => svc.create({
name: 'bad-keys',
kv: { '1BAD': 'value' },
user: TEST_USERNAME,
})).toThrow(/Invalid env key/);
});
it('listVersions returns newest first', () => {
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'v-list', kv: { A: '1' }, user: TEST_USERNAME });
svc.update(id, { kv: { A: '2', B: '3' }, user: TEST_USERNAME, note: 'add B' });
svc.update(id, { kv: { A: '4' }, user: TEST_USERNAME });
const versions = svc.listVersions(id);
expect(versions.map(v => v.version)).toEqual([3, 2, 1]);
expect(versions[1].note).toBe('add B');
});
});
// ---- importFromStack against the local node ----
describe('SecretsService.importFromStack (local node)', () => {
beforeEach(() => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'demo');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'DB_URL=postgres://x\nAPI_KEY=abc\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
});
it('reads the local stack .env into a KV map', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const kv = await SecretsService.getInstance().importFromStack(localNode.id, 'demo', '.env');
expect(kv).toEqual({ DB_URL: 'postgres://x', API_KEY: 'abc' });
});
});
// ---- executePush against the local node ----
describe('SecretsService.executePush (local node)', () => {
beforeEach(() => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'targetstack');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\nAPI_KEY=old\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
});
it('overlays bundle keys onto the existing .env and records audit rows', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({
name: 'push-bundle',
kv: { API_KEY: 'new', DEBUG: '1' },
user: TEST_USERNAME,
});
const result = await svc.executePush(
id,
{ type: 'nodes', ids: [localNode.id] },
'targetstack',
'.env',
TEST_USERNAME,
);
expect(result.results).toHaveLength(1);
expect(result.results[0].status).toBe('ok');
expect(result.results[0].added).toBe(1); // DEBUG
expect(result.results[0].changed).toBe(1); // API_KEY
const composeDir = process.env.COMPOSE_DIR!;
const envText = fs.readFileSync(path.join(composeDir, 'targetstack', '.env'), 'utf-8');
const kv = parseEnv(envText);
// Overlay: existing EXISTING preserved, API_KEY replaced, DEBUG added.
expect(kv.EXISTING).toBe('keep');
expect(kv.API_KEY).toBe('new');
expect(kv.DEBUG).toBe('1');
const pushes = db.listSecretPushes(id);
expect(pushes).toHaveLength(1);
expect(pushes[0].status).toBe('ok');
expect(pushes[0].push_id).toBe(result.pushId);
});
it('marks a node failed when the env file is not declared and continues', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'push-bad-file', kv: { X: '1' }, user: TEST_USERNAME });
const result = await svc.executePush(
id,
{ type: 'nodes', ids: [localNode.id] },
'targetstack',
'nonexistent.env',
TEST_USERNAME,
);
expect(result.results[0].status).toBe('failed');
expect(result.results[0].error).toMatch(/not found/);
});
});
// ---- getAuditSummary patterns ----
describe('getAuditSummary for secrets routes', () => {
it('resolves create / update / delete', () => {
expect(getAuditSummary('POST', '/secrets')).toBe('Created secret');
expect(getAuditSummary('PUT', '/secrets/42')).toBe('Updated secret: 42');
expect(getAuditSummary('DELETE', '/secrets/42')).toBe('Deleted secret: 42');
});
it('resolves push and import wildcards', () => {
expect(getAuditSummary('POST', '/secrets/42/import-from-stack')).toBe('Imported env into secret: 42');
expect(getAuditSummary('POST', '/secrets/42/push/preview')).toBe('Previewed secret push: 42');
expect(getAuditSummary('POST', '/secrets/42/push')).toBe('Pushed secret: 42');
});
});
// ---- Route guards via supertest ----
describe('Routes /api/secrets tier gating and lock', () => {
it('returns 403 when license is community', async () => {
const { LicenseService } = await import('../services/LicenseService');
// Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests.
// requirePaid only consults getTier once per request via effectiveTier(req).
const inst = LicenseService.getInstance();
const tierSpy = vi.spyOn(inst, 'getTier');
tierSpy.mockReturnValueOnce('community');
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('rejects unauthenticated requests', async () => {
const res = await request(app).get('/api/secrets');
expect(res.status).toBe(401);
});
it('returns 200 when paid', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('rejects malformed body on POST /secrets', async () => {
const res = await request(app)
.post('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ name: 'x', kv: 'not-an-object' });
expect(res.status).toBe(400);
});
});
+2
View File
@@ -48,6 +48,7 @@ import { containersRouter, portsRouter } from './routes/containers';
import { nodesRouter } from './routes/nodes';
import { stacksRouter } from './routes/stacks';
import { stackActivityRouter } from './routes/stackActivity';
import { secretsRouter } from './routes/secrets';
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
// util._extend internally. The warning fires at runtime when createProxyServer() is
@@ -96,6 +97,7 @@ app.use('/api/convert', convertRouter);
app.use('/api/alerts', alertsRouter);
app.use('/api/labels', labelsRouter);
app.use('/api/stacks', stackLabelsRouter);
app.use('/api/secrets', secretsRouter);
app.use('/api/api-tokens', apiTokensRouter);
app.use('/api/audit-log', auditLogRouter);
app.use('/api/fleet', fleetRouter);
+282
View File
@@ -0,0 +1,282 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireBody } from '../middleware/tierGates';
import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService';
import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
export const secretsRouter = Router();
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/;
function isValidName(name: unknown): name is string {
return typeof name === 'string' && NAME_PATTERN.test(name);
}
function isValidEnvFileBasename(name: unknown): name is string {
if (typeof name !== 'string' || name.length === 0 || name.length > 64) return false;
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(name);
}
function isValidKv(value: unknown): value is SecretKv {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (typeof k !== 'string' || typeof v !== 'string') return false;
}
return true;
}
function isValidSelector(value: unknown): value is BlueprintSelector {
if (!value || typeof value !== 'object') return false;
const sel = value as { type?: unknown; ids?: unknown; any?: unknown; all?: unknown };
if (sel.type === 'nodes') {
return Array.isArray(sel.ids) && sel.ids.every(id => typeof id === 'number');
}
if (sel.type === 'labels') {
const isStringArr = (a: unknown) => Array.isArray(a) && a.every(s => typeof s === 'string');
return isStringArr(sel.any) && isStringArr(sel.all);
}
return false;
}
function getActor(req: Request): string {
return req.user?.username || 'unknown';
}
interface PushBody { selector: BlueprintSelector; stackName: string; envFileBasename: string }
function parsePushBody(body: unknown): PushBody | { error: string } {
if (!body || typeof body !== 'object') return { error: 'Request body is required' };
const { selector, stackName, envFileBasename } = body as { selector?: unknown; stackName?: unknown; envFileBasename?: unknown };
if (!isValidSelector(selector)) return { error: 'selector is invalid' };
if (typeof stackName !== 'string' || !isValidStackName(stackName)) return { error: 'stackName is invalid' };
const basename = envFileBasename === undefined ? '.env' : envFileBasename;
if (!isValidEnvFileBasename(basename)) return { error: 'envFileBasename is invalid' };
return { selector, stackName, envFileBasename: basename };
}
secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const items = SecretsService.getInstance().list();
res.json(items);
} catch (err) {
console.error('[Secrets] List error:', err);
res.status(500).json({ error: 'Failed to list secrets' });
}
});
secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const { name, description, kv, note } = req.body as { name?: unknown; description?: unknown; kv?: unknown; note?: unknown };
if (!isValidName(name)) {
res.status(400).json({ error: 'name is required (letters, digits, spaces, dot, dash, underscore; 2-64 chars)' });
return;
}
if (description !== undefined && typeof description !== 'string') {
res.status(400).json({ error: 'description must be a string' });
return;
}
if (!isValidKv(kv)) {
res.status(400).json({ error: 'kv must be an object of string values' });
return;
}
if (note !== undefined && typeof note !== 'string') {
res.status(400).json({ error: 'note must be a string' });
return;
}
const result = SecretsService.getInstance().create({
name,
description: typeof description === 'string' ? description : undefined,
kv,
user: getActor(req),
note: typeof note === 'string' ? note : undefined,
});
res.status(201).json(result);
} catch (err) {
if (isSqliteUniqueViolation(err)) {
res.status(409).json({ error: 'A secret with that name already exists' });
return;
}
console.error('[Secrets] Create error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to create secret') });
}
});
secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const summary = SecretsService.getInstance().getCurrent(id);
if (!summary) {
res.status(404).json({ error: 'Secret not found' });
return;
}
const kv = SecretsService.getInstance().getDecryptedKv(id);
res.json({ ...summary, kv });
} catch (err) {
console.error('[Secrets] Get error:', err);
res.status(500).json({ error: 'Failed to read secret' });
}
});
secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const { description, kv, note } = req.body as { description?: unknown; kv?: unknown; note?: unknown };
if (description !== undefined && typeof description !== 'string') {
res.status(400).json({ error: 'description must be a string' });
return;
}
if (!isValidKv(kv)) {
res.status(400).json({ error: 'kv must be an object of string values' });
return;
}
if (note !== undefined && typeof note !== 'string') {
res.status(400).json({ error: 'note must be a string' });
return;
}
const existing = DatabaseService.getInstance().getSecret(id);
if (!existing) {
res.status(404).json({ error: 'Secret not found' });
return;
}
const result = SecretsService.getInstance().update(id, {
description: typeof description === 'string' ? description : undefined,
kv,
user: getActor(req),
note: typeof note === 'string' ? note : undefined,
});
res.json(result);
} catch (err) {
console.error('[Secrets] Update error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to update secret') });
}
});
secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const ok = SecretsService.getInstance().delete(id);
if (!ok) {
res.status(404).json({ error: 'Secret not found' });
return;
}
res.json({ ok: true });
} catch (err) {
console.error('[Secrets] Delete error:', err);
res.status(500).json({ error: 'Failed to delete secret' });
}
});
secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
if (!DatabaseService.getInstance().getSecret(id)) {
res.status(404).json({ error: 'Secret not found' });
return;
}
res.json(SecretsService.getInstance().listVersions(id));
} catch (err) {
console.error('[Secrets] Versions error:', err);
res.status(500).json({ error: 'Failed to list versions' });
}
});
secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const { nodeId, stackName, envFileBasename } = req.body as { nodeId?: unknown; stackName?: unknown; envFileBasename?: unknown };
if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) {
res.status(400).json({ error: 'nodeId must be a number' });
return;
}
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
res.status(400).json({ error: 'stackName is invalid' });
return;
}
const basename = envFileBasename === undefined ? '.env' : envFileBasename;
if (!isValidEnvFileBasename(basename)) {
res.status(400).json({ error: 'envFileBasename is invalid' });
return;
}
const kv = await SecretsService.getInstance().importFromStack(nodeId, stackName, basename);
res.json({ kv });
} catch (err) {
console.error('[Secrets] Import error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to import env from stack') });
}
});
secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const parsed = parsePushBody(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
if (!DatabaseService.getInstance().getSecret(id)) {
res.status(404).json({ error: 'Secret not found' });
return;
}
const plan = await SecretsService.getInstance().previewPushDiff(id, parsed.selector, parsed.stackName, parsed.envFileBasename);
res.json(plan);
} catch (err) {
console.error('[Secrets] Preview error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to preview push') });
}
});
secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
if (id === null) return;
const parsed = parsePushBody(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const secret = DatabaseService.getInstance().getSecret(id);
if (!secret) {
res.status(404).json({ error: 'Secret not found' });
return;
}
try {
const result = await SecretsService.getInstance().executePush(id, parsed.selector, parsed.stackName, parsed.envFileBasename, getActor(req));
console.log(`[Secrets] Push ${sanitizeForLog(secret.name)} v${secret.current_version}: ${result.results.length} nodes`);
res.json(result);
} catch (err) {
if (err instanceof PushBusyError) {
res.status(409).json({ error: 'A push for this secret is already running' });
return;
}
throw err;
}
} catch (err) {
console.error('[Secrets] Push error:', err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to push secret') });
}
});
+1 -1
View File
@@ -37,7 +37,7 @@ function notifyActionSuccess(category: NotificationCategory, message: string, st
.catch(err => console.error('[Stacks] Failed to dispatch activity for %s:', sanitizeForLog(stackName), err));
}
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
const fsService = FileSystemService.getInstance(nodeId);
const stackDir = path.join(fsService.getBaseDir(), stackName);
const defaultEnvPath = path.join(stackDir, '.env');
+197
View File
@@ -306,6 +306,46 @@ export interface AuditLogEntry {
summary: string;
}
export interface SecretRow {
id: number;
name: string;
description: string;
current_version: number;
created_at: number;
created_by: string;
updated_at: number;
}
export interface SecretVersionRow {
id: number;
secret_id: number;
version: number;
encrypted_payload: string;
key_count: number;
created_at: number;
created_by: string;
note: string;
}
export type SecretPushStatus = 'ok' | 'failed' | 'skipped';
export interface SecretPushRow {
id: number;
secret_id: number;
version: number;
push_id: string;
node_id: number;
stack_name: string;
env_file_basename: string;
status: SecretPushStatus;
error: string;
added_count: number;
changed_count: number;
unchanged_count: number;
pushed_by: string;
pushed_at: number;
}
export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin';
/** Map an API token's scope to the synthesized user role used during request authorization. */
@@ -1030,6 +1070,52 @@ 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 secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
current_version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
CREATE TABLE IF NOT EXISTS secret_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret_id INTEGER NOT NULL,
version INTEGER NOT NULL,
encrypted_payload TEXT NOT NULL,
key_count INTEGER NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
UNIQUE(secret_id, version),
FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_secret_versions_secret ON secret_versions(secret_id);
CREATE TABLE IF NOT EXISTS secret_pushes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret_id INTEGER NOT NULL,
version INTEGER NOT NULL,
push_id TEXT NOT NULL,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
env_file_basename TEXT NOT NULL DEFAULT '.env',
status TEXT NOT NULL,
error TEXT NOT NULL DEFAULT '',
added_count INTEGER NOT NULL DEFAULT 0,
changed_count INTEGER NOT NULL DEFAULT 0,
unchanged_count INTEGER NOT NULL DEFAULT 0,
pushed_by TEXT NOT NULL,
pushed_at INTEGER NOT NULL,
FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_secret_pushes_push ON secret_pushes(push_id);
CREATE INDEX IF NOT EXISTS idx_secret_pushes_secret_version ON secret_pushes(secret_id, version);
CREATE INDEX IF NOT EXISTS idx_secret_pushes_node ON secret_pushes(node_id, stack_name);
`);
// Apply migrations safely (ignore if columns already exist)
@@ -4077,4 +4163,115 @@ export class DatabaseService {
public deleteDeployment(blueprintId: number, nodeId: number): void {
this.db.prepare('DELETE FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').run(blueprintId, nodeId);
}
// --- Secrets ---
public listSecrets(): SecretRow[] {
return this.db.prepare(
'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets ORDER BY name ASC'
).all() as SecretRow[];
}
public getSecret(id: number): SecretRow | undefined {
return this.db.prepare(
'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets WHERE id = ?'
).get(id) as SecretRow | undefined;
}
public listSecretVersions(secretId: number): SecretVersionRow[] {
return this.db.prepare(
'SELECT id, secret_id, version, encrypted_payload, key_count, created_at, created_by, note FROM secret_versions WHERE secret_id = ? ORDER BY version DESC'
).all(secretId) as SecretVersionRow[];
}
public getCurrentSecretVersion(secretId: number): SecretVersionRow | undefined {
return this.db.prepare(
`SELECT v.id, v.secret_id, v.version, v.encrypted_payload, v.key_count, v.created_at, v.created_by, v.note
FROM secret_versions v
INNER JOIN secrets s ON s.id = v.secret_id AND s.current_version = v.version
WHERE v.secret_id = ?`
).get(secretId) as SecretVersionRow | undefined;
}
public createSecretWithVersion(input: {
name: string;
description: string;
encryptedPayload: string;
keyCount: number;
createdBy: string;
note: string;
}): { id: number; version: number } {
const now = Date.now();
const txn = this.db.transaction(() => {
const insertSecret = this.db.prepare(
'INSERT INTO secrets (name, description, current_version, created_at, created_by, updated_at) VALUES (?, ?, 1, ?, ?, ?)'
);
const result = insertSecret.run(input.name, input.description, now, input.createdBy, now);
const secretId = Number(result.lastInsertRowid);
this.db.prepare(
'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, 1, ?, ?, ?, ?, ?)'
).run(secretId, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note);
return { id: secretId, version: 1 };
});
return txn();
}
public updateSecretWithVersion(input: {
secretId: number;
description: string | null;
encryptedPayload: string;
keyCount: number;
createdBy: string;
note: string;
}): { version: number } {
const now = Date.now();
const txn = this.db.transaction(() => {
const current = this.db.prepare('SELECT current_version FROM secrets WHERE id = ?').get(input.secretId) as { current_version: number } | undefined;
if (!current) throw new Error('Secret not found');
const nextVersion = current.current_version + 1;
this.db.prepare(
'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(input.secretId, nextVersion, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note);
if (input.description === null) {
this.db.prepare('UPDATE secrets SET current_version = ?, updated_at = ? WHERE id = ?').run(nextVersion, now, input.secretId);
} else {
this.db.prepare('UPDATE secrets SET current_version = ?, description = ?, updated_at = ? WHERE id = ?').run(nextVersion, input.description, now, input.secretId);
}
return { version: nextVersion };
});
return txn();
}
public deleteSecret(id: number): boolean {
const result = this.db.prepare('DELETE FROM secrets WHERE id = ?').run(id);
return result.changes > 0;
}
public insertSecretPushes(rows: Array<Omit<SecretPushRow, 'id'>>): void {
if (rows.length === 0) return;
const stmt = this.db.prepare(
`INSERT INTO secret_pushes (secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error, added_count, changed_count, unchanged_count, pushed_by, pushed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const txn = this.db.transaction((entries: Array<Omit<SecretPushRow, 'id'>>) => {
for (const r of entries) {
stmt.run(
r.secret_id, r.version, r.push_id, r.node_id, r.stack_name, r.env_file_basename,
r.status, r.error, r.added_count, r.changed_count, r.unchanged_count, r.pushed_by, r.pushed_at,
);
}
});
txn(rows);
}
public listSecretPushes(secretId: number, limit = 50): SecretPushRow[] {
return this.db.prepare(
`SELECT id, secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error,
added_count, changed_count, unchanged_count, pushed_by, pushed_at
FROM secret_pushes
WHERE secret_id = ?
ORDER BY pushed_at DESC
LIMIT ?`
).all(secretId, limit) as SecretPushRow[];
}
}
+526
View File
@@ -0,0 +1,526 @@
import path from 'path';
import crypto from 'crypto';
import { CryptoService } from './CryptoService';
import { DatabaseService, type BlueprintSelector, type Node, type SecretRow, type SecretVersionRow, type SecretPushStatus } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { NodeLabelService } from './NodeLabelService';
import { resolveAllEnvFilePaths } from '../routes/stacks';
import { getErrorMessage } from '../utils/errors';
export type SecretKv = Record<string, string>;
export type DiffStatus = 'added' | 'changed' | 'removed' | 'unchanged';
export interface SecretDiffEntry {
key: string;
status: DiffStatus;
before?: string;
after?: string;
}
export interface SecretSummary {
id: number;
name: string;
description: string;
currentVersion: number;
keyCount: number;
createdAt: number;
createdBy: string;
updatedAt: number;
}
export interface SecretVersionSummary {
version: number;
keyCount: number;
createdAt: number;
createdBy: string;
note: string;
}
export interface SecretPushPlanEntry {
nodeId: number;
nodeName: string;
stackName: string;
envFileBasename: string;
reachable: boolean;
stackExists: boolean;
error?: string;
diff: SecretDiffEntry[];
added: number;
changed: number;
unchanged: number;
removedInformational: number;
}
export interface SecretPushResultEntry {
nodeId: number;
nodeName: string;
stackName: string;
envFileBasename: string;
status: SecretPushStatus;
error?: string;
added: number;
changed: number;
unchanged: number;
}
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const REQUEST_TIMEOUT_MS = 10_000;
export function parseEnv(text: string): SecretKv {
const out: SecretKv = {};
if (!text) return out;
const lines = text.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq < 0) continue;
const key = line.slice(0, eq).trim();
if (!ENV_KEY_PATTERN.test(key)) {
console.warn('[Secrets] Dropping invalid env key:', key);
continue;
}
const rawValue = line.slice(eq + 1);
// Strip optional surrounding quotes.
if (rawValue.length >= 2) {
const first = rawValue[0];
const last = rawValue[rawValue.length - 1];
if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) {
const inner = rawValue.slice(1, -1);
if (first === '"') {
out[key] = inner
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
} else {
out[key] = inner;
}
continue;
}
}
// Bare value: strip trailing inline comment ` #...`
const commentIdx = rawValue.search(/\s#/);
const trimmed = (commentIdx >= 0 ? rawValue.slice(0, commentIdx) : rawValue).trim();
out[key] = trimmed;
}
return out;
}
export function serializeEnv(kv: SecretKv): string {
const keys = Object.keys(kv).sort();
const lines: string[] = [];
for (const k of keys) {
const v = kv[k];
if (/[\s#=\\"']/.test(v) || v === '') {
const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
lines.push(`${k}="${escaped}"`);
} else {
lines.push(`${k}=${v}`);
}
}
return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
}
// Bundle wins for keys it defines; keys missing from the overlay are preserved.
export function applyOverlay(existing: SecretKv, overlay: SecretKv): SecretKv {
return { ...existing, ...overlay };
}
export function computeDiff(existing: SecretKv, overlay: SecretKv): SecretDiffEntry[] {
const keys = new Set<string>([...Object.keys(existing), ...Object.keys(overlay)]);
const result: SecretDiffEntry[] = [];
for (const key of [...keys].sort()) {
const inE = key in existing;
const inO = key in overlay;
if (inO && !inE) {
result.push({ key, status: 'added', after: overlay[key] });
} else if (!inO && inE) {
result.push({ key, status: 'removed', before: existing[key] });
} else if (inO && inE && overlay[key] !== existing[key]) {
result.push({ key, status: 'changed', before: existing[key], after: overlay[key] });
} else {
result.push({ key, status: 'unchanged' });
}
}
return result;
}
function aggregateDiff(diff: SecretDiffEntry[]): { added: number; changed: number; unchanged: number; removedInformational: number } {
const r = { added: 0, changed: 0, unchanged: 0, removedInformational: 0 };
for (const e of diff) {
if (e.status === 'added') r.added += 1;
else if (e.status === 'changed') r.changed += 1;
else if (e.status === 'unchanged') r.unchanged += 1;
else r.removedInformational += 1;
}
return r;
}
function isLocalNode(node: Node): boolean {
return node.type === 'local';
}
function encryptKv(kv: SecretKv): string {
return CryptoService.getInstance().encrypt(JSON.stringify(kv));
}
function decryptKv(payload: string): SecretKv {
if (!payload) return {};
const plaintext = CryptoService.getInstance().decrypt(payload);
if (!plaintext) return {};
try {
const parsed: unknown = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const out: SecretKv = {};
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch (err) {
console.error('[Secrets] Failed to parse decrypted payload:', err);
return {};
}
}
function validateKv(kv: SecretKv): { ok: true } | { ok: false; error: string } {
for (const key of Object.keys(kv)) {
if (!ENV_KEY_PATTERN.test(key)) {
return { ok: false, error: `Invalid env key: ${key}` };
}
if (typeof kv[key] !== 'string') {
return { ok: false, error: `Value for ${key} must be a string` };
}
}
return { ok: true };
}
interface ResolvedEnvFile { absolutePath: string }
async function resolveEnvFileLocal(nodeId: number, stackName: string, basename: string): Promise<ResolvedEnvFile | null> {
const paths = await resolveAllEnvFilePaths(nodeId, stackName);
if (paths.length === 0) {
if (basename === '.env') {
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
return { absolutePath: path.join(baseDir, stackName, '.env') };
}
return null;
}
const match = paths.find(p => path.basename(p) === basename);
return match ? { absolutePath: match } : null;
}
async function resolveEnvFileRemote(node: Node, stackName: string, basename: string): Promise<ResolvedEnvFile | null> {
if (!node.api_url || !node.api_token) return null;
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const res = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/envs`, {
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
if (res.status === 404 && basename === '.env') {
return { absolutePath: '.env' };
}
throw new Error(`failed to list env files (HTTP ${res.status})`);
}
const body = await res.json() as { envFiles?: string[] };
const paths = Array.isArray(body.envFiles) ? body.envFiles : [];
if (paths.length === 0 && basename === '.env') {
return { absolutePath: '.env' };
}
const match = paths.find(p => path.basename(p) === basename);
return match ? { absolutePath: match } : null;
}
async function readEnvLocal(nodeId: number, absolutePath: string): Promise<string> {
const fsService = FileSystemService.getInstance(nodeId);
try {
return await fsService.readFile(absolutePath, 'utf-8');
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code === 'ENOENT') return '';
throw err;
}
}
async function writeEnvLocal(nodeId: number, absolutePath: string, content: string): Promise<void> {
const fsService = FileSystemService.getInstance(nodeId);
await fsService.writeFile(absolutePath, content, 'utf-8');
}
async function readEnvRemote(node: Node, stackName: string, absolutePath: string): Promise<string> {
if (!node.api_url || !node.api_token) throw new Error('node has no api_url or api_token');
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const url = new URL(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`);
if (absolutePath !== '.env') url.searchParams.set('file', absolutePath);
const res = await fetch(url.toString(), {
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (res.status === 404) return '';
if (!res.ok) throw new Error(`failed to read env (HTTP ${res.status})`);
return await res.text();
}
async function writeEnvRemote(node: Node, stackName: string, absolutePath: string, content: string): Promise<void> {
if (!node.api_url || !node.api_token) throw new Error('node has no api_url or api_token');
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = {
Authorization: `Bearer ${node.api_token}`,
'Content-Type': 'application/json',
};
const url = new URL(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`);
if (absolutePath !== '.env') url.searchParams.set('file', absolutePath);
const res = await fetch(url.toString(), {
method: 'PUT',
headers,
body: JSON.stringify({ content }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`failed to write env (HTTP ${res.status}${body ? ': ' + body.slice(0, 200) : ''})`);
}
}
interface ResolvedRead { absolutePath: string; text: string }
async function readExistingEnv(node: Node, stackName: string, basename: string): Promise<ResolvedRead | null> {
if (isLocalNode(node)) {
const resolved = await resolveEnvFileLocal(node.id, stackName, basename);
if (!resolved) return null;
const text = await readEnvLocal(node.id, resolved.absolutePath);
return { absolutePath: resolved.absolutePath, text };
}
const resolved = await resolveEnvFileRemote(node, stackName, basename);
if (!resolved) return null;
const text = await readEnvRemote(node, stackName, resolved.absolutePath);
return { absolutePath: resolved.absolutePath, text };
}
async function writeEnvForNode(node: Node, stackName: string, absolutePath: string, content: string): Promise<void> {
if (isLocalNode(node)) {
await writeEnvLocal(node.id, absolutePath, content);
} else {
await writeEnvRemote(node, stackName, absolutePath, content);
}
}
function toSummary(secret: SecretRow, version: SecretVersionRow | undefined): SecretSummary {
return {
id: secret.id,
name: secret.name,
description: secret.description,
currentVersion: secret.current_version,
keyCount: version?.key_count ?? 0,
createdAt: secret.created_at,
createdBy: secret.created_by,
updatedAt: secret.updated_at,
};
}
const activePushes = new Set<number>();
export class SecretsService {
private static instance: SecretsService | null = null;
static getInstance(): SecretsService {
if (!SecretsService.instance) {
SecretsService.instance = new SecretsService();
}
return SecretsService.instance;
}
private constructor() { /* singleton */ }
list(): SecretSummary[] {
const db = DatabaseService.getInstance();
const rows = db.listSecrets();
return rows.map(row => {
const v = db.getCurrentSecretVersion(row.id);
return toSummary(row, v);
});
}
getCurrent(id: number): SecretSummary | null {
const db = DatabaseService.getInstance();
const row = db.getSecret(id);
if (!row) return null;
const v = db.getCurrentSecretVersion(id);
return toSummary(row, v);
}
listVersions(id: number): SecretVersionSummary[] {
const db = DatabaseService.getInstance();
return db.listSecretVersions(id).map(v => ({
version: v.version,
keyCount: v.key_count,
createdAt: v.created_at,
createdBy: v.created_by,
note: v.note,
}));
}
create(input: { name: string; description?: string; kv: SecretKv; user: string; note?: string }): { id: number; version: number } {
const validation = validateKv(input.kv);
if (!validation.ok) throw new Error(validation.error);
const db = DatabaseService.getInstance();
return db.createSecretWithVersion({
name: input.name,
description: input.description ?? '',
encryptedPayload: encryptKv(input.kv),
keyCount: Object.keys(input.kv).length,
createdBy: input.user,
note: input.note ?? '',
});
}
update(id: number, input: { description?: string; kv: SecretKv; user: string; note?: string }): { version: number } {
const validation = validateKv(input.kv);
if (!validation.ok) throw new Error(validation.error);
const db = DatabaseService.getInstance();
return db.updateSecretWithVersion({
secretId: id,
description: input.description === undefined ? null : input.description,
encryptedPayload: encryptKv(input.kv),
keyCount: Object.keys(input.kv).length,
createdBy: input.user,
note: input.note ?? '',
});
}
delete(id: number): boolean {
return DatabaseService.getInstance().deleteSecret(id);
}
getDecryptedKv(id: number): SecretKv {
const db = DatabaseService.getInstance();
const v = db.getCurrentSecretVersion(id);
if (!v) return {};
return decryptKv(v.encrypted_payload);
}
async importFromStack(nodeId: number, stackName: string, envFileBasename = '.env'): Promise<SecretKv> {
const node = DatabaseService.getInstance().getNodes().find(n => n.id === nodeId);
if (!node) throw new Error('Node not found');
const read = await readExistingEnv(node, stackName, envFileBasename);
if (!read) throw new Error(`env file '${envFileBasename}' not found on node ${node.name}`);
return parseEnv(read.text);
}
async previewPushDiff(id: number, selector: BlueprintSelector, stackName: string, envFileBasename: string): Promise<SecretPushPlanEntry[]> {
const db = DatabaseService.getInstance();
const overlay = this.getDecryptedKv(id);
const matched = NodeLabelService.getInstance().matchSelector(selector, db.getNodes());
// Preview is read-only and per-node; fan out in parallel for snappier wizard UX.
return Promise.all(matched.map(async (node): Promise<SecretPushPlanEntry> => {
const entry: SecretPushPlanEntry = {
nodeId: node.id,
nodeName: node.name,
stackName,
envFileBasename,
reachable: true,
stackExists: true,
diff: [],
added: 0,
changed: 0,
unchanged: 0,
removedInformational: 0,
};
try {
const read = await readExistingEnv(node, stackName, envFileBasename);
if (!read) {
entry.stackExists = false;
entry.error = `env file '${envFileBasename}' not found`;
return entry;
}
entry.diff = computeDiff(parseEnv(read.text), overlay);
Object.assign(entry, aggregateDiff(entry.diff));
} catch (err) {
entry.reachable = false;
entry.error = getErrorMessage(err, 'preview failed');
}
return entry;
}));
}
async executePush(id: number, selector: BlueprintSelector, stackName: string, envFileBasename: string, user: string): Promise<{ pushId: string; results: SecretPushResultEntry[] }> {
if (activePushes.has(id)) {
throw new PushBusyError(id);
}
const db = DatabaseService.getInstance();
const secret = db.getSecret(id);
if (!secret) throw new Error('Secret not found');
const versionRow = db.getCurrentSecretVersion(id);
if (!versionRow) throw new Error('Secret has no current version');
const overlay = decryptKv(versionRow.encrypted_payload);
const matched = NodeLabelService.getInstance().matchSelector(selector, db.getNodes());
const pushId = crypto.randomUUID();
const results: SecretPushResultEntry[] = [];
const rows: Array<Parameters<typeof db.insertSecretPushes>[0][number]> = [];
const pushedAt = Date.now();
// Sequential write order keeps audit-row ordering deterministic and avoids
// stampeding any single remote that's targeted by multiple labels.
activePushes.add(id);
try {
for (const node of matched) {
const result: SecretPushResultEntry = {
nodeId: node.id,
nodeName: node.name,
stackName,
envFileBasename,
status: 'ok',
added: 0,
changed: 0,
unchanged: 0,
};
try {
const read = await readExistingEnv(node, stackName, envFileBasename);
if (!read) throw new Error(`env file '${envFileBasename}' not found`);
const existing = parseEnv(read.text);
const agg = aggregateDiff(computeDiff(existing, overlay));
const newText = serializeEnv(applyOverlay(existing, overlay));
await writeEnvForNode(node, stackName, read.absolutePath, newText);
result.added = agg.added;
result.changed = agg.changed;
result.unchanged = agg.unchanged;
} catch (err) {
result.status = 'failed';
result.error = getErrorMessage(err, 'push failed');
}
results.push(result);
rows.push({
secret_id: id,
version: versionRow.version,
push_id: pushId,
node_id: node.id,
stack_name: stackName,
env_file_basename: envFileBasename,
status: result.status,
error: result.error ?? '',
added_count: result.added,
changed_count: result.changed,
unchanged_count: result.unchanged,
pushed_by: user,
pushed_at: pushedAt,
});
}
db.insertSecretPushes(rows);
} finally {
activePushes.delete(id);
}
return { pushId, results };
}
}
export class PushBusyError extends Error {
constructor(public readonly secretId: number) {
super(`Push already running for secret ${secretId}`);
this.name = 'PushBusyError';
}
}
+8
View File
@@ -131,6 +131,14 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
// Blueprints (Federation pin)
'PUT /blueprints/*/pin': 'Updated blueprint pin',
// Fleet secrets
'POST /secrets': 'Created secret',
'PUT /secrets': 'Updated secret',
'DELETE /secrets': 'Deleted secret',
'POST /secrets/*/import-from-stack': 'Imported env into secret',
'POST /secrets/*/push/preview': 'Previewed secret push',
'POST /secrets/*/push': 'Pushed secret',
};
// Pre-sorted at module load: most specific patterns (by segment count) first.