Skip to content

k8s/api: auto-rollback failed deploys to the prior revision#7

Merged
vigneshsubbiah16 merged 11 commits into
mainfrom
feat/deploy-auto-rollback
Apr 23, 2026
Merged

k8s/api: auto-rollback failed deploys to the prior revision#7
vigneshsubbiah16 merged 11 commits into
mainfrom
feat/deploy-auto-rollback

Conversation

@vigneshsubbiah16

@vigneshsubbiah16 vigneshsubbiah16 commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the top remaining Phase 2.11 safety gap: shipit now auto-rolls-back a deploy whose pods never become ready.

Before this PR, DeployApp() returned success the moment kube accepted the spec — even if the new pods immediately crash-looped on ImagePullBackOff. Status was marked running on a deployment that wasn't running. Operators had to notice and fire RollbackApp manually.

Now: every deploy is followed by a bounded rollout watch; on failure, the prior revision is redeployed inline; the UI reflects the actual cluster state.

What lands

Five bisectable commits:

Commit What
5977bdc k8s/rollout.goWatchRollout 2s polling + DeploymentProgressDeadline helper; tests for happy/timeout/ProgressDeadlineExceeded/ctx-cancel/Get-error paths
2241472 Pure refactor — extract buildDeployRequestFromApp + buildDeployRequestFromRevision helpers; de-dupes field mapping from deployApp and RollbackApp
0ce583f Wire WatchRollout into deployApp; new app status verifying; on failure → failed with rollout error
97b56f0 autoRollback method: loads revision N-1 on failure, redeploys inline; new statuses rolling_back/rolled_back
749dbfa Address elite-pr-review feedback — ingress re-sync on rollback (was a real bug), drop unused ErrRolloutFailed sentinel, misc comment/log cleanups, ROADMAP follow-ups

No DB migration. All new statuses are free-form strings. FE treats unknown statuses as in-progress ("info" variant).

Behavior

deployApp
 ├── pre-deploy hook (if any) ─ fail → app: failed
 ├── client.DeployApp(...)    ─ fail → app: failed
 ├── app.status = verifying
 ├── client.WatchRollout(ctx, progressDeadline + 10s)
 │    ├── ready                    → app: running, revision N: success
 │    └── ProgressDeadlineExceeded → autoRollback(appID, N, err)
 └── autoRollback
      ├── N == 1              → app: failed, revision 1: failed (no prior)
      ├── GetRevision(N-1)    ─ missing → app: failed, revision N: failed
      ├── app.status = rolling_back
      ├── DeployApp(prior)
      │    ├── ok    → app: running on N-1, revision N: rolled_back, re-sync ingress
      │    └── fail  → app: failed, revision N: failed (both errors logged)
      └── done

Key design decisions

Test plan

Automated (in this PR — all green, go vet clean):

  • TestWatchRollout_HappyPath — ready status → returns nil
  • TestWatchRollout_GenerationLagTimesOutStatus.ObservedGeneration < Generation → never ready → ctx deadline
  • TestWatchRollout_ProgressDeadlineExceededFailsFast — condition set → fast-fail with rollout failed: prefix
  • TestWatchRollout_CtxCancelledExternally — external cancel → context.Canceled surfaced
  • TestWatchRollout_GetErrorSurfaces — reactor injects 500 on Get → surfaces
  • TestProgressDeadline_UsesDefaultWhenUnset / ReadsSpecValue
  • TestAutoRollback_HappyPath_NoRollback — happy path; no rollback triggered
  • TestAutoRollback_WatchFails_RollbackSucceeds — broken deploy → rollback redeploys prior image
  • TestAutoRollback_FirstDeployFails_NoRollback — N==1 case; no phantom rollback
  • TestAutoRollback_RollbackDeployFails — reactor rejects rollback update; both errors surface

