/** * Blueprint source and deployment transitions. * * These have no production caller yet; the Blueprint routes and the reconciler * are wired to them in the same step. They are tested directly so the shape a * caller must satisfy is pinned here rather than inferred from the deriver. * * The rule they share is that a terminal event has to name the request it is * answering. A node that acknowledges a superseded intent has not converged on * anything anyone asked for, and recording it as an acknowledgement is how a * fleet comes to report agreement it does not have. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { GitOpsStore, emptyTargetRow } from '../services/gitops/store'; import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions'; import { projectApplication } from '../services/gitops/derive'; import type { GitOpsApplicationRow, GitOpsIntentRevisionRow, GitOpsRolloutCandidateRow, } from '../services/gitops/types'; describe('gitops blueprint transitions', () => { let tmpDir: string; beforeAll(async () => { tmpDir = await setupTestDb(); GitOpsStore.resetForTests(); GitOpsTransitions.resetForTests(); }); afterAll(() => { cleanupTestDb(tmpDir); }); it('mints an intent and opens a candidate against it', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-intent', 101); tx.intentRevised({ applicationId: 'app-intent', intent: intent('int-1', 'app-intent', 101), envelope: env('op-int-1'), }); expect(store.getApplication('app-intent')?.intent_revision_id).toBe('int-1'); tx.rolloutCandidateOpened({ applicationId: 'app-intent', candidate: candidate('cand-1', 'app-intent', 'int-1'), envelope: env('op-cand-1'), }); const app = store.getApplication('app-intent')!; expect(app.rollout_candidate_id).toBe('cand-1'); // Candidate-time facts only: nothing here claims anything was authorized. const row = store.getRolloutCandidate('cand-1')!; expect(row.intent_revision_id).toBe('int-1'); expect(row.accepted_generation_id).toBeNull(); }); it('refuses a candidate that does not name the current intent', () => { const tx = GitOpsTransitions.getInstance(); seedInline('app-stale-cand', 102); tx.intentRevised({ applicationId: 'app-stale-cand', intent: intent('int-2', 'app-stale-cand', 102), envelope: env('op-int-2'), }); expect(() => tx.rolloutCandidateOpened({ applicationId: 'app-stale-cand', candidate: { ...candidate('cand-2', 'app-stale-cand', 'int-nonexistent') }, envelope: env('op-cand-2'), })).toThrow(/current intent/); }); it('records a deploy, then accepts the ack that names it', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-ack', 103, 1); tx.intentRevised({ applicationId: 'app-ack', intent: intent('int-3', 'app-ack', 103), envelope: env('op-int-3') }); tx.blueprintDeployStarted({ applicationId: 'app-ack', nodeId: 1, intentRevisionId: 'int-3', rolloutCandidateId: null, envelope: env('op-dep-3'), }); let target = store.getTarget('app-ack', 1)!; expect(target.active_operation_stage).toBe('blueprint_deploy_started'); expect(target.active_intent_revision_id).toBe('int-3'); // Nothing is acknowledged yet: the request is in flight, not converged. expect(target.intent_revision_id).toBeNull(); tx.blueprintAckRecorded({ applicationId: 'app-ack', nodeId: 1, intentRevisionId: 'int-3', rolloutCandidateId: null, legacyAppliedRevision: 7, envelope: env('op-ack-3'), }); target = store.getTarget('app-ack', 1)!; expect(target.intent_revision_id).toBe('int-3'); expect(target.active_operation_stage).toBeNull(); expect(target.legacy_applied_revision).toBe(7); // A Blueprint target has no Git generation to point at. expect(target.desired_generation_id).toBeNull(); }); it('ignores an acknowledgement for an intent the target was never asked to run', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-super', 104, 1); tx.intentRevised({ applicationId: 'app-super', intent: intent('int-4', 'app-super', 104), envelope: env('op-int-4') }); tx.blueprintDeployStarted({ applicationId: 'app-super', nodeId: 1, intentRevisionId: 'int-4', rolloutCandidateId: null, envelope: env('op-dep-4'), }); // A newer intent superseded the one this node is running. tx.intentRevised({ applicationId: 'app-super', intent: intent('int-5', 'app-super', 104), envelope: env('op-int-5') }); expect(() => tx.blueprintAckRecorded({ applicationId: 'app-super', nodeId: 1, intentRevisionId: 'int-5', rolloutCandidateId: null, legacyAppliedRevision: null, envelope: env('op-ack-5'), })).toThrow(/was not asked to run/); expect(store.getTarget('app-super', 1)?.intent_revision_id).toBeNull(); }); it('clears a deploy failure only when the next deploy is acknowledged', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-fail', 105, 1); tx.intentRevised({ applicationId: 'app-fail', intent: intent('int-6', 'app-fail', 105), envelope: env('op-int-6') }); tx.blueprintDeployStarted({ applicationId: 'app-fail', nodeId: 1, intentRevisionId: 'int-6', rolloutCandidateId: null, envelope: env('op-dep-6'), }); tx.blueprintDeployFailed({ applicationId: 'app-fail', nodeId: 1, failureClass: 'name_conflict', envelope: env('op-dep-6'), }); let target = store.getTarget('app-fail', 1)!; expect(target.failure_stage).toBe('blueprint_deploy'); expect(target.failure_class).toBe('name_conflict'); expect(target.active_operation_stage).toBeNull(); // A failure does not acknowledge anything. expect(target.intent_revision_id).toBeNull(); tx.blueprintDeployStarted({ applicationId: 'app-fail', nodeId: 1, intentRevisionId: 'int-6', rolloutCandidateId: null, envelope: env('op-dep-6b'), }); tx.blueprintAckRecorded({ applicationId: 'app-fail', nodeId: 1, intentRevisionId: 'int-6', rolloutCandidateId: null, legacyAppliedRevision: null, envelope: env('op-ack-6b'), }); target = store.getTarget('app-fail', 1)!; expect(target.failure_stage).toBeNull(); expect(target.intent_revision_id).toBe('int-6'); }); it('withdraws against the intent being removed, not a later one', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-wd', 106, 1); tx.intentRevised({ applicationId: 'app-wd', intent: intent('int-7', 'app-wd', 106), envelope: env('op-int-7') }); tx.blueprintWithdrawStarted({ applicationId: 'app-wd', nodeId: 1, intentRevisionId: 'int-7', envelope: env('op-wd-7'), }); expect(() => tx.blueprintWithdrawn({ applicationId: 'app-wd', nodeId: 1, intentRevisionId: 'int-other', envelope: env('op-wd-7'), })).toThrow(/was not asked to run/); tx.blueprintWithdrawn({ applicationId: 'app-wd', nodeId: 1, intentRevisionId: 'int-7', envelope: env('op-wd-7'), }); const target = store.getTarget('app-wd', 1)!; expect(target.target_status).toBe('tombstoned'); expect(target.active_operation_stage).toBeNull(); }); it('re-opens a severed placement when a deploy starts again', () => { // Withdrawal is terminal for the placement, not for the node. A later // explicit deploy re-activates the target and records the revival in the // same event, so the projection and the workload cannot disagree about // whether this node runs the Blueprint. const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-revive', 220, 1); tx.intentRevised({ applicationId: 'app-revive', intent: intent('int-rev', 'app-revive', 220), envelope: env('op-rev-int'), }); tx.blueprintDeployStarted({ applicationId: 'app-revive', nodeId: 1, intentRevisionId: 'int-rev', rolloutCandidateId: null, envelope: env('op-rev-d1'), }); tx.blueprintAckRecorded({ applicationId: 'app-revive', nodeId: 1, intentRevisionId: 'int-rev', rolloutCandidateId: null, legacyAppliedRevision: null, envelope: env('op-rev-a1'), }); tx.blueprintWithdrawStarted({ applicationId: 'app-revive', nodeId: 1, intentRevisionId: 'int-rev', envelope: env('op-rev-w1'), }); tx.blueprintWithdrawn({ applicationId: 'app-revive', nodeId: 1, intentRevisionId: 'int-rev', envelope: env('op-rev-w2'), }); expect(store.getTarget('app-revive', 1)?.target_status).toBe('tombstoned'); tx.blueprintDeployStarted({ applicationId: 'app-revive', nodeId: 1, intentRevisionId: 'int-rev', rolloutCandidateId: null, envelope: env('op-rev-d2'), }); const revived = store.getTarget('app-revive', 1)!; expect(revived.target_status).toBe('active'); expect(revived.active_operation_stage).toBe('blueprint_deploy_started'); // The acknowledged intent survives severance; only a fresh ack rewrites it. expect(revived.intent_revision_id).toBe('int-rev'); }); it('keeps a failed withdraw distinct from a failed deploy', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-wdf', 107, 1); tx.intentRevised({ applicationId: 'app-wdf', intent: intent('int-8', 'app-wdf', 107), envelope: env('op-int-8') }); tx.blueprintWithdrawStarted({ applicationId: 'app-wdf', nodeId: 1, intentRevisionId: 'int-8', envelope: env('op-wd-8'), }); tx.blueprintWithdrawFailed({ applicationId: 'app-wdf', nodeId: 1, failureClass: 'post_mutation', envelope: env('op-wd-8'), }); const target = store.getTarget('app-wdf', 1)!; expect(target.failure_stage).toBe('blueprint_withdraw'); // Still active: a withdraw that failed has not removed the deployment. expect(target.target_status).toBe('active'); }); it('releases the request identity with the operation, not just the stage', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-ident', 120, 1); tx.intentRevised({ applicationId: 'app-ident', intent: intent('int-20', 'app-ident', 120), envelope: env('op-int-20') }); tx.blueprintDeployStarted({ applicationId: 'app-ident', nodeId: 1, intentRevisionId: 'int-20', rolloutCandidateId: null, envelope: env('op-dep-20'), }); tx.blueprintDeployFailed({ applicationId: 'app-ident', nodeId: 1, failureClass: 'pre_mutation', envelope: env('op-dep-20'), }); // Identity has to go with the stage. Left behind, a later start that only // sets a stage would make the superseded intent read as live again, and a // duplicate ack for it would then be accepted. const target = store.getTarget('app-ident', 1)!; expect(target.active_operation_stage).toBeNull(); expect(target.active_intent_revision_id).toBeNull(); expect(target.active_rollout_candidate_id).toBeNull(); expect(() => tx.blueprintAckRecorded({ applicationId: 'app-ident', nodeId: 1, intentRevisionId: 'int-20', rolloutCandidateId: null, legacyAppliedRevision: null, envelope: env('op-ack-20'), })).toThrow(/was not asked to run/); }); it('acknowledges an interrupted deploy, and retires the interruption with it', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-int', 121, 1); tx.intentRevised({ applicationId: 'app-int', intent: intent('int-21', 'app-int', 121), envelope: env('op-int-21') }); tx.blueprintDeployStarted({ applicationId: 'app-int', nodeId: 1, intentRevisionId: 'int-21', rolloutCandidateId: 'cand-21', envelope: env('op-dep-21'), }); tx.interruptActiveOperations('app-int', env('op-boot-21')); expect(store.getTarget('app-int', 1)?.interruption_stage).toBe('blueprint_deploy_started'); // An ack that arrives after a restart still names a request this target was // genuinely given, so it is accepted. tx.blueprintAckRecorded({ applicationId: 'app-int', nodeId: 1, intentRevisionId: 'int-21', rolloutCandidateId: 'cand-21', legacyAppliedRevision: null, envelope: env('op-ack-21'), }); const target = store.getTarget('app-int', 1)!; expect(target.intent_revision_id).toBe('int-21'); expect(target.rollout_candidate_id).toBe('cand-21'); // Retired, or it would keep matching and let a third ack regress the // pointer after two later deploys had succeeded. expect(target.interruption_stage).toBeNull(); expect(target.interruption_intent_revision_id).toBeNull(); expect(target.interruption_rollout_candidate_id).toBeNull(); }); it('refuses an acknowledgement that pairs the deployed intent with another candidate', () => { const tx = GitOpsTransitions.getInstance(); seedInline('app-pair', 122, 1); tx.intentRevised({ applicationId: 'app-pair', intent: intent('int-22', 'app-pair', 122), envelope: env('op-int-22') }); tx.blueprintDeployStarted({ applicationId: 'app-pair', nodeId: 1, intentRevisionId: 'int-22', rolloutCandidateId: 'cand-22', envelope: env('op-dep-22'), }); expect(() => tx.blueprintAckRecorded({ applicationId: 'app-pair', nodeId: 1, intentRevisionId: 'int-22', rolloutCandidateId: 'cand-other', legacyAppliedRevision: null, envelope: env('op-ack-22'), })).toThrow(/not the one deployed/); }); it('will not settle a deploy out of a withdraw, or the reverse', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-cross', 123, 1); tx.intentRevised({ applicationId: 'app-cross', intent: intent('int-23', 'app-cross', 123), envelope: env('op-int-23') }); tx.blueprintWithdrawStarted({ applicationId: 'app-cross', nodeId: 1, intentRevisionId: 'int-23', envelope: env('op-wd-23'), }); // Same intent, but it names the deploy this target is not running. Taking // it would claim the deployment is live while it is being torn down. expect(() => tx.blueprintAckRecorded({ applicationId: 'app-cross', nodeId: 1, intentRevisionId: 'int-23', rolloutCandidateId: null, legacyAppliedRevision: null, envelope: env('op-ack-23'), })).toThrow(/was not asked to run/); expect(store.getTarget('app-cross', 1)?.target_status).toBe('active'); expect(store.getTarget('app-cross', 1)?.active_operation_stage).toBe('blueprint_withdraw_started'); }); it('refuses a start that would displace an unrelated operation', () => { const tx = GitOpsTransitions.getInstance(); seedInline('app-conflict', 124, 1); tx.intentRevised({ applicationId: 'app-conflict', intent: intent('int-24', 'app-conflict', 124), envelope: env('op-int-24') }); tx.blueprintDeployStarted({ applicationId: 'app-conflict', nodeId: 1, intentRevisionId: 'int-24', rolloutCandidateId: null, envelope: env('op-dep-24'), }); // Overwriting would leave the displaced operation with no terminal event // and no history saying it was abandoned. expect(() => tx.blueprintWithdrawStarted({ applicationId: 'app-conflict', nodeId: 1, intentRevisionId: 'int-24', envelope: env('op-wd-24-other'), })).toThrow(/conflicting target operation/); }); it('records an observation without acknowledging or minting anything', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-obs', 108, 1); tx.intentRevised({ applicationId: 'app-obs', intent: intent('int-9', 'app-obs', 108), envelope: env('op-int-9') }); const before = store.getApplication('app-obs')!; for (const stage of ['blueprint_state_review', 'blueprint_evict_blocked', 'blueprint_drifted', 'blueprint_correcting'] as const) { tx.blueprintObservation({ applicationId: 'app-obs', nodeId: 1, stage, envelope: env(`op-obs-${stage}`) }); } const after = store.getApplication('app-obs')!; expect(after.intent_revision_id).toBe(before.intent_revision_id); expect(after.rollout_candidate_id).toBe(before.rollout_candidate_id); expect(store.getTarget('app-obs', 1)?.intent_revision_id).toBeNull(); }); it('projects every observation stage as its runtime status', () => { // Recording an observation nothing reads would leave a deployed Blueprint // reporting itself as never applied, which is what the pointers alone say. const tx = GitOpsTransitions.getInstance(); const expected = { blueprint_state_review: 'pending_state_review', blueprint_evict_blocked: 'evict_blocked', blueprint_drifted: 'drifted', blueprint_correcting: 'correcting', } as const; // One live application per Blueprint, so each case needs its own id. Object.entries(expected).forEach(([stage, status], index) => { const applicationId = `app-proj-${stage}`; seedInline(applicationId, 200 + index, 1); tx.blueprintObservation({ applicationId, nodeId: 1, stage: stage as keyof typeof expected, envelope: env(`op-proj-${stage}`), }); expect(runtimeStatusOf(applicationId), stage).toBe(status); }); }); it('stops projecting an observation once something else happens to the target', () => { // The observation is what was seen last, not a state the target is stuck // in. A deploy after it has to win, or a corrected stack reads as drifting // for ever. A deploy start rather than a tombstone, so the runtime // assertion is load-bearing: the tombstone check sits above the observation // branch and would hold whatever `latest_stage` said. const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); seedInline('app-superseded', 210, 1); tx.intentRevised({ applicationId: 'app-superseded', intent: intent('int-sup', 'app-superseded', 210), envelope: env('op-sup-int'), }); tx.blueprintObservation({ applicationId: 'app-superseded', nodeId: 1, stage: 'blueprint_drifted', envelope: env('op-sup-obs'), }); expect(runtimeStatusOf('app-superseded')).toBe('drifted'); tx.blueprintDeployStarted({ applicationId: 'app-superseded', nodeId: 1, intentRevisionId: 'int-sup', rolloutCandidateId: null, envelope: env('op-sup-deploy'), }); expect(store.getTarget('app-superseded', 1)?.latest_stage).toBe('blueprint_deploy_started'); expect(runtimeStatusOf('app-superseded')).not.toBe('drifted'); }); it('does not let an observation mask a failure this node actually hit', () => { // The ordering claim in the deriver, asserted at its upper boundary. A // failed mutation describes what this node did; an observation describes // what was seen about it. Reporting the observation instead would hide a // deploy that broke the running workload. const tx = GitOpsTransitions.getInstance(); seedInline('app-failfirst', 211, 1); tx.deployFailed('app-failfirst', 1, 'post_mutation', env('op-fail')); tx.blueprintObservation({ applicationId: 'app-failfirst', nodeId: 1, stage: 'blueprint_drifted', envelope: env('op-fail-obs'), }); expect(runtimeStatusOf('app-failfirst')).toBe('failed_after_mutation'); }); }); function runtimeStatusOf(applicationId: string): string | undefined { const projection = projectApplication(applicationId, false); if (projection.targetMode === 'not_applicable') throw new Error('expected application'); return projection.targets[0]?.runtime.status; } function seedInline(applicationId: string, blueprintId: number, nodeId?: number): void { const store = GitOpsStore.getInstance(); store.insertApplication(inlineApp(applicationId, blueprintId)); if (nodeId !== undefined) { store.upsertTarget(emptyTargetRow(applicationId, nodeId, 1)); } } function env(operationId: string): EventEnvelope { return { operationId, actor: 'tester', trigger: 'manual', at: Date.now() }; } function intent(id: string, applicationId: string, blueprintId: number): GitOpsIntentRevisionRow { return { id, application_id: applicationId, blueprint_id: blueprintId, compose_content_sha256: 'c'.repeat(64), blueprint_revision: 1, deploy_stack_name: 'bp-stack', selector_json: '{"nodeIds":[1]}', pinned_node_id: null, cordon_implications_json: '{}', rollout_strategy_json: '{}', runtime_drift_policy: null, stateful_policy_json: null, health_failure_rollback_policy_json: null, operation_id: `op-${id}`, actor: 'tester', created_at: 1, }; } function candidate(id: string, applicationId: string, intentRevisionId: string): GitOpsRolloutCandidateRow { return { id, application_id: applicationId, intent_revision_id: intentRevisionId, compose_content_sha256: 'c'.repeat(64), accepted_generation_id: null, artifact_set_id: null, required_targets_json: '{"nodeIds":[1]}', authoritative: 1, provenance: 'intent_change', operation_id: `op-${id}`, created_at: 1, }; } function inlineApp(id: string, blueprintId: number): GitOpsApplicationRow { return { id, lifecycle_key: `blueprint:${blueprintId}`, lifecycle_status: 'active', target_mode: 'inline_blueprint', stack_name: null, blueprint_id: blueprintId, configured_repo_url: null, repo_identity_json: null, configured_ref: null, compose_paths_json: null, context_dir: null, sync_env: 0, env_path: null, materialization_fingerprint: null, desired_commit_sha: null, fetched_commit_sha: null, candidate_generation_id: null, accepted_generation_id: null, candidate_plan_blocked: 0, review_required: 0, artifact_set_id: null, latest_artifact_set_id: null, intent_revision_id: null, rollout_candidate_id: null, rollout_generation_id: null, source_acceptance_ref: null, placement_approval_ref: null, rollout_authorization_ref: null, legacy_combined_approval_ref: null, preflight_fingerprint: null, latest_operation_id: null, active_operation_id: null, active_operation_stage: null, active_operation_at: null, active_generation_id: null, pause_at: null, pause_reason: null, partial_json: null, failure_stage: null, failure_class: null, failure_at: null, retry_at: null, retry_count: 0, suspended_at: null, recovery_ref: null, recovery_phase: null, interruption_stage: null, interruption_at: null, interruption_operation_id: null, interruption_generation_id: null, evidence_fresh_at: null, evidence_limitations_json: null, created_at: 1, updated_at: 1, }; }