feat(blueprints): require confirmed rollout preview before reconcile (#1649)

* feat(blueprints): require confirmed rollout preview before reconcile

Persist place/remove approval with an intent fingerprint and transition matrix so Apply, Retry, ticks, and pin cannot mutate the fleet until the operator confirms the current blast radius. Preview surfaces requirements, health, and informational in-flight rows without executing them.

* fix(blueprints): silence unused retry nodeId lint error

* test(blueprints): harden approval gate coverage and preview clarity

Add real reconcileOne place/remove fan-out and STALE_GUARD regressions, surface reachability in the rollout dialog, align warning totals, and document the fail-closed upgrade pause.

* test(blueprints): cover legacy approval schema migration

Seed a pre-approval database with an enabled Blueprint and live deployment, run production DatabaseService startup, and assert pending null auth columns plus a fail-closed reconcile gate.

* test(blueprints): clarify legacy approval migration fixture

Extract seed/boot helpers so the migration regression reads as a linear upgrade path without changing assertions.

* fix(blueprints): report apply outcomes and gate manual withdraw

Return per-node reconcile outcomes from Confirm Apply, block create preview on unmanaged same-name stacks, and require an approved remove outcome for every manual withdraw or evict.

* fix(blueprints): scope withdraw approval to destructive eviction

Require remove approval only for snapshot/evict confirms and evict_blocked rows. Keep plain stateless standard withdraw as an immediate stop, and update withdraw-route tests to seed remove approval when needed.
This commit is contained in:
Anso
2026-07-19 15:30:26 -04:00
committed by GitHub
parent 972f2b9483
commit d94e586af3
20 changed files with 3129 additions and 152 deletions
@@ -0,0 +1,210 @@
/**
* Fail-closed approval columns on blueprints.
* Seeds a pre-approval schema with an enabled Blueprint (and live placement),
* then opens production DatabaseService startup so migrateAddBlueprintApproval runs.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { DatabaseService } from '../services/DatabaseService';
const LEGACY_BLUEPRINT = 'legacy-web';
const APPROVAL_COLUMNS = [
'approval_status',
'approved_intent_fingerprint',
'approved_blast_json',
'approved_at',
'approved_by',
] as const;
const ABSENT_BEFORE_MIGRATION = ['approval_status', 'approved_blast_json'] as const;
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
type ApprovalRow = {
approval_status: string | null;
approved_intent_fingerprint: string | null;
approved_blast_json: string | null;
approved_at: number | null;
approved_by: string | null;
enabled: number;
};
function blueprintColumnNames(db: Database.Database): string[] {
return (db.prepare('PRAGMA table_info(blueprints)').all() as Array<{ name: string }>).map(c => c.name);
}
function readApprovalRow(db: Database.Database, name: string): ApprovalRow {
return db.prepare(
`SELECT approval_status, approved_intent_fingerprint, approved_blast_json,
approved_at, approved_by, enabled
FROM blueprints WHERE name = ?`,
).get(name) as ApprovalRow;
}
function expectPendingNullAuth(row: ApprovalRow): void {
expect(row.approval_status).toBe('pending');
expect(row.approved_intent_fingerprint).toBeNull();
expect(row.approved_blast_json).toBeNull();
expect(row.approved_at).toBeNull();
expect(row.approved_by).toBeNull();
expect(row.enabled).toBe(1);
}
function seedLegacyPreApprovalSchema(dbPath: string, now: number): string[] {
const seed = new Database(dbPath);
try {
// Legacy schema: no approval_* columns and no pinned_node_id.
// Include a matching node + active deployment so a wrongly approved
// migration would have something to mutate on reconcile.
seed.exec(`
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
created_at INTEGER NOT NULL
);
INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
VALUES (1, 'legacy-local', 'local', '/tmp/compose', 1, 'online', ${now});
CREATE TABLE blueprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
compose_content TEXT NOT NULL,
selector_json TEXT NOT NULL,
drift_mode TEXT NOT NULL DEFAULT 'suggest',
classification TEXT NOT NULL DEFAULT 'unknown',
classification_reasons TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
revision INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
created_by TEXT
);
INSERT INTO blueprints (
name, description, compose_content, selector_json, drift_mode,
classification, classification_reasons, enabled, revision,
created_at, updated_at, created_by
) VALUES (
'${LEGACY_BLUEPRINT}',
NULL,
'services:\n app:\n image: nginx\n',
'{"type":"nodes","ids":[1]}',
'observe',
'stateless',
'[]',
1,
1,
${now},
${now},
'admin'
);
CREATE TABLE blueprint_deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
blueprint_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
status TEXT NOT NULL,
applied_revision INTEGER,
last_deployed_at INTEGER,
last_checked_at INTEGER,
last_drift_at INTEGER,
drift_summary TEXT,
last_error TEXT,
UNIQUE(blueprint_id, node_id)
);
INSERT INTO blueprint_deployments
(blueprint_id, node_id, status, applied_revision, last_deployed_at)
VALUES (1, 1, 'active', 1, ${now});
`);
return blueprintColumnNames(seed);
} finally {
seed.close();
}
}
function bootDatabaseService(scratchDir: string): DatabaseService {
process.env.DATA_DIR = scratchDir;
process.env.COMPOSE_DIR = path.join(scratchDir, 'compose');
fs.mkdirSync(process.env.COMPOSE_DIR, { recursive: true });
resetDatabaseSingleton();
return DatabaseService.getInstance();
}
describe('blueprint approval column migration', () => {
let scratchDir: string | null = null;
afterEach(() => {
vi.restoreAllMocks();
resetDatabaseSingleton();
if (scratchDir) {
try {
fs.rmSync(scratchDir, { recursive: true, force: true });
} catch {
// best-effort
}
scratchDir = null;
}
});
it('migrates a pre-approval enabled blueprint to pending with no authorization material', async () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-bp-approval-mig-'));
const now = Date.now();
const colNamesBefore = seedLegacyPreApprovalSchema(path.join(scratchDir, 'sencho.db'), now);
for (const absent of ABSENT_BEFORE_MIGRATION) {
expect(colNamesBefore).not.toContain(absent);
}
const db = bootDatabaseService(scratchDir);
for (const required of APPROVAL_COLUMNS) {
expect(blueprintColumnNames(db.getDb())).toContain(required);
}
// Assert raw SQLite values (not parseBlueprint coercion).
expectPendingNullAuth(readApprovalRow(db.getDb(), LEGACY_BLUEPRINT));
const row = db.getBlueprintByName(LEGACY_BLUEPRINT);
expect(row).toBeTruthy();
expect(row!.id).toBe(1);
expect(db.getNodes().some(n => n.id === 1)).toBe(true);
expect(db.listDeployments(1).some(d => d.node_id === 1 && d.status === 'active')).toBe(true);
const { BlueprintService } = await import('../services/BlueprintService');
const { BlueprintReconciler } = await import('../services/BlueprintReconciler');
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
const upsertSpy = vi.spyOn(db, 'upsertDeployment');
await BlueprintReconciler.getInstance().reconcileOne(row!.id);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).not.toHaveBeenCalled();
expect(upsertSpy).not.toHaveBeenCalled();
// Idempotent reopen keeps a single set of columns and pending row state.
resetDatabaseSingleton();
process.env.DATA_DIR = scratchDir;
const db2 = DatabaseService.getInstance();
expect(blueprintColumnNames(db2.getDb()).filter(name => name === 'approval_status')).toHaveLength(1);
expectPendingNullAuth(readApprovalRow(db2.getDb(), LEGACY_BLUEPRINT));
});
});
@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import {
intentFingerprint,
parseApprovedBlastJson,
serializeApprovedBlast,
deriveBlastFromConfirmableActions,
evaluateEffectiveApproval,
isActionAuthorized,
outcomeForConfirmableAction,
filterAuthorizedExecutorActions,
confirmableActionsEqual,
parseConfirmableActionsBody,
type PreviewAction,
} from '../services/blueprintApproval';
import type { Blueprint } from '../services/DatabaseService';
function baseBlueprint(overrides: Partial<Blueprint> = {}): Blueprint {
return {
id: 1,
name: 'web',
description: null,
compose_content: 'services:\n web:\n image: nginx\n',
selector: { type: 'nodes', ids: [1] },
drift_mode: 'suggest',
classification: 'stateless',
classification_reasons: [],
enabled: true,
revision: 1,
created_at: 0,
updated_at: 0,
created_by: 'admin',
pinned_node_id: null,
approval_status: 'pending',
approved_intent_fingerprint: null,
approved_blast_json: null,
approved_at: null,
approved_by: null,
...overrides,
};
}
describe('blueprintApproval matrix', () => {
it('maps clear_stale_guard to remove and clear_reversed_evict to place', () => {
expect(outcomeForConfirmableAction('clear_stale_guard')).toBe('remove');
expect(outcomeForConfirmableAction('clear_reversed_evict')).toBe('place');
});
it('authorizes place for create/update/check and never remove', () => {
expect(isActionAuthorized('place', 'create')).toBe(true);
expect(isActionAuthorized('place', 'update')).toBe(true);
expect(isActionAuthorized('place', 'check_enforce')).toBe(true);
expect(isActionAuthorized('place', 'remove')).toBe(false);
expect(isActionAuthorized('place', 'clear_stale_guard')).toBe(false);
expect(isActionAuthorized('place', 'in_flight_deploy')).toBe(false);
});
it('authorizes remove for remove/await_evict/clear_stale and never create', () => {
expect(isActionAuthorized('remove', 'remove')).toBe(true);
expect(isActionAuthorized('remove', 'await_evict_confirm')).toBe(true);
expect(isActionAuthorized('remove', 'clear_stale_guard')).toBe(true);
expect(isActionAuthorized('remove', 'create')).toBe(false);
expect(isActionAuthorized('remove', 'clear_reversed_evict')).toBe(false);
});
});
describe('blast JSON', () => {
it('round-trips canonical sorted blast', () => {
const json = serializeApprovedBlast([
{ nodeId: 3, outcome: 'remove' },
{ nodeId: 1, outcome: 'place' },
]);
const parsed = parseApprovedBlastJson(json);
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.entries).toEqual([
{ nodeId: 1, outcome: 'place' },
{ nodeId: 3, outcome: 'remove' },
]);
});
it('rejects unknown keys and non-canonical order without mutating', () => {
expect(parseApprovedBlastJson('[{"nodeId":1,"outcome":"place","extra":1}]').ok).toBe(false);
expect(parseApprovedBlastJson('[{"nodeId":2,"outcome":"place"},{"nodeId":1,"outcome":"place"}]').ok).toBe(false);
expect(parseApprovedBlastJson('not-json').ok).toBe(false);
});
it('derives place/remove from confirmable actions', () => {
const blast = deriveBlastFromConfirmableActions([
{ nodeId: 1, action: 'create' },
{ nodeId: 2, action: 'clear_stale_guard' },
{ nodeId: 3, action: 'skip_cordoned' },
]);
expect(blast).toEqual([
{ nodeId: 1, outcome: 'place' },
{ nodeId: 2, outcome: 'remove' },
]);
});
});
describe('evaluateEffectiveApproval', () => {
it('returns pending when approval is missing or fingerprint drifts', () => {
const bp = baseBlueprint();
const fp = intentFingerprint(bp);
expect(evaluateEffectiveApproval(bp, [{ nodeId: 1, action: 'create' }]).effectiveApproval).toBe('pending');
const approved = baseBlueprint({
approval_status: 'approved',
approved_intent_fingerprint: fp,
approved_blast_json: serializeApprovedBlast([{ nodeId: 1, outcome: 'place' }]),
});
expect(evaluateEffectiveApproval(approved, [{ nodeId: 1, action: 'create' }]).effectiveApproval).toBe('approved');
const renamed = { ...approved, name: 'renamed' };
expect(evaluateEffectiveApproval(renamed, [{ nodeId: 1, action: 'create' }]).effectiveApproval).toBe('pending');
});
it('returns reapproval_required when a new node needs place without blast entry', () => {
const bp = baseBlueprint();
const fp = intentFingerprint(bp);
const approved = baseBlueprint({
approval_status: 'approved',
approved_intent_fingerprint: fp,
approved_blast_json: serializeApprovedBlast([{ nodeId: 1, outcome: 'place' }]),
});
const result = evaluateEffectiveApproval(approved, [
{ nodeId: 1, action: 'check_observe' },
{ nodeId: 2, action: 'create' },
]);
expect(result.effectiveApproval).toBe('reapproval_required');
expect(result.unauthorizedActions).toEqual([{ nodeId: 2, action: 'create' }]);
});
it('malformed blast reads as pending', () => {
const bp = baseBlueprint({
approval_status: 'approved',
approved_intent_fingerprint: intentFingerprint(baseBlueprint()),
approved_blast_json: '[{bad',
});
expect(evaluateEffectiveApproval(bp, [{ nodeId: 1, action: 'create' }]).effectiveApproval).toBe('pending');
});
it('filters executor actions by matrix and never keeps informational', () => {
const blast = [{ nodeId: 1, outcome: 'place' as const }];
const filtered = filterAuthorizedExecutorActions(blast, [
{ nodeId: 1, action: 'create' },
{ nodeId: 1, action: 'in_flight_deploy' as PreviewAction },
{ nodeId: 1, action: 'remove' },
]);
expect(filtered).toEqual([{ nodeId: 1, action: 'create' }]);
});
});
describe('confirm body parse', () => {
it('accepts equal confirmable sets regardless of order', () => {
const a = [{ nodeId: 2, action: 'remove' as const }, { nodeId: 1, action: 'create' as const }];
const b = [{ nodeId: 1, action: 'create' as const }, { nodeId: 2, action: 'remove' as const }];
expect(confirmableActionsEqual(a, b)).toBe(true);
});
it('rejects invalid POST bodies', () => {
expect(parseConfirmableActionsBody(null).ok).toBe(false);
expect(parseConfirmableActionsBody([{ nodeId: 1, action: 'nope' }]).ok).toBe(false);
expect(parseConfirmableActionsBody([{ nodeId: 1, action: 'create', extra: 1 }]).ok).toBe(false);
});
});
@@ -60,6 +60,7 @@ beforeEach(() => {
vi.restoreAllMocks();
mockTier('community');
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileOne').mockResolvedValue(undefined);
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileConfirmedPlan').mockResolvedValue({ outcomes: [] });
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM blueprint_deployments').run();
db.prepare('DELETE FROM blueprints').run();
@@ -109,9 +110,18 @@ describe('Blueprints on Community tier', () => {
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie);
.set('Cookie', adminCookie)
.send({
planFingerprint: preview.body.planFingerprint,
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
@@ -0,0 +1,307 @@
/**
* Preview-confirm apply binding and stale-guard behaviour.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
let counter = 0;
function seedNode(): { id: number; name: string } {
counter += 1;
const name = `bp-preview-node-${counter}`;
const db = DatabaseService.getInstance().getDb();
const result = db.prepare(
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
).run(name, Date.now());
return { id: result.lastInsertRowid as number, name };
}
function validBlueprintBody(nodeId: number) {
return {
name: `bp-preview-${counter + 1}`,
compose_content: 'services:\n app:\n image: nginx\n',
selector: { type: 'nodes', ids: [nodeId] },
drift_mode: 'observe',
};
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileConfirmedPlan').mockResolvedValue({ outcomes: [] });
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM blueprint_deployments').run();
db.prepare('DELETE FROM blueprints').run();
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
});
describe('POST /api/blueprints/:id/apply confirm binding', () => {
it('rejects apply without planFingerprint', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(400);
expect(res.body.code).toBe('CONFIRM_REQUIRED');
});
it('rejects stale fingerprint with PREVIEW_STALE', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({
planFingerprint: 'deadbeef',
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(409);
expect(res.body.code).toBe('PREVIEW_STALE');
});
it('persists approval and calls reconcileConfirmedPlan with executor actions', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
expect(preview.body.effectiveApproval).toBe('pending');
expect(preview.body.planFingerprint).toBeTruthy();
const reconcileSpy = vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan);
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({
planFingerprint: preview.body.planFingerprint,
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(200);
expect(res.body.effectiveApproval).toBe('approved');
expect(res.body.outcomes).toEqual([]);
expect(res.body.outcomeSummary).toEqual({
total: 0,
ok: 0,
failed: 0,
pending: 0,
skipped: 0,
});
expect(reconcileSpy).toHaveBeenCalledWith(created.body.id, preview.body.executorActions);
const detail = await request(app)
.get(`/api/blueprints/${created.body.id}`)
.set('Cookie', adminCookie);
expect(detail.status).toBe(200);
expect(detail.body.effectiveApproval).toBe('approved');
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
expect(stored.approval_status).toBe('approved');
expect(stored.approved_intent_fingerprint).toBe(preview.body.planFingerprint);
expect(stored.updated_at).toBe(created.body.updated_at);
});
it('returns failed outcomes without pretending the rollout was clean', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockResolvedValueOnce({
outcomes: [{
nodeId: node.id,
nodeName: node.name,
action: 'create',
status: 'failed',
error: 'EHOSTUNREACH',
}],
});
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({
planFingerprint: preview.body.planFingerprint,
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(200);
expect(res.body.effectiveApproval).toBe('approved');
expect(res.body.message).toMatch(/node failures/i);
expect(res.body.outcomeSummary.failed).toBe(1);
expect(res.body.outcomes[0].error).toBe('EHOSTUNREACH');
});
it('marks create as blocked when an unmanaged same-name stack already exists', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const composeDir = process.env.COMPOSE_DIR!;
DatabaseService.getInstance().getDb()
.prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?')
.run(composeDir, node.id);
const stackDir = path.join(composeDir, created.body.name as string);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'docker-compose.yml'), 'services:\n app:\n image: nginx\n');
const { BlueprintService } = await import('../services/BlueprintService');
const conflict = await BlueprintService.getInstance().hasNameConflict(
created.body.name as string,
DatabaseService.getInstance().getNode(node.id)!,
);
expect(conflict).toBe(true);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const change = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
expect(change.action).toBe('blocked_name_conflict');
expect(change.severity).toBe('blocker');
expect(preview.body.summary.blocker).toBeGreaterThan(0);
});
it('blocks rename while a non-withdrawn deployment exists', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: created.body.id,
node_id: node.id,
status: 'active',
applied_revision: 1,
last_deployed_at: Date.now(),
});
const res = await request(app)
.put(`/api/blueprints/${created.body.id}`)
.set('Cookie', adminCookie)
.send({ name: 'renamed-while-live' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('RENAME_BLOCKED');
});
});
describe('preview projection status precedence', () => {
it('emits informational in_flight_deploy for a leaving deploying row, not remove', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send({
...validBlueprintBody(node.id),
selector: { type: 'nodes', ids: [] },
});
expect(created.status).toBe(201);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: created.body.id,
node_id: node.id,
status: 'deploying',
applied_revision: 1,
});
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const row = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
expect(row?.action).toBe('in_flight_deploy');
expect(row?.kind).toBe('informational');
expect(preview.body.executorActions.some(
(a: { nodeId: number; action: string }) => a.nodeId === node.id && a.action === 'remove',
)).toBe(false);
});
it('emits clear_stale_guard for never-deployed pending_state_review leavers', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send({
name: `bp-stale-${counter}`,
compose_content: 'services:\n app:\n image: nginx\n volumes:\n - data:/data\nvolumes:\n data:\n',
selector: { type: 'nodes', ids: [] },
drift_mode: 'observe',
});
expect(created.status).toBe(201);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: created.body.id,
node_id: node.id,
status: 'pending_state_review',
last_deployed_at: null,
});
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const row = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
expect(row?.action).toBe('clear_stale_guard');
expect(preview.body.executorActions).toContainEqual({ nodeId: node.id, action: 'clear_stale_guard' });
});
});
@@ -0,0 +1,407 @@
/**
* Approval-gated reconciler safety: pending / malformed / drifted approval must
* not mutate the fleet; confirmed place/remove only authorizes matching nodes.
* These tests call the real reconcileOne path (not a mocked reconcileConfirmedPlan).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import {
intentFingerprint,
serializeApprovedBlast,
} from '../services/blueprintApproval';
let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
let BlueprintService: typeof import('../services/BlueprintService').BlueprintService;
let NodeLabelService: typeof import('../services/NodeLabelService').NodeLabelService;
let counter = 0;
function seedNode(opts: {
type?: 'local' | 'remote';
mode?: string;
status?: string;
last_successful_contact?: number | null;
pilot_last_seen?: number | null;
} = {}): { id: number; name: string } {
counter += 1;
const name = `bp-gate-node-${counter}`;
const db = DatabaseService.getInstance().getDb();
const type = opts.type ?? 'local';
const mode = opts.mode ?? 'proxy';
const status = opts.status ?? 'online';
const result = db.prepare(
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at, last_successful_contact, pilot_last_seen)
VALUES (?, ?, ?, '/tmp/compose', 0, ?, ?, ?, ?)`,
).run(
name,
type,
mode,
status,
Date.now(),
opts.last_successful_contact ?? null,
opts.pilot_last_seen ?? null,
);
return { id: result.lastInsertRowid as number, name };
}
function createBp(opts: {
nodeIds?: number[];
labelsAny?: string[];
classification?: 'stateless' | 'stateful';
compose?: string;
}) {
counter += 1;
return DatabaseService.getInstance().createBlueprint({
name: `bp-gate-${counter}`,
description: null,
compose_content: opts.compose ?? 'services:\n app:\n image: nginx\n',
selector: opts.labelsAny
? { type: 'labels', any: opts.labelsAny, all: [] }
: { type: 'nodes', ids: opts.nodeIds ?? [] },
drift_mode: 'observe',
classification: opts.classification ?? 'stateless',
classification_reasons: opts.classification === 'stateful' ? ['named volume'] : [],
enabled: true,
created_by: 'admin',
});
}
function approvePlace(blueprintId: number, nodeIds: number[]) {
const bp = DatabaseService.getInstance().getBlueprint(blueprintId)!;
DatabaseService.getInstance().setBlueprintApproval(blueprintId, {
intentFingerprint: intentFingerprint(bp),
blastJson: serializeApprovedBlast(nodeIds.map(nodeId => ({ nodeId, outcome: 'place' as const }))),
approvedBy: 'admin',
});
}
function approveRemove(blueprintId: number, nodeIds: number[]) {
const bp = DatabaseService.getInstance().getBlueprint(blueprintId)!;
DatabaseService.getInstance().setBlueprintApproval(blueprintId, {
intentFingerprint: intentFingerprint(bp),
blastJson: serializeApprovedBlast(nodeIds.map(nodeId => ({ nodeId, outcome: 'remove' as const }))),
approvedBy: 'admin',
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
({ BlueprintService } = await import('../services/BlueprintService'));
({ NodeLabelService } = await import('../services/NodeLabelService'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM blueprint_deployments').run();
db.prepare('DELETE FROM blueprints').run();
db.prepare('DELETE FROM node_labels').run();
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
});
describe('reconcileOne approval gate (real path)', () => {
it('does not deploy or withdraw when approval is pending', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
expect(bp.approval_status).toBe('pending');
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).not.toHaveBeenCalled();
expect(DatabaseService.getInstance().listDeployments(bp.id)).toEqual([]);
});
it('does not mutate when approval_status is approved but blast JSON is malformed', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
const db = DatabaseService.getInstance().getDb();
db.prepare(
`UPDATE blueprints SET approval_status = 'approved',
approved_intent_fingerprint = ?,
approved_blast_json = '{bad'
WHERE id = ?`,
).run(intentFingerprint(bp), bp.id);
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).not.toHaveBeenCalled();
expect(DatabaseService.getInstance().listDeployments(bp.id)).toEqual([]);
});
it('does not mutate when the intent fingerprint has drifted', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
approvePlace(bp.id, [node.id]);
DatabaseService.getInstance().updateBlueprint(bp.id, {
compose_content: 'services:\n app:\n image: nginx:alpine\n',
});
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).not.toHaveBeenCalled();
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
expect(refreshed.approval_status).toBe('pending');
});
it('deploys only place-authorized nodes after confirmation', async () => {
const nodeA = seedNode();
const nodeB = seedNode();
const bp = createBp({ nodeIds: [nodeA.id, nodeB.id] });
approvePlace(bp.id, [nodeA.id]);
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).toHaveBeenCalled();
const deployedIds = deploySpy.mock.calls.map(c => (c[1] as { id: number }).id);
expect(deployedIds).toContain(nodeA.id);
expect(deployedIds).not.toContain(nodeB.id);
expect(withdrawSpy).not.toHaveBeenCalled();
});
it('withdraws only remove-authorized nodes after confirmation', async () => {
const nodeA = seedNode();
const nodeB = seedNode();
// Empty desired set so both live rows become remove candidates.
const bp = createBp({ nodeIds: [] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeA.id,
status: 'active',
last_deployed_at: Date.now(),
});
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeB.id,
status: 'active',
last_deployed_at: Date.now(),
});
approveRemove(bp.id, [nodeA.id]);
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).toHaveBeenCalled();
const withdrawnIds = withdrawSpy.mock.calls.map(c => (c[1] as { id: number }).id);
expect(withdrawnIds).toContain(nodeA.id);
expect(withdrawnIds).not.toContain(nodeB.id);
});
it('does not place on a newly labeled node without reapproval', async () => {
const nodeA = seedNode();
const nodeB = seedNode();
NodeLabelService.getInstance().addLabel(nodeA.id, 'web');
const bp = createBp({ labelsAny: ['web'] });
approvePlace(bp.id, [nodeA.id]);
NodeLabelService.getInstance().addLabel(nodeB.id, 'web');
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
const deployedIds = deploySpy.mock.calls.map(c => (c[1] as { id: number }).id);
expect(deployedIds).not.toContain(nodeB.id);
expect(deployedIds).toContain(nodeA.id);
});
it('pin clears approval so post-pin reconcileOne does not mutate', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
approvePlace(bp.id, [node.id]);
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
const pinRes = await request(app)
.put(`/api/blueprints/${bp.id}/pin`)
.set('Cookie', adminCookie)
.send({ nodeId: node.id });
expect(pinRes.status).toBe(200);
expect(pinRes.body.approval_status).toBe('pending');
// Pin route fires reconcileOne async; also call explicitly for determinism.
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).not.toHaveBeenCalled();
});
});
describe('Accept/Evict STALE_GUARD', () => {
it('refuses Accept without a valid place approval', async () => {
const node = seedNode();
const bp = createBp({
nodeIds: [node.id],
classification: 'stateful',
compose: 'services:\n app:\n image: nginx\n volumes:\n - data:/data\nvolumes:\n data:\n',
});
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: node.id,
status: 'pending_state_review',
});
const res = await request(app)
.post(`/api/blueprints/${bp.id}/accept/${node.id}`)
.set('Cookie', adminCookie)
.send({ mode: 'fresh' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('STALE_GUARD');
});
it('refuses Evict from evict_blocked without a valid remove approval', async () => {
const node = seedNode();
const bp = createBp({
nodeIds: [],
classification: 'stateful',
compose: 'services:\n app:\n image: nginx\n volumes:\n - data:/data\nvolumes:\n data:\n',
});
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: node.id,
status: 'evict_blocked',
last_deployed_at: Date.now(),
});
approvePlace(bp.id, [node.id]); // place-only; evict needs remove
const res = await request(app)
.post(`/api/blueprints/${bp.id}/withdraw/${node.id}`)
.set('Cookie', adminCookie)
.send({ confirm: 'evict_and_destroy' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('STALE_GUARD');
});
it('refuses Evict on an active deployment without remove approval', async () => {
const node = seedNode();
const bp = createBp({
nodeIds: [node.id],
classification: 'stateful',
compose: 'services:\n app:\n image: nginx\n volumes:\n - data:/data\nvolumes:\n data:\n',
});
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: node.id,
status: 'active',
last_deployed_at: Date.now(),
});
approvePlace(bp.id, [node.id]);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
const res = await request(app)
.post(`/api/blueprints/${bp.id}/withdraw/${node.id}`)
.set('Cookie', adminCookie)
.send({ confirm: 'evict_and_destroy' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('STALE_GUARD');
expect(withdrawSpy).not.toHaveBeenCalled();
});
it('allows standard withdraw on an active stateless deployment without remove approval', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: node.id,
status: 'active',
last_deployed_at: Date.now(),
});
approvePlace(bp.id, [node.id]);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
const res = await request(app)
.post(`/api/blueprints/${bp.id}/withdraw/${node.id}`)
.set('Cookie', adminCookie)
.send({ confirm: 'standard' });
expect(res.status).toBe(200);
expect(withdrawSpy).toHaveBeenCalledOnce();
});
});
describe('approval defaults and corrupt-approval fail-closed', () => {
it('new blueprints persist pending approval columns', () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
expect(bp.approval_status).toBe('pending');
expect(bp.approved_intent_fingerprint).toBeNull();
expect(bp.approved_blast_json).toBeNull();
expect(bp.approved_at).toBeNull();
expect(bp.approved_by).toBeNull();
});
it('approved rows without a usable blast stay pending for effective approval and do not mutate', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
const db = DatabaseService.getInstance().getDb();
db.prepare(
`UPDATE blueprints SET approval_status = 'approved',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL
WHERE id = ?`,
).run(bp.id);
const detail = await request(app).get(`/api/blueprints/${bp.id}`).set('Cookie', adminCookie);
expect(detail.status).toBe(200);
expect(detail.body.effectiveApproval).toBe('pending');
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(deploySpy).not.toHaveBeenCalled();
});
});
describe('preview health and warning totals', () => {
it('includes reachabilityNote in blocker copy for offline remotes', async () => {
const node = seedNode({
type: 'remote',
mode: 'proxy',
status: 'offline',
last_successful_contact: Math.floor(Date.now() / 1000) - 10,
});
const bp = createBp({ nodeIds: [node.id] });
const preview = await request(app).get(`/api/blueprints/${bp.id}/preview`).set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const change = preview.body.changes.find((c: { nodeId: number }) => c.nodeId === node.id);
expect(change.reachabilityNote).toMatch(/offline/i);
expect(change.severity).toBe('blocker');
const blocker = preview.body.blockers.find((b: { id: string }) => b.id.includes(String(node.id)));
expect(blocker.message).toMatch(/offline|unknown/i);
});
it('counts requirement and compatibility warnings in summary.warning', async () => {
const node = seedNode();
const bp = createBp({
nodeIds: [node.id],
compose: 'services:\n app:\n image: nginx\n environment:\n - DB_PASSWORD=${DB_PASSWORD}\n',
});
// Force a classification reason into the blueprint row for compat warnings.
DatabaseService.getInstance().getDb().prepare(
`UPDATE blueprints SET classification_reasons = ? WHERE id = ?`,
).run(JSON.stringify(['uses named volumes']), bp.id);
const preview = await request(app).get(`/api/blueprints/${bp.id}/preview`).set('Cookie', adminCookie);
expect(preview.status).toBe(200);
expect(preview.body.warnings.length).toBeGreaterThan(0);
expect(preview.body.summary.warning).toBe(preview.body.warnings.length);
expect(preview.body.summary.blocker).toBe(preview.body.blockers.length);
});
});
@@ -4,10 +4,17 @@
* fleet_snapshots row + one fleet_snapshot_files row, and aborts the eviction
* (without invoking withdrawFromNode) when the snapshot write fails. Also
* verifies evict_and_destroy and stateless withdraws do not create snapshots.
*
* Destructive confirms require a remove-approved blast on a node that is no
* longer desired. Stateless standard withdraw does not.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import {
intentFingerprint,
serializeApprovedBlast,
} from '../services/blueprintApproval';
let tmpDir: string;
let app: import('express').Express;
@@ -39,7 +46,7 @@ beforeEach(() => {
db.prepare('DELETE FROM blueprints').run();
db.prepare('DELETE FROM fleet_snapshot_files').run();
db.prepare('DELETE FROM fleet_snapshots').run();
db.prepare("DELETE FROM nodes WHERE is_default = 0").run();
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
});
function seedNode(): { id: number; name: string } {
@@ -48,7 +55,7 @@ function seedNode(): { id: number; name: string } {
const db = DatabaseService.getInstance().getDb();
const result = db.prepare(
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
).run(name, Date.now());
return { id: result.lastInsertRowid as number, name };
}
@@ -78,6 +85,17 @@ function seedActiveDeployment(blueprintId: number, nodeId: number, revision: num
node_id: nodeId,
status: 'active',
applied_revision: revision,
last_deployed_at: Date.now(),
});
}
/** Approve remove for nodes that have left the selector (empty desired set). */
function approveRemove(blueprintId: number, nodeIds: number[]) {
const bp = DatabaseService.getInstance().getBlueprint(blueprintId)!;
DatabaseService.getInstance().setBlueprintApproval(blueprintId, {
intentFingerprint: intentFingerprint(bp),
blastJson: serializeApprovedBlast(nodeIds.map(nodeId => ({ nodeId, outcome: 'remove' as const }))),
approvedBy: 'admin',
});
}
@@ -85,8 +103,10 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
it('snapshot_then_evict on a stateful blueprint captures compose into fleet_snapshots, then withdraws', async () => {
const node = seedNode();
const compose = 'services:\n db:\n image: postgres:16\n volumes:\n - data:/var/lib/postgresql/data\nvolumes:\n data:\n';
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id], composeContent: compose });
// Empty selector: node is not desired, so remove approval can authorize eviction.
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [], composeContent: compose });
seedActiveDeployment(bp.id, node.id, bp.revision);
approveRemove(bp.id, [node.id]);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode')
.mockResolvedValue({ status: 'withdrawn' });
@@ -127,8 +147,9 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
it('aborts the eviction with 500 and does NOT call withdrawFromNode when the snapshot write fails', async () => {
const node = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] });
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [] });
seedActiveDeployment(bp.id, node.id, bp.revision);
approveRemove(bp.id, [node.id]);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode')
.mockResolvedValue({ status: 'withdrawn' });
@@ -152,8 +173,9 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
it('cleans up the orphan snapshot row when insertSnapshotFiles fails after createSnapshot succeeded', async () => {
const node = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] });
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [] });
seedActiveDeployment(bp.id, node.id, bp.revision);
approveRemove(bp.id, [node.id]);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode')
.mockResolvedValue({ status: 'withdrawn' });
@@ -177,8 +199,9 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
it('evict_and_destroy on a stateful blueprint does NOT create a snapshot', async () => {
const node = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] });
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [] });
seedActiveDeployment(bp.id, node.id, bp.revision);
approveRemove(bp.id, [node.id]);
vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode')
.mockResolvedValue({ status: 'withdrawn' });
@@ -265,8 +288,9 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
it('snapshot_then_evict returns 500 when compose_content is empty', async () => {
const node = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id], composeContent: ' \n \n' });
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [], composeContent: ' \n \n' });
seedActiveDeployment(bp.id, node.id, bp.revision);
approveRemove(bp.id, [node.id]);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode')
.mockResolvedValue({ status: 'withdrawn' });
@@ -284,4 +308,22 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
const count = (db.prepare('SELECT COUNT(*) as n FROM fleet_snapshots').get() as { n: number }).n;
expect(count).toBe(0);
});
it('rejects destructive evict on an active still-desired node without remove approval', async () => {
const node = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] });
seedActiveDeployment(bp.id, node.id, bp.revision);
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode')
.mockResolvedValue({ status: 'withdrawn' });
const res = await request(app)
.post(`/api/blueprints/${bp.id}/withdraw/${node.id}`)
.set('Cookie', adminCookie)
.send({ confirm: 'evict_and_destroy' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('STALE_GUARD');
expect(withdrawSpy).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -59,13 +59,13 @@ export interface StackEnvSources {
authoredComposeText: string;
}
interface EnvFileEntry {
export interface EnvFileEntry {
rawPath: string;
required: boolean;
}
/** Normalize a service `env_file:` field (string, array of strings, or long-form objects). */
function normalizeEnvFileField(envFile: unknown): EnvFileEntry[] {
export function normalizeEnvFileField(envFile: unknown): EnvFileEntry[] {
if (typeof envFile === 'string') return [{ rawPath: envFile, required: true }];
if (!Array.isArray(envFile)) return [];
const out: EnvFileEntry[] = [];
+105 -33
View File
@@ -8,9 +8,19 @@ import {
type DriftMode,
} from '../services/DatabaseService';
import { BlueprintService } from '../services/BlueprintService';
import { BlueprintReconciler } from '../services/BlueprintReconciler';
import {
BlueprintReconciler,
messageForConfirmedOutcomes,
summarizeConfirmedOutcomes,
} from '../services/BlueprintReconciler';
import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
import { NodeLabelService } from '../services/NodeLabelService';
import { buildBlueprintPreview, evaluateLightweightEffectiveApproval } from '../services/blueprintPreviewProjection';
import {
confirmableActionsEqual,
deriveBlastFromConfirmableActions,
parseConfirmableActionsBody,
serializeApprovedBlast,
} from '../services/blueprintApproval';
import { isValidStackName } from '../utils/validation';
import { parseIntParam } from '../utils/parseIntParam';
import { isDebugEnabled } from '../utils/debug';
@@ -110,7 +120,14 @@ function summarizeBlueprint(blueprintId: number) {
for (const dep of deployments) {
counts[dep.status] = (counts[dep.status] ?? 0) + 1;
}
return { blueprint, deployments, statusCounts: counts };
const auth = evaluateLightweightEffectiveApproval(blueprintId);
return {
blueprint,
deployments,
statusCounts: counts,
effectiveApproval: auth?.effectiveApproval ?? 'pending',
unauthorizedActions: auth?.unauthorizedActions ?? [],
};
}
blueprintsRouter.get('/', (req: Request, res: Response): void => {
@@ -120,7 +137,14 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => {
const deployments = DatabaseService.getInstance().listDeployments(b.id);
const counts: Record<string, number> = {};
for (const dep of deployments) counts[dep.status] = (counts[dep.status] ?? 0) + 1;
return { ...b, deploymentCounts: counts, deploymentTotal: deployments.length };
const auth = evaluateLightweightEffectiveApproval(b.id);
return {
...b,
deploymentCounts: counts,
deploymentTotal: deployments.length,
effectiveApproval: auth?.effectiveApproval ?? 'pending',
unauthorizedActions: auth?.unauthorizedActions ?? [],
};
});
res.json(summaries);
} catch (error) {
@@ -191,7 +215,17 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
if (body.name !== undefined) {
const nameError = validateName(body.name);
if (nameError) { res.status(400).json({ error: nameError }); return; }
updates.name = (body.name as string).trim();
const nextName = (body.name as string).trim();
const existing = DatabaseService.getInstance().getBlueprint(id);
if (existing && existing.name !== nextName
&& DatabaseService.getInstance().hasNonWithdrawnBlueprintDeployments(id)) {
res.status(409).json({
error: 'Rename is blocked while non-withdrawn deployments or guards exist. Withdraw or resolve them first.',
code: 'RENAME_BLOCKED',
});
return;
}
updates.name = nextName;
}
if (body.description !== undefined) {
const descError = validateDescription(body.description);
@@ -363,8 +397,50 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise
res.status(409).json({ error: 'Blueprint is disabled. Enable it before applying.', code: 'blueprint_disabled' });
return;
}
await BlueprintReconciler.getInstance().reconcileOne(id);
res.json({ message: 'Reconciliation triggered', blueprintId: id });
const body = (req.body ?? {}) as { planFingerprint?: unknown; actions?: unknown };
if (typeof body.planFingerprint !== 'string' || body.planFingerprint.length === 0) {
res.status(400).json({ error: 'planFingerprint is required', code: 'CONFIRM_REQUIRED' });
return;
}
const parsedActions = parseConfirmableActionsBody(body.actions);
if (!parsedActions.ok) {
res.status(400).json({ error: `Invalid actions: ${parsedActions.reason}`, code: 'CONFIRM_REQUIRED' });
return;
}
const preview = await buildBlueprintPreview(id);
if (!preview) {
res.status(404).json({ error: 'Blueprint not found' });
return;
}
if (preview.summary.blocker > 0) {
res.status(409).json({ error: 'Plan has blockers', code: 'PLAN_BLOCKED', preview });
return;
}
if (
body.planFingerprint !== preview.planFingerprint
|| !confirmableActionsEqual(parsedActions.actions, preview.confirmableActions)
) {
res.status(409).json({ error: 'Preview is stale; refresh and confirm again', code: 'PREVIEW_STALE', preview });
return;
}
const blast = deriveBlastFromConfirmableActions(preview.confirmableActions);
DatabaseService.getInstance().setBlueprintApproval(id, {
intentFingerprint: preview.planFingerprint,
blastJson: serializeApprovedBlast(blast),
approvedBy: req.user?.username ?? null,
});
const plan = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(id, preview.executorActions);
const outcomeSummary = summarizeConfirmedOutcomes(plan.outcomes);
res.json({
message: messageForConfirmedOutcomes(outcomeSummary),
blueprintId: id,
effectiveApproval: 'approved',
outcomes: plan.outcomes,
outcomeSummary,
});
} catch (error) {
console.error('[Blueprints] Apply error:', error);
res.status(500).json({ error: 'Failed to apply blueprint' });
@@ -395,6 +471,18 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
});
return;
}
// Destructive eviction (and reconciler-queued evict_blocked rows) require a
// current approved remove outcome. Plain stateless "standard" withdraw
// remains an immediate operator stop (no remove blast required).
const existingDep = DatabaseService.getInstance().getDeployment(id, nodeId);
const destructiveConfirm = confirm === 'snapshot_then_evict' || confirm === 'evict_and_destroy';
if (destructiveConfirm || existingDep?.status === 'evict_blocked') {
const guard = BlueprintReconciler.getInstance().validateWithdrawConfirmation(id, nodeId);
if (!guard.ok) {
res.status(409).json({ error: guard.error, code: guard.code });
return;
}
}
let snapshotId: number | null = null;
if (confirm === 'snapshot_then_evict') {
const compose = blueprint.compose_content;
@@ -462,9 +550,9 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response)
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const dep = DatabaseService.getInstance().getDeployment(id, nodeId);
if (!dep || dep.status !== 'pending_state_review') {
res.status(409).json({ error: 'Deployment is not awaiting state review' });
const guard = BlueprintReconciler.getInstance().validateGuardConfirmation(id, nodeId, 'accept');
if (!guard.ok) {
res.status(409).json({ error: guard.error, code: guard.code });
return;
}
// 'restore_from_snapshot' is reserved for the future Volume Migration feature.
@@ -477,29 +565,13 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response)
}
});
blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
blueprintsRouter.get('/:id/preview', async (req: Request, res: Response): Promise<void> => {
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const allNodes = DatabaseService.getInstance().getNodes();
const desired = NodeLabelService.getInstance().matchSelector(blueprint.selector, allNodes);
const existing = DatabaseService.getInstance().listDeployments(id);
const desiredIds = new Set(desired.map(n => n.id));
const willDeploy = desired.filter(n => !existing.some(d => d.node_id === n.id));
const willCheck = desired.filter(n => existing.some(d => d.node_id === n.id && d.status === 'active'));
const willEvict = existing
.filter(d => !desiredIds.has(d.node_id) && d.status !== 'withdrawn')
.map(d => d.node_id);
res.json({
blueprintId: id,
classification: blueprint.classification,
matchedNodes: desired.map(n => ({ id: n.id, name: n.name, type: n.type })),
plannedDeployments: willDeploy.map(n => ({ id: n.id, name: n.name })),
plannedDriftChecks: willCheck.map(n => ({ id: n.id, name: n.name })),
plannedEvictions: willEvict,
});
const preview = await buildBlueprintPreview(id);
if (!preview) { res.status(404).json({ error: 'Blueprint not found' }); return; }
res.json(preview);
} catch (error) {
console.error('[Blueprints] Preview error:', error);
res.status(500).json({ error: 'Failed to preview blueprint' });
@@ -531,9 +603,9 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<vo
const updated = DatabaseService.getInstance().setBlueprintPinnedNode(id, nodeId);
if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (isDebugEnabled()) console.log('[Federation:diag] pinned blueprint=%s node=%s', sanitizeForLog(id), sanitizeForLog(nodeId));
// Trigger immediate reconciliation so the pin takes effect without
// waiting for the next 60s tick. Errors here are logged but do not
// fail the request: the pin is already persisted.
// Pin clears approval, so reconcileOne cannot mutate until Confirm Apply.
// Still call it so the fail-closed pending state is evaluated immediately
// instead of waiting for the next tick.
if (updated.enabled) {
BlueprintReconciler.getInstance().reconcileOne(id).catch(err => {
console.warn('[Blueprints] post-pin reconcileOne failed:', err);
+349 -57
View File
@@ -4,15 +4,96 @@ import {
type BlueprintDeployment,
type Node,
} from './DatabaseService';
import { BlueprintService } from './BlueprintService';
import { BlueprintService, type DeployOutcome } from './BlueprintService';
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
import { NodeLabelService } from './NodeLabelService';
import { NotificationService } from './NotificationService';
import { sanitizeForLog } from '../utils/safeLog';
import {
type ConfirmableActionRef,
type PreviewAction,
filterAuthorizedExecutorActions,
intentFingerprint,
parseApprovedBlastJson,
} from './blueprintApproval';
import {
applyClearReversedEvict,
applyClearStaleGuard,
buildBlueprintPreview,
} from './blueprintPreviewProjection';
const RECONCILER_INTERVAL_MS = 60_000;
const RECONCILER_INITIAL_DELAY_MS = 5_000;
export type ConfirmedActionOutcomeStatus = 'ok' | 'failed' | 'name_conflict' | 'pending' | 'skipped';
export interface ConfirmedActionOutcome {
nodeId: number;
nodeName: string;
action: PreviewAction;
status: ConfirmedActionOutcomeStatus;
error?: string | null;
}
export interface ConfirmedPlanResult {
outcomes: ConfirmedActionOutcome[];
}
export interface ConfirmedOutcomeSummary {
total: number;
ok: number;
failed: number;
pending: number;
skipped: number;
}
export function summarizeConfirmedOutcomes(outcomes: ConfirmedActionOutcome[]): ConfirmedOutcomeSummary {
let ok = 0;
let failed = 0;
let pending = 0;
let skipped = 0;
for (const outcome of outcomes) {
switch (outcome.status) {
case 'ok':
ok += 1;
break;
case 'failed':
case 'name_conflict':
failed += 1;
break;
case 'pending':
pending += 1;
break;
case 'skipped':
skipped += 1;
break;
}
}
return { total: outcomes.length, ok, failed, pending, skipped };
}
export function messageForConfirmedOutcomes(summary: ConfirmedOutcomeSummary): string {
if (summary.failed > 0) return 'Rollout confirmed with node failures';
if (summary.pending > 0) return 'Rollout confirmed; some actions are still in progress';
return 'Rollout confirmed';
}
function mapDeployOutcome(
base: { nodeId: number; nodeName: string; action: PreviewAction },
result: DeployOutcome,
): ConfirmedActionOutcome {
if (result.status === 'active' || result.status === 'withdrawn') {
return { ...base, status: 'ok' };
}
if (result.status === 'name_conflict') {
return { ...base, status: 'name_conflict', error: result.error ?? 'name_conflict' };
}
if (result.status === 'pending' || result.status === 'deploying' || result.status === 'withdrawing') {
return { ...base, status: 'pending', error: result.error ?? null };
}
return { ...base, status: 'failed', error: result.error ?? result.status };
}
function isDeveloperModeEnabled(): boolean {
try {
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
@@ -91,9 +172,9 @@ export class BlueprintReconciler {
}
/**
* Force reconciliation for a single blueprint. Invoked by the /apply
* endpoint so users get immediate action without waiting for the
* interval.
* Force reconciliation for a single blueprint. Invoked after Confirm
* persists approval, or by pin (still gated). Prefer reconcileConfirmedPlan
* for Apply so execution uses the validated immutable action set.
*/
async reconcileOne(blueprintId: number): Promise<void> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
@@ -103,6 +184,31 @@ export class BlueprintReconciler {
await this.reconcileBlueprint(blueprint, nodes);
}
/**
* Execute only the provided executor actions for an already-validated plan.
* Does not recompute or widen the action set. Returns per-node outcomes so
* Apply can report partial failure without claiming a clean rollout.
*/
async reconcileConfirmedPlan(
blueprintId: number,
executorActions: ConfirmableActionRef[],
): Promise<ConfirmedPlanResult> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint || !blueprint.enabled) {
return { outcomes: [] };
}
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (!parsed.ok || blueprint.approval_status !== 'approved') {
diagnosticLog('reconcileConfirmedPlan skipped: approval missing or invalid', { blueprintId });
return { outcomes: [] };
}
const authorized = filterAuthorizedExecutorActions(parsed.entries, executorActions);
const nodes = DatabaseService.getInstance().getNodes();
const byId = new Map(nodes.map(n => [n.id, n]));
const outcomes = await this.executeAuthorizedActions(blueprint, byId, authorized);
return { outcomes };
}
private async evaluate(): Promise<void> {
if (this.running) return; // prevent overlap on slow ticks
this.running = true;
@@ -128,69 +234,236 @@ export class BlueprintReconciler {
}
private async reconcileBlueprint(blueprint: Blueprint, allNodes: Node[]): Promise<void> {
const decision = this.computeDecision(blueprint, allNodes);
diagnosticLog('decision computed', {
const preview = await buildBlueprintPreview(blueprint.id);
if (!preview) return;
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (
blueprint.approval_status !== 'approved'
|| !parsed.ok
|| blueprint.approved_intent_fingerprint !== intentFingerprint(blueprint)
) {
diagnosticLog('reconcile skipped: no valid approval', {
blueprintId: blueprint.id,
effectiveApproval: preview.effectiveApproval,
});
return;
}
const authorized = filterAuthorizedExecutorActions(parsed.entries, preview.executorActions);
if (authorized.length === 0) {
diagnosticLog('reconcile skipped: no authorized executor actions', { blueprintId: blueprint.id });
return;
}
diagnosticLog('decision authorized', {
blueprintId: blueprint.id,
blueprintName: blueprint.name,
revision: blueprint.revision,
deploy: decision.deploy.length,
withdraw: decision.withdraw.length,
check: decision.check.length,
stateReview: decision.stateReview.length,
evictBlocked: decision.evictBlocked.length,
authorized: authorized.length,
unauthorized: preview.unauthorizedActions.length,
});
// 1. State-review guard for stateful blueprints reaching new nodes.
for (const node of decision.stateReview) {
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'pending_state_review',
last_checked_at: Date.now(),
drift_summary: existing
? 'Stateful blueprint revision change awaits operator confirmation'
: 'Stateful blueprint awaiting operator confirmation before first deploy',
});
}
const byId = new Map(allNodes.map(n => [n.id, n]));
await this.executeAuthorizedActions(blueprint, byId, authorized);
}
// 2. Eviction guard for stateful blueprints leaving the selector.
for (const node of decision.evictBlocked) {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'evict_blocked',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint eviction requires operator confirmation',
});
}
// 3. Deploy missing/stale entries (stateless or pre-accepted stateful).
private async executeAuthorizedActions(
blueprint: Blueprint,
byId: Map<number, Node>,
actions: ConfirmableActionRef[],
): Promise<ConfirmedActionOutcome[]> {
const svc = BlueprintService.getInstance();
for (const node of decision.deploy) {
await svc.deployToNode(blueprint, node);
const outcomes: ConfirmedActionOutcome[] = [];
for (const { nodeId, action } of actions) {
const node = byId.get(nodeId);
if (!node) {
console.warn(
`[BlueprintReconciler] confirmed plan skipped missing node ${nodeId} for blueprint ${blueprint.id}`,
);
outcomes.push({
nodeId,
nodeName: `node-${nodeId}`,
action,
status: 'skipped',
error: 'Node not found',
});
continue;
}
outcomes.push(await this.executeOneAction(blueprint, node, action, svc));
}
return outcomes;
}
private async executeOneAction(
blueprint: Blueprint,
node: Node,
action: PreviewAction,
svc: BlueprintService,
): Promise<ConfirmedActionOutcome> {
const base = { nodeId: node.id, nodeName: node.name, action };
switch (action) {
case 'await_state_review': {
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'pending_state_review',
last_checked_at: Date.now(),
drift_summary: existing
? 'Stateful blueprint revision change awaits operator confirmation'
: 'Stateful blueprint awaiting operator confirmation before first deploy',
});
return { ...base, status: 'ok' };
}
case 'await_evict_confirm': {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'evict_blocked',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint eviction requires operator confirmation',
});
return { ...base, status: 'ok' };
}
case 'clear_reversed_evict':
applyClearReversedEvict(blueprint.id, node.id);
return { ...base, status: 'ok' };
case 'clear_stale_guard':
applyClearStaleGuard(blueprint.id, node.id);
return { ...base, status: 'ok' };
case 'create':
case 'update': {
const result = await svc.deployToNode(blueprint, node);
return mapDeployOutcome(base, result);
}
case 'remove': {
const result = await svc.withdrawFromNode(blueprint, node);
return mapDeployOutcome(base, result);
}
case 'check_observe':
case 'check_enforce': {
const driftResult = await svc.checkForDrift(blueprint, node);
if (!driftResult.drifted) return { ...base, status: 'ok' };
const reason = driftResult.reason ?? 'unknown drift';
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'drifted',
last_checked_at: Date.now(),
last_drift_at: Date.now(),
drift_summary: reason,
});
// observe/suggest/enforce: notify path via handleDrift still respects drift_mode
await this.handleDrift(blueprint, node, reason);
return { ...base, status: 'ok' };
}
default:
return { ...base, status: 'skipped', error: `Unsupported action ${action}` };
}
}
/**
* Validate Accept against current approval and placement.
* Returns ok when the guard row and approved place outcome still match.
*/
validateGuardConfirmation(
blueprintId: number,
nodeId: number,
kind: 'accept' | 'evict',
): { ok: true } | { ok: false; code: string; error: string } {
if (kind === 'evict') {
// Guard-path Evict still requires the evict_blocked row; open withdraw uses
// validateWithdrawConfirmation without that flag.
return this.validateWithdrawConfirmation(blueprintId, nodeId, { requireEvictBlocked: true });
}
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return { ok: false, code: 'not_found', error: 'Blueprint not found' };
const node = db.getNode(nodeId);
if (!node) return { ok: false, code: 'not_found', error: 'Node not found' };
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status !== 'pending_state_review') {
return { ok: false, code: 'STALE_GUARD', error: 'Deployment is not awaiting state review' };
}
// 4. Withdraw stateless deployments leaving the selector.
for (const node of decision.withdraw) {
await svc.withdrawFromNode(blueprint, node);
const approval = this.requireApprovedRemoveOrPlace(blueprint, nodeId, 'place');
if (!approval.ok) return approval;
const desired = this.listDesiredNodes(blueprint, db.getNodes()).some(n => n.id === nodeId);
if (!desired) {
return { ok: false, code: 'STALE_GUARD', error: 'Node is no longer an approved placement target' };
}
return { ok: true };
}
/**
* Validate manual withdraw/evict against current remove approval.
* Manual destroy of an active row still requires an approved remove outcome
* and a node that is no longer desired (same contract as reconciler remove).
*/
validateWithdrawConfirmation(
blueprintId: number,
nodeId: number,
opts: { requireEvictBlocked?: boolean } = {},
): { ok: true } | { ok: false; code: string; error: string } {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return { ok: false, code: 'not_found', error: 'Blueprint not found' };
const node = db.getNode(nodeId);
if (!node) return { ok: false, code: 'not_found', error: 'Node not found' };
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status === 'withdrawn') {
return { ok: false, code: 'STALE_GUARD', error: 'No withdrawable deployment on this node' };
}
if (opts.requireEvictBlocked && dep.status !== 'evict_blocked') {
return { ok: false, code: 'STALE_GUARD', error: 'Deployment is not awaiting eviction confirmation' };
}
// 5. Drift check for active deployments.
for (const node of decision.check) {
const driftResult = await svc.checkForDrift(blueprint, node);
if (!driftResult.drifted) continue;
const reason = driftResult.reason ?? 'unknown drift';
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'drifted',
last_checked_at: Date.now(),
last_drift_at: Date.now(),
drift_summary: reason,
});
await this.handleDrift(blueprint, node, reason);
const approval = this.requireApprovedRemoveOrPlace(blueprint, nodeId, 'remove');
if (!approval.ok) return approval;
const desired = this.listDesiredNodes(blueprint, db.getNodes()).some(n => n.id === nodeId);
if (desired) {
return { ok: false, code: 'STALE_GUARD', error: 'Node is no longer an approved removal target' };
}
return { ok: true };
}
private requireApprovedRemoveOrPlace(
blueprint: Blueprint,
nodeId: number,
outcomeNeeded: 'place' | 'remove',
): { ok: true } | { ok: false; code: string; error: string } {
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (
blueprint.approval_status !== 'approved'
|| !parsed.ok
|| blueprint.approved_intent_fingerprint !== intentFingerprint(blueprint)
) {
return { ok: false, code: 'STALE_GUARD', error: 'Blueprint approval is no longer valid; preview and confirm again' };
}
const outcome = parsed.entries.find(e => e.nodeId === nodeId)?.outcome;
if (outcome !== outcomeNeeded) {
const errors: Record<'place' | 'remove', string> = {
place: 'Node is no longer an approved placement target',
remove: 'Node is no longer an approved removal target',
};
return { ok: false, code: 'STALE_GUARD', error: errors[outcomeNeeded] };
}
return { ok: true };
}
/** Public wrapper for preview/approval projection (read-only). */
computeDecisionForPreview(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
return this.computeDecision(blueprint, allNodes);
}
/** Desired node set after pin/selector (read-only). */
listDesiredNodes(blueprint: Blueprint, allNodes: Node[]): Node[] {
if (blueprint.pinned_node_id !== null) {
const pinned = allNodes.find(n => n.id === blueprint.pinned_node_id);
return pinned ? [pinned] : [];
}
return NodeLabelService.getInstance().matchSelector(blueprint.selector, allNodes);
}
private computeDecision(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
@@ -244,8 +517,16 @@ export class BlueprintReconciler {
}
continue;
}
// Operator-blocking states must not be auto-acted on
if (dep.status === 'pending_state_review' || dep.status === 'evict_blocked' || dep.status === 'name_conflict') {
// In-flight and operator-blocking states: projection emits informational
// or await_* rows. Never queue effectful deploy/withdraw while in flight.
if (
dep.status === 'deploying'
|| dep.status === 'correcting'
|| dep.status === 'withdrawing'
|| dep.status === 'pending_state_review'
|| dep.status === 'evict_blocked'
|| dep.status === 'name_conflict'
) {
continue;
}
if (dep.status === 'active' && dep.applied_revision === blueprint.revision) {
@@ -270,6 +551,17 @@ export class BlueprintReconciler {
for (const dep of existingDeployments) {
if (desiredIds.has(dep.node_id)) continue;
if (dep.status === 'withdrawn') continue;
// Projection owns informational in-flight rows and clear_stale_guard
// (never-deployed pending_state_review). Do not queue withdraw/evict.
if (
dep.status === 'deploying'
|| dep.status === 'correcting'
|| dep.status === 'withdrawing'
|| dep.status === 'name_conflict'
|| (dep.status === 'pending_state_review' && dep.last_deployed_at == null)
) {
continue;
}
const node = allNodes.find(n => n.id === dep.node_id);
if (!node) continue;
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
+95 -2
View File
@@ -539,6 +539,11 @@ export interface Blueprint {
updated_at: number;
created_by: string | null;
pinned_node_id: number | null;
approval_status: 'pending' | 'approved';
approved_intent_fingerprint: string | null;
approved_blast_json: string | null;
approved_at: number | null;
approved_by: string | null;
}
export interface BlueprintDeployment {
@@ -1001,6 +1006,7 @@ export class DatabaseService {
this.migrateAddNodeLastContact();
this.migrateAddNodeCordonFields();
this.migrateAddBlueprintPinnedNode();
this.migrateAddBlueprintApproval();
this.migrateAutoHealNodeId();
this.migrateFleetSyncStickyError();
this.migrateStackDossierHashes();
@@ -2353,6 +2359,27 @@ export class DatabaseService {
this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
}
private migrateAddBlueprintApproval(): void {
this.tryAddColumn('blueprints', 'approval_status', "TEXT NOT NULL DEFAULT 'pending'");
this.tryAddColumn('blueprints', 'approved_intent_fingerprint', 'TEXT');
this.tryAddColumn('blueprints', 'approved_blast_json', 'TEXT');
this.tryAddColumn('blueprints', 'approved_at', 'INTEGER');
this.tryAddColumn('blueprints', 'approved_by', 'TEXT');
// Fail-closed backfill: any pre-existing approved-looking default stays pending.
try {
this.db.prepare(
`UPDATE blueprints SET approval_status = 'pending',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL,
approved_at = NULL,
approved_by = NULL
WHERE approval_status IS NULL OR approval_status NOT IN ('pending', 'approved')`,
).run();
} catch (e) {
console.warn('[DatabaseService] blueprint approval backfill:', (e as Error).message);
}
}
private migrateFleetSyncStickyError(): void {
this.tryAddColumn('fleet_sync_status', 'sticky_error_code', 'TEXT');
this.tryAddColumn('fleet_sync_status', 'sticky_error_expected', 'TEXT');
@@ -3897,11 +3924,60 @@ export class DatabaseService {
}
public setBlueprintPinnedNode(blueprintId: number, nodeId: number | null): Blueprint | undefined {
this.db.prepare('UPDATE blueprints SET pinned_node_id = ?, updated_at = ? WHERE id = ?')
.run(nodeId, Date.now(), blueprintId);
this.db.prepare(
`UPDATE blueprints SET pinned_node_id = ?, updated_at = ?,
approval_status = 'pending',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL,
approved_at = NULL,
approved_by = NULL
WHERE id = ?`,
).run(nodeId, Date.now(), blueprintId);
return this.getBlueprint(blueprintId);
}
/** Persist approval without bumping operational updated_at. */
public setBlueprintApproval(
blueprintId: number,
input: {
intentFingerprint: string;
blastJson: string;
approvedBy: string | null;
},
): Blueprint | undefined {
this.db.prepare(
`UPDATE blueprints SET
approval_status = 'approved',
approved_intent_fingerprint = ?,
approved_blast_json = ?,
approved_at = ?,
approved_by = ?
WHERE id = ?`,
).run(input.intentFingerprint, input.blastJson, Date.now(), input.approvedBy, blueprintId);
return this.getBlueprint(blueprintId);
}
public clearBlueprintApproval(blueprintId: number): void {
this.db.prepare(
`UPDATE blueprints SET
approval_status = 'pending',
approved_intent_fingerprint = NULL,
approved_blast_json = NULL,
approved_at = NULL,
approved_by = NULL
WHERE id = ?`,
).run(blueprintId);
}
/** True when any deployment row is not withdrawn (blocks rename). */
public hasNonWithdrawnBlueprintDeployments(blueprintId: number): boolean {
const row = this.db.prepare(
`SELECT 1 AS ok FROM blueprint_deployments
WHERE blueprint_id = ? AND status != 'withdrawn' LIMIT 1`,
).get(blueprintId) as { ok: number } | undefined;
return !!row;
}
public updateNodeLastContact(nodeId: number): void {
this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
.run(Math.floor(Date.now() / 1000), nodeId);
@@ -7031,6 +7107,11 @@ export class DatabaseService {
updated_at: row.updated_at as number,
created_by: (row.created_by as string | null) ?? null,
pinned_node_id: (row.pinned_node_id as number | null) ?? null,
approval_status: (row.approval_status as 'pending' | 'approved' | undefined) === 'approved' ? 'approved' : 'pending',
approved_intent_fingerprint: (row.approved_intent_fingerprint as string | null) ?? null,
approved_blast_json: (row.approved_blast_json as string | null) ?? null,
approved_at: (row.approved_at as number | null) ?? null,
approved_by: (row.approved_by as string | null) ?? null,
};
}
@@ -7113,6 +7194,18 @@ export class DatabaseService {
if (updates.classification_reasons !== undefined) { fields.push('classification_reasons = ?'); values.push(JSON.stringify(updates.classification_reasons)); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.bumpRevision) { fields.push('revision = revision + 1'); }
const invalidatesApproval = updates.name !== undefined
|| updates.compose_content !== undefined
|| updates.selector !== undefined
|| updates.drift_mode !== undefined
|| updates.enabled !== undefined;
if (invalidatesApproval) {
fields.push("approval_status = 'pending'");
fields.push('approved_intent_fingerprint = NULL');
fields.push('approved_blast_json = NULL');
fields.push('approved_at = NULL');
fields.push('approved_by = NULL');
}
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
+300
View File
@@ -0,0 +1,300 @@
/**
* Blueprint rollout approval: intent fingerprint, blast JSON parse,
* place/remove transition matrix, and effective approval evaluation.
*/
import { createHash } from 'crypto';
import type { Blueprint, BlueprintSelector, DriftMode } from './DatabaseService';
export type ApprovalOutcome = 'place' | 'remove';
export type EffectiveApproval = 'pending' | 'approved' | 'reapproval_required';
export type PreviewAction =
| 'create'
| 'update'
| 'remove'
| 'check_observe'
| 'check_enforce'
| 'await_state_review'
| 'await_evict_confirm'
| 'clear_stale_guard'
| 'clear_reversed_evict'
| 'in_flight_deploy'
| 'in_flight_correct'
| 'in_flight_withdraw'
| 'skip_cordoned'
| 'blocked_name_conflict';
export type ActionKind = 'executor' | 'informational';
export interface ApprovedNodeOutcome {
nodeId: number;
outcome: ApprovalOutcome;
}
export interface ConfirmableActionRef {
nodeId: number;
action: PreviewAction;
}
const PLACE_EXECUTOR = new Set<PreviewAction>([
'create',
'update',
'check_observe',
'check_enforce',
'await_state_review',
'clear_reversed_evict',
]);
const REMOVE_EXECUTOR = new Set<PreviewAction>([
'remove',
'await_evict_confirm',
'clear_stale_guard',
]);
const INFORMATIONAL = new Set<PreviewAction>([
'in_flight_deploy',
'in_flight_correct',
'in_flight_withdraw',
'skip_cordoned',
'blocked_name_conflict',
]);
const ALL_ACTIONS = new Set<PreviewAction>([
...PLACE_EXECUTOR,
...REMOVE_EXECUTOR,
...INFORMATIONAL,
]);
export function actionKind(action: PreviewAction): ActionKind {
return INFORMATIONAL.has(action) ? 'informational' : 'executor';
}
export function isExecutorAction(action: PreviewAction): boolean {
return !INFORMATIONAL.has(action);
}
/** Outcome derived when this confirmable action appears in a confirmed plan. */
export function outcomeForConfirmableAction(action: PreviewAction): ApprovalOutcome | null {
if (PLACE_EXECUTOR.has(action) || action === 'in_flight_deploy' || action === 'in_flight_correct') {
return 'place';
}
if (REMOVE_EXECUTOR.has(action) || action === 'in_flight_withdraw') {
return 'remove';
}
return null;
}
export function isActionAuthorized(outcome: ApprovalOutcome, action: PreviewAction): boolean {
if (INFORMATIONAL.has(action)) return false;
if (outcome === 'place') return PLACE_EXECUTOR.has(action);
return REMOVE_EXECUTOR.has(action);
}
function canonicalSelectorJson(selector: BlueprintSelector): string {
if (selector.type === 'nodes') {
const ids = [...selector.ids].sort((a, b) => a - b);
return JSON.stringify({ type: 'nodes', ids });
}
const any = [...selector.any].sort((a, b) => a.localeCompare(b));
const all = [...selector.all].sort((a, b) => a.localeCompare(b));
return JSON.stringify({ type: 'labels', any, all });
}
export function intentFingerprint(blueprint: Pick<
Blueprint,
'id' | 'name' | 'revision' | 'selector' | 'pinned_node_id' | 'enabled' | 'drift_mode' | 'compose_content'
>): string {
const composeHash = createHash('sha256').update(blueprint.compose_content, 'utf8').digest('hex');
const lines = [
`id:${blueprint.id}`,
`name:${blueprint.name}`,
`revision:${blueprint.revision}`,
`selector:${canonicalSelectorJson(blueprint.selector)}`,
`pin:${blueprint.pinned_node_id ?? 'null'}`,
`enabled:${blueprint.enabled ? '1' : '0'}`,
`drift:${blueprint.drift_mode}`,
`compose:${composeHash}`,
].sort((a, b) => a.localeCompare(b));
return createHash('sha256').update(lines.join('\n'), 'utf8').digest('hex');
}
export function serializeApprovedBlast(entries: ApprovedNodeOutcome[]): string {
const sorted = [...entries].sort((a, b) => a.nodeId - b.nodeId);
return JSON.stringify(sorted.map(e => ({ nodeId: e.nodeId, outcome: e.outcome })));
}
export type ParseBlastResult =
| { ok: true; entries: ApprovedNodeOutcome[] }
| { ok: false; reason: string };
/**
* Fail-closed parser. Rejects unknown keys, duplicates, invalid ids/outcomes,
* and non-canonical ordering. Does not mutate the database.
*/
function isPlainObject(item: unknown): item is Record<string, unknown> {
return item != null && typeof item === 'object' && !Array.isArray(item);
}
function hasExactKeys(obj: Record<string, unknown>, keyA: string, keyB: string): boolean {
const keys = Object.keys(obj);
return keys.length === 2 && keys.includes(keyA) && keys.includes(keyB);
}
function isPositiveInt(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
/**
* Fail-closed parser. Rejects unknown keys, duplicates, invalid ids/outcomes,
* and non-canonical ordering. Does not mutate the database.
*/
export function parseApprovedBlastJson(raw: string | null | undefined): ParseBlastResult {
if (raw == null || raw === '') {
return { ok: false, reason: 'missing' };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, reason: 'malformed_json' };
}
if (!Array.isArray(parsed)) {
return { ok: false, reason: 'not_array' };
}
const entries: ApprovedNodeOutcome[] = [];
const seen = new Set<number>();
for (const item of parsed) {
if (!isPlainObject(item) || !hasExactKeys(item, 'nodeId', 'outcome')) {
return { ok: false, reason: isPlainObject(item) ? 'unknown_keys' : 'bad_element' };
}
const { nodeId, outcome } = item;
if (!isPositiveInt(nodeId)) {
return { ok: false, reason: 'invalid_node_id' };
}
if (outcome !== 'place' && outcome !== 'remove') {
return { ok: false, reason: 'invalid_outcome' };
}
if (seen.has(nodeId)) {
return { ok: false, reason: 'duplicate_node' };
}
seen.add(nodeId);
entries.push({ nodeId, outcome });
}
for (let i = 1; i < entries.length; i++) {
if (entries[i].nodeId <= entries[i - 1].nodeId) {
return { ok: false, reason: 'non_canonical_order' };
}
}
return { ok: true, entries };
}
export function deriveBlastFromConfirmableActions(actions: ConfirmableActionRef[]): ApprovedNodeOutcome[] {
const byNode = new Map<number, ApprovalOutcome>();
for (const { nodeId, action } of actions) {
const outcome = outcomeForConfirmableAction(action);
if (!outcome) continue;
const existing = byNode.get(nodeId);
if (!existing) {
byNode.set(nodeId, outcome);
} else if (existing !== outcome) {
// Conflicting outcomes for one node in one confirm: last write wins is unsafe;
// prefer remove if both appear (fail closed toward requiring reapproval next).
byNode.set(nodeId, 'remove');
}
}
return [...byNode.entries()]
.map(([nodeId, outcome]) => ({ nodeId, outcome }))
.sort((a, b) => a.nodeId - b.nodeId);
}
export function confirmableActionsEqual(a: ConfirmableActionRef[], b: ConfirmableActionRef[]): boolean {
if (a.length !== b.length) return false;
const key = (x: ConfirmableActionRef) => `${x.nodeId}:${x.action}`;
const sa = a.map(key).sort();
const sb = b.map(key).sort();
return sa.every((v, i) => v === sb[i]);
}
export function parseConfirmableActionsBody(
raw: unknown,
): { ok: true; actions: ConfirmableActionRef[] } | { ok: false; reason: string } {
if (!Array.isArray(raw)) {
return { ok: false, reason: 'not_array' };
}
const actions: ConfirmableActionRef[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!isPlainObject(item) || !hasExactKeys(item, 'nodeId', 'action')) {
return { ok: false, reason: isPlainObject(item) ? 'unknown_keys' : 'bad_element' };
}
const { nodeId, action } = item;
if (!isPositiveInt(nodeId)) {
return { ok: false, reason: 'invalid_node_id' };
}
if (typeof action !== 'string' || !ALL_ACTIONS.has(action as PreviewAction)) {
return { ok: false, reason: 'invalid_action' };
}
const k = `${nodeId}:${action}`;
if (seen.has(k)) {
return { ok: false, reason: 'duplicate_pair' };
}
seen.add(k);
actions.push({ nodeId, action: action as PreviewAction });
}
return { ok: true, actions };
}
export interface BlueprintApprovalFields {
approval_status: 'pending' | 'approved';
approved_intent_fingerprint: string | null;
approved_blast_json: string | null;
approved_at: number | null;
approved_by: string | null;
}
export function evaluateEffectiveApproval(
blueprint: Blueprint & Partial<BlueprintApprovalFields>,
executorActions: ConfirmableActionRef[],
): { effectiveApproval: EffectiveApproval; unauthorizedActions: ConfirmableActionRef[]; blast: ApprovedNodeOutcome[] | null } {
const status = blueprint.approval_status ?? 'pending';
const fp = blueprint.approved_intent_fingerprint ?? null;
const rawBlast = blueprint.approved_blast_json ?? null;
const parsed = parseApprovedBlastJson(rawBlast);
const currentFp = intentFingerprint(blueprint);
if (status !== 'approved' || !fp || !parsed.ok || fp !== currentFp) {
return { effectiveApproval: 'pending', unauthorizedActions: executorActions, blast: null };
}
const byNode = new Map(parsed.entries.map(e => [e.nodeId, e.outcome]));
const unauthorized: ConfirmableActionRef[] = [];
for (const ref of executorActions) {
const outcome = byNode.get(ref.nodeId);
if (!outcome || !isActionAuthorized(outcome, ref.action)) {
unauthorized.push(ref);
}
}
if (unauthorized.length > 0) {
return {
effectiveApproval: 'reapproval_required',
unauthorizedActions: unauthorized,
blast: parsed.entries,
};
}
return { effectiveApproval: 'approved', unauthorizedActions: [], blast: parsed.entries };
}
export function filterAuthorizedExecutorActions(
blast: ApprovedNodeOutcome[],
executorActions: ConfirmableActionRef[],
): ConfirmableActionRef[] {
const byNode = new Map(blast.map(e => [e.nodeId, e.outcome]));
return executorActions.filter(ref => {
const outcome = byNode.get(ref.nodeId);
return outcome != null && isActionAuthorized(outcome, ref.action);
});
}
export type { DriftMode };
@@ -0,0 +1,645 @@
/**
* Pure Blueprint rollout preview projection. Zero DB writes.
* Executor cleanup helpers for clear_reversed_evict / clear_stale_guard live at the bottom.
*/
import { parse as parseYaml } from 'yaml';
import {
DatabaseService,
type Blueprint,
type BlueprintDeployment,
type Node,
} from './DatabaseService';
import { BlueprintReconciler, type ReconcileDecision } from './BlueprintReconciler';
import { parseInterpolationRefs } from '../helpers/envVarParse';
import { normalizeEnvFileField } from '../helpers/envFileResolution';
import { isLikelySecretKey } from '../helpers/secretClassification';
import {
type PreviewAction,
type ConfirmableActionRef,
type EffectiveApproval,
type BlueprintApprovalFields,
actionKind,
isExecutorAction,
intentFingerprint,
evaluateEffectiveApproval,
} from './blueprintApproval';
export const BLUEPRINT_PREVIEW_PILOT_STALE_MS = 60_000;
export const BLUEPRINT_PREVIEW_PROXY_STALE_MS = 120_000;
export type PreviewSeverity = 'safe' | 'warning' | 'blocker';
export interface PreviewChangeRow {
nodeId: number;
nodeName: string;
nodeType: 'local' | 'remote';
mode: string | null;
status: Node['status'];
contactAt: number | null;
contactSource: 'local' | 'pilot_last_seen' | 'last_successful_contact';
action: PreviewAction;
severity: PreviewSeverity;
kind: 'executor' | 'informational';
detail: string;
reachabilityNote: string;
}
export interface PreviewRequirementVariable {
name: string;
required: boolean;
hasDefault: boolean;
alternate: boolean;
likelySecret: boolean;
}
export interface PreviewRequirementEnvFile {
path: string;
required: boolean;
}
export interface PreviewRequirements {
variables: PreviewRequirementVariable[];
envFiles: PreviewRequirementEnvFile[];
composeSecrets: Array<{ name: string }>;
}
export interface PreviewWarningItem {
id: string;
source: 'change' | 'requirement' | 'compat' | 'health';
severity: PreviewSeverity;
message: string;
}
export interface BlueprintPreviewResult {
blueprintId: number;
classification: Blueprint['classification'];
matchedNodes: Array<{ id: number; name: string; type: 'local' | 'remote' }>;
plannedDeployments: Array<{ id: number; name: string }>;
plannedDriftChecks: Array<{ id: number; name: string }>;
plannedEvictions: number[];
name: string;
revision: number;
updatedAt: number;
driftMode: Blueprint['drift_mode'];
stackName: string;
approvalStatus: 'pending' | 'approved';
effectiveApproval: EffectiveApproval;
planFingerprint: string;
generatedAt: number;
summary: { safe: number; warning: number; blocker: number; total: number };
changes: PreviewChangeRow[];
confirmableActions: ConfirmableActionRef[];
executorActions: ConfirmableActionRef[];
unauthorizedActions: ConfirmableActionRef[];
requirements: PreviewRequirements;
compatibilityWarnings: string[];
healthNote: string;
blockers: PreviewWarningItem[];
warnings: PreviewWarningItem[];
}
const HEALTH_NOTE = 'Reachability is from cached node status and mode-specific contact timestamps; preview does not probe remotes.';
function isMutatingAction(action: PreviewAction): boolean {
return action === 'create'
|| action === 'update'
|| action === 'remove'
|| action === 'check_enforce';
}
function contactInfo(node: Node): {
contactAt: number | null;
contactSource: PreviewChangeRow['contactSource'];
reachabilityNote: string;
elevateMutating: PreviewSeverity | null;
} {
if (node.type === 'local') {
return {
contactAt: null,
contactSource: 'local',
reachabilityNote: 'Local node',
elevateMutating: null,
};
}
if (node.mode === 'pilot_agent') {
const seen = node.pilot_last_seen ?? null;
const age = seen != null ? Date.now() - seen : null;
const heartbeatStale = seen == null || (age != null && age > BLUEPRINT_PREVIEW_PILOT_STALE_MS);
if (node.status === 'offline' || node.status === 'unknown') {
return {
contactAt: seen,
contactSource: 'pilot_last_seen',
reachabilityNote: node.status === 'offline' && !heartbeatStale
? 'Pilot heartbeat fresh but cached status is offline'
: 'Pilot node cached as offline or unknown',
elevateMutating: 'blocker',
};
}
if (heartbeatStale) {
return {
contactAt: seen,
contactSource: 'pilot_last_seen',
reachabilityNote: 'Pilot heartbeat expired (cached)',
elevateMutating: 'warning',
};
}
return {
contactAt: seen,
contactSource: 'pilot_last_seen',
reachabilityNote: 'Pilot heartbeat fresh (cached)',
elevateMutating: null,
};
}
const sec = node.last_successful_contact ?? null;
const contactAt = sec != null ? sec * 1000 : null;
const age = contactAt != null ? Date.now() - contactAt : null;
if (node.status === 'offline' || node.status === 'unknown') {
return {
contactAt,
contactSource: 'last_successful_contact',
reachabilityNote: 'Remote node cached as offline or unknown',
elevateMutating: 'blocker',
};
}
if (contactAt == null || (age != null && age > BLUEPRINT_PREVIEW_PROXY_STALE_MS)) {
return {
contactAt,
contactSource: 'last_successful_contact',
reachabilityNote: 'Proxy contact missing or stale (cached status)',
elevateMutating: 'warning',
};
}
return {
contactAt,
contactSource: 'last_successful_contact',
reachabilityNote: 'Proxy contact fresh (cached)',
elevateMutating: null,
};
}
function applyHealthSeverity(
action: PreviewAction,
base: PreviewSeverity,
elevate: PreviewSeverity | null,
): PreviewSeverity {
if (!elevate) return base;
if (isMutatingAction(action)) {
if (elevate === 'blocker') return 'blocker';
return base === 'safe' ? 'warning' : base;
}
// Non-mutating actions: soft-elevate safe→warning only; never promote to blocker.
if (elevate === 'warning' && base === 'safe') return 'warning';
return base;
}
function driftCheckAction(driftMode: Blueprint['drift_mode']): PreviewAction {
return driftMode === 'enforce' ? 'check_enforce' : 'check_observe';
}
function driftCheckSeverity(action: PreviewAction): PreviewSeverity {
return action === 'check_enforce' ? 'warning' : 'safe';
}
function composeSecretName(sec: unknown): string | null {
if (typeof sec === 'string') return sec;
if (sec && typeof sec === 'object' && typeof (sec as { source?: unknown }).source === 'string') {
return (sec as { source: string }).source;
}
return null;
}
function pushUniqueSecret(composeSecrets: Array<{ name: string }>, name: string): void {
if (!composeSecrets.some(c => c.name === name)) {
composeSecrets.push({ name });
}
}
function extractRequirements(composeContent: string): {
requirements: PreviewRequirements;
compat: string[];
reqWarnings: PreviewWarningItem[];
} {
const compat: string[] = [];
const reqWarnings: PreviewWarningItem[] = [];
const variables: PreviewRequirementVariable[] = [];
const envFiles: PreviewRequirementEnvFile[] = [];
const composeSecrets: Array<{ name: string }> = [];
let parsed: unknown;
try {
parsed = parseYaml(composeContent);
} catch (err) {
compat.push(`compose YAML did not parse: ${err instanceof Error ? err.message : String(err)}`);
return {
requirements: { variables: [], envFiles: [], composeSecrets: [] },
compat,
reqWarnings,
};
}
for (const ref of parseInterpolationRefs(composeContent)) {
variables.push({
name: ref.name,
required: ref.required,
hasDefault: ref.hasDefault,
alternate: ref.alternate,
likelySecret: isLikelySecretKey(ref.name),
});
if (ref.required) {
reqWarnings.push({
id: `req:var:${ref.name}`,
source: 'requirement',
severity: 'warning',
message: `Required interpolation \${${ref.name}} must be set on target nodes`,
});
}
}
if (parsed && typeof parsed === 'object') {
const doc = parsed as Record<string, unknown>;
const services = (doc.services && typeof doc.services === 'object')
? doc.services as Record<string, unknown>
: {};
const byPath = new Map<string, boolean>();
for (const [svcName, svc] of Object.entries(services)) {
if (!svc || typeof svc !== 'object') continue;
const s = svc as Record<string, unknown>;
for (const entry of normalizeEnvFileField(s.env_file)) {
const prev = byPath.get(entry.rawPath);
if (prev === undefined) {
byPath.set(entry.rawPath, entry.required);
} else if (prev !== entry.required) {
byPath.set(entry.rawPath, true);
reqWarnings.push({
id: `req:envfile-conflict:${entry.rawPath}`,
source: 'requirement',
severity: 'warning',
message: `env_file "${entry.rawPath}" has conflicting required flags (service ${svcName})`,
});
}
}
if (Array.isArray(s.secrets)) {
for (const sec of s.secrets) {
const name = composeSecretName(sec);
if (name) pushUniqueSecret(composeSecrets, name);
}
}
}
for (const [path, required] of byPath) {
envFiles.push({ path, required });
if (required) {
reqWarnings.push({
id: `req:envfile:${path}`,
source: 'requirement',
severity: 'warning',
message: `Required env_file "${path}" must exist on target nodes`,
});
}
}
const topSecrets = doc.secrets;
if (topSecrets && typeof topSecrets === 'object') {
for (const name of Object.keys(topSecrets as Record<string, unknown>)) {
pushUniqueSecret(composeSecrets, name);
}
}
}
return { requirements: { variables, envFiles, composeSecrets }, compat, reqWarnings };
}
interface RawAction {
node: Node;
action: PreviewAction;
severity: PreviewSeverity;
detail: string;
}
function projectActions(
blueprint: Blueprint,
allNodes: Node[],
deployments: BlueprintDeployment[],
decision: ReconcileDecision,
): RawAction[] {
const byId = new Map(allNodes.map(n => [n.id, n]));
const depByNode = new Map(deployments.map(d => [d.node_id, d]));
const desiredNodes = BlueprintReconciler.getInstance().listDesiredNodes(blueprint, allNodes);
const desiredIds = new Set(desiredNodes.map(n => n.id));
const out: RawAction[] = [];
const seen = new Set<number>();
const push = (node: Node, action: PreviewAction, severity: PreviewSeverity, detail: string) => {
out.push({ node, action, severity, detail });
seen.add(node.id);
};
// Status-precedence pass: in-flight, name conflict, and clear_* must win over
// decision.withdraw / evictBlocked so Confirm never authorizes mid-flight mutates
// and never-deployed guards stay on the remove-only clear_stale path.
for (const dep of deployments) {
const node = byId.get(dep.node_id);
if (!node) continue;
const desired = desiredIds.has(dep.node_id);
const status = dep.status;
if (status === 'name_conflict') {
push(node, 'blocked_name_conflict', 'blocker', 'Name conflict; will not deploy or withdraw');
continue;
}
if (status === 'deploying') {
push(node, 'in_flight_deploy', 'warning', desired ? 'Deploy in flight' : 'Deploy in flight (leaving selector)');
continue;
}
if (status === 'correcting') {
push(node, 'in_flight_correct', 'warning', desired ? 'Drift correction in flight' : 'Correction in flight (leaving selector)');
continue;
}
if (status === 'withdrawing') {
push(node, 'in_flight_withdraw', 'warning', 'Withdraw in flight');
continue;
}
if (!desired && status === 'pending_state_review' && dep.last_deployed_at == null) {
push(node, 'clear_stale_guard', 'warning', 'Never-deployed state-review row; clear without compose down');
continue;
}
if (desired && status === 'evict_blocked') {
push(node, 'clear_reversed_evict', 'warning', 'Eviction guard while node is desired again; clear guard only');
}
}
for (const node of decision.deploy) {
if (seen.has(node.id)) continue;
const dep = depByNode.get(node.id);
if (!dep) push(node, 'create', 'safe', 'New placement');
else push(node, 'update', 'safe', 'Revision or retry deploy');
}
for (const node of decision.withdraw) {
if (seen.has(node.id)) continue;
push(node, 'remove', 'safe', 'Withdraw from selector');
}
for (const node of decision.check) {
if (seen.has(node.id)) continue;
const action = driftCheckAction(blueprint.drift_mode);
push(
node,
action,
driftCheckSeverity(action),
action === 'check_enforce' ? 'Drift check may auto-correct (enforce)' : 'Drift observe/suggest check',
);
}
for (const node of decision.stateReview) {
if (seen.has(node.id)) continue;
push(node, 'await_state_review', 'warning', 'Stateful placement awaits operator confirmation');
}
for (const node of decision.evictBlocked) {
if (seen.has(node.id)) continue;
push(node, 'await_evict_confirm', 'warning', 'Stateful eviction awaits operator confirmation');
}
for (const node of desiredNodes) {
if (seen.has(node.id)) continue;
const dep = depByNode.get(node.id);
if (!dep && node.cordoned && blueprint.pinned_node_id !== node.id) {
push(node, 'skip_cordoned', 'warning', 'Cordoned; new placements skipped');
}
}
for (const dep of deployments) {
if (seen.has(dep.node_id)) continue;
const node = byId.get(dep.node_id);
if (!node) continue;
const desired = desiredIds.has(dep.node_id);
const status = dep.status;
if (desired) {
if (status === 'pending_state_review') {
push(node, 'await_state_review', 'warning', 'Awaiting state review');
} else if (status === 'drifted') {
const action = driftCheckAction(blueprint.drift_mode);
push(node, action, driftCheckSeverity(action), 'Drifted; check pending');
} else if (status === 'failed' || status === 'pending') {
push(node, 'update', 'safe', 'Retry failed or pending deployment');
} else if (status === 'withdrawn') {
push(node, 'create', 'safe', 'Withdrawn but desired again');
} else if (status === 'active') {
const action = driftCheckAction(blueprint.drift_mode);
push(node, action, driftCheckSeverity(action), 'Active deployment check');
}
} else {
if (status === 'withdrawn') continue;
if (status === 'pending_state_review' || status === 'evict_blocked') {
push(node, 'await_evict_confirm', 'warning', 'Stateful leave awaits eviction confirmation');
} else if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
push(node, 'await_evict_confirm', 'warning', 'Stateful leave awaits confirmation');
} else {
push(node, 'remove', 'safe', 'Leave selector');
}
}
}
return out;
}
function asApprovedBlueprint(blueprint: Blueprint): Blueprint & BlueprintApprovalFields {
const b = blueprint as Blueprint & Partial<BlueprintApprovalFields>;
return {
...blueprint,
approval_status: b.approval_status ?? 'pending',
approved_intent_fingerprint: b.approved_intent_fingerprint ?? null,
approved_blast_json: b.approved_blast_json ?? null,
approved_at: b.approved_at ?? null,
approved_by: b.approved_by ?? null,
};
}
/** Upgrade create rows to blockers when an unmanaged same-name stack already exists. */
async function applyCreateNameConflictBlockers(blueprintName: string, raw: RawAction[]): Promise<void> {
const { BlueprintService } = await import('./BlueprintService');
const svc = BlueprintService.getInstance();
for (const row of raw) {
if (row.action !== 'create') continue;
if (!(await svc.hasNameConflict(blueprintName, row.node))) continue;
row.action = 'blocked_name_conflict';
row.severity = 'blocker';
row.detail = 'Unmanaged stack with this name already exists on this node';
}
}
export async function buildBlueprintPreview(blueprintId: number): Promise<BlueprintPreviewResult | null> {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return null;
const allNodes = db.getNodes();
const deployments = db.listDeployments(blueprintId);
const decision = BlueprintReconciler.getInstance().computeDecisionForPreview(blueprint, allNodes);
const raw = projectActions(blueprint, allNodes, deployments, decision);
await applyCreateNameConflictBlockers(blueprint.name, raw);
const changes: PreviewChangeRow[] = [];
for (const row of raw) {
const health = contactInfo(row.node);
const severity = applyHealthSeverity(row.action, row.severity, health.elevateMutating);
changes.push({
nodeId: row.node.id,
nodeName: row.node.name,
nodeType: row.node.type,
mode: row.node.mode ?? null,
status: row.node.status,
contactAt: health.contactAt,
contactSource: health.contactSource,
action: row.action,
severity,
kind: actionKind(row.action),
detail: row.detail,
reachabilityNote: health.reachabilityNote,
});
}
const toActionRef = (c: { nodeId: number; action: PreviewAction }): ConfirmableActionRef => ({
nodeId: c.nodeId,
action: c.action,
});
const confirmable = changes
.filter(c => c.action !== 'skip_cordoned' && c.action !== 'blocked_name_conflict')
.map(toActionRef);
const executorActions = changes.filter(c => isExecutorAction(c.action)).map(toActionRef);
const approvedBp = asApprovedBlueprint(blueprint);
const { effectiveApproval, unauthorizedActions } = evaluateEffectiveApproval(approvedBp, executorActions);
const { requirements, compat, reqWarnings } = extractRequirements(blueprint.compose_content);
const compatibilityWarnings = [...blueprint.classification_reasons, ...compat];
const blockers: PreviewWarningItem[] = [];
const warnings: PreviewWarningItem[] = [];
let safeCount = 0;
for (const c of changes) {
if (c.severity === 'safe') {
safeCount += 1;
continue;
}
let message = `${c.nodeName}: ${c.detail}`;
if (c.reachabilityNote !== 'Local node') {
message += ` [${c.nodeType}/${c.status}: ${c.reachabilityNote}]`;
}
const item: PreviewWarningItem = {
id: `change:${c.nodeId}:${c.action}`,
source: 'change',
severity: c.severity,
message,
};
if (c.severity === 'blocker') blockers.push(item);
else warnings.push(item);
}
warnings.push(...reqWarnings);
for (const msg of compatibilityWarnings) {
warnings.push({ id: `compat:${createHashId(msg)}`, source: 'compat', severity: 'warning', message: msg });
}
// Totals include requirement and compatibility warnings so UI header counts
// match the lists operators see (not only per-change severity).
const summary = {
safe: safeCount,
warning: warnings.length,
blocker: blockers.length,
total: changes.length,
};
const desiredNodes = BlueprintReconciler.getInstance().listDesiredNodes(blueprint, allNodes);
const desiredIds = new Set(desiredNodes.map(n => n.id));
const willDeploy = desiredNodes.filter(n => !deployments.some(d => d.node_id === n.id));
const willCheck = desiredNodes.filter(n => deployments.some(d => d.node_id === n.id && d.status === 'active'));
const willEvict = deployments
.filter(d => !desiredIds.has(d.node_id) && d.status !== 'withdrawn')
.map(d => d.node_id);
return {
blueprintId: blueprint.id,
classification: blueprint.classification,
matchedNodes: desiredNodes.map(n => ({ id: n.id, name: n.name, type: n.type })),
plannedDeployments: willDeploy.map(n => ({ id: n.id, name: n.name })),
plannedDriftChecks: willCheck.map(n => ({ id: n.id, name: n.name })),
plannedEvictions: willEvict,
name: blueprint.name,
revision: blueprint.revision,
updatedAt: blueprint.updated_at,
driftMode: blueprint.drift_mode,
stackName: blueprint.name,
approvalStatus: approvedBp.approval_status,
effectiveApproval,
planFingerprint: intentFingerprint(blueprint),
generatedAt: Date.now(),
summary,
changes,
confirmableActions: confirmable,
executorActions,
unauthorizedActions,
requirements,
compatibilityWarnings,
healthNote: HEALTH_NOTE,
blockers,
warnings,
};
}
function createHashId(msg: string): string {
let h = 0;
for (let i = 0; i < msg.length; i++) h = ((h << 5) - h + msg.charCodeAt(i)) | 0;
return String(h);
}
/** Lightweight list/detail evaluator: no requirements/health fanout. */
export function evaluateLightweightEffectiveApproval(blueprintId: number): {
effectiveApproval: EffectiveApproval;
unauthorizedActions: ConfirmableActionRef[];
} | null {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return null;
const allNodes = db.getNodes();
const deployments = db.listDeployments(blueprintId);
const decision = BlueprintReconciler.getInstance().computeDecisionForPreview(blueprint, allNodes);
const raw = projectActions(blueprint, allNodes, deployments, decision);
const executorActions = raw
.filter(r => isExecutorAction(r.action))
.map(r => ({ nodeId: r.node.id, action: r.action }));
const { effectiveApproval, unauthorizedActions } = evaluateEffectiveApproval(
asApprovedBlueprint(blueprint),
executorActions,
);
return { effectiveApproval, unauthorizedActions };
}
/** Executor: clear reversed eviction guard. Cleanup-only this pass. */
export function applyClearReversedEvict(blueprintId: number, nodeId: number): void {
const db = DatabaseService.getInstance();
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status !== 'evict_blocked') return;
if (dep.last_deployed_at == null) {
db.deleteDeployment(blueprintId, nodeId);
return;
}
db.upsertDeployment({
blueprint_id: blueprintId,
node_id: nodeId,
status: 'active',
applied_revision: dep.applied_revision,
last_deployed_at: dep.last_deployed_at,
last_checked_at: dep.last_checked_at,
last_drift_at: dep.last_drift_at,
drift_summary: null,
last_error: null,
});
}
/** Executor: delete never-deployed stale guard row. */
export function applyClearStaleGuard(blueprintId: number, nodeId: number): void {
const db = DatabaseService.getInstance();
const dep = db.getDeployment(blueprintId, nodeId);
if (!dep || dep.status !== 'pending_state_review' || dep.last_deployed_at != null) return;
db.deleteDeployment(blueprintId, nodeId);
}
+14 -11
View File
@@ -20,7 +20,7 @@ Blueprints live under **Fleet · Deployments**.
Three moving parts cooperate per blueprint.
1. **The declared spec.** A blueprint is a row in Sencho's database. It carries the compose YAML, the selector (labels or explicit node IDs), the drift policy, and a monotonic revision number. The revision auto-increments every time the compose changes.
2. **The reconciler.** A background loop on the controlling Sencho ticks every 60 seconds (with a 5-second initial delay after startup) and on demand via **Apply now**. Each tick: resolve the selector, compare every desired-vs-live node, and queue one of five actions per node (deploy, withdraw, drift-check, request operator confirmation, or block on operator confirmation).
2. **The reconciler.** A background loop on the controlling Sencho ticks every 60 seconds (with a 5-second initial delay after startup) and on demand after **Confirm Apply**. Each tick only mutates the fleet when the blueprint's current operational intent is approved. The tick resolves the selector, compares every desired-vs-live node, and runs only the place or remove outcomes the operator already confirmed.
3. **The executor.** Per-node deploy and withdraw run against the local Docker socket on local nodes, and through the standard authenticated proxy to `/api/stacks` on remote nodes. Every blueprint deployment writes a `.blueprint.json` marker into the stack directory; the marker carries the blueprint ID, the revision, and the last-applied timestamp.
The marker is the trust root. If a directory by the blueprint's name already exists on a node and does not carry a matching marker, the reconciler refuses to touch it and surfaces a **Name conflict** on the deployment row. A Blueprint named `nginx` will never overwrite an existing user-authored `nginx` stack on any node.
@@ -31,7 +31,7 @@ Drift detection runs on every tick for every Active deployment regardless of pol
## Key capabilities
**One declaration covers many nodes.** Pick nodes by label or by node ID. The selector set is re-resolved on every reconciliation tick, so adding a node with a matching label deploys the stack within one minute. Removing a label, removing a node, or changing the selector withdraws the deployment on the same cadence (subject to the stateful-eviction safety rail).
**One declaration covers many nodes.** Pick nodes by label or by node ID. The selector set is re-resolved on every reconciliation tick. When a new node matches after a label change, the catalog shows **reapproval required** until you confirm the updated blast radius. Removing a label, removing a node, or shrinking the selector likewise needs confirmation before withdraw outcomes run (subject to the stateful-eviction safety rail).
**Drift detection always on.** Each tick compares the marker's revision against the live containers and the blueprint's current revision, then checks that all containers labeled with the compose project name are running. Drift is recorded on the deployment row whatever the policy is. **Observe** records it silently, **Suggest** also dispatches a notification, **Enforce** also redeploys.
@@ -51,7 +51,7 @@ Drift detection runs on every tick for every Active deployment regardless of pol
| User role | **Admin** to create, edit, withdraw, accept, and apply. Operators and viewers can read the catalog and the detail sheet. Pinning requires admin. |
| Nodes | At least one node that the selector resolves to. Remote nodes need a healthy proxy connection; see [Multi-node management](/features/multi-node) and [Pilot Agent](/features/pilot-agent) for enrollment. |
| Compose YAML | Valid `docker-compose.yml`, 96 KiB or fewer. |
| Blueprint name | 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. The name doubles as the stack directory on every targeted node and is immutable after creation. |
| Blueprint name | 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. The name doubles as the stack directory on every targeted node. Rename is blocked while any non-withdrawn deployment or guard exists. |
| Selector | A `labels` or `nodes` selector with up to 200 entries per side. An empty resolved set is allowed but produces no deployments. |
| Compose directory | Per-node compose directory must be writable. Remote nodes must accept the controlling instance's bearer token; this is the same channel the rest of the fleet management already uses. |
@@ -73,7 +73,7 @@ The first time you visit the tab, the catalog is empty and a three-step explaine
| Field | Purpose |
|---|---|
| **Name** | Used as the stack directory on every targeted node (`<COMPOSE_DIR>/<blueprint-name>/`). Lowercase letters, digits, hyphens, and underscores only. Fixed once the blueprint exists. |
| **Name** | Used as the stack directory on every targeted node (`<COMPOSE_DIR>/<blueprint-name>/`). Lowercase letters, digits, hyphens, and underscores only. Rename is allowed only when every deployment is withdrawn (or there are none); live placements and guards block rename. |
| **Description** | Short prose for the catalog tile and the detail header. |
| **Compose** | Standard `docker-compose.yml`. The same file ships to every targeted node. Sencho parses it on save and classifies the blueprint as stateless, stateful, or unknown. The YAML must parse successfully and stay under 96 KiB. |
| **Selector** | Either label expressions (any-of plus all-of) or a list of node IDs picked by hand. |
@@ -183,13 +183,15 @@ The common paths through the status enum.
2. Click **New Blueprint**.
3. Fill in the name, description, compose YAML, selector, and drift policy.
4. Watch the classification banner update as you type. It tells you whether the blueprint is portable or pinned to data.
5. Click **Create blueprint**. Sencho immediately runs one reconciliation tick.
5. Click **Create blueprint**. The blueprint starts with approval **pending**. No fleet mutation runs until you confirm a rollout preview.
If the YAML is malformed or larger than 96 KiB, Sencho rejects the save before creating a Blueprint row.
### Apply on demand
The reconciler runs every minute. To trigger it now, for example after editing the selector or the compose file, click **Apply now** on the detail sheet. The action also resurfaces a deployment that is in **Failed** status by retrying it.
The reconciler runs every minute, but only for blueprints whose current operational intent is **approved**. To authorize or re-authorize a rollout, click **Apply now** on the detail sheet (or **Retry** on a failed row). Sencho opens a rollout preview that lists place and remove outcomes, requirements, and health notes. **Confirm Apply** stores that approval and runs the confirmed executor actions. If some nodes fail (for example a remote host is unreachable) or an unmanaged same-name stack blocks placement, Confirm still records approval, but the response and toast report those per-node outcomes instead of a clean success. Changing the compose, selector, name, pin, drift mode, or enabled flag clears approval back to pending. Catalog tiles show **pending** or **reapproval required** when confirmation is needed again (for example after a label change adds a new target node).
Raw `POST /api/blueprints/:id/apply` clients must send `{ planFingerprint, actions }` from a fresh `GET .../preview`. A body without that confirmation is rejected.
### Edit
@@ -209,6 +211,7 @@ In the deployment table, click **Withdraw** on the node's row. For stateless blu
- **Snapshot, then evict (recommended)** captures the blueprint's compose definition into [Fleet · Snapshots](/features/fleet-backups) before running `docker compose down`. The volume bytes still leave the node when compose tears down the named volumes; the snapshot only preserves the YAML so you can redeploy it elsewhere.
- **Evict and destroy data** runs the eviction without a snapshot. Type the blueprint name to confirm.
- Manual **Evict and destroy** / **Snapshot, then evict** require a current approved remove outcome for that node, and the node must no longer be desired. Confirm a remove rollout after the node leaves the selector first; destructive Evict while the node is still desired, or without remove approval, returns a stale-guard error. Plain stateless **Withdraw** does not require remove approval.
<Frame caption="Stateful eviction dialog. Snapshot, then evict captures the compose YAML to Fleet · Snapshots; Evict and destroy data requires typing the blueprint name to confirm.">
<img src="/images/blueprint-model/eviction-dialog.png" alt="Stateful eviction dialog with Snapshot then evict and Evict and destroy data options" />
@@ -273,10 +276,10 @@ By design, Blueprints do not include:
Concrete operational constraints:
- **Reconciler cadence.** The tick interval is 60 seconds (5-second initial delay after startup). Use **Apply now** to force an immediate tick after a change.
- **Reconciler cadence.** The tick interval is 60 seconds (5-second initial delay after startup). Approved blueprints reconcile on that cadence. Use **Apply now** and **Confirm Apply** after a change that clears approval.
- **Compose size.** YAML must be 96 KiB or fewer. Split very large compose files into smaller blueprints, or move generated content out of the compose body.
- **Selector size.** A selector accepts up to 200 entries per side (200 `nodes.ids`, or up to 200 each in `labels.any` and `labels.all`).
- **Name.** 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. Names are immutable after creation; to rename, recreate the blueprint and withdraw the old one.
- **Name.** 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. Rename is allowed only when every deployment is withdrawn (or there are none). Live placements and guards must be resolved first; rename clears approval back to pending.
- **Snapshot semantics.** **Snapshot, then evict** captures the compose YAML only. Volume bytes are removed by `docker compose down` just as with **Evict and destroy data**.
- **Restore from snapshot.** Reserved for the future Volume Migration feature and currently disabled in the state review dialog.
@@ -289,7 +292,7 @@ Sencho's compose-native lane does not include automatic volume shipping. **Snaps
1. Stop the Blueprint deployment on node A from the deployment table. Use **Snapshot, then evict** so the compose YAML is parked in [Fleet · Snapshots](/features/fleet-backups) while you handle volumes.
2. Use your host tooling (`docker run --rm -v <volume>:/data busybox tar -czf - /data > snapshot.tar.gz`, or app-aware tooling such as `pg_basebackup`, `mysqldump`, or `mongodump`) to capture the volume on node A.
3. Transfer the artifact to node B and restore it into the named volume there.
4. Update the Blueprint's selector to include node B; click **Apply now**.
4. Update the Blueprint's selector to include node B; click **Apply now**, review the preview, and **Confirm Apply**.
A future Volume Migration feature will automate this with app-aware backup tooling.
@@ -343,7 +346,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli
By design. A pin replaces the selector entirely with the single pinned node, even if the selector resolves to other nodes. Either clear the pin in the Federation tab to restore selector-driven placement, or move the pin to a different node.
</Accordion>
<Accordion title="The reconciler took a minute to react to a change I just made">
The reconciler tick interval is one minute. To force an immediate evaluation after editing the selector, the compose, or the drift policy, click **Apply now** on the detail sheet. **Apply now** is also the recovery action for a row in **Failed** status.
The reconciler tick interval is one minute for approved blueprints. After editing the selector, the compose, or the drift policy, click **Apply now**, review the rollout preview, and **Confirm Apply**. The same path recovers a row in **Failed** status.
</Accordion>
</AccordionGroup>
@@ -357,7 +360,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli
A Blueprint is a fleet-wide declaration: one compose YAML targeting a set of nodes. Each node where the blueprint resolves materializes as a Stack on that node, in the same compose directory layout the rest of the per-stack lane uses, with a `.blueprint.json` marker added. You can browse the materialized stack in the per-node Stacks view; the Blueprint is the source of truth that drives it.
</Accordion>
<Accordion title="How fast does drift get noticed?">
Within one minute. The reconciler ticks every 60 seconds with a 5-second initial delay after startup; **Apply now** on the detail sheet forces an immediate tick. Within Enforce mode, drift correction begins on the same tick that detects the drift.
After you confirm the updated rollout, the next reconciler tick (or the Confirm Apply execution itself) places or removes as approved. The reconciler ticks every 60 seconds with a 5-second initial delay after startup. Within Enforce mode, drift correction for an already-approved place outcome begins on the same tick that detects the drift.
</Accordion>
<Accordion title="Can I roll back to a previous revision?">
Not through a one-click history. Blueprints intentionally do not keep a versioned revision history. To revert, paste the prior compose into the editor and save; the reconciler treats the change as a new revision and redeploys.
+1
View File
@@ -42,6 +42,7 @@ Sencho handles all schema changes internally. When the application starts, it ch
- **Encryption of sensitive values** (node API tokens, registry credentials) that were previously stored in plaintext
- **SSO and RBAC setup** for single sign-on provider config and role-based access
- **Legacy cleanup** of obsolete fields from pre-0.7 versions (SSH/TLS columns)
- **Blueprint rollout approval** columns (`approval_status`, intent fingerprint, and blast JSON). Existing Blueprints start **pending**. Automatic reconciliation and enforcement pause until an admin opens **Apply now**, reviews the rollout preview, and confirms. New installs behave the same way for each Blueprint until the first confirmation.
You never need to run SQL commands, migration scripts, or any manual database operations.
@@ -154,6 +154,14 @@ function BlueprintTile({ blueprint, onClick }: { blueprint: BlueprintListItem; o
<span className="text-warning">disabled</span>
</>
)}
{blueprint.effectiveApproval && blueprint.effectiveApproval !== 'approved' && (
<>
<span>·</span>
<span className={blueprint.effectiveApproval === 'reapproval_required' ? 'text-warning' : 'text-stat-subtitle'}>
{blueprint.effectiveApproval === 'reapproval_required' ? 'reapproval required' : 'pending'}
</span>
</>
)}
</div>
</button>
);
@@ -7,12 +7,12 @@
* of them, so the sheet can never issue a request the API answers with 403.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import type { BlueprintSummary } from '@/lib/blueprintsApi';
vi.mock('@/lib/blueprintsApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/blueprintsApi')>();
return { ...actual, getBlueprint: vi.fn(), applyBlueprint: vi.fn() };
return { ...actual, getBlueprint: vi.fn(), applyBlueprint: vi.fn(), previewBlueprint: vi.fn() };
});
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ nodes: [] }) }));
@@ -25,6 +25,12 @@ vi.mock('./BlueprintDeploymentTable', () => ({
BlueprintDeploymentTable: () => <div data-testid="deployment-table" />,
}));
vi.mock('./RolloutPreviewDialog', () => ({
RolloutPreviewDialog: ({ open }: { open: boolean }) => (
open ? <div data-testid="rollout-preview-dialog">preview</div> : null
),
}));
import { getBlueprint } from '@/lib/blueprintsApi';
import { BlueprintDetail } from './BlueprintDetail';
@@ -48,6 +54,7 @@ function summary(): BlueprintSummary {
},
deployments: [],
statusCounts: {},
effectiveApproval: 'pending',
};
}
@@ -101,32 +108,20 @@ describe('BlueprintDetail data fetching', () => {
expect(vi.mocked(getBlueprint)).toHaveBeenLastCalledWith(2);
});
it('keeps the loaded body on screen while a refresh is in flight', async () => {
let settleRefresh = () => {};
vi.mocked(getBlueprint)
.mockResolvedValueOnce(summary())
.mockImplementationOnce(
() => new Promise<BlueprintSummary>((resolve) => { settleRefresh = () => resolve(summary()); }),
);
it('opens the rollout preview dialog when Apply now is clicked', async () => {
render(
<BlueprintDetail blueprintId={1} open onOpenChange={noop} onChanged={noop} canEdit distinctLabels={[]} />,
);
expect(await screen.findByText('Show compose source')).toBeInTheDocument();
const callsAfterLoad = vi.mocked(getBlueprint).mock.calls.length;
// Applying reloads the blueprint. The populated body must stay mounted instead
// of collapsing to the loading skeleton while that reload is in flight.
fireEvent.click(screen.getByRole('button', { name: /apply now/i }));
await waitFor(() => expect(vi.mocked(getBlueprint).mock.calls.length).toBe(callsAfterLoad + 1));
// The deployment table only renders in the loaded body branch, never in the
// skeleton, so its presence proves the skeleton did not take over the refresh.
expect(screen.getByTestId('rollout-preview-dialog')).toBeInTheDocument();
// Opening the dialog must not refetch the detail sheet body.
expect(vi.mocked(getBlueprint)).toHaveBeenCalledTimes(callsAfterLoad);
expect(screen.getByText('Show compose source')).toBeInTheDocument();
expect(screen.getByTestId('deployment-table')).toBeInTheDocument();
settleRefresh();
await screen.findByText('Show compose source');
});
});
@@ -13,7 +13,6 @@ import {
type WithdrawConfirm,
type AcceptMode,
getBlueprint,
applyBlueprint,
updateBlueprint,
deleteBlueprint,
withdrawDeployment,
@@ -24,6 +23,7 @@ import { BlueprintEditor } from './BlueprintEditor';
import { BlueprintDeploymentTable } from './BlueprintDeploymentTable';
import { EvictionDialog } from './EvictionDialog';
import { StateReviewDialog } from './StateReviewDialog';
import { RolloutPreviewDialog } from './RolloutPreviewDialog';
import { useNodes } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
@@ -46,6 +46,7 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
const [stateReviewTarget, setStateReviewTarget] = useState<{ nodeId: number; nodeName: string } | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [previewOpen, setPreviewOpen] = useState(false);
const { nodes } = useNodes();
// Hold the latest onOpenChange without making it a refresh dependency. Parents
@@ -78,19 +79,9 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
const blueprint = summary?.blueprint;
async function handleApply() {
if (!blueprint) return;
setSubmitting(true);
try {
await applyBlueprint(blueprint.id);
toast.success('Reconciliation triggered');
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to apply blueprint');
} finally {
setSubmitting(false);
}
async function handleRolloutApplied() {
await refresh();
onChanged();
}
async function handleSaveEdit(input: CreateBlueprintInput | UpdateBlueprintInput) {
@@ -156,7 +147,13 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
await refresh();
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to withdraw');
const status = (err as Error & { status?: number }).status;
const message = err instanceof Error ? err.message : 'Failed to withdraw';
if (status === 409 && /approval|removal|stale/i.test(message)) {
toast.error('Confirm a remove rollout for this node before destructive eviction.');
} else {
toast.error(message);
}
} finally {
setBusyNodeId(null);
setEvictTarget(null);
@@ -182,12 +179,12 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
/**
* Row-level retry isn't a backend primitive: the reconciler operates on the whole
* blueprint, not a single deployment. We surface the row's "Retry" button and
* trigger a full reconciliation tick, which retries failed deployments naturally.
* open the rollout preview so the operator confirms the full plan.
* The nodeId argument satisfies BlueprintDeploymentTable.onRetry's signature.
*/
async function handleRetryRow(nodeId: number): Promise<void> {
void nodeId;
await handleApply();
setPreviewOpen(true);
}
function openWithdraw(nodeId: number) {
@@ -206,8 +203,12 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
? `${describeSelector(blueprint.selector)} · ${blueprint.drift_mode} · rev ${blueprint.revision}`
: (loading ? 'Loading…' : '');
const approvalLabel = summary?.effectiveApproval === 'reapproval_required'
? 'reapproval required'
: summary?.effectiveApproval ?? 'pending';
const footerContext = blueprint
? `Updated ${formatTimeAgo(blueprint.updated_at)}${blueprint.enabled ? '' : ' · reconciler disabled'}`
? `Updated ${formatTimeAgo(blueprint.updated_at)}${blueprint.enabled ? '' : ' · reconciler disabled'} · ${approvalLabel}`
: undefined;
const secondaryActions = blueprint && canEdit
@@ -238,7 +239,7 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
primaryAction={blueprint && canEdit ? {
label: 'Apply now',
icon: Play,
onClick: handleApply,
onClick: () => setPreviewOpen(true),
disabled: submitting || !blueprint.enabled || editMode,
} : undefined}
secondaryActions={secondaryActions}
@@ -337,6 +338,15 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
onAccept={(mode) => performAccept(stateReviewTarget.nodeId, mode)}
/>
)}
{blueprint && (
<RolloutPreviewDialog
blueprintId={blueprint.id}
blueprintName={blueprint.name}
open={previewOpen}
onOpenChange={setPreviewOpen}
onApplied={handleRolloutApplied}
/>
)}
{blueprint && (
<Modal open={deleteOpen} onOpenChange={(o) => { if (!o) { setDeleteOpen(false); setDeleteConfirmText(''); } }} size="md">
<ModalDestructiveHeader
@@ -0,0 +1,93 @@
/**
* RolloutPreviewDialog rendering: reachability notes and full warning lists.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { BlueprintPreview } from '@/lib/blueprintsApi';
vi.mock('@/lib/blueprintsApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/blueprintsApi')>();
return { ...actual, previewBlueprint: vi.fn(), applyBlueprint: vi.fn() };
});
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
import { previewBlueprint } from '@/lib/blueprintsApi';
import { RolloutPreviewDialog } from './RolloutPreviewDialog';
function previewFixture(overrides: Partial<BlueprintPreview> = {}): BlueprintPreview {
return {
blueprintId: 1,
classification: 'stateless',
matchedNodes: [{ id: 2, name: 'edge', type: 'remote' }],
plannedDeployments: [],
plannedDriftChecks: [],
plannedEvictions: [],
name: 'web',
revision: 1,
updatedAt: 0,
driftMode: 'observe',
stackName: 'web',
approvalStatus: 'pending',
effectiveApproval: 'pending',
planFingerprint: 'abc',
generatedAt: Date.now(),
summary: { safe: 0, warning: 2, blocker: 1, total: 1 },
changes: [{
nodeId: 2,
nodeName: 'edge',
nodeType: 'remote',
status: 'offline',
action: 'create',
severity: 'blocker',
kind: 'executor',
detail: 'New placement',
reachabilityNote: 'Remote node cached as offline or unknown',
}],
confirmableActions: [{ nodeId: 2, action: 'create' }],
executorActions: [{ nodeId: 2, action: 'create' }],
unauthorizedActions: [],
requirements: { variables: [], envFiles: [], composeSecrets: [] },
compatibilityWarnings: ['uses named volumes'],
healthNote: 'Reachability is from cached node status',
blockers: [{
id: 'change:2:create',
message: 'edge: New placement [remote/offline: Remote node cached as offline or unknown]',
}],
warnings: [
{ id: 'compat:1', message: 'uses named volumes' },
{ id: 'req:1', message: 'Required variable DB_PASSWORD' },
],
...overrides,
};
}
beforeEach(() => {
vi.mocked(previewBlueprint).mockResolvedValue(previewFixture());
});
describe('RolloutPreviewDialog', () => {
it('shows reachability note and does not truncate compatibility warnings', async () => {
render(
<RolloutPreviewDialog
blueprintId={1}
blueprintName="web"
open
onOpenChange={() => {}}
onApplied={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getAllByText(/Remote node cached as offline/i).length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText(/Warnings \(2\)/i)).toBeInTheDocument();
expect(screen.getByText('uses named volumes')).toBeInTheDocument();
expect(screen.getByText('Required variable DB_PASSWORD')).toBeInTheDocument();
expect(screen.getByText(/Blockers \(1\)/i)).toBeInTheDocument();
expect(screen.getByText(/\(remote\/offline\)/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /confirm apply/i })).toBeDisabled();
});
});
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from 'react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import {
type BlueprintPreview,
previewBlueprint,
applyBlueprint,
} from '@/lib/blueprintsApi';
interface RolloutPreviewDialogProps {
blueprintId: number;
blueprintName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onApplied: () => void;
}
function approvalLabel(value: BlueprintPreview['effectiveApproval']): string {
if (value === 'reapproval_required') return 'reapproval required';
return value;
}
function sectionBorderClass(tone: 'destructive' | 'warning' | 'neutral'): string {
if (tone === 'destructive') return 'border-destructive/30 bg-destructive/5';
if (tone === 'warning') return 'border-warning/30 bg-warning/5';
return 'border-card-border bg-glass-highlight';
}
export function RolloutPreviewDialog({
blueprintId,
blueprintName,
open,
onOpenChange,
onApplied,
}: RolloutPreviewDialogProps) {
const [preview, setPreview] = useState<BlueprintPreview | null>(null);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const onOpenChangeRef = useRef(onOpenChange);
useEffect(() => { onOpenChangeRef.current = onOpenChange; }, [onOpenChange]);
useEffect(() => {
if (!open) {
setPreview(null);
setSubmitting(false);
return;
}
let cancelled = false;
setLoading(true);
setPreview(null);
previewBlueprint(blueprintId)
.then((result) => {
if (!cancelled) setPreview(result);
})
.catch((err) => {
if (cancelled) return;
toast.error(err instanceof Error ? err.message : 'Failed to preview rollout');
onOpenChangeRef.current(false);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [open, blueprintId]);
const blocked = (preview?.summary.blocker ?? 0) > 0;
const canConfirm = !!preview && !loading && !submitting && !blocked;
async function handleConfirm() {
if (!preview) return;
setSubmitting(true);
try {
const result = await applyBlueprint(blueprintId, {
planFingerprint: preview.planFingerprint,
actions: preview.confirmableActions,
});
const { failed = 0, pending = 0 } = result.outcomeSummary ?? {};
if (failed > 0) {
toast.warning(result.message || 'Rollout confirmed with node failures');
} else if (pending > 0) {
toast.info(result.message || 'Rollout confirmed; some actions are still in progress');
} else {
toast.success(result.message || 'Rollout confirmed');
}
onApplied();
onOpenChange(false);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to apply blueprint';
const status = (err as Error & { status?: number }).status;
if (status === 409) {
try {
setPreview(await previewBlueprint(blueprintId));
} catch (refreshErr) {
toast.error(refreshErr instanceof Error ? refreshErr.message : 'Failed to refresh preview');
}
}
toast.error(message);
} finally {
setSubmitting(false);
}
}
const hasRequirements = !!preview && (
preview.requirements.variables.length > 0
|| preview.requirements.envFiles.length > 0
|| preview.requirements.composeSecrets.length > 0
);
return (
<Modal open={open} onOpenChange={onOpenChange} size="lg">
<ModalHeader
kicker="BLUEPRINT · ROLLOUT PREVIEW"
title={`Confirm rollout: ${blueprintName}`}
description="Review the blast radius before authorizing place or remove outcomes."
/>
<ModalBody>
{loading || !preview ? (
<p className="text-sm text-muted-foreground">Computing preview</p>
) : (
<div className="space-y-4 max-md:max-h-[60vh] max-md:overflow-y-auto">
<p className="text-xs text-stat-subtitle">
Enabled blueprints still need this confirmation before the reconciler mutates the fleet.
</p>
<div className="flex flex-wrap gap-3 text-xs font-mono uppercase tracking-[0.15em]">
<span className="text-stat-value">Safe {preview.summary.safe}</span>
<span className="text-warning">Warnings {preview.summary.warning}</span>
<span className="text-destructive">Blockers {preview.summary.blocker}</span>
<span className="text-stat-subtitle">{approvalLabel(preview.effectiveApproval)}</span>
</div>
<p className="text-xs text-stat-subtitle">{preview.healthNote}</p>
{preview.blockers.length > 0 && (
<Section title={`Blockers (${preview.blockers.length})`} tone="destructive">
{preview.blockers.map(b => (
<li key={b.id} className="text-xs text-stat-value">{b.message}</li>
))}
</Section>
)}
{preview.warnings.length > 0 && (
<Section title={`Warnings (${preview.warnings.length})`} tone="warning">
{preview.warnings.map(w => (
<li key={w.id} className="text-xs text-stat-value">{w.message}</li>
))}
</Section>
)}
<Section title="Changes" tone="neutral">
{preview.changes.map(c => {
const nodeMeta = [c.nodeType, c.status].filter(Boolean).join('/');
const reachNote = c.reachabilityNote && c.reachabilityNote !== 'Local node'
? c.reachabilityNote
: null;
return (
<li key={`${c.nodeId}:${c.action}`} className="text-xs text-stat-value">
<span className="font-mono">{c.nodeName}</span>
{nodeMeta ? (
<span className="text-stat-subtitle"> ({nodeMeta})</span>
) : null}
{' · '}
{c.action}
{' · '}
{c.severity}
{': '}
{c.detail}
{reachNote ? (
<span className="text-stat-subtitle"> · {reachNote}</span>
) : null}
</li>
);
})}
{preview.changes.length === 0 && (
<li className="text-xs text-stat-subtitle">No node actions in this plan.</li>
)}
</Section>
{hasRequirements && (
<Section title="Requirements" tone="neutral">
{preview.requirements.variables.map(v => (
<li key={v.name} className="text-xs font-mono text-stat-value">
{`\${${v.name}}`}
{v.required ? ' required' : ''}
{v.likelySecret ? ' (likely secret)' : ''}
</li>
))}
{preview.requirements.envFiles.map(f => (
<li key={f.path} className="text-xs font-mono text-stat-value">
env_file {f.path}{f.required ? ' required' : ''}
</li>
))}
{preview.requirements.composeSecrets.map(s => (
<li key={s.name} className="text-xs font-mono text-stat-value">
secret {s.name}
</li>
))}
</Section>
)}
</div>
)}
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
}
primary={
<Button size="sm" onClick={() => void handleConfirm()} disabled={!canConfirm}>
{submitting ? 'Applying…' : 'Confirm Apply'}
</Button>
}
/>
</Modal>
);
}
function Section({
title,
tone,
children,
}: {
title: string;
tone: 'destructive' | 'warning' | 'neutral';
children: React.ReactNode;
}) {
return (
<div className={`rounded-md border ${sectionBorderClass(tone)} px-3 py-2`}>
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-stat-subtitle mb-1.5">{title}</div>
<ul className="space-y-1 list-disc pl-4">{children}</ul>
</div>
);
}
+97 -2
View File
@@ -34,11 +34,17 @@ export interface Blueprint {
updated_at: number;
created_by: string | null;
pinned_node_id: number | null;
approval_status?: 'pending' | 'approved';
approved_at?: number | null;
approved_by?: string | null;
}
export type EffectiveApproval = 'pending' | 'approved' | 'reapproval_required';
export interface BlueprintListItem extends Blueprint {
deploymentCounts: Partial<Record<BlueprintDeploymentStatus, number>>;
deploymentTotal: number;
effectiveApproval?: EffectiveApproval;
}
export interface BlueprintDeployment {
@@ -58,6 +64,7 @@ export interface BlueprintSummary {
blueprint: Blueprint;
deployments: BlueprintDeployment[];
statusCounts: Partial<Record<BlueprintDeploymentStatus, number>>;
effectiveApproval?: EffectiveApproval;
}
export interface AnalyzerResult {
@@ -70,6 +77,29 @@ export interface AnalyzerResult {
parseError?: string;
}
export type PreviewAction =
| 'create'
| 'update'
| 'remove'
| 'check_observe'
| 'check_enforce'
| 'await_state_review'
| 'await_evict_confirm'
| 'clear_stale_guard'
| 'clear_reversed_evict'
| 'in_flight_deploy'
| 'in_flight_correct'
| 'in_flight_withdraw'
| 'skip_cordoned'
| 'blocked_name_conflict';
export interface BlueprintPreviewWarning {
id: string;
message: string;
source?: string;
severity?: string;
}
export interface BlueprintPreview {
blueprintId: number;
classification: BlueprintClassification;
@@ -77,6 +107,42 @@ export interface BlueprintPreview {
plannedDeployments: Array<{ id: number; name: string }>;
plannedDriftChecks: Array<{ id: number; name: string }>;
plannedEvictions: number[];
name: string;
revision: number;
updatedAt: number;
driftMode: DriftMode;
stackName: string;
approvalStatus: 'pending' | 'approved';
effectiveApproval: EffectiveApproval;
planFingerprint: string;
generatedAt: number;
summary: { safe: number; warning: number; blocker: number; total: number };
changes: Array<{
nodeId: number;
nodeName: string;
nodeType: 'local' | 'remote';
mode?: string | null;
status: 'online' | 'offline' | 'unknown';
contactAt?: number | null;
contactSource?: 'local' | 'pilot_last_seen' | 'last_successful_contact';
action: PreviewAction;
severity: 'safe' | 'warning' | 'blocker';
kind: 'executor' | 'informational';
detail: string;
reachabilityNote: string;
}>;
confirmableActions: Array<{ nodeId: number; action: PreviewAction }>;
executorActions: Array<{ nodeId: number; action: PreviewAction }>;
unauthorizedActions: Array<{ nodeId: number; action: PreviewAction }>;
requirements: {
variables: Array<{ name: string; required: boolean; hasDefault: boolean; alternate: boolean; likelySecret: boolean }>;
envFiles: Array<{ path: string; required: boolean }>;
composeSecrets: Array<{ name: string }>;
};
compatibilityWarnings: string[];
healthNote: string;
blockers: BlueprintPreviewWarning[];
warnings: BlueprintPreviewWarning[];
}
export type WithdrawConfirm = 'standard' | 'snapshot_then_evict' | 'evict_and_destroy';
@@ -168,8 +234,37 @@ export async function deleteBlueprint(id: number): Promise<void> {
// ---- Apply / Withdraw / Accept / Preview ----
export async function applyBlueprint(id: number): Promise<{ message: string }> {
const res = await apiFetch(`/blueprints/${id}/apply`, { method: 'POST', localOnly: true });
export interface ApplyBlueprintOutcome {
nodeId: number;
nodeName: string;
action: PreviewAction;
status: 'ok' | 'failed' | 'name_conflict' | 'pending' | 'skipped';
error?: string | null;
}
export interface ApplyBlueprintResult {
message: string;
blueprintId: number;
effectiveApproval: string;
outcomes: ApplyBlueprintOutcome[];
outcomeSummary: {
total: number;
ok: number;
failed: number;
pending: number;
skipped: number;
};
}
export async function applyBlueprint(
id: number,
confirm: { planFingerprint: string; actions: Array<{ nodeId: number; action: PreviewAction }> },
): Promise<ApplyBlueprintResult> {
const res = await apiFetch(`/blueprints/${id}/apply`, {
method: 'POST',
body: JSON.stringify(confirm),
localOnly: true,
});
return expectJson(res, 'Failed to apply blueprint');
}