Manual (after merge — non-prod first):

  • Deploy a broken image (intentional bad tag) → observe verifyingrolling_backrunning on prior revision in the UI
  • Deploy a broken image with no prior revision → app stuck in failed, logs show first-deploy-cannot-rollback
  • Deploy an image that's slow to become ready (long cold start exceeding default 600s progressDeadline) → auto-rollback fires at 610s watch deadline
  • During a real rollout, kick a SetAutoscaling call → verify the mutex serializes it and neither state is silently reverted

Known v1 gaps (tracked in ROADMAP Phase 2.11)

  • Server restart mid-watch leaves a row stuck in verifying — no boot-time reconciler. Tracked as "Orphaned-verifying recovery" follow-up.
  • Secret key deletions between N-1 and N break rollback silently (env var missing at runtime). Tracked as "Snapshot secret key names in app_revisions" follow-up.
  • Handler-level DB-status assertions not tested — current tests cover k8s-client behavior only. Tracked as "Handler-level test harness" follow-up (needs a design decision: DB interface extraction vs pg-test-container).

Heads-up for FE team

Three new app status strings may appear: verifying, rolling_back, rolled_back. Current StatusBadge (web/src/components/ui/Badge.tsx:62) falls through unknown statuses to the blue "info" variant — the literal string will render. Worth a small UX pass in a follow-up PR to give these distinct icons/colors.

🤖 Generated with Claude Code

Greptile Summary

This PR closes the Phase 2.11 auto-rollback safety gap: deployApp now watches the rollout after every deploy, and on ProgressDeadlineExceeded it redeploys the last successful revision inline. All four previously-reported P1 issues (transient-error spurious rollback, revision-number collision, default-rollback landing on a failed revision, and manual RollbackApp feeding autoRollback the broken revision) have been addressed in the follow-up commits via retry logic in WatchRollout, GetNextRevisionNumber (MAX+1), GetLastSuccessfulRevisionBefore, and pre-setting current_revision before deployApp fires.

Confidence Score: 5/5

Safe to merge — all previously reported P1 issues are resolved; remaining findings are P2 style observations that do not affect correctness.

Every P1 from the prior review round has a concrete fix: transient-error retry loop in WatchRollout, MAX+1 revision allocation, GetLastSuccessfulRevisionBefore skipping failed/rolled_back rows, and pre-setting current_revision before deployApp fires during RollbackApp. The two remaining observations are P2 design/style points that do not cause data loss or incorrect cluster state.

No files require special attention for merge safety.

Important Files Changed

Filename Overview
internal/k8s/rollout.go Adds WatchRollout with 2s polling, transient-error retry loop (addresses prior P1), rolloutReady/rolloutFailed helpers, and DeploymentProgressDeadline — clean and well-tested.
internal/api/handlers.go Wires WatchRollout + autoRollback into deployApp; RollbackApp pre-sets current_revision to fix C1; syncSecretsToCluster extracted and called from both paths; all prior P1s addressed. Minor: default no-arg rollback after a manual rollback returns to the escaped revision (P2).
internal/db/queries.go Adds GetNextRevisionNumber (MAX+1, fixes revision-collision P1) and GetLastSuccessfulRevisionBefore (fixes default-rollback-to-failed-revision P1); non-nil return on error departs from Go convention (P2).
internal/api/deploy_request.go Pure refactor — extracts buildDeployRequestFromApp and buildDeployRequestFromRevision; de-dupes field mapping; no logic changes.
internal/k8s/rollout_test.go Comprehensive: covers happy path, generation lag, ProgressDeadlineExceeded fast-fail, ctx cancellation, persistent API errors, and transient-error recovery (regression guard for prior P1).
internal/k8s/auto_rollback_test.go Covers k8s-layer rollback primitives: happy path, watch-fail+rollback-succeeds, first-deploy-no-rollback, rollback-also-fails. DB-side transitions noted as a known gap pending a handler-level test harness.
ROADMAP.md Progress update: Phase 2.11 bumped to ~60%; completed items checked; new follow-up items added for orphaned-verifying recovery, secret key snapshotting, and handler-level test harness.

