fix(deploy_status_reconcile): detect Job-failed silent build crashes - #65
Merged
Merged
Conversation
The reconciler previously only queried the runtime appsv1.Deployment, so
a kaniko build Job that hit BackoffLimitExceeded was invisible once the
build pod was GC'd: GetDeployment returned NotFound and the row mapped
to `stopped` (a terminal status that looks like a teardown), or stayed
at `building` forever if the api goroutine crashed mid-runDeploy. This
was the silent-deploy-failure bug class the user hit on
truehomie-api-36ace884.deployment.instanode.dev (2026-05-30).
Add a second k8s probe after the Deployment query: GetBuildJob against
batchv1.Jobs("build-<appID>") in the same namespace. A Job in Failed
phase (BackoffLimit exhausted, ActiveDeadlineSeconds exceeded, or any
JobCondition.Type=Failed) is authoritative — flip the row to `failed`
regardless of what the runtime Deployment says. The Job object survives
its TTLSecondsAfterFinished (5 min in the api) which is long enough for
the 30s reconciler tick. The existing in-sweep autopsy capture fires on
the resulting status transition.
Rule 25 observability: new Prom counter
instant_deploy_job_failed_detected_total{reason} (BackoffLimitExceeded,
DeadlineExceeded, PodFailurePolicy, plus bounded fallbacks). NR alert
suggestion documented in metrics.go. PR2 covers the
autopsy-log-capture-when-pod-GC'd side; tracked separately for
independent rollback.
Coverage block:
Symptom: deployments.status stuck at 'building' after kaniko
BackoffLimitExceeded; no autopsy, no failure email.
Enumeration: rg "GetDeployment|appsv1.Deployments" worker/internal/jobs
Sites found: 1 (deploy_status_reconcile.go:computeNewStatus)
Sites touched: 1
Coverage test: TestDeployStatusReconcile_JobFailedAfterPodGC,
TestDeployStatusReconcile_JobActiveAndPodMissing_StaysBuilding,
TestDeployStatusReconcile_BothNotFound_StaysStopped,
TestDeployStatusReconcile_JobQueryError_FallsThroughToDeployment,
TestJobIsFailed_Matrix, TestJobFailureReason,
TestK8sDeployStatusClient_GetBuildJob_NotFoundPath.
Live verified: PENDING — verify post-merge via rule 14 (curl healthz),
then trigger a deliberately failing Dockerfile build and
observe row transition to 'failed' within ~30s.
make gate: green locally (matches deploy.yml test step).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
mastermanas805
added a commit
that referenced
this pull request
May 30, 2026
mastermanas805
added a commit
that referenced
this pull request
May 30, 2026
…backstop (#66) * fix(deploy_failure_autopsy): capture build logs + emit failure email backstop The autopsy already wrote a deployment_events row with kind=failure_autopsy, but in the 2026-05-30 silent-deploy-failure incident: - deployments.error_message was never updated → CLI / dashboard list views showed an empty error column. - No audit_log row was emitted → event_email_forwarder never dispatched the user-visible failure email (the api's runDeploy normally emits this, but the api goroutine had already crashed mid-build). - The autopsy only queried the runtime app pod (label instant-app-id=<appID>). For a Job-only failure (PR 1's case) the app pod was never created → last_lines stayed empty even when the build pod was still alive with the kaniko stderr tail that explains the failure. This PR adds three things to captureDeploymentAutopsy: 1. Build-pod fallback: when the app pod yields no logs, list pods matching label "job-name=build-<appID>" and pull the kaniko log tail. On success the reason is upgraded from Unknown to BuildFailed. 2. updateDeploymentErrorMessage: stamps "<reason>: <hint snippet>" onto deployments.error_message when the column is NULL or empty (the non-clobber guard prevents overwriting a more-specific api-side error). 3. emitDeployFailedAudit: looks up team_id from deployments, then INSERTs an audit_log row with kind=deploy.failed so event_email_forwarder dispatches the failure email. Metadata includes source="worker_autopsy" so an operator can distinguish the worker-backstop emit from the api's synchronous one. Rule 25 observability: new Prom counter instant_deploy_autopsy_captured_total{outcome} with bounded labels (logs_captured | logs_unavailable | already_present | audit_emit_failed). NR alert suggestions documented in metrics.go. All four label families are primed at process start (metrics_test.go) so the dashboard panel renders from /metrics scrape #1. Coverage block: Symptom: failed deployments had no error_message in the API row response and the user never received a deploy.failed email. Enumeration: rg "captureDeploymentAutopsy|deploy.failed|error_message" worker/internal/jobs (also api/internal/handlers for sources). Sites found: 1 (deploy_failure_autopsy.go:captureDeploymentAutopsy). Sites touched: 1 (callers in deploy_status_reconcile.go work unchanged). Coverage test: TestAutopsy_PodAlive_CapturesLogs, TestAutopsy_PodGCd_FallsBackToJobEvent, TestAutopsy_Idempotent, TestAutopsy_BuildPodFallback, TestUpdateDeploymentErrorMessage_OnlyUpdatesEmptyColumn, TestUpdateDeploymentErrorMessage_DBError, TestFirstSentence, TestEmitDeployFailedAudit_NoRow / _LookupError / _NilTeamID / _InsertError / _SummaryTruncation, TestAutopsyAlreadyPresentWithReason (3 branches), TestFindBuildPodName (3 branches), TestAutopsy_NamespaceMismatch_EarlyReturn. Live verified: PENDING — verify post-merge via rule 14, then trigger a deliberately failing Dockerfile build and confirm the user receives the deploy.failed email AND GET /deploy/:id returns a populated error_message + failure.last_lines. Pairs with PR fix/deploy-status-reconcile-job-failed (PR 1) — together they close the silent-deploy-failure bug class. Each ships as a separate PR for independent rollback per swarm charter. make gate: green locally (matches deploy.yml test step). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(autopsy): tighten coverage + use audit_emit_failed metric label CI feedback on the parent commit: - lint: const autopsyOutcomeAuditEmitFailed was unused. It is now emitted from the audit-emit error path (paired with the existing WARN log), which is what the constant always documented but the caller didn't actually invoke. - coverage: patch coverage gate (100%) flagged four uncovered branches in deploy_failure_autopsy.go. Each now has a dedicated test: * already_present outcome path → TestAutopsy_IdempotentAlreadyPresentBranch * build-pod fallback reason upgrade Unknown→BuildFailed → TestAutopsy_BuildPodFallback_UpgradesUnknownToBuildFailed * updateDeploymentErrorMessage empty-reason fallback → TestUpdateDeploymentErrorMessage_EmptyReasonFallsBackToUnknown * audit-emit failure metric increment → TestAutopsy_AuditEmitFailed_IncrementsCounter json.Marshal of a map[string]any error path (unreachable for this shape) was simplified to mirror the orphan_sweep_reconciler's emitOrphanAudit pattern (_-ignore). Local diff-cover against origin/master now reports 100% patch coverage on internal/jobs/deploy_failure_autopsy.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: remove duplicate buildJobNamePrefix const (merged via #65) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fix the silent-deploy-failure bug class hit on 2026-05-30 (user deploy
truehomie-api-36ace884.deployment.instanode.devsat atbuildingforever after a kaniko build crashed and the pod was GC'd).
The reconciler previously only queried
appsv1.Deployments(ns).Get. Whena build Job hits
BackoffLimitExceededorDeadlineExceeded, the runtimeDeployment is NEVER created (buildImage returns before applyDeployment
runs), so GetDeployment returned NotFound and the row mapped to
stopped— a terminal status that looks to the user like a teardown, with no
autopsy and no failure surface.
This PR adds a second probe: after GetDeployment, the reconciler also
calls
BatchV1().Jobs(ns).Get("build-<appID>"). A Job with aJobFailedcondition ORStatus.Failed > BackoffLimitis authoritative— flip the row to
failedregardless of what the Deployment says. TheJob object survives its
TTLSecondsAfterFinished(5 min in the api)which is long enough for the 30s reconciler tick to observe it.
The existing in-sweep autopsy capture path fires on the resulting
status transition unchanged. PR2 (separate, for independent rollback)
covers the autopsy log-capture path when the pod is already gone.
Coverage block (rule 17)
Rule 25 observability
New Prom counter
instant_deploy_job_failed_detected_total{reason}(labels:
BackoffLimitExceeded,DeadlineExceeded,PodFailurePolicy,plus bounded fallbacks
failed_no_reasonandbackoff_limit_exceeded).NR alert suggestion documented inline; dashboard tile +
METRICS-CATALOG.md row tracked in the infra repo follow-up.
Test plan
make gategreen locally (matches deploy.yml test step)go test ./internal/jobs/... -run 'TestDeployStatusReconcile_Job|TestJobIsFailed|TestJobFailureReason|TestK8sDeployStatusClient_GetBuildJob|TestDeployStatusReconcile_BothNotFound' -count=1commit_idon/healthzmatches HEAD (rule 14)failedwithin ~30s, autopsy row created🤖 Generated with Claude Code