mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user