Comments Outside Diff (4)

  1. internal/api/handlers.go, line 999-1004 (link)

    P1 Manual rollback triggers auto-rollback back to the broken revision

    RollbackApp calls deployApp, which now includes WatchRollout + autoRollback. If the manual rollback deploy fails its watch, autoRollback is invoked with newRevision = N+1 and fetches GetRevision(N) — the revision the user was rolling back from. It then redeploys that revision and marks the app "running", silently undoing the user's intent and (re-)applying the known-broken spec.

    Concretely: app is at revision N (broken) → user calls RollbackApp to N-1 → deployApp creates revision N+1 with N-1's spec → watch fails (transient or infra issue) → autoRollback deploys revision N → app reports "running" on the broken revision.

    A guard in autoRollback distinguishing calls originating from a user-initiated rollback vs. a forward deploy — or simply not routing RollbackApp through deployApp's watch loop — would prevent this behavior.

  2. internal/api/handlers.go, line 1018-1019 (link)

    P1 RollbackApp routes through deployApp — auto-rollback fires on the wrong revision

    go h.deployApp(appID, updatedApp, kubeconfig) is the same code path used by the forward deploy. Since deployApp now wires in WatchRollout + autoRollback, if the manual rollback deploy fails its watch (including for a transient API error), autoRollback is invoked with newRevision = N+1 and fetches GetRevision(N) — the broken revision the user was rolling back from. It then marks the app "running" on the exact broken image the user just tried to escape.

    Concrete sequence: app at revision N (broken) → user calls RollbackApp to N-1 → deployApp creates N+1 (N-1 spec) → watch fails → autoRollback(N+1)GetRevision(N) → redeploys broken image → status = "running".

    A guard in autoRollback — or a separate deployForRollback that skips the watch+autoRollback chain — would prevent this loop.

  3. internal/api/handlers.go, line 525 (link)

    P1 Next deploy after auto-rollback always fails (unique constraint violation)

    autoRollback calls UpdateAppRevision(ctx, appID, prior.RevisionNumber) (line 760), setting apps.current_revision = N-1. The next forward deploy then computes newRevision = app.CurrentRevision + 1 = N. But revision N already exists in app_revisions with status "rolled_back" — and the table enforces UNIQUE(app_id, revision_number) (migration 004_app_revisions.sql). CreateRevision will hit a unique constraint violation, the error is caught at line 556–559, and the deploy is immediately marked "failed" before any k8s work happens.

    Every deploy after a successful auto-rollback will silently fail at the revision-creation step until someone manually reconciles current_revision or deletes the conflicting row.

    One fix is to keep current_revision at N rather than rolling it back, so the next deploy correctly creates N+1:

    // In autoRollback, after successful rollback:
    // Keep current_revision at N (the failed revision number) so the
    // next deploy creates N+1, avoiding a UNIQUE collision on revision N.
    h.db.UpdateAppRevision(ctx, appID, newRevision)     // stays at N, not N-1
    h.db.UpdateAppStatus(ctx, appID, "running", nil)
    h.db.UpdateRevisionStatus(ctx, appID, newRevision, "rolled_back", &origMsg)
  4. internal/api/handlers.go, line 967-976 (link)

    P1 Default rollback target lands on a rolled-back/failed revision after any auto-rollback

    GetRevision(CurrentRevision - 1) assumes revision numbers are sequential. With this PR's GetNextRevisionNumber (MAX+1), gaps are intentionally created: after an auto-rollback, current_revision stays at the last success (say N), the next deploy creates revision N+2 (not N+1), and on success sets current_revision = N+2. A default RollbackApp call then fetches revision N+2 - 1 = N+1 — the failed/rolled-back revision — and redeploys it, undoing the user's intent.

    Concrete sequence:

    • rev 1 success → current_revision = 1
    • rev 2 deploy → ProgressDeadlineExceeded → auto-rollback → current_revision stays 1
    • rev 3 success → current_revision = 3
    • RollbackApp (no revision specified) → GetRevision(3−1=2) → broken rev 2 gets redeployed

    The fix is to find the highest-numbered revision below current_revision that has deploy_status = 'success', or to add a previous_revision column to the apps table.

