Skip to content

Commit 7a2eb87

Browse files
fix(deploy): mark broken-image deploys failed on ProgressDeadlineExceeded (#280)
deploymentStatus mapped a rollout that exceeded its progress deadline with no available replica (pods created but containers can't start: CreateContainerError "no command specified", ImagePullBackOff, CrashLoopBackOff) to "deploying" forever — it only checked DeploymentReplicaFailure + replica counts. Add a Progressing=False/ProgressDeadlineExceeded -> failed branch (after the healthy check, so a partially-failed redeploy whose old ReplicaSet still serves stays healthy). Mirrors the worker's deploy_status_reconcile fix. - new FailureReason StartFailed (+ hint) for the create/run-container-error class; added to the exhaustive knownReasons hint test. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4dff0ad commit 7a2eb87

5 files changed

Lines changed: 104 additions & 1 deletion

File tree

internal/models/deployment_event.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ const (
6161
// (10-minute deadline in runDeploy / waitForJobComplete).
6262
FailureReasonDeadlineExceeded = "DeadlineExceeded"
6363

64+
// FailureReasonStartFailed means k8s created the app's pod but the
65+
// container could not start — the runtime "CreateContainerError",
66+
// "CreateContainerConfigError", or "RunContainerError" waiting reasons.
67+
// The modal cause is a built image with no CMD/ENTRYPOINT ("no command
68+
// specified") or an invalid container configuration. Distinct from
69+
// ImagePullBackOff (image unreachable) and CrashLoopBackOff (image runs
70+
// then exits non-zero): here the container is never successfully created.
71+
FailureReasonStartFailed = "StartFailed"
72+
6473
// FailureReasonError covers transient k8s API errors and generic
6574
// "ReplicaFailure" conditions that don't map to a more specific reason.
6675
FailureReasonError = "Error"

internal/models/deployment_failure_hints.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ var FailureHint = map[string]string{
3939
"Large base images or slow package installs can cause this. " +
4040
"Try a smaller base image (e.g. alpine) and pre-install dependencies in the Dockerfile.",
4141

42+
FailureReasonStartFailed: "Kubernetes created your app's pod but the container could not start. " +
43+
"The most common cause is a built image with no CMD/ENTRYPOINT (nothing to run) " +
44+
"or an invalid container configuration. Make sure your Dockerfile ends with a " +
45+
"CMD or ENTRYPOINT instruction, then re-deploy.",
46+
4247
FailureReasonError: "A Kubernetes replica failure was detected. " +
4348
"This is often a transient scheduling or resource constraint. " +
4449
"Re-deploy to retry; if it persists, check your Dockerfile for correct CMD/ENTRYPOINT.",

internal/models/deployment_failure_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ var knownReasons = []string{
2121
FailureReasonCrashLoopBackOff,
2222
FailureReasonBuildFailed,
2323
FailureReasonDeadlineExceeded,
24+
FailureReasonStartFailed,
2425
FailureReasonError,
2526
FailureReasonUnknown,
2627
}

internal/providers/compute/k8s/client.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2451,26 +2451,67 @@ func deployIngressURL(appID string) string {
24512451
return scheme + "://" + appID + "." + domain
24522452
}
24532453

2454+
// progressDeadlineExceededReason is the Reason k8s stamps on a Deployment's
2455+
// Progressing condition (status=False) when a rollout fails to make progress
2456+
// within spec.progressDeadlineSeconds (default 600s). k8s does not export it as
2457+
// a typed constant (it lives in the deployment controller as
2458+
// deploymentutil.TimedOutReason), so we name it here per the no-hardcoded-
2459+
// strings rule. `kubectl rollout status` treats this exact reason as a failed
2460+
// rollout.
2461+
const progressDeadlineExceededReason = "ProgressDeadlineExceeded"
2462+
24542463
// deploymentStatus translates k8s Deployment conditions and replica counts into
24552464
// one of: building|deploying|healthy|failed|stopped.
24562465
func deploymentStatus(deploy *appsv1.Deployment) string {
2457-
// Check for failure conditions first.
2466+
// Replica-creation failure first (the ReplicaSet could not create pods:
2467+
// quota exhausted, forbidden, etc.) — terminal.
24582468
for _, cond := range deploy.Status.Conditions {
24592469
if cond.Type == appsv1.DeploymentReplicaFailure && cond.Status == corev1.ConditionTrue {
24602470
return "failed"
24612471
}
24622472
}
24632473

2474+
// At least one replica serving → healthy. Checked BEFORE the progress-
2475+
// deadline failure below so a partially-failed *redeploy* whose previous
2476+
// ReplicaSet still serves is reported healthy, not failed.
24642477
if deploy.Status.AvailableReplicas >= 1 {
24652478
return "healthy"
24662479
}
2480+
2481+
// Rollout exceeded its progress deadline with NO available replica: the
2482+
// pods were created but their containers cannot start — the modal cause is
2483+
// a broken built image (CreateContainerError "no command specified",
2484+
// ImagePullBackOff, or CrashLoopBackOff). k8s does NOT retry past the
2485+
// deadline, so this is terminal. Without this branch such a deploy reports
2486+
// "deploying" forever (UnavailableReplicas>0 below) and never reaches a
2487+
// terminal state — the silent runtime-deploy-failure class (twin of the
2488+
// build-Job-failed fix). Kept in sync with the worker's
2489+
// deploy_status_reconcile.deploymentStatusFromK8s.
2490+
if deploymentProgressDeadlineExceeded(deploy) {
2491+
return "failed"
2492+
}
2493+
24672494
if deploy.Status.UpdatedReplicas > 0 || deploy.Status.UnavailableReplicas > 0 {
24682495
return "deploying"
24692496
}
24702497
// No replicas scheduled yet.
24712498
return "building"
24722499
}
24732500

2501+
// deploymentProgressDeadlineExceeded reports whether the Deployment's
2502+
// Progressing condition is False with reason ProgressDeadlineExceeded — k8s's
2503+
// definitive "this rollout will not make progress" verdict.
2504+
func deploymentProgressDeadlineExceeded(deploy *appsv1.Deployment) bool {
2505+
for _, cond := range deploy.Status.Conditions {
2506+
if cond.Type == appsv1.DeploymentProgressing &&
2507+
cond.Status == corev1.ConditionFalse &&
2508+
cond.Reason == progressDeadlineExceededReason {
2509+
return true
2510+
}
2511+
}
2512+
return false
2513+
}
2514+
24742515
// maxExtractedTarBytes caps the total uncompressed size extractTarGz will
24752516
// write. A crafted gzip bomb compresses to a few KB but expands to gigabytes;
24762517
// without a ceiling that fills the extraction volume. 512 MiB is comfortably

internal/providers/compute/k8s/coverage_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,53 @@ func TestDeploymentStatus(t *testing.T) {
189189
if got := deploymentStatus(failed); got != "failed" {
190190
t.Errorf("failed = %q", got)
191191
}
192+
193+
// ProgressDeadlineExceeded with NO available replica → failed. This is the
194+
// silent runtime-deploy-failure case: pods created but the container can't
195+
// start (broken image / no CMD), Progressing=False, UnavailableReplicas>0.
196+
// Pre-fix this mapped to "deploying" forever.
197+
progressTimeout := &appsv1.Deployment{Status: appsv1.DeploymentStatus{
198+
UnavailableReplicas: 1,
199+
Conditions: []appsv1.DeploymentCondition{
200+
{
201+
Type: appsv1.DeploymentProgressing,
202+
Status: corev1.ConditionFalse,
203+
Reason: progressDeadlineExceededReason,
204+
},
205+
},
206+
}}
207+
if got := deploymentStatus(progressTimeout); got != "failed" {
208+
t.Errorf("progress-deadline-exceeded = %q, want failed", got)
209+
}
210+
211+
// A serving deployment (AvailableReplicas>=1) whose newest rollout timed
212+
// out (e.g. a failed redeploy that left the previous ReplicaSet serving)
213+
// stays healthy — the available-replica check precedes the deadline check.
214+
healthyDespiteTimeout := &appsv1.Deployment{Status: appsv1.DeploymentStatus{
215+
AvailableReplicas: 1,
216+
Conditions: []appsv1.DeploymentCondition{
217+
{
218+
Type: appsv1.DeploymentProgressing,
219+
Status: corev1.ConditionFalse,
220+
Reason: progressDeadlineExceededReason,
221+
},
222+
},
223+
}}
224+
if got := deploymentStatus(healthyDespiteTimeout); got != "healthy" {
225+
t.Errorf("healthy-despite-timeout = %q, want healthy", got)
226+
}
227+
228+
// Progressing=True (rollout still within its deadline) must NOT be read as
229+
// a deadline failure — it stays deploying.
230+
progressingOK := &appsv1.Deployment{Status: appsv1.DeploymentStatus{
231+
UnavailableReplicas: 1,
232+
Conditions: []appsv1.DeploymentCondition{
233+
{Type: appsv1.DeploymentProgressing, Status: corev1.ConditionTrue, Reason: "ReplicaSetUpdated"},
234+
},
235+
}}
236+
if got := deploymentStatus(progressingOK); got != "deploying" {
237+
t.Errorf("progressing-ok = %q, want deploying", got)
238+
}
192239
}
193240

194241
// ── Tarball extraction ───────────────────────────────────────────────────────

0 commit comments

Comments
 (0)