diff --git a/backend/src/__tests__/blueprints-preview-apply.test.ts b/backend/src/__tests__/blueprints-preview-apply.test.ts index 01cc812a..f2f26190 100644 --- a/backend/src/__tests__/blueprints-preview-apply.test.ts +++ b/backend/src/__tests__/blueprints-preview-apply.test.ts @@ -144,6 +144,128 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => { expect(stored.updated_at).toBe(created.body.updated_at); }); + it('returns PREVIEW_STALE when compose drifts between preview and approval persist', 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 db = DatabaseService.getInstance(); + const originalSet = db.setBlueprintApproval.bind(db); + vi.spyOn(db, 'setBlueprintApproval').mockImplementation((id, input) => { + // Concurrent edit landed compose v2 before approval was written; Apply + // still persists the v1 fingerprint onto that row. + db.getDb().prepare('UPDATE blueprints SET compose_content = ? WHERE id = ?').run( + 'services:\n app:\n image: nginx:evil\n', + id, + ); + return originalSet(id, input); + }); + + 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(409); + expect(res.body.code).toBe('PREVIEW_STALE'); + expect(reconcileSpy).not.toHaveBeenCalled(); + + const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!; + expect(stored.approval_status).toBe('pending'); + expect(stored.approved_intent_fingerprint).toBeNull(); + }); + + it('returns PREVIEW_STALE when reconcileConfirmedPlan refuses after approval', 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: [], + refused: true, + }); + + 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(409); + expect(res.body.code).toBe('PREVIEW_STALE'); + + const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!; + expect(stored.approval_status).toBe('pending'); + expect(stored.approved_intent_fingerprint).toBeNull(); + }); + + it('reports live pending approval when approval is cleared during reconcile', 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).mockImplementationOnce(async (id) => { + // Concurrent edit invalidated approval while the confirmed snapshot ran. + DatabaseService.getInstance().clearBlueprintApproval(id); + return { + outcomes: [{ + nodeId: node.id, + nodeName: node.name, + action: 'create', + status: 'ok', + }], + }; + }); + + 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('pending'); + expect(res.body.message).toMatch(/approval is no longer current/i); + expect(res.body.outcomes).toHaveLength(1); + expect(res.body.outcomes[0].status).toBe('ok'); + expect(res.body.outcomeSummary.ok).toBe(1); + + const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!; + expect(stored.approval_status).toBe('pending'); + }); + it('returns failed outcomes without pretending the rollout was clean', async () => { const node = seedNode(); counter += 1; diff --git a/backend/src/__tests__/blueprints-reconciler-gate.test.ts b/backend/src/__tests__/blueprints-reconciler-gate.test.ts index 755ccaaf..18f41c42 100644 --- a/backend/src/__tests__/blueprints-reconciler-gate.test.ts +++ b/backend/src/__tests__/blueprints-reconciler-gate.test.ts @@ -246,6 +246,71 @@ describe('reconcileOne approval gate (real path)', () => { }); }); +describe('reconcileConfirmedPlan fingerprint gate', () => { + it('does not deploy when approval fingerprint no longer matches live compose', async () => { + const node = seedNode(); + const bp = createBp({ nodeIds: [node.id] }); + // Simulate an approved row whose fingerprint lags a concurrent compose edit. + const staleFp = intentFingerprint(bp); + DatabaseService.getInstance().getDb().prepare( + `UPDATE blueprints SET compose_content = ?, + approval_status = 'approved', + approved_intent_fingerprint = ?, + approved_blast_json = ?, + approved_at = ?, + approved_by = 'admin' + WHERE id = ?`, + ).run( + 'services:\n app:\n image: nginx:evil\n', + staleFp, + serializeApprovedBlast([{ nodeId: node.id, outcome: 'place' }]), + Date.now(), + bp.id, + ); + + const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' }); + const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' }); + + const result = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(bp.id, [ + { nodeId: node.id, action: 'create' }, + ]); + + expect(result.outcomes).toEqual([]); + expect(result.refused).toBe(true); + expect(deploySpy).not.toHaveBeenCalled(); + expect(withdrawSpy).not.toHaveBeenCalled(); + }); + + it('deploys authorized actions when approval fingerprint matches live intent', async () => { + const authorized = seedNode(); + const unauthorized = seedNode(); + const bp = createBp({ nodeIds: [authorized.id, unauthorized.id] }); + approvePlace(bp.id, [authorized.id]); + + const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' }); + const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' }); + + const result = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(bp.id, [ + { nodeId: authorized.id, action: 'create' }, + { nodeId: unauthorized.id, action: 'create' }, + ]); + + expect(result.refused).toBeFalsy(); + expect(deploySpy).toHaveBeenCalledTimes(1); + expect(deploySpy).toHaveBeenCalledWith( + expect.objectContaining({ id: bp.id, compose_content: bp.compose_content }), + expect.objectContaining({ id: authorized.id }), + ); + expect(withdrawSpy).not.toHaveBeenCalled(); + expect(result.outcomes).toEqual([{ + nodeId: authorized.id, + nodeName: authorized.name, + action: 'create', + status: 'ok', + }]); + }); +}); + describe('Accept/Evict STALE_GUARD', () => { it('refuses Accept without a valid place approval', async () => { const node = seedNode(); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index 62e7538d..0679d97c 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -11,6 +11,7 @@ import { BlueprintService } from '../services/BlueprintService'; import { BlueprintReconciler, messageForConfirmedOutcomes, + messageForSnapshotFinishedWithStaleApproval, summarizeConfirmedOutcomes, } from '../services/BlueprintReconciler'; import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer'; @@ -18,6 +19,8 @@ import { buildBlueprintPreview, evaluateLightweightEffectiveApproval } from '../ import { confirmableActionsEqual, deriveBlastFromConfirmableActions, + evaluateEffectiveApproval, + intentFingerprint, parseConfirmableActionsBody, serializeApprovedBlast, } from '../services/blueprintApproval'; @@ -427,17 +430,42 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise } const blast = deriveBlastFromConfirmableActions(preview.confirmableActions); - DatabaseService.getInstance().setBlueprintApproval(id, { + const approved = DatabaseService.getInstance().setBlueprintApproval(id, { intentFingerprint: preview.planFingerprint, blastJson: serializeApprovedBlast(blast), approvedBy: req.user?.username ?? null, }); - const plan = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(id, preview.executorActions); + // planFingerprint is from an earlier preview. Concurrent compose/selector + // edits can invalidate it before approval persists, or the reconciler can + // refuse if the live gate no longer matches. Both paths clear and 409. + const approvalMatches = !!approved && intentFingerprint(approved) === preview.planFingerprint; + const plan = approvalMatches + ? await BlueprintReconciler.getInstance().reconcileConfirmedPlan(id, preview.executorActions) + : null; + if (!plan || plan.refused) { + DatabaseService.getInstance().clearBlueprintApproval(id); + const fresh = await buildBlueprintPreview(id); + res.status(409).json({ + error: 'Preview is stale; refresh and confirm again', + code: 'PREVIEW_STALE', + preview: fresh, + }); + return; + } + // Snapshot deploy may finish after a concurrent edit cleared approval. + // Report live effectiveApproval; do not hardcode "approved". + const live = DatabaseService.getInstance().getBlueprint(id); + const { effectiveApproval } = live + ? evaluateEffectiveApproval(live, preview.executorActions) + : { effectiveApproval: 'pending' as const }; const outcomeSummary = summarizeConfirmedOutcomes(plan.outcomes); + const message = effectiveApproval === 'approved' + ? messageForConfirmedOutcomes(outcomeSummary) + : messageForSnapshotFinishedWithStaleApproval(outcomeSummary); res.json({ - message: messageForConfirmedOutcomes(outcomeSummary), + message, blueprintId: id, - effectiveApproval: 'approved', + effectiveApproval, outcomes: plan.outcomes, outcomeSummary, }); diff --git a/backend/src/services/BlueprintReconciler.ts b/backend/src/services/BlueprintReconciler.ts index a928292f..b49ac6f8 100644 --- a/backend/src/services/BlueprintReconciler.ts +++ b/backend/src/services/BlueprintReconciler.ts @@ -37,6 +37,8 @@ export interface ConfirmedActionOutcome { export interface ConfirmedPlanResult { outcomes: ConfirmedActionOutcome[]; + /** True when the approval gate refused execution (Apply must not claim success). */ + refused?: boolean; } export interface ConfirmedOutcomeSummary { @@ -78,6 +80,17 @@ export function messageForConfirmedOutcomes(summary: ConfirmedOutcomeSummary): s return 'Rollout confirmed'; } +/** Apply finished a confirmed snapshot, but live approval is no longer current. */ +export function messageForSnapshotFinishedWithStaleApproval(summary: ConfirmedOutcomeSummary): string { + if (summary.failed > 0) { + return 'Confirmed snapshot finished with node failures; approval is no longer current'; + } + if (summary.pending > 0) { + return 'Confirmed snapshot finished with actions still in progress; approval is no longer current'; + } + return 'Confirmed snapshot finished; approval is no longer current'; +} + function mapDeployOutcome( base: { nodeId: number; nodeName: string; action: PreviewAction }, result: DeployOutcome, @@ -195,12 +208,18 @@ export class BlueprintReconciler { ): Promise { const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId); if (!blueprint || !blueprint.enabled) { - return { outcomes: [] }; + return { outcomes: [], refused: true }; } const parsed = parseApprovedBlastJson(blueprint.approved_blast_json); - if (!parsed.ok || blueprint.approval_status !== 'approved') { - diagnosticLog('reconcileConfirmedPlan skipped: approval missing or invalid', { blueprintId }); - return { outcomes: [] }; + // Same fail-closed gate as tick reconcile: never execute when approval is + // missing, invalid, or the stored fingerprint no longer matches live intent. + if ( + !parsed.ok + || blueprint.approval_status !== 'approved' + || blueprint.approved_intent_fingerprint !== intentFingerprint(blueprint) + ) { + diagnosticLog('reconcileConfirmedPlan skipped: approval missing, invalid, or drifted', { blueprintId }); + return { outcomes: [], refused: true }; } const authorized = filterAuthorizedExecutorActions(parsed.entries, executorActions); const nodes = DatabaseService.getInstance().getNodes(); diff --git a/frontend/src/components/blueprints/RolloutPreviewDialog.tsx b/frontend/src/components/blueprints/RolloutPreviewDialog.tsx index 23eb089d..ee2844dc 100644 --- a/frontend/src/components/blueprints/RolloutPreviewDialog.tsx +++ b/frontend/src/components/blueprints/RolloutPreviewDialog.tsx @@ -78,6 +78,8 @@ export function RolloutPreviewDialog({ const { failed = 0, pending = 0 } = result.outcomeSummary ?? {}; if (failed > 0) { toast.warning(result.message || 'Rollout confirmed with node failures'); + } else if (result.effectiveApproval !== 'approved') { + toast.warning(result.message || 'Confirmed snapshot finished; approval is no longer current'); } else if (pending > 0) { toast.info(result.message || 'Rollout confirmed; some actions are still in progress'); } else {