Reviews (8): Last reviewed commit: "api/db: belt-and-suspenders for C1 + doc..." | Re-trigger Greptile

vigneshsubbiah16 and others added 6 commits April 18, 2026 20:20
Polls Deployment.Status every 2s and returns when either:
- all updated replicas are ready and available
- the Progressing condition flips to False/ProgressDeadlineExceeded
  (returns an error wrapping ErrRolloutFailed, so callers can distinguish
  a hard failure from a ctx-timeout)
- ctx is cancelled or deadlines

Wired into deployApp in a follow-up commit. This commit is standalone:
the new file is not yet imported from internal/api.

Polling (vs a kube watch) keeps tests identical to production — the fake
clientset does not emit Modify events for Status subresource updates.
Centralises the DeployRequest field mapping that deployApp was doing inline.
Adds a parallel revision→DeployRequest helper for the upcoming auto-rollback
path (unused in this commit).

No behavior change: existing tests stay green.
After client.DeployApp returns nil, transition the app to 'verifying'
and wait for WatchRollout to report the Deployment actually became
healthy. On failure, mark app + revision failed; on success, keep the
existing transitions (UpdateAppRevision, status=running, revision=success).

No rollback yet — that's the next commit. Ships cleanly on its own:
without the watch, 'running' was a lie for any deploy that crash-looped
or got stuck pulling an image.
When WatchRollout reports the new revision never became healthy
(ProgressDeadlineExceeded or ctx timeout), redeploy revision N-1
inline — same client.DeployApp path, no watch on the rollback itself
(v1; if it fails we mark the app failed and stop).

Revision-number rules:
  - N == 1 → no prior revision exists. Log first-deploy-cannot-rollback
    and leave the app in 'failed'. No redeploy attempt.
  - N >  1 → redeploy from the N-1 snapshot. On success, app status →
    'running', app.current_revision → N-1, revision N → 'rolled_back'
    (with the original watch error in the message). On failure, both
    errors are logged and persisted; app stays 'failed'.

Runs inside the existing per-app deploy goroutine under the mutex
from PR #6, so a rollback cannot race a new deploy for the same app.
Worst-case window doubles (progress deadline + grace, then the rollback
DeployApp) — accepted.

Tests: four scenarios at the k8s-client level — happy path, watch-
fails-rollback-succeeds, first-deploy-fail, and rollback-also-fails.
A comment at the top of the test file explains why the tests live at
the k8s primitive layer instead of the api-handler layer (there is no
injection seam for *db.DB or k8s.Client today; adding one is
out of scope).
CRITICAL / WARNING items addressed:

- C2 — Document the DeleteOldRevisions(keep=10) invariant at the call
  site so future aggressive pruning can't silently break autoRollback's
  ability to load revision N-1.
- W1 — Tighten auto_rollback_test.go header: enumerate exactly which
  DB status transitions are NOT exercised (app.status strings,
  rolled_back marker, CurrentRevision update, N<=1 guard, secret
  re-resolution, ingress re-sync). Tracked in ROADMAP as handler-test-
  harness follow-up.
- W3 — Soften DeploymentProgressDeadline log: "using default progress
  deadline" prefix instead of "lookup failed" (the fallback is correct
  behavior during the expected race on first-ever deploy, not an
  error).
- W4 (real bug) — Extract syncCustomDomainIngress helper that takes
  an explicit port *int. Happy path passes app.Port; autoRollback
  passes prior.Port. App fields aren't re-copied from revisions on
  rollback, so the live app.Port still reflects the broken revision
  after a rollback succeeds — we need the revision's port.
- W5 — Update autoRollback doc: 2 x progressDeadline upper bound on
  mutex hold time; concurrent deploys queue behind it.

INFO items addressed:
- I2 — Drop ErrRolloutFailed sentinel. Unused by callers (dead code).
  Tests now assert on "rollout failed:" prefix via strings.HasPrefix.
- I3 — Log style: reason=%v matches the rest of the codebase's
  key=%v convention.

Not fixed in code (tracked in ROADMAP Phase 2.11 follow-ups):
- C1 — Orphaned-verifying recovery (boot-time reconciler).
- W2 — Snapshot secret key names in app_revisions so rollback can
  detect deleted keys.
- Handler-level test harness (W1 coverage gap).

ROADMAP: check off auto-rollback + per-app deploy mutex (PR #6) +
rollingUpdateBudget effective-fleet fix (PR #6). Phase 2.11 progress
30% -> 60%. Add three new follow-up items.

Tests: full internal/k8s + internal/api suites pass. go vet clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trivial nit caught by the second-pass review — match the key=value
convention used everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread internal/api/handlers.go Outdated
Comment on lines +731 to +734
secretName := ""
if secrets, _ := h.db.GetSecretsByAppID(ctx, appID); len(secrets) > 0 {
secretName = app.Name + "-secrets"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 K8s Secret object not re-synced on rollback

The forward deployApp path calls client.CreateOrUpdateSecret(...) to write secret values into the cluster before deploying. autoRollback only records the secret name and skips CreateOrUpdateSecret. If secret values in the DB changed between the time the failed deploy ran and the time the rollback fires (e.g., an operator rotated a credential during the watch window), the K8s Secret object will retain stale values from the forward deploy — the rollback pods will use incorrect credentials at runtime.

The fix is to decrypt and re-sync secrets in autoRollback the same way deployApp does, before calling client.DeployApp.

Comment thread internal/k8s/rollout.go
Comment on lines +55 to +66
func rolloutReady(d *appsv1.Deployment) bool {
if d.Spec.Replicas == nil {
return false
}
if d.Status.ObservedGeneration < d.Generation {
return false
}
desired := *d.Spec.Replicas
return d.Status.UpdatedReplicas == desired &&
d.Status.ReadyReplicas == desired &&
d.Status.AvailableReplicas == desired
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 rolloutReady returns true immediately for zero-replica deployments

When *d.Spec.Replicas == 0, the comparison 0 == 0 && 0 == 0 && 0 == 0 trivially satisfies the ready check, so WatchRollout returns nil on the first poll. This is technically correct Kubernetes semantics (a scaled-to-zero deployment is considered healthy), but if the system ever deploys with 0 replicas (e.g., during an HPA-off transition or a misconfigured CreateApp), the deploy will be marked "running" instantly without any actual pods. A comment documenting this behaviour would help future readers distinguish intentional correctness from an oversight.

- handlers.go: extract syncSecretsToCluster helper; autoRollback now
  re-syncs the cluster Secret object from current DB state before the
  rollback DeployApp. Forward path refactored to use the same helper
  (no behavior change there — secrets aren't versioned in revisions,
  so "current DB state" IS the authoritative source). Closes the
  idempotency gap Greptile flagged where a secret rotation during the
  watch window would leave stale values in the cluster Secret when
  the rollback fired.

- rollout.go: document zero-replica rolloutReady semantics — a
  Deployment scaled to 0 is trivially ready once the generation is
  observed (matches kube + kubectl rollout status). No behavior
  change; adds the "why this is intentional" comment Greptile asked
  for so future readers don't flag it as an oversight.

Tests: full internal/k8s + internal/api suites green, go vet clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread internal/k8s/rollout.go Outdated
Comment on lines +29 to +35
dep, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("watch rollout: get deployment: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Transient kube API error triggers spurious auto-rollback

Any single transient error from the kube API server (network blip, 5xx, etcd leader election) causes WatchRollout to return a non-nil error immediately, which then triggers autoRollback in deployApp even though the deployment's pods may be healthy and converging. A healthy deploy that hits one transient Get will be silently rolled back to the prior image.

The fix is to distinguish between transient errors (retry with backoff) and definitive failures (fail-fast). At minimum the error path should retry a configurable number of times before surfacing, similar to how kubectl rollout status behaves:

dep, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
    if ctx.Err() != nil {
        return ctx.Err()
    }
    // Transient API error — log and wait for next poll rather than
    // returning immediately and triggering an unwarranted rollback.
    log.Printf("watch rollout: transient get error (will retry): %v", err)
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-ticker.C:
    }
    continue
}

@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

Addressed both Greptile P2 findings in 0c09d86:

  1. K8s Secret re-sync on rollback (handlers.go:734) — extracted syncSecretsToCluster helper; autoRollback now re-syncs the cluster Secret from current DB state before its DeployApp call. Forward path uses the same helper. Closes the window where a secret rotation during the watch would leave stale values in the cluster when the rollback fires.

  2. Zero-replica rolloutReady (rollout.go:66) — added a comment documenting that a scaled-to-zero Deployment is trivially ready once the generation is observed (matches kubectl rollout status semantics). No behavior change — this is intentional correctness, now labeled as such.

Tests green, go vet clean.

@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread internal/k8s/rollout.go Outdated
Comment on lines +25 to +34
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()

for {
dep, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("watch rollout: get deployment: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Single transient API error immediately triggers auto-rollback

WatchRollout returns a non-nil error on the very first Get failure, which causes deployApp to invoke autoRollback — even when the deployment's pods are healthy and still converging. A one-off network blip, etcd leader election hiccup, or transient 5xx will silently roll a working deploy back to the prior image.

The fix is to retry transient errors a bounded number of times before surfacing:

dep, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
    if ctx.Err() != nil {
        return ctx.Err()
    }
    // Transient API error — log and wait for next poll rather than
    // returning immediately and triggering an unwarranted rollback.
    log.Printf("watch rollout: transient get error (will retry): %v", err)
    // fall through to the select/ticker to retry on next tick
} else {
    if rolloutReady(dep) { return nil }
    if reason, failed := rolloutFailed(dep); failed {
        return fmt.Errorf("rollout failed: %s", reason)
    }
}

kubectl rollout status uses a similar retry-on-error approach to distinguish transient API failures from definitive rollout failures.

Before: a single transient apiserver error (network blip, etcd leader
election, 5xx) from the polling Get immediately surfaced from
WatchRollout, which caused deployApp to invoke autoRollback on a deploy
whose pods were still converging fine. Real production-risk behavior —
an unrelated control-plane hiccup during a 600s watch window would
silently roll a healthy deploy back.

Now: non-ctx Get errors are logged and the watch loop continues to the
next tick. The watch ctx (progressDeadline + 10s ≈ 610s) is the total
retry budget. If the apiserver is unreachable for the entire window,
ctx.Err() fires naturally and the rollout is treated as a timeout —
which IS correct for an unreachable control plane.

Tests: TestWatchRollout_GetErrorSurfaces renamed to
TestWatchRollout_PersistentGetErrorHitsCtxTimeout (asserts ctx.Err()
instead of the old typed-error surface). New
TestWatchRollout_TransientGetErrorRecovers (regression guard) injects
one transient Get failure followed by a ready deployment and asserts
WatchRollout returns nil without rollback.

Closes Greptile P1 (PR #7 comment 3106354508).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

Fixed the Greptile P1 (transient Get → spurious rollback) in 5669aba.

WatchRollout now treats non-ctx Get errors as retryable — logs and falls through to the next tick rather than surfacing immediately. The watch ctx (progressDeadline + 10s ≈ 610s) remains the total retry budget; if the apiserver is truly unreachable for the whole window, ctx.Err() fires naturally and the rollout times out normally.

Tests:

  • TestWatchRollout_PersistentGetErrorHitsCtxTimeout (replaces the old GetErrorSurfaces) — all Gets fail → expects context.DeadlineExceeded, not the typed error
  • TestWatchRollout_TransientGetErrorRecovers (regression guard) — one transient Get failure followed by a ready deployment → expects nil return, no rollback, proves at least 2 Gets happened

Full internal/k8s + internal/api suites pass under -race.

@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

@greptileai

Before this fix, any app that had ever had an auto-rollback fire would
have its NEXT deploy fail at CreateRevision with a UNIQUE(app_id,
revision_number) violation. Reproduce:

  state: CurrentRevision=5, revisions [1..5]
  deploy 6 → breaks → autoRollback sets CurrentRevision=5 and marks
                      revision 6 rolled_back
  next deploy → newRevision = CurrentRevision+1 = 6
                CreateRevision(6) → UNIQUE violation, app stuck

Two coupled fixes:

1. db: new GetNextRevisionNumber(appID) returning COALESCE(MAX, 0)+1.
   deployApp switches from app.CurrentRevision+1 to this. CurrentRevision
   tracks the last *successful* deploy; after a rollback it regresses,
   so CurrentRevision+1 collides with the rolled_back revision that
   still exists.

2. handlers: autoRollback's rollback target is now app.CurrentRevision
   (last success), not newRevision-1. With MAX+1 allocation in place,
   newRevision-1 can be a previously-rolled-back revision from an
   earlier incident — rolling back to it would redeploy broken code.
   Since app.CurrentRevision is only written on successful deploys, it
   is always a safe target.

Also addresses a smaller issue Greptile flagged on RollbackApp (line
~1018): the ignored error from h.db.GetApp would pass nil into
deployApp and nil-deref on first field access. Now: log and fall back
to the pre-update `app` snapshot. deployApp's own in-goroutine re-fetch
will retry shortly after.

Test coverage: the sequence "deploy → autoRollback → next deploy"
requires a handler-level DB harness which this repo doesn't have (see
ROADMAP Phase 2.11 "Handler-level test harness"). The fix is exercised
by code review + the existing k8s-level tests which continue to pass
unchanged. File a follow-up once the harness lands to add the full
collision regression test.

Tests: internal/k8s + internal/api suites pass under -race. go vet clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

Fixed both Greptile findings in a770fdf.

Critical: revision-number collision after auto-rollback

Reproduced the bug:

state: CurrentRevision=5, revisions [1..5]
deploy 6 → breaks → autoRollback sets CurrentRevision=5, marks rev 6 'rolled_back'
next deploy → newRevision = CurrentRevision+1 = 6
             CreateRevision(6) → UNIQUE(app_id, revision_number) violation

Two coupled fixes:

  1. db.GetNextRevisionNumber(appID) returns COALESCE(MAX(revision_number), 0) + 1. deployApp switches from CurrentRevision+1 to this. Collision-free regardless of how many rollbacks have happened.

  2. autoRollback rollback target is now app.CurrentRevision (the last successful deploy), NOT newRevision-1. With MAX+1 allocation, newRevision-1 can be a previously-rolled-back revision from an earlier incident; targeting it would redeploy broken code. CurrentRevision is only written on successful deploys, so it is always a safe target. Also dropped the redundant UpdateAppRevision(prior.RevisionNumber) at the end — it was writing the same value already in the row.

Minor: error-ignored GetApp in RollbackApp

Now: log the error and fall back to the pre-UpdateApp app snapshot (valid pointer, stale by one field-copy; deployApp's own in-goroutine re-fetch will retry). Without this, a failing GetApp passed a nil pointer into deployApp that would nil-deref on first field access.

Test coverage

The collision regression (full "deploy → autoRollback → next deploy" sequence) needs a handler-level DB harness this repo doesn't have yet; tracked in ROADMAP Phase 2.11 as "Handler-level test harness" follow-up. The two fixes are small and well-commented; existing k8s-level tests continue to pass unchanged under -race.

@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

@greptileai

Elite PR review on top of a770fdf caught two critical bugs in the
manual-rollback path, both introduced or amplified by the auto-rollback
feature:

C1 — Manual RollbackApp → watch fails → autoRollback redeploys the
broken revision the user was escaping from.

  RollbackApp.UpdateApp copied the target revision's fields onto the
  apps row but never updated current_revision. The row still pointed at
  the broken revision. If deployApp's watch timed out during the
  rollback (transient control-plane issue, slow image pull),
  autoRollback would read app.CurrentRevision — now the broken
  revision — and redeploy it. App marked "running" on the broken image.

  Fix: UpdateAppRevision(targetRevision.RevisionNumber) right after
  UpdateApp, before deployApp fires. Now CurrentRevision points at the
  rollback target, so if autoRollback triggers it lands on the correct
  revision.

C2 — Default manual rollback (no revision specified) uses
CurrentRevision-1, which with MAX+1 allocation can be a rolled_back or
failed revision from a prior incident.

  Sequence: rev 1 success → auto-rollback on rev 2 (current stays 1)
  → rev 3 success (current=3) → user hits default rollback →
  GetRevision(3-1=2) → broken spec re-applied.

  Fix: new db.GetLastSuccessfulRevisionBefore(appID, below) query that
  selects the highest revision_number with deploy_status='success'
  strictly below the given number. RollbackApp's default path uses it
  instead of CurrentRevision-1. Returns ErrNoRows for apps with no
  prior success; surfaces as 404 "no previous successful revision to
  rollback to".

Closes elite-pr-review findings C1 and C2 on PR #7.

Tests: internal/k8s + internal/api suites pass under -race. go vet
clean. Handler-level integration test for the full
(deploy → autoRollback → manual-rollback → watch-timeout → correct
target) sequence requires the DB harness tracked in ROADMAP Phase 2.11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vigneshsubbiah16

Copy link
Copy Markdown
Contributor Author

Fixed both CRITICAL elite-pr-review findings in 880c347:

C1 — Manual RollbackApp → autoRollback redeploys the broken revision. RollbackApp.UpdateApp copied the target's fields to the apps row but never updated current_revision, so if the rollback's own watch timed out autoRollback would read app.CurrentRevision (still the broken one) and redeploy it. Now UpdateAppRevision(targetRevision.RevisionNumber) runs right after UpdateApp, before deployApp fires.

C2 — Default rollback picked CurrentRevision-1, which with MAX+1 gaps could be a rolled_back revision. New db.GetLastSuccessfulRevisionBefore(appID, below) selects the highest revision_number with deploy_status='success' strictly below below. Default RollbackApp path uses it. Apps with no prior success get a clean 404 with "no previous successful revision to rollback to".

Tests: internal/k8s + internal/api suites green under -race, go vet clean. End-to-end regression (forward-deploy → autoRollback → manual-rollback → watch-timeout → correct target) still blocked on the handler test harness tracked in ROADMAP Phase 2.11.

Elite review of 880c347 approved 4/5 with one narrow window worth
closing and one doc polish:

- RollbackApp: after UpdateAppRevision succeeds in the DB, also mutate
  the pre-update `app` snapshot's CurrentRevision in memory. If the
  post-update GetApp AND deployApp's in-goroutine GetApp both fail
  (narrow same-outage window), deployApp still sees the correct
  CurrentRevision and autoRollback targets the rollback destination
  rather than the broken revision. Closes the "re-fetch fallback re-
  opens C1" window the reviewer flagged.

- GetLastSuccessfulRevisionBefore doc comment: note that the returned
  pointer is non-nil even on ErrNoRows (consistent with the rest of
  the file) and callers must check err before dereferencing.

Tests: internal/k8s + internal/api suites green under -race, go vet
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vigneshsubbiah16 vigneshsubbiah16 merged commit fda8af4 into main Apr 23, 2026
1 check passed
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