From 7767fd6f93287c68df0b0fe7ae9aeca8ee52f6ae Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 23 Sep 2026 01:18:58 -0400 Subject: [PATCH] fix(gitops): name the unresolved artifact identity and drop the stale source report (#1960) The operator source-accept route seeds an unresolved artifact set and no producer resolves one for a Git-managed Blueprint, so authorization refused with a registry-worded message that named the wrong cause. Refuse before evaluating registry readiness and report the artifact identity, on both the route and the placement facet, through one shared predicate. Placement also reported source acceptance pending from a rollout candidate bound to the previous authorized generation, which no generation is waiting on. Only a live staged generation counts as that decision now. Source acceptance stays available while a Blueprint is disabled, matching the backend, and the dispatch-failure note no longer points at a surface without a retry control. --- .../gitops-authority-actions-route.test.ts | 82 +++++++++++ ...ps-blueprint-rollout-authorization.test.ts | 8 ++ backend/src/__tests__/gitops-derive.test.ts | 128 ++++++++++++++++++ backend/src/routes/gitopsApplications.ts | 2 +- backend/src/services/gitops/derive.ts | 21 ++- backend/src/services/gitops/handoff.ts | 6 + backend/src/services/gitops/preflight.ts | 24 ++++ docs/features/blueprint-model.mdx | 6 +- .../gitops/GitOpsAuthorityActions.test.tsx | 13 +- .../gitops/GitOpsAuthorityActions.tsx | 9 +- 10 files changed, 288 insertions(+), 11 deletions(-) diff --git a/backend/src/__tests__/gitops-authority-actions-route.test.ts b/backend/src/__tests__/gitops-authority-actions-route.test.ts index cd2fe675..f1357a0d 100644 --- a/backend/src/__tests__/gitops-authority-actions-route.test.ts +++ b/backend/src/__tests__/gitops-authority-actions-route.test.ts @@ -15,6 +15,7 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes import { directApplicationFixture } from './helpers/gitopsFixtures'; import { commitBlueprintCreate } from '../services/gitops/blueprintProducers'; import { + encodeArtifactEvidenceJson, encodeGitOpsApprovedTargetEffectJson, encodeGitOpsRequiredTargetsJson, } from '../services/gitops/json'; @@ -581,6 +582,87 @@ describe('POST /api/gitops/applications/:id/rollout/authorize', () => { expect(store.getApplication(seeded.applicationId)!.rollout_authorization_ref).toBeNull(); }); + it('names the unresolved artifact identity when an operator-accepted generation is authorized', async () => { + const seeded = seedGitManagedBlueprint({ sourceAccepted: false }); + + // The operator review chain, through the routes only: the acceptance seed + // carries an unresolved artifact set, exactly as production does. + const accept = await request(app) + .post(`/api/gitops/applications/bp:${seeded.blueprintId}/source/accept`) + .set('Cookie', adminCookie) + .send({ generationId: seeded.generationId }); + expect(accept.status).toBe(200); + const accepted = GitOpsStore.getInstance().getApplication(seeded.applicationId)!; + expect(GitOpsStore.getInstance().getArtifactSet(accepted.artifact_set_id!)?.qualification).toBe('unresolved'); + + const preview = await buildBlueprintPreview(seeded.blueprintId); + const approve = await request(app) + .post(`/api/gitops/applications/bp:${seeded.blueprintId}/placement/approve`) + .set('Cookie', adminCookie) + .send({ planFingerprint: preview!.planFingerprint, actions: preview!.confirmableActions }); + expect(approve.status).toBe(200); + + const dispatchSpy = vi + .spyOn(GitSourceService.getInstance(), 'dispatchAcceptedGeneration') + .mockResolvedValue({ status: 'dispatched' }); + const authorize = await request(app) + .post(`/api/gitops/applications/bp:${seeded.blueprintId}/rollout/authorize`) + .set('Cookie', adminCookie) + .send({}); + expect(authorize.status).toBe(409); + expect(authorize.body.code).toBe('ROLLOUT_AUTHORIZATION_REFUSED'); + expect(authorize.body.error).toMatch(/artifact identity/i); + expect(authorize.body.error).not.toMatch(/registry readiness/i); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(GitOpsStore.getInstance().getApplication(seeded.applicationId)!.rollout_authorization_ref).toBeNull(); + }); + + it('authorizes and dispatches once executable artifact evidence is recorded', async () => { + const seeded = seedGitManagedBlueprint({ sourceAccepted: false }); + await request(app) + .post(`/api/gitops/applications/bp:${seeded.blueprintId}/source/accept`) + .set('Cookie', adminCookie) + .send({ generationId: seeded.generationId }) + .expect(200); + const preview = await buildBlueprintPreview(seeded.blueprintId); + await request(app) + .post(`/api/gitops/applications/bp:${seeded.blueprintId}/placement/approve`) + .set('Cookie', adminCookie) + .send({ planFingerprint: preview!.planFingerprint, actions: preview!.confirmableActions }) + .expect(200); + + // No producer resolves artifact evidence for a Git-managed Blueprint yet, + // so this records what that producer will write: resolved evidence for the + // accepted generation, advancing the pointer. It proves the chain + // completes once evidence exists, and guards the refusal above from + // over-reaching. + const appRow = GitOpsStore.getInstance().getApplication(seeded.applicationId)!; + GitOpsTransitions.getInstance().recordArtifactEvidence({ + applicationId: seeded.applicationId, + generationId: appRow.accepted_generation_id!, + artifactSetId: `art-${randomUUID().slice(0, 8)}`, + evidenceVersion: 2, + qualification: 'exact', + evidenceJson: encodeArtifactEvidenceJson({ kind: 'exact', identity: 'sha256:deadbeef' }), + authoritative: 0, + envelope: { operationId: randomUUID(), actor: 'tester', trigger: 'manual', at: Date.now() }, + }); + expect(GitOpsStore.getInstance().getApplication(seeded.applicationId)!.artifact_set_id).toBeTruthy(); + + const dispatchSpy = vi + .spyOn(GitSourceService.getInstance(), 'dispatchAcceptedGeneration') + .mockResolvedValue({ status: 'dispatched' }); + const authorize = await request(app) + .post(`/api/gitops/applications/bp:${seeded.blueprintId}/rollout/authorize`) + .set('Cookie', adminCookie) + .send({}); + expect(authorize.status).toBe(200); + expect(authorize.body).toMatchObject({ dispatched: true, note: null }); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const authorized = GitOpsStore.getInstance().getApplication(seeded.applicationId)!; + expect(authorized.rollout_authorization_ref).toBeTruthy(); + }); + it('rejects a caller without stack:deploy', async () => { const seeded = seedGitManagedBlueprint({ sourceAccepted: true }); const res = await request(app) diff --git a/backend/src/__tests__/gitops-blueprint-rollout-authorization.test.ts b/backend/src/__tests__/gitops-blueprint-rollout-authorization.test.ts index dac5364a..05d69009 100644 --- a/backend/src/__tests__/gitops-blueprint-rollout-authorization.test.ts +++ b/backend/src/__tests__/gitops-blueprint-rollout-authorization.test.ts @@ -498,6 +498,14 @@ describe('ensureRolloutAuthorization', () => { } }); + it('refuses to authorize while the accepted artifact evidence is unresolved', async () => { + const fixture = seedAuthorizedReadyApp({ artifactQualification: 'unresolved' }); + const result = await ensureRolloutAuthorization(fixture.applicationId, 'tester'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/artifact identity/i); + expect(GitOpsStore.getInstance().getApplication(fixture.applicationId)!.rollout_authorization_ref).toBeNull(); + }); + it('is idempotent when a live authorization already matches', async () => { const fixture = seedAuthorizedReadyApp(); const first = await ensureRolloutAuthorization(fixture.applicationId, 'tester'); diff --git a/backend/src/__tests__/gitops-derive.test.ts b/backend/src/__tests__/gitops-derive.test.ts index 9907b086..d457a69e 100644 --- a/backend/src/__tests__/gitops-derive.test.ts +++ b/backend/src/__tests__/gitops-derive.test.ts @@ -891,6 +891,134 @@ describe('gitops derivation', () => { expect(projection.facets.placement.status).toBe('blueprint_bound'); }); + it('does not report source acceptance pending for a stale candidate binding', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication(rawApp('app-bp-git-rebound', { + target_mode: 'blueprint', + blueprint_id: 94, + lifecycle_key: 'blueprint:94', + stack_name: null, + intent_revision_id: 'ir-94', + candidate_generation_id: null, + accepted_generation_id: 'gen-new', + source_acceptance_ref: 'acc-new', + placement_approval_ref: 'place-94', + artifact_set_id: 'art-94', + latest_artifact_set_id: 'art-94', + rollout_candidate_id: 'cand-94', + })); + // The candidate was bound by an earlier rollout to the previous + // generation. Its binding is not a generation awaiting acceptance. + store.insertRolloutCandidate({ + id: 'cand-94', + application_id: 'app-bp-git-rebound', + intent_revision_id: 'ir-94', + compose_content_sha256: 'c'.repeat(64), + accepted_generation_id: 'gen-old', + artifact_set_id: null, + required_targets_json: '{"nodeIds":[1]}', + authoritative: 1, + provenance: 'intent_change', + operation_id: 'op-cand-94', + created_at: 1, + }); + store.insertArtifactSet({ + id: 'art-94', + generation_id: 'gen-new', + evidence_version: 1, + authoritative: 0, + qualification: 'unresolved', + evidence_json: '{"kind":"unresolved"}', + created_at: 1, + }); + + const projection = projectApplication('app-bp-git-rebound', false); + if (projection.targetMode === 'not_applicable') throw new Error('expected application'); + expect(projection.facets.placement.status).toBe('blueprint_bound'); + }); + + it('names the unresolved artifact identity as the placement block reason', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication(rawApp('app-bp-git-artifact', { + target_mode: 'blueprint', + blueprint_id: 95, + lifecycle_key: 'blueprint:95', + stack_name: null, + intent_revision_id: 'ir-95', + candidate_generation_id: null, + accepted_generation_id: 'gen-95', + source_acceptance_ref: 'acc-95', + placement_approval_ref: 'place-95', + artifact_set_id: 'art-95', + latest_artifact_set_id: 'art-95', + rollout_candidate_id: 'cand-95', + })); + store.insertRolloutCandidate({ + id: 'cand-95', + application_id: 'app-bp-git-artifact', + intent_revision_id: 'ir-95', + 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-cand-95', + created_at: 1, + }); + store.insertArtifactSet({ + id: 'art-95', + generation_id: 'gen-95', + evidence_version: 1, + authoritative: 0, + qualification: 'unresolved', + evidence_json: '{"kind":"unresolved"}', + created_at: 1, + }); + store.insertGeneration(gen('gen-95', 'app-bp-git-artifact')); + const approvalBase = { + application_id: 'app-bp-git-artifact', + generation_id: null, + intent_revision_id: null, + artifact_set_id: null, + rollout_candidate_id: null, + rollout_generation_id: null, + source_acceptance_ref: null, + placement_approval_ref: null, + required_targets_json: null, + preflight_fingerprint: null, + fingerprint: null, + blast_json: null, + policy_provenance_json: null, + actor: 'tester', + created_at: 1, + }; + store.insertApproval({ + ...approvalBase, + id: 'acc-95', + kind: 'source_acceptance', + authority: 'operator', + authoritative: 1, + generation_id: 'gen-95', + }); + store.insertApproval({ + ...approvalBase, + id: 'place-95', + kind: 'placement_approval', + authority: 'operator', + authoritative: 1, + intent_revision_id: 'ir-95', + required_targets_json: '{"nodeIds":[1]}', + blast_json: '[]', + }); + + const projection = projectApplication('app-bp-git-artifact', false); + if (projection.targetMode === 'not_applicable') throw new Error('expected application'); + expect(projection.facets.placement.status).toBe('preflight_blocked'); + if (projection.facets.placement.status !== 'preflight_blocked') throw new Error('expected blocked'); + expect(projection.facets.placement.reason).toMatch(/artifact identity/i); + }); + it('still judges a target with no desired id against its deployed pointer', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); diff --git a/backend/src/routes/gitopsApplications.ts b/backend/src/routes/gitopsApplications.ts index 358702a1..84b96155 100644 --- a/backend/src/routes/gitopsApplications.ts +++ b/backend/src/routes/gitopsApplications.ts @@ -810,7 +810,7 @@ gitopsApplicationsRouter.post('/:id/rollout/authorize', async (req: Request, res '[GitOps authority] Rollout dispatch failed after authorization:', sanitizeForLog(error instanceof Error ? error.message : String(error)), ); - note = 'The rollout was authorized but could not start. Retry it from the Blueprint deployments surface.'; + note = 'The rollout was authorized but could not start; the authorization stands and the rollout remains queued.'; } res.json({ ok: true, dispatched, note }); }); diff --git a/backend/src/services/gitops/derive.ts b/backend/src/services/gitops/derive.ts index 7bff04e7..5e8a90db 100644 --- a/backend/src/services/gitops/derive.ts +++ b/backend/src/services/gitops/derive.ts @@ -12,6 +12,7 @@ import { SopsIdentityStore } from './sops/identityStore'; import { buildPreflightEvidence, decodePreflightEvidenceJson, + executableArtifactRefusalReason, fingerprintPreflightEvidence, isPreflightBlocked, REGISTRY_PREFLIGHT_UNEVALUATED_REASON, @@ -566,10 +567,14 @@ function derivePlacement( // A Git-managed application takes every generation through its own // acceptance, so a staged generation that is not the accepted one leaves // source acceptance outstanding even when an earlier generation was already - // accepted. The other modes keep the original ref-based test: their staged - // candidate is the Inline Apply review, not a source decision. + // accepted. Only a live staged generation asks for that decision: the + // candidate's own binding names the generation an earlier rollout + // authorized, and after a source-only update it names the previous one, so + // treating it as pending would report a review no generation is waiting for. + // The other modes keep the original ref-based test: their staged candidate is + // the Inline Apply review, not a source decision. const sourceAcceptanceOutstanding = app.target_mode === 'blueprint' - ? candidateGenerationId !== null && candidateGenerationId !== app.accepted_generation_id + ? app.candidate_generation_id !== null && app.candidate_generation_id !== app.accepted_generation_id : candidateGenerationId !== null && !app.source_acceptance_ref; if (sourceAcceptanceOutstanding && candidateGenerationId != null) { return { @@ -620,6 +625,16 @@ function derivePlacement( })); const binding = { ...ingredients, preflightFingerprint: fingerprint }; + // 2a. Executable artifact evidence must exist before registry readiness is a + // question: naming the artifact identity keeps the operator off a registry + // hunt when the set was never resolved. + const artifactRefusal = executableArtifactRefusalReason( + store.getArtifactSet(ingredients.artifactSetId)?.qualification, + ); + if (artifactRefusal) { + return { status: 'preflight_blocked', reason: artifactRefusal, binding }; + } + // 2. Stored blocked/unknown outranks live pointers. if (stored && isPreflightBlocked(stored)) { return { diff --git a/backend/src/services/gitops/handoff.ts b/backend/src/services/gitops/handoff.ts index b2667568..b9ec9373 100644 --- a/backend/src/services/gitops/handoff.ts +++ b/backend/src/services/gitops/handoff.ts @@ -13,6 +13,7 @@ import { GitOpsTransitions, type EventEnvelope } from './transitions'; import { decodePreflightEvidenceJson, encodePreflightEvidenceJson, + executableArtifactRefusalReason, fingerprintPreflightEvidence, isPreflightBlocked, registryPreflightBlockReason, @@ -264,6 +265,11 @@ export async function ensureRolloutAuthorization( }; } + const artifactRefusal = executableArtifactRefusalReason( + store.getArtifactSet(ingredients.artifactSetId)?.qualification, + ); + if (artifactRefusal) return { ok: false, reason: artifactRefusal }; + let composeContent: string | null = null; const genRow = store.getGeneration(ingredients.acceptedGenerationId); if (genRow) { diff --git a/backend/src/services/gitops/preflight.ts b/backend/src/services/gitops/preflight.ts index b9447f30..0b7e1d31 100644 --- a/backend/src/services/gitops/preflight.ts +++ b/backend/src/services/gitops/preflight.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto'; import { encodeGitOpsJson, isPreflightFingerprint } from './json'; +import type { ArtifactQualification } from './types'; export type PreflightSlotStatus = 'unknown' | 'not_required' | 'ready' | 'blocked'; @@ -243,6 +244,29 @@ export function isRegistryPreflightTransient(body: PreflightEvidenceBody): boole export const REGISTRY_PREFLIGHT_UNEVALUATED_REASON = 'Registry readiness has not been evaluated yet.'; +/** + * Operator-facing reason when the accepted generation has no executable + * artifact evidence yet. + * + * Registry readiness is not the question until an artifact set exists to probe: + * every target would report unknown for the same missing-input reason, so a + * registry-worded refusal would blame the registry for the model's own gap. + */ +export const EXECUTABLE_ARTIFACT_UNRESOLVED_REASON = + 'Executable artifact identity is not resolved for this generation, so the rollout cannot be authorized.'; + +/** + * The refusal reason for an accepted artifact set that cannot authorize a + * rollout, or null when the qualification can (or no row was read). One + * predicate so the route refusal and the placement facet cannot drift apart. + */ +export function executableArtifactRefusalReason( + qualification: ArtifactQualification | null | undefined, +): string | null { + if (qualification !== 'unresolved' && qualification !== 'unavailable') return null; + return EXECUTABLE_ARTIFACT_UNRESOLVED_REASON; +} + /** Operator-facing reason from the worst target class. Never secrets. */ export function registryPreflightBlockReason(body: PreflightEvidenceBody): string { const priority: RegistryTargetReadinessClass[] = [ diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx index 858b62fb..9e623b3d 100644 --- a/docs/features/blueprint-model.mdx +++ b/docs/features/blueprint-model.mdx @@ -221,9 +221,9 @@ A Git-managed Blueprint takes each authority step as its own action. The GitOps - **Accept source** records that a fetched candidate generation is the content to run. It appears when the source policy holds a candidate for review, which is always the case for a source whose policy is not automatic. - **Review and approve placement** opens the rollout preview, the same reviewed plan the Inline flow shows: every node to place or remove, with its reachability. Confirming records the placement approval and freezes that blast for the current intent, so a later edit or roster change requires a fresh review. The button appears once source is accepted and the current candidate has no placement approval. -- **Authorize rollout** evaluates registry preflight, records the rollout authorization, and starts the sequential rollout across the approved targets. It appears while the rollout is not authorized or while the recorded authorization was granted against earlier inputs. A blocked preflight refuses the authorization with its reason; when a target cannot start, the authorization is recorded and the surface reports what stopped the rollout. +- **Authorize rollout** evaluates registry preflight, records the rollout authorization, and starts the sequential rollout across the approved targets. It appears while the rollout is not authorized or while the recorded authorization was granted against earlier inputs. Authorization needs resolved executable artifact evidence for the accepted generation, which the artifact card reports: while that evidence is unresolved or unavailable, the authorization is refused with the artifact identity as the reason, and a blocked registry preflight refuses it with the registry reason. When a target cannot start, the authorization is recorded and the surface reports what stopped the rollout. -A disabled Blueprint withholds placement approval and rollout authorization until you enable it again, the same way **Apply now** waits for an Inline Blueprint to be enabled. +A disabled Blueprint withholds placement approval and rollout authorization until you enable it again, the same way **Apply now** waits for an Inline Blueprint to be enabled. Accepting source stays available while a Blueprint is disabled, because reviewing content changes nothing on the fleet. The **Apply now** flow stays Inline-only. For a Git-managed Blueprint the rollout preview is a placement review: its confirm writes the placement approval instead of applying the frozen snapshot. @@ -407,7 +407,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli Not through a one-click history. Blueprints intentionally do not keep a versioned revision history. To revert an Inline Blueprint, paste the prior compose into the editor and save; the reconciler treats the change as a new revision and redeploys. Git-managed Blueprints place from an authorized accepted generation, not from the detach snapshot. Detach Git first if you need Inline paste-and-apply. - Yes, from an authorized accepted Git generation. Accept the source, approve the placement, then authorize the rollout; each step is its own action on the Blueprint sheet's GitOps section and on the application's page in GitOps. Place never uses the frozen detach snapshot on the Blueprint row. Convert and Adopt bind the live Git source; Detach restores Inline editing when a snapshot exists. + Yes, from an authorized accepted Git generation. Accept the source, approve the placement, then authorize the rollout; each step is its own action on the Blueprint sheet's GitOps section and on the application's page in GitOps. Authorization requires resolved executable artifact evidence for the accepted generation, so the artifact card decides whether the rollout can be authorized; place never uses the frozen detach snapshot on the Blueprint row. Convert and Adopt bind the live Git source; Detach restores Inline editing when a snapshot exists. No. The snapshot captures the compose YAML only, so you can redeploy the same definition elsewhere. Volume bytes (the database files, the cached state, the uploads directory) are removed by `docker compose down` along with the named volumes. To preserve data across an eviction, capture the volume by hand before withdrawing. diff --git a/frontend/src/components/gitops/GitOpsAuthorityActions.test.tsx b/frontend/src/components/gitops/GitOpsAuthorityActions.test.tsx index b70b39f9..292f348b 100644 --- a/frontend/src/components/gitops/GitOpsAuthorityActions.test.tsx +++ b/frontend/src/components/gitops/GitOpsAuthorityActions.test.tsx @@ -192,10 +192,19 @@ describe('GitOpsAuthorityActions', () => { await waitFor(() => expect(acceptGitOpsSource).toHaveBeenCalledWith('bp:5', 'gen-new')); }); - it('withholds every action while the Blueprint is disabled', () => { - renderActions(placementPending(), () => true, () => {}, false); + it('withholds placement and rollout while the Blueprint is disabled', () => { + const { unmount } = renderActions(placementPending(), () => true, () => {}, false); expect(screen.getByTestId('gitops-authority-actions')).toHaveTextContent('Blueprint disabled'); expect(screen.getByTestId('gitops-action-approve-placement')).toBeDisabled(); + unmount(); + + renderActions(rolloutPending(), () => true, () => {}, false); + expect(screen.getByTestId('gitops-action-authorize-rollout')).toBeDisabled(); + }); + + it('keeps source acceptance available while the Blueprint is disabled', () => { + renderActions(sourcePending(), () => true, () => {}, false); + expect(screen.getByTestId('gitops-action-accept-source')).toBeEnabled(); }); it('offers nothing for a Direct or Inline application', () => { diff --git a/frontend/src/components/gitops/GitOpsAuthorityActions.tsx b/frontend/src/components/gitops/GitOpsAuthorityActions.tsx index 6b307171..78f3d129 100644 --- a/frontend/src/components/gitops/GitOpsAuthorityActions.tsx +++ b/frontend/src/components/gitops/GitOpsAuthorityActions.tsx @@ -24,7 +24,12 @@ interface GitOpsAuthorityActionsProps { * one the session may not hold. */ can?: (action: PermissionAction) => boolean; - /** The bound Blueprint's state, when the surface knows it; disabled withholds every action. */ + /** + * The bound Blueprint's state, when the surface knows it. Disabled withholds + * the execution authority (placement and rollout) but not source + * acceptance: content review stays available while the reconciler is off, + * the same way an Inline Blueprint's editor stays editable. + */ blueprintEnabled?: boolean; } @@ -139,7 +144,7 @@ export default function GitOpsAuthorityActions({ size="sm" className="gap-1.5 max-md:min-h-11" onClick={() => void handleAcceptSource()} - disabled={pending !== null || !blueprintEnabled} + disabled={pending !== null} data-testid="gitops-action-accept-source" >