mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"features/fleet-federation",
|
||||
"features/fleet-actions",
|
||||
"features/fleet-sync",
|
||||
"features/fleet-secrets",
|
||||
"features/fleet-backups",
|
||||
"features/remote-updates",
|
||||
"features/scheduled-operations"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "Fleet Secrets"
|
||||
description: "Centralized, encrypted, versioned env-var bundles you can push to labeled nodes' stacks."
|
||||
---
|
||||
|
||||
Fleet Secrets gives you one place to author the environment variables a stack needs, and one action to push them to every node that runs that stack. Bundles are encrypted at rest, every save bumps a version, and every push is recorded in the audit log with a per-node diff.
|
||||
|
||||
Fleet Secrets is available on **Skipper** and **Admiral** licenses. The tab is hidden on Community.
|
||||
|
||||
## What's in scope
|
||||
|
||||
| Today | Not yet |
|
||||
|-------|---------|
|
||||
| Bundles of `KEY=value` env pairs | Certificates, key files, credential JSON blobs |
|
||||
| Push to nodes by label selector | Pin a bundle to specific node IDs |
|
||||
| Versioning on every save with a change note | Scheduled or auto-rotation |
|
||||
| Diff preview before each push | Multi-region or per-environment overlays |
|
||||
|
||||
If a feature is not in the table above, it's deferred to a later release. Fleet Secrets in 1.0 is intentionally focused on env-var drift across copies of the same stack.
|
||||
|
||||
## Create a bundle
|
||||
|
||||
1. Open **Fleet → Secrets**.
|
||||
2. Click **New bundle**.
|
||||
3. Give it a name (letters, digits, dot, dash, underscore; 2–64 chars) and an optional description.
|
||||
4. Add `KEY=value` rows. Keys must match the env-var convention (`A-Z`, digits, underscore; cannot start with a digit). The eye icon toggles value visibility; the copy icon copies a single value to the clipboard.
|
||||
5. Add a **Change note** (optional but recommended) explaining why this version exists.
|
||||
6. Click **Save**. The bundle is now version `v1`.
|
||||
|
||||
Saving the bundle encrypts the payload with the instance's data key (AES-256-GCM via the same key that protects MFA secrets and registry credentials). The plaintext only ever exists in memory while you're editing.
|
||||
|
||||
## Edit and version
|
||||
|
||||
Editing a bundle creates a new version. Each version is immutable once saved. Open a bundle and switch to the **Versions** tab to see who saved each version and any change notes attached.
|
||||
|
||||
Saves are last-write-wins. There's no row-level lock; if two operators edit the same bundle concurrently, the later save creates the higher version number and the earlier save remains visible in history.
|
||||
|
||||
## Push to nodes
|
||||
|
||||
A push reads the bundle's current version, computes the diff against each target's existing env file, and writes the merged result.
|
||||
|
||||
1. From the bundle row, click the **Send** action to open the push wizard.
|
||||
2. **Target tab:**
|
||||
- Pick one or more node labels.
|
||||
- Choose **any** (a node matches if it has any of the selected labels) or **all** (a node must have all selected labels).
|
||||
- Enter the **Stack name** that the bundle should be applied to. The stack must exist on each target node by that name.
|
||||
- Pick the **Env file** to write. The dropdown lists every file declared by the stack's compose on a representative target node — the canonical `.env` plus any files referenced via `env_file:` directives. Per-node compose files can differ; nodes that do not declare the chosen file are reported as failed for that push.
|
||||
3. Click **Preview**.
|
||||
4. **Preview tab:** the table shows one row per matched node. Each row reports a quick header (`+added · ~changed · ·unchanged`) and an expander with the per-key diff. Drift — keys present on the target that are absent from the bundle — is shown as informational `removed` rows. The push will not delete those keys.
|
||||
5. Click **Push to N nodes**.
|
||||
6. **Results tab:** per-node status pills (`ok`, `failed`, `skipped`). Hover a failure to see the error.
|
||||
|
||||
Pushes are sequential per bundle to make audit ordering predictable. Only one push for a given bundle can run at a time; a second concurrent push attempt returns `409 Conflict` until the first finishes.
|
||||
|
||||
## Merge semantics: overlay
|
||||
|
||||
Fleet Secrets uses **overlay** merge:
|
||||
|
||||
- A key in the bundle that is missing from the target is **added**.
|
||||
- A key in both with a different value is **changed** to the bundle's value.
|
||||
- A key in both with the same value is **unchanged**.
|
||||
- A key on the target that is **not in the bundle** is preserved on the target. The diff shows it under `removed (drift)` so you can see the divergence, but the push leaves it alone.
|
||||
|
||||
This is deliberately conservative for a v1 push action. If a key truly needs to be removed from a target's env file, do it in the stack editor on that node, then push the bundle to bring the rest of the keys in line.
|
||||
|
||||
## Audit trail
|
||||
|
||||
Every mutating action shows up in **Audit Log**:
|
||||
|
||||
| Action | Audit summary |
|
||||
|--------|---------------|
|
||||
| Create bundle | `Created secret` |
|
||||
| Update bundle (new version) | `Updated secret: <id>` |
|
||||
| Delete bundle | `Deleted secret: <id>` |
|
||||
| Import env from a stack | `Imported env into secret: <id>` |
|
||||
| Preview a push | `Previewed secret push: <id>` |
|
||||
| Execute a push | `Pushed secret: <id>` |
|
||||
|
||||
The execute-push action also writes one row per target node in `secret_pushes`, capturing the bundle id, version, push id (a UUID grouping all rows from the same push), node id, stack name, env file, status, error message, and per-node `added` / `changed` / `unchanged` counts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "env file '<name>' not found on node <X>"
|
||||
|
||||
The node's compose file for that stack does not declare the env file you asked Fleet Secrets to write to. Either pick a different env file in the wizard's **Env file** dropdown, or update that node's compose to reference the file via `env_file:`.
|
||||
|
||||
### "stack not found"
|
||||
|
||||
The named stack does not exist on that node. Fleet Secrets pushes to existing stacks only — it does not create stack directories.
|
||||
|
||||
### A push partially succeeded
|
||||
|
||||
Per-node failures are independent. The bundle and other targets are unaffected; only the failing node missed the push. Fix the underlying cause (file declared in compose, stack created, node reachable) and re-run the push. Idempotent overlay means re-pushing is safe.
|
||||
|
||||
### "A push for this secret is already running"
|
||||
|
||||
Only one push per bundle runs at a time. Wait for the previous push to finish, then retry.
|
||||
|
||||
### Values look wrong on the target
|
||||
|
||||
Open the bundle and switch to **Versions** — confirm the version you intended to push is the current one. Open the wizard, run **Preview** without pushing, and read the diff. The preview is a faithful representation of what the next push will write.
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import {
|
||||
RefreshCw, Search, Camera,
|
||||
Network, SlidersHorizontal,
|
||||
@@ -20,11 +19,11 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
import { FederationTab } from './fleet/FederationTab';
|
||||
import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
@@ -33,7 +32,6 @@ interface FleetViewProps {
|
||||
export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const { isPaid, license } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const experimental = useExperimental();
|
||||
|
||||
const { prefs, updatePrefs } = useFleetPreferences();
|
||||
const updateStatus = useFleetUpdateStatus();
|
||||
@@ -107,15 +105,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{experimental && (
|
||||
<>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets">
|
||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</>
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets">
|
||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
@@ -198,15 +193,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsContent value="actions">
|
||||
<FleetActionsTab nodes={overview.allNodes} />
|
||||
</TabsContent>
|
||||
{experimental && (
|
||||
{isPaid && (
|
||||
<TabsContent value="secrets">
|
||||
<FleetSoonPlaceholder
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
kicker="Secrets"
|
||||
title="One source of truth for env, creds and certs"
|
||||
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
|
||||
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
|
||||
/>
|
||||
<SecretsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, EyeOff, Plus, Trash2, Copy, Loader2 } from 'lucide-react';
|
||||
import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import {
|
||||
type SecretSummary,
|
||||
type SecretVersionSummary,
|
||||
createSecret,
|
||||
updateSecret,
|
||||
getSecret,
|
||||
listSecretVersions,
|
||||
} from '@/lib/secretsApi';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
|
||||
interface KvRow { id: string; key: string; value: string; reveal: boolean }
|
||||
type Mode = 'create' | 'edit';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When provided, edit existing bundle; when null, create new. */
|
||||
secret: SecretSummary | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/;
|
||||
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function makeRowId(): string {
|
||||
return `r-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function kvToRows(kv: Record<string, string>): KvRow[] {
|
||||
return Object.keys(kv)
|
||||
.sort()
|
||||
.map((key) => ({ id: makeRowId(), key, value: kv[key], reveal: false }));
|
||||
}
|
||||
|
||||
function rowsToKv(rows: KvRow[]): { kv: Record<string, string> } | { error: string } {
|
||||
const kv: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const k = r.key.trim();
|
||||
if (!k) continue;
|
||||
if (!KEY_PATTERN.test(k)) {
|
||||
return { error: `Invalid key: ${k}` };
|
||||
}
|
||||
if (seen.has(k)) {
|
||||
return { error: `Duplicate key: ${k}` };
|
||||
}
|
||||
seen.add(k);
|
||||
kv[k] = r.value;
|
||||
}
|
||||
return { kv };
|
||||
}
|
||||
|
||||
export function SecretBundleSheet({ open, onOpenChange, secret, onSaved }: Props) {
|
||||
const mode: Mode = secret ? 'edit' : 'create';
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [rows, setRows] = useState<KvRow[]>([]);
|
||||
const [note, setNote] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('keys');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [versions, setVersions] = useState<SecretVersionSummary[]>([]);
|
||||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
if (secret) {
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const fresh = await getSecret(secret.id);
|
||||
if (cancelled) return;
|
||||
setName(fresh.name);
|
||||
setDescription(fresh.description);
|
||||
setRows(kvToRows(fresh.kv));
|
||||
setNote('');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load secret');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setRows([{ id: makeRowId(), key: '', value: '', reveal: true }]);
|
||||
setNote('');
|
||||
setActiveTab('keys');
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [open, secret]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !secret || activeTab !== 'versions') return;
|
||||
let cancelled = false;
|
||||
setVersionsLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const list = await listSecretVersions(secret.id);
|
||||
if (!cancelled) setVersions(list);
|
||||
} catch (err) {
|
||||
if (!cancelled) toast.error(err instanceof Error ? err.message : 'Failed to load versions');
|
||||
} finally {
|
||||
if (!cancelled) setVersionsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, secret, activeTab]);
|
||||
|
||||
const tabs: SystemSheetTab[] = mode === 'edit'
|
||||
? [{ id: 'keys', label: 'Keys', count: rows.filter(r => r.key.trim().length > 0).length }, { id: 'versions', label: 'Versions' }]
|
||||
: [{ id: 'keys', label: 'Keys', count: rows.filter(r => r.key.trim().length > 0).length }];
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!NAME_PATTERN.test(name)) return false;
|
||||
if (rows.some(r => r.key.trim().length === 0 && r.value.length > 0)) return false;
|
||||
return true;
|
||||
}, [name, rows]);
|
||||
|
||||
async function handleSave() {
|
||||
if (mode === 'create' && !NAME_PATTERN.test(name)) {
|
||||
toast.error('Name must be 2-64 characters (letters, digits, dot, dash, underscore)');
|
||||
return;
|
||||
}
|
||||
const built = rowsToKv(rows);
|
||||
if ('error' in built) {
|
||||
toast.error(built.error);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const result = await createSecret({ name, description: description || undefined, kv: built.kv, note: note || undefined });
|
||||
toast.success(`Bundle saved (v${result.version})`);
|
||||
} else if (secret) {
|
||||
const result = await updateSecret(secret.id, { description, kv: built.kv, note: note || undefined });
|
||||
toast.success(`Bundle saved (v${result.version})`);
|
||||
}
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save bundle');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setRows((prev) => [...prev, { id: makeRowId(), key: '', value: '', reveal: true }]);
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
setRows((prev) => prev.filter(r => r.id !== id));
|
||||
}
|
||||
|
||||
function updateRow(id: string, patch: Partial<KvRow>) {
|
||||
setRows((prev) => prev.map(r => r.id === id ? { ...r, ...patch } : r));
|
||||
}
|
||||
|
||||
async function handleCopyValue(value: string) {
|
||||
try {
|
||||
await copyToClipboard(value);
|
||||
toast.success('Copied');
|
||||
} catch {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Secrets', mode === 'create' ? 'New bundle' : (secret?.name ?? 'Bundle')]}
|
||||
name={mode === 'create' ? 'New secret bundle' : (secret?.name ?? 'Bundle')}
|
||||
meta={mode === 'edit' && secret ? `v${secret.currentVersion} · ${secret.keyCount} keys` : undefined}
|
||||
primaryAction={{
|
||||
label: submitting ? 'Saving…' : 'Save',
|
||||
onClick: handleSave,
|
||||
disabled: submitting || loading || !canSubmit,
|
||||
}}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
size="lg"
|
||||
>
|
||||
{activeTab === 'keys' && (
|
||||
<div className="space-y-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-stat-subtitle"><Loader2 className="w-4 h-4 animate-spin" /> Loading bundle…</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetSection title="Identity">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="secret-name" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Name</label>
|
||||
<Input
|
||||
id="secret-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="app-secrets"
|
||||
disabled={mode === 'edit'}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="secret-description" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Description</label>
|
||||
<Input
|
||||
id="secret-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Production database and API credentials"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title={`Key/value pairs · ${rows.filter(r => r.key.trim().length > 0).length}`}>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex items-start gap-2">
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(row.id, { key: e.target.value })}
|
||||
placeholder="KEY_NAME"
|
||||
className="font-mono w-[200px]"
|
||||
/>
|
||||
<span className="text-stat-subtitle pt-2">=</span>
|
||||
<Input
|
||||
type={row.reveal ? 'text' : 'password'}
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(row.id, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => updateRow(row.id, { reveal: !row.reveal })}
|
||||
aria-label={row.reveal ? 'Hide value' : 'Show value'}
|
||||
>
|
||||
{row.reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void handleCopyValue(row.value)}
|
||||
aria-label="Copy value"
|
||||
disabled={!row.value}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive/70" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRow} className="gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add key
|
||||
</Button>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Change note (optional)">
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={mode === 'create' ? 'initial bundle' : 'Why this change?'}
|
||||
/>
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'versions' && (
|
||||
<div className="space-y-2">
|
||||
{versionsLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-stat-subtitle"><Loader2 className="w-4 h-4 animate-spin" /> Loading…</div>
|
||||
)}
|
||||
{!versionsLoading && versions.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">No version history yet.</div>
|
||||
)}
|
||||
{!versionsLoading && versions.map((v) => (
|
||||
<div key={v.version} className="rounded border border-card-border/60 bg-popover/40 px-3 py-2 flex items-baseline gap-3">
|
||||
<div className="font-mono text-sm tabular-nums">v{v.version}</div>
|
||||
<div className="text-xs text-stat-subtitle">{v.keyCount} keys</div>
|
||||
<div className="text-xs text-stat-subtitle flex-1 truncate">{v.note || '(no note)'}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">{v.createdBy}</div>
|
||||
<div className="text-[10px] font-mono text-stat-subtitle tabular-nums">{new Date(v.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, Send, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, MinusCircle, type LucideIcon } from 'lucide-react';
|
||||
import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MultiSelectCombobox, type MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { listDistinctLabels, type BlueprintSelector } from '@/lib/blueprintsApi';
|
||||
import {
|
||||
type SecretSummary,
|
||||
type SecretPushPlanEntry,
|
||||
type SecretPushResultEntry,
|
||||
type SecretPushStatus,
|
||||
type DiffStatus,
|
||||
previewPush,
|
||||
executePush,
|
||||
} from '@/lib/secretsApi';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
secret: SecretSummary | null;
|
||||
}
|
||||
|
||||
type Stage = 'target' | 'preview' | 'results';
|
||||
|
||||
const DIFF_STATUS_COLOR: Record<DiffStatus, string> = {
|
||||
added: 'text-emerald-500',
|
||||
changed: 'text-amber-500',
|
||||
removed: 'text-stat-subtitle',
|
||||
unchanged: 'text-stat-subtitle/60',
|
||||
};
|
||||
|
||||
const RESULT_ICON: Record<SecretPushStatus, LucideIcon> = {
|
||||
ok: CheckCircle2,
|
||||
failed: AlertCircle,
|
||||
skipped: MinusCircle,
|
||||
};
|
||||
|
||||
const RESULT_ICON_CLASS: Record<SecretPushStatus, string> = {
|
||||
ok: 'text-emerald-500',
|
||||
failed: 'text-destructive',
|
||||
skipped: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
export function SecretPushSheet({ open, onOpenChange, secret }: Props) {
|
||||
const { nodes } = useNodes();
|
||||
const [stage, setStage] = useState<Stage>('target');
|
||||
const [labelMode, setLabelMode] = useState<'any' | 'all'>('any');
|
||||
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set());
|
||||
const [allLabels, setAllLabels] = useState<string[]>([]);
|
||||
const [stackName, setStackName] = useState('');
|
||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||
const [envFile, setEnvFile] = useState('.env');
|
||||
const [envFilesLoading, setEnvFilesLoading] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [pushLoading, setPushLoading] = useState(false);
|
||||
const [plan, setPlan] = useState<SecretPushPlanEntry[]>([]);
|
||||
const [results, setResults] = useState<SecretPushResultEntry[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStage('target');
|
||||
setSelectedLabels(new Set());
|
||||
setStackName('');
|
||||
setEnvFiles([]);
|
||||
setEnvFile('.env');
|
||||
setPlan([]);
|
||||
setResults([]);
|
||||
setExpanded(new Set());
|
||||
listDistinctLabels()
|
||||
.then(setAllLabels)
|
||||
.catch(() => setAllLabels([]));
|
||||
}, [open]);
|
||||
|
||||
const labelOptions: MultiSelectOption[] = useMemo(
|
||||
() => allLabels.map((l) => ({ value: l, label: l })),
|
||||
[allLabels],
|
||||
);
|
||||
|
||||
function buildSelector(): BlueprintSelector {
|
||||
const labels = Array.from(selectedLabels);
|
||||
return { type: 'labels', any: labelMode === 'any' ? labels : [], all: labelMode === 'all' ? labels : [] };
|
||||
}
|
||||
|
||||
async function loadEnvFiles() {
|
||||
if (!stackName.trim()) return;
|
||||
const ref = nodes[0];
|
||||
if (!ref) return;
|
||||
setEnvFilesLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName.trim())}/envs`, {
|
||||
headers: { 'x-node-id': String(ref.id) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setEnvFiles([]);
|
||||
setEnvFile('.env');
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { envFiles?: string[] };
|
||||
const basenames = (body.envFiles ?? []).map((p) => p.split(/[\\/]/).pop() ?? '');
|
||||
const unique = Array.from(new Set(basenames.filter(Boolean)));
|
||||
if (!unique.includes('.env')) unique.unshift('.env');
|
||||
setEnvFiles(unique);
|
||||
if (!unique.includes(envFile)) setEnvFile(unique[0] ?? '.env');
|
||||
} catch {
|
||||
setEnvFiles([]);
|
||||
} finally {
|
||||
setEnvFilesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'target') return;
|
||||
if (!stackName.trim()) {
|
||||
setEnvFiles([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => { void loadEnvFiles(); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage, stackName, nodes.length]);
|
||||
|
||||
async function handlePreview() {
|
||||
if (!secret) return;
|
||||
if (selectedLabels.size === 0) {
|
||||
toast.error('Pick at least one label');
|
||||
return;
|
||||
}
|
||||
if (!stackName.trim()) {
|
||||
toast.error('Stack name is required');
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const result = await previewPush(secret.id, {
|
||||
selector: buildSelector(),
|
||||
stackName: stackName.trim(),
|
||||
envFileBasename: envFile,
|
||||
});
|
||||
if (result.length === 0) {
|
||||
toast.error('No nodes match this selector');
|
||||
return;
|
||||
}
|
||||
setPlan(result);
|
||||
setStage('preview');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to preview push');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush() {
|
||||
if (!secret) return;
|
||||
setPushLoading(true);
|
||||
try {
|
||||
const result = await executePush(secret.id, {
|
||||
selector: buildSelector(),
|
||||
stackName: stackName.trim(),
|
||||
envFileBasename: envFile,
|
||||
});
|
||||
setResults(result.results);
|
||||
setStage('results');
|
||||
const okCount = result.results.filter(r => r.status === 'ok').length;
|
||||
const failCount = result.results.length - okCount;
|
||||
if (failCount === 0) toast.success(`Pushed to ${okCount} ${okCount === 1 ? 'node' : 'nodes'}`);
|
||||
else toast.error(`${okCount} ok · ${failCount} failed`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to push secret');
|
||||
} finally {
|
||||
setPushLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(nodeId: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const tabs: SystemSheetTab[] = [
|
||||
{ id: 'target', label: 'Target' },
|
||||
{ id: 'preview', label: 'Preview' },
|
||||
{ id: 'results', label: 'Results' },
|
||||
];
|
||||
|
||||
const primaryAction = (() => {
|
||||
if (stage === 'target') return {
|
||||
label: previewLoading ? 'Previewing…' : 'Preview',
|
||||
onClick: handlePreview,
|
||||
disabled: previewLoading || selectedLabels.size === 0 || !stackName.trim(),
|
||||
icon: previewLoading ? Loader2 : undefined,
|
||||
};
|
||||
if (stage === 'preview') return {
|
||||
label: pushLoading ? 'Pushing…' : `Push to ${plan.length} ${plan.length === 1 ? 'node' : 'nodes'}`,
|
||||
onClick: handlePush,
|
||||
disabled: pushLoading,
|
||||
icon: Send,
|
||||
};
|
||||
return {
|
||||
label: 'Done',
|
||||
onClick: () => onOpenChange(false),
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Secrets', secret?.name ?? '', 'Push']}
|
||||
name={secret ? `Push '${secret.name}'` : 'Push secret'}
|
||||
meta={secret ? `v${secret.currentVersion} · ${secret.keyCount} keys` : undefined}
|
||||
primaryAction={primaryAction}
|
||||
tabs={tabs}
|
||||
activeTab={stage}
|
||||
onTabChange={(id) => {
|
||||
if (id === 'target') setStage('target');
|
||||
else if (id === 'preview' && plan.length > 0) setStage('preview');
|
||||
else if (id === 'results' && results.length > 0) setStage('results');
|
||||
}}
|
||||
size="xl"
|
||||
>
|
||||
{stage === 'target' && (
|
||||
<div className="space-y-5">
|
||||
<SheetSection title="Target nodes">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">
|
||||
<span>Match</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 rounded border ${labelMode === 'any' ? 'border-brand text-stat-value' : 'border-card-border text-stat-subtitle'}`}
|
||||
onClick={() => setLabelMode('any')}
|
||||
>
|
||||
any
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 rounded border ${labelMode === 'all' ? 'border-brand text-stat-value' : 'border-card-border text-stat-subtitle'}`}
|
||||
onClick={() => setLabelMode('all')}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
<span>of these labels</span>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
options={labelOptions}
|
||||
selected={selectedLabels}
|
||||
onSelectionChange={setSelectedLabels}
|
||||
placeholder="Pick labels…"
|
||||
emptyText={allLabels.length === 0 ? 'No node labels yet. Add them via Fleet › Overview.' : 'No matches'}
|
||||
/>
|
||||
{selectedLabels.size > 0 && (
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">
|
||||
{nodes.length} node{nodes.length === 1 ? '' : 's'} known to fleet · preview will resolve exact matches
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Target stack">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="push-stack" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Stack name</label>
|
||||
<Input
|
||||
id="push-stack"
|
||||
value={stackName}
|
||||
onChange={(e) => setStackName(e.target.value)}
|
||||
placeholder="my-app"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="push-envfile" className="block text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Env file</label>
|
||||
<select
|
||||
id="push-envfile"
|
||||
value={envFile}
|
||||
onChange={(e) => setEnvFile(e.target.value)}
|
||||
disabled={envFilesLoading || envFiles.length === 0}
|
||||
className="font-mono text-sm rounded border border-card-border bg-popover/40 px-2 py-1.5 min-w-[200px]"
|
||||
>
|
||||
{envFiles.length === 0 ? <option value=".env">.env</option> : envFiles.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed">
|
||||
Lists files declared by the stack's compose on a representative target. Per-node compose can differ; nodes that don't declare the chosen file are reported as failed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'preview' && (
|
||||
<div className="space-y-3">
|
||||
{plan.length === 0 && (
|
||||
<div className="text-sm text-stat-subtitle">Run preview from the Target tab.</div>
|
||||
)}
|
||||
{plan.map((entry) => (
|
||||
<div key={entry.nodeId} className="rounded border border-card-border/60 bg-popover/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(entry.nodeId)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left"
|
||||
>
|
||||
{expanded.has(entry.nodeId) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<span className="font-mono text-sm flex-1 truncate">{entry.nodeName}</span>
|
||||
{!entry.reachable || !entry.stackExists ? (
|
||||
<span className="text-xs text-destructive">{entry.error ?? 'unreachable'}</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs tabular-nums flex items-center gap-2">
|
||||
<span className="text-emerald-500">+{entry.added}</span>
|
||||
<span className="text-amber-500">~{entry.changed}</span>
|
||||
<span className="text-stat-subtitle">·{entry.unchanged}</span>
|
||||
{entry.removedInformational > 0 && <span className="text-stat-subtitle">drift {entry.removedInformational}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded.has(entry.nodeId) && entry.diff.length > 0 && (
|
||||
<div className="border-t border-card-border/40 px-3 py-2 space-y-1">
|
||||
{entry.diff.map((d) => (
|
||||
<div key={d.key} className="flex items-baseline gap-2 font-mono text-xs">
|
||||
<span className={`w-16 uppercase tracking-[0.18em] text-[10px] ${DIFF_STATUS_COLOR[d.status]}`}>{d.status}</span>
|
||||
<span className="flex-1 truncate">{d.key}</span>
|
||||
{d.status === 'changed' && <span className="text-stat-subtitle">old to new</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'results' && (
|
||||
<div className="space-y-2">
|
||||
{results.map((r) => {
|
||||
const Icon = RESULT_ICON[r.status];
|
||||
return (
|
||||
<div key={r.nodeId} className="rounded border border-card-border/60 bg-popover/40 px-3 py-2 flex items-center gap-3">
|
||||
<Icon className={`w-4 h-4 ${RESULT_ICON_CLASS[r.status]}`} />
|
||||
<span className="font-mono text-sm flex-1 truncate">{r.nodeName}</span>
|
||||
{r.status === 'ok' ? (
|
||||
<span className="font-mono text-xs tabular-nums flex items-center gap-2">
|
||||
<span className="text-emerald-500">+{r.added}</span>
|
||||
<span className="text-amber-500">~{r.changed}</span>
|
||||
<span className="text-stat-subtitle">·{r.unchanged}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-destructive truncate max-w-[300px]" title={r.error}>{r.error ?? r.status}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { KeyRound, Plus, Pencil, Send, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { listSecrets, deleteSecret, type SecretSummary } from '@/lib/secretsApi';
|
||||
import { SecretBundleSheet } from './SecretBundleSheet';
|
||||
import { SecretPushSheet } from './SecretPushSheet';
|
||||
|
||||
export function SecretsTab() {
|
||||
const [items, setItems] = useState<SecretSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<SecretSummary | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [pushing, setPushing] = useState<SecretSummary | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const list = await listSecrets();
|
||||
setItems(list);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load secrets';
|
||||
setLoadError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleDelete(secret: SecretSummary) {
|
||||
if (!confirm(`Delete bundle '${secret.name}'? This removes all versions and push history.`)) return;
|
||||
setDeletingId(secret.id);
|
||||
try {
|
||||
await deleteSecret(secret.id);
|
||||
toast.success('Bundle deleted');
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete bundle');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-xs text-stat-subtitle font-mono uppercase tracking-[0.18em]">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading secrets…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded-xl border border-destructive/30 bg-destructive/5 p-6 space-y-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-destructive">Could not load secrets</div>
|
||||
<p className="text-sm text-stat-subtitle leading-relaxed">{loadError}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void refresh()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display italic text-[1.5rem] leading-tight text-stat-value">Secret bundles</h2>
|
||||
<p className="text-sm text-stat-subtitle">Centralized env-var bundles, encrypted at rest, versioned, pushed to labeled nodes.</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setCreating(true)} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" /> New bundle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState onCreate={() => setCreating(true)} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-card-border/60 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-popover/40 border-b border-card-border/60">
|
||||
<tr className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">
|
||||
<th className="px-4 py-2 text-left font-normal">Name</th>
|
||||
<th className="px-4 py-2 text-left font-normal">Description</th>
|
||||
<th className="px-4 py-2 text-right font-normal tabular-nums">Version</th>
|
||||
<th className="px-4 py-2 text-right font-normal tabular-nums">Keys</th>
|
||||
<th className="px-4 py-2 text-left font-normal">Updated</th>
|
||||
<th className="px-4 py-2 text-right font-normal" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-b border-card-border/30 last:border-b-0 hover:bg-popover/20">
|
||||
<td className="px-4 py-2 font-mono">{s.name}</td>
|
||||
<td className="px-4 py-2 text-stat-subtitle truncate max-w-[300px]">{s.description || <span className="text-stat-subtitle/50">·</span>}</td>
|
||||
<td className="px-4 py-2 text-right font-mono tabular-nums">v{s.currentVersion}</td>
|
||||
<td className="px-4 py-2 text-right font-mono tabular-nums">{s.keyCount}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-stat-subtitle tabular-nums">{new Date(s.updatedAt).toLocaleString()}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setEditing(s)} aria-label="Edit">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setPushing(s)} aria-label="Push">
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void handleDelete(s)}
|
||||
disabled={deletingId === s.id}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive/70" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SecretBundleSheet
|
||||
open={creating || editing !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
secret={editing}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
|
||||
<SecretPushSheet
|
||||
open={pushing !== null}
|
||||
onOpenChange={(o) => { if (!o) setPushing(null); }}
|
||||
secret={pushing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-card-border/60 bg-popover/30 p-8 text-center space-y-4">
|
||||
<KeyRound className="mx-auto w-8 h-8 text-stat-subtitle" />
|
||||
<div>
|
||||
<h3 className="font-display italic text-[1.25rem] text-stat-value">One source of truth for env</h3>
|
||||
<p className="text-sm text-stat-subtitle leading-relaxed mt-1">
|
||||
Build a bundle of key=value pairs, push it to nodes by label, see exactly what changed before you write.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={onCreate} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Create your first bundle
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { apiFetch } from './api';
|
||||
import type { BlueprintSelector } from './blueprintsApi';
|
||||
|
||||
export type DiffStatus = 'added' | 'changed' | 'removed' | 'unchanged';
|
||||
export type SecretPushStatus = 'ok' | 'failed' | 'skipped';
|
||||
|
||||
export interface SecretSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
currentVersion: number;
|
||||
keyCount: number;
|
||||
createdAt: number;
|
||||
createdBy: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SecretWithKv extends SecretSummary {
|
||||
kv: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SecretVersionSummary {
|
||||
version: number;
|
||||
keyCount: number;
|
||||
createdAt: number;
|
||||
createdBy: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SecretDiffEntry {
|
||||
key: string;
|
||||
status: DiffStatus;
|
||||
before?: string;
|
||||
after?: 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;
|
||||
}
|
||||
|
||||
async function expectJson<T>(res: Response, fallback: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let detail = fallback;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error;
|
||||
} catch {
|
||||
// body not JSON
|
||||
}
|
||||
const err = new Error(detail) as Error & { status: number };
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function expectOk(res: Response, fallback: string): Promise<void> {
|
||||
if (!res.ok) {
|
||||
let detail = fallback;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error;
|
||||
} catch {
|
||||
// body not JSON
|
||||
}
|
||||
const err = new Error(detail) as Error & { status: number };
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSecrets(): Promise<SecretSummary[]> {
|
||||
const res = await apiFetch('/secrets', { localOnly: true });
|
||||
return expectJson<SecretSummary[]>(res, 'Failed to load secrets');
|
||||
}
|
||||
|
||||
export async function getSecret(id: number): Promise<SecretWithKv> {
|
||||
const res = await apiFetch(`/secrets/${id}`, { localOnly: true });
|
||||
return expectJson<SecretWithKv>(res, 'Failed to load secret');
|
||||
}
|
||||
|
||||
export interface CreateSecretInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
kv: Record<string, string>;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export async function createSecret(input: CreateSecretInput): Promise<{ id: number; version: number }> {
|
||||
const res = await apiFetch('/secrets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<{ id: number; version: number }>(res, 'Failed to create secret');
|
||||
}
|
||||
|
||||
export interface UpdateSecretInput {
|
||||
description?: string;
|
||||
kv: Record<string, string>;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export async function updateSecret(id: number, input: UpdateSecretInput): Promise<{ version: number }> {
|
||||
const res = await apiFetch(`/secrets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<{ version: number }>(res, 'Failed to update secret');
|
||||
}
|
||||
|
||||
export async function deleteSecret(id: number): Promise<void> {
|
||||
const res = await apiFetch(`/secrets/${id}`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
await expectOk(res, 'Failed to delete secret');
|
||||
}
|
||||
|
||||
export async function listSecretVersions(id: number): Promise<SecretVersionSummary[]> {
|
||||
const res = await apiFetch(`/secrets/${id}/versions`, { localOnly: true });
|
||||
return expectJson<SecretVersionSummary[]>(res, 'Failed to load versions');
|
||||
}
|
||||
|
||||
export async function importFromStack(id: number, input: { nodeId: number; stackName: string; envFileBasename?: string }): Promise<{ kv: Record<string, string> }> {
|
||||
const res = await apiFetch(`/secrets/${id}/import-from-stack`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<{ kv: Record<string, string> }>(res, 'Failed to import env from stack');
|
||||
}
|
||||
|
||||
export interface PushInput {
|
||||
selector: BlueprintSelector;
|
||||
stackName: string;
|
||||
envFileBasename: string;
|
||||
}
|
||||
|
||||
export async function previewPush(id: number, input: PushInput): Promise<SecretPushPlanEntry[]> {
|
||||
const res = await apiFetch(`/secrets/${id}/push/preview`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<SecretPushPlanEntry[]>(res, 'Failed to preview push');
|
||||
}
|
||||
|
||||
export async function executePush(id: number, input: PushInput): Promise<{ pushId: string; results: SecretPushResultEntry[] }> {
|
||||
const res = await apiFetch(`/secrets/${id}/push`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<{ pushId: string; results: SecretPushResultEntry[] }>(res, 'Failed to push secret');
|
||||
}
|
||||
Reference in New Issue
Block a user