mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(blueprints): gate confirmed apply on live intent fingerprint (#1663)
* fix(blueprints): gate confirmed apply on live intent fingerprint reconcileConfirmedPlan only checked approval_status, so a concurrent compose edit during Apply could deploy unapproved content under a stale fingerprint. Match the tick-path fingerprint gate, refuse Apply when live intent drifts, and surface reconciler refusal as PREVIEW_STALE instead of a false success. * test(blueprints): cover matching-fingerprint reconcileConfirmedPlan path Prove the production allow branch still deploys authorized actions when the approval fingerprint matches, and does not execute unauthorized blast nodes. * fix(blueprints): report live approval after confirmed snapshot apply A concurrent edit can clear approval while multi-node snapshot deploy is still running. Keep the in-flight snapshot contract, but re-read live effectiveApproval before responding and warn in the rollout dialog when approval is no longer current.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<ConfirmedPlanResult> {
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user