Skip to content

fix(deploy_status_reconcile): detect Job-failed silent build crashes - #65

Merged
mastermanas805 merged 1 commit into
masterfrom
fix/deploy-status-reconcile-job-failed
May 30, 2026
Merged

fix(deploy_status_reconcile): detect Job-failed silent build crashes#65
mastermanas805 merged 1 commit into
masterfrom
fix/deploy-status-reconcile-job-failed

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

Summary

Fix the silent-deploy-failure bug class hit on 2026-05-30 (user deploy
truehomie-api-36ace884.deployment.instanode.dev sat at building
forever after a kaniko build crashed and the pod was GC'd).

The reconciler previously only queried appsv1.Deployments(ns).Get. When
a build Job hits BackoffLimitExceeded or DeadlineExceeded, the runtime
Deployment 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 a
JobFailed condition OR Status.Failed > BackoffLimit is authoritative
— flip the row to failed regardless of what the Deployment says. The
Job 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)

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.

Rule 25 observability

New Prom counter instant_deploy_job_failed_detected_total{reason}
(labels: BackoffLimitExceeded, DeadlineExceeded, PodFailurePolicy,
plus bounded fallbacks failed_no_reason and backoff_limit_exceeded).
NR alert suggestion documented inline; dashboard tile +
METRICS-CATALOG.md row tracked in the infra repo follow-up.

Test plan

  • make gate green locally (matches deploy.yml test step)
  • All new unit tests pass: go test ./internal/jobs/... -run 'TestDeployStatusReconcile_Job|TestJobIsFailed|TestJobFailureReason|TestK8sDeployStatusClient_GetBuildJob|TestDeployStatusReconcile_BothNotFound' -count=1
  • CI green
  • After merge + deploy: verify commit_id on /healthz matches HEAD (rule 14)
  • After merge + deploy: enqueue a deliberately failing build, observe row flips to failed within ~30s, autopsy row created

🤖 Generated with Claude Code

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>
@mastermanas805
mastermanas805 merged commit f868f2b into master May 30, 2026
11 checks passed
@mastermanas805
mastermanas805 deleted the fix/deploy-status-reconcile-job-failed branch May 30, 2026 11:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant