Skip to content

feat(operator): treat a stage timeout as a retryable failure (#373) - #402

Merged
ayuskauskas merged 16 commits into
feature/package-as-jobsfrom
jobs-migration/373-timeout-retry
Aug 11, 2026
Merged

feat(operator): treat a stage timeout as a retryable failure (#373)#402
ayuskauskas merged 16 commits into
feature/package-as-jobsfrom
jobs-migration/373-timeout-retry

Conversation

@ayuskauskas

@ayuskauskas ayuskauskas commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #373. Part of #223, based on feature/package-as-jobs (not main).

#316 set the stage deadline on JobSpec.ActiveDeadlineSeconds, which is terminal: exceeding it fails the Job permanently with no replacement pod, so the first expiry parked. That made a timeout the one failure class with no retry. This moves the bound to the pod template so an expired attempt is Failed and replaced like any other failure, and gives up on a finite backoffLimit instead of math.MaxInt32. No operator-side retry counter to persist.

Three bounds instead of one

Package Jobs:

Field Value
spec.template.spec.activeDeadlineSeconds stageTimeout — per attempt
spec.backoffLimit JOB_BACKOFF_LIMIT (new, default 3)
spec.activeDeadlineSeconds derived ceiling

The ceiling is (backoffLimit+1) × (stageTimeout + gracefulShutdown + 10m), clamped to MaxInt32. It exists only for the case a per-attempt clock cannot bound: that clock runs from pod.Status.StartTime, which a pod the kubelet never acknowledges never gets. gracefulShutdown is in the formula because podReplacementPolicy: Failed waits out every shutdown — without it a slow shutdown could truncate the retry budget into a DeadlineExceeded that reads as a hang. Deriving it rather than adding a fourth knob makes a ceiling below the retry budget unrepresentable.

stageTimeout: 0 now removes the time bound but keeps the retry budget. That semantic shift is called out in the docstring.

Interrupt Jobs are deliberately unchanged (constraint 2). Under OnFailure backoffLimit counts container restarts, so a finite budget would be spent by the in-place restart that is the reboot recovery, and the bound must span the reboot, which a per-attempt clock cannot.

Two things not in the issue — please look here first

The park signal inverts as #373 anticipated, but a bare BackoffLimitExceeded turned out not to be safe to park on, and pod_controller.go could not stay untouched.

1. BackoffLimitExceeded alone does not park. These pods carry spec.nodeName rather than going through the scheduler, so kubelet admission is the only gate they face; a node at capacity or returning from a reboot can reject several node-pinned replacements in a row, each Failed with no container statuses and no DisruptionTarget for the Ignore rule to match. No podFailurePolicy rule can absorb them — onExitCodes needs container statuses there are none of, the same argument that keeps ImagePullBackOff out of the policy. The design doc already documents this at line 269; at MaxInt32 it cost an archive slot, at 3 it exhausts the budget in ~70s and would park a package that never ran a line of script. Terminal failure is now believed only when a retained archive really failed (nonzero exit, or killed by its own deadline). The Job-level ceiling needs no such evidence — nothing finished inside the entire budget.

Accepted gap: the pruner keeps only first+last, so a genuine failure sandwiched between rejections can be pruned before the check runs. That errs toward re-running rather than parking, which is the safe direction.

2. pod_controller.go could not stay untouched. Constraint 4 assumed "a deadline-killed container exits nonzero," which only holds if a container was running. For the headline case — unpullable image, dead registry — the stuck init container is Waiting{ImagePullBackOff} when the pod deadline fires, or gets rewritten to Terminated{ContainerStatusUnknown} by the kubelet on termination. podFailureIsGenuine rejects both. Left alone, a hang would have read in_progress for 4 × stageTimeout (4h at defaults) — worse than the 1h #316 gives today, and a regression against the exact case #316 targeted. The Pod watch now also keys on the pod-level DeadlineExceeded reason, which nothing else sets.

So on constraint 4 the answer is "accept it": first genuine failure writes erroring, a successful retry records complete over it. Nothing writes in_progress back — ApplyPackage bails on the unfinished Job before the upsert — so the sequence is in_progress → erroring → complete, not a flap.

Also included

shouldDeleteFinishedJob now requires the state-recorded marker for Failed Jobs, not just Complete. A finite backoffLimit takes a Job from first failure to terminal in about a minute, so the heavy pass would otherwise race the erroring write into a fresh, equally doomed attempt. The window this closes existed before, but at ~1h wide it was theoretical.

Behavior change worth a changelog line

backoffLimit bounds every failure class, not just timeouts. A crash-looping package parks after 4 attempts (~70s, paced by the Job controller's 10s/20s/40s backoff) where it previously retried for the whole stageTimeout (~1h, ~10 attempts). Packages that ride out transient environment flakiness — a registry blip, a mount not up yet — lose that hour of self-healing. That is why the limit is an operator knob rather than a constant; the default stays 3 per the issue.

Verify

  • The "pause-suspend deletions don't count toward backoffLimit" question from the issue was confirmed empirically by @lockwobr before this work; nothing here depends on re-checking it, but pause-suspends-jobs remains the e2e that would catch a regression.
  • spec.suspend resets status.startTime but not status.failed, so pausing to investigate a half-failed stage and resuming leaves the remaining budget unchanged. Noted in the design doc's pause section.

Testing

  • make unit-tests — 14 suites pass; 21 new/changed specs, including the ceiling arithmetic and its overflow clamp, the interrupt asymmetry, BackoffLimitExceeded parking only on genuine evidence (4-entry table), and pod-deadline erroring for a container that never started.
  • make lint (0 issues), make license-check, helm lint / helm template all clean.
  • e2e not run locally (needs a cluster). simple-nodewright/assert_jobs.yaml pins the new three-bound shape; failure-nodewright gains assert_parked_job.yaml pinning the park itself — terminal BackoffLimitExceeded, failed: 4, state-recorded, and the 24h failure TTL.

Docs

stageTimeout docstring + regenerated CRD + chart mirror; the design doc's Job shape, retry, stage-deadline, pause, finished-Job-rules and rejected-alternatives sections, plus the stale operator/api/v1alpha1/skyhook_types.go path in its file list (the field lives under api/nodewright/); and the erroring row in operator-status-definitions.md, which now also means "gave up and will not retry without intervention". JOB_BACKOFF_LIMIT is wired through operator/config/manager/manager.yaml, chart/templates/deployment.yaml and chart/values.yaml, per the scope note carried over from #372.

Also closes #424 (#411 item 3 — handleExistingJob / shouldDeleteFinishedJob now exempt any unprocessed finished Job, 7f5ef8aa) and #425 (#411 item 4 — the stageTimeout contract is documented rather than made live, 4c11b177).

@github-actions github-actions Bot added doc Documentation change (PR path label; doc issues use the Documentation type) component/operator Skyhook operator (controller-manager) component/chart Helm chart component/ci CI workflows, GitHub Actions, and repo tooling component/tests End-to-end / chainsaw test suites (k8s-tests) labels Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds JOB_BACKOFF_LIMIT with a default of three retries for package-stage Jobs. Package Jobs use per-attempt pod deadlines and derived Job-level ceilings. Interrupt Jobs retain unbounded retries and stage-wide deadlines. Pod and Job reconciliation classify genuine, deadline, disruption, and admission failures. Finished Jobs remain until outcomes are recorded. Tests and documentation cover configuration, lifecycle behavior, and parked Jobs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: lockwobr, rice-riley

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #373 by adding per-attempt pod deadlines, finite retries, a Job-level ceiling, preserved interrupt behavior, and updated documentation.
Out of Scope Changes check ✅ Passed The code, configuration, documentation, and test changes support the linked issue objectives without identifiable unrelated changes.
Title check ✅ Passed The title clearly summarizes the main change: stage timeouts become retryable failures for the operator.
Description check ✅ Passed The description directly explains the retry behavior, configuration changes, failure handling, testing, and documentation updates.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jobs-migration/373-timeout-retry

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
operator/internal/controller/skyhook_controller.go (1)

2502-2508: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve every unprocessed finished Job.

Line 2505 protects an unprocessed JobComplete, but an unprocessed JobFailed reaches Line 2508 and is foreground-deleted. This can delete a terminal failure before JobReconciler.handleFailedJob classifies it, writes erroring, and sets state-recorded. The next pass can then recreate the stage instead of parking a genuine failure.

Return early for all !jobProcessed(existing) outcomes. Add a regression test for an unprocessed failed Job whose node entry is not yet erroring.

Proposed fix
+	if !jobProcessed(existing) {
+		return nil // JobReconcile owns unrecorded terminal outcomes
+	}
+
 	if isParkedJob(existing) && r.entryErroringAtStage(skyhookNode, _package, stage) {
 		return nil // parked: the finished Job is doing its job, absorb this recreate attempt
 	}
-	if hasJobCondition(existing, batchv1.JobComplete) && !jobProcessed(existing) {
-		return nil // unrecorded completion; JobReconcile owns it, do not discard it
-	}
 	return deleteJobForeground(ctx, r.Client, existing)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/skyhook_controller.go` around lines 2502 - 2508,
Update the existing Job preservation logic around isParkedJob and
hasJobCondition so every unprocessed terminal Job, including JobFailed, returns
early instead of reaching deleteJobForeground; retain deletion for processed
Jobs. Add a regression test covering an unprocessed failed Job whose node entry
is not yet erroring, verifying it is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Around line 173-177: Update the “Log visibility” section to state that
deadline expiry terminates the Pod’s containers and marks it Failed with reason
DeadlineExceeded, without deleting the Pod object; distinguish separate Job/Pod
garbage collection from log retention while preserving the rationale and
existing best-effort log-tail snapshot behavior.

In `@operator/internal/controller/skyhook_controller.go`:
- Around line 150-152: Update the backoffLimit descriptions in
operator/internal/controller/skyhook_controller.go lines 150-152,
operator/config/manager/manager.yaml lines 112-114, and
docs/designs/2026-07-10-package-execution-as-jobs.md line 79 to describe the
value as retries after the initial attempt, or explicitly state that total
attempts equal backoffLimit plus one; preserve the existing zero-value
semantics.

---

Outside diff comments:
In `@operator/internal/controller/skyhook_controller.go`:
- Around line 2502-2508: Update the existing Job preservation logic around
isParkedJob and hasJobCondition so every unprocessed terminal Job, including
JobFailed, returns early instead of reaching deleteJobForeground; retain
deletion for processed Jobs. Add a regression test covering an unprocessed
failed Job whose node entry is not yet erroring, verifying it is preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: e380a9a2-af95-4b06-a7d5-d705a0533d71

📥 Commits

Reviewing files that changed from the base of the PR and between 7d065f7 and 6f61a9b.

📒 Files selected for processing (20)
  • chart/templates/deployment.yaml
  • chart/templates/nodewright-crd.yaml
  • chart/values.yaml
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • docs/operator-status-definitions.md
  • k8s-tests/chainsaw/nodewright/failure-nodewright/assert_parked_job.yaml
  • k8s-tests/chainsaw/nodewright/failure-nodewright/chainsaw-test.yaml
  • k8s-tests/chainsaw/nodewright/simple-nodewright/assert_jobs.yaml
  • operator/api/nodewright/v1alpha1/nodewright_types.go
  • operator/config/crd/bases/nodewright.nvidia.com_nodewrights.yaml
  • operator/config/manager/manager.yaml
  • operator/internal/controller/job_builder.go
  • operator/internal/controller/job_builder_test.go
  • operator/internal/controller/job_controller.go
  • operator/internal/controller/job_controller_test.go
  • operator/internal/controller/pod_controller.go
  • operator/internal/controller/skyhook_controller.go
  • operator/internal/controller/skyhook_controller_test.go
  • operator/internal/controller/swap_test.go
  • operator/internal/controller/workload_migration_test.go

Comment thread docs/designs/2026-07-10-package-execution-as-jobs.md Outdated
Comment thread operator/internal/controller/skyhook_controller.go Outdated
@coveralls

coveralls commented Aug 3, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31534479143

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit a3dd68c on feature/package-as-jobs.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 79.018%

Details

  • Patch coverage: 8 uncovered changes across 2 files (103 of 111 lines covered, 92.79%).

Uncovered Changes

File Changed Covered %
operator/internal/controller/job_controller.go 33 28 84.85%
operator/internal/controller/skyhook_controller.go 33 30 90.91%
Total (4 files) 111 103 92.79%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 13507
Covered Lines: 10673
Line Coverage: 79.02%
Coverage Strength: 8.13 hits per line

💛 - Coveralls

ayuskauskas added a commit that referenced this pull request Aug 3, 2026
…-expiry text

CodeRabbit review on #402, both findings valid:

- backoffLimit: 3 permits one initial attempt plus three retries. Four
  comments described it as the attempt count; say retries and spell out
  backoffLimit+1 total attempts.
- The Log visibility section still described deadline expiry as deleting
  the running pod. That is now only true of the Job-level ceiling; a
  per-attempt expiry terminates the containers and marks the pod Failed
  with reason DeadlineExceeded in place, so the attempt survives as an
  ordinary archive with its logs. Distinguish the two and keep the
  snapshot rationale, which the ceiling case still needs.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Addressed both CodeRabbit findings in a31ccbe — both were valid.

  1. backoffLimit described as attempts, not retries. Correct: 3 is one initial attempt plus three retries. Fixed in all four places (the JobOperatorOptions docstring, manager.yaml, values.yaml, and the e2e assert comment), each now stating "retries after the first attempt" and "at most backoffLimit+1 times". The design doc already said backoffLimit + 1 attempts, and assert_parked_job.yaml already pins failed: 4, so no behavioral drift — just imprecise prose.

  2. Deadline-expiry description in Log visibility. Correct, and a real miss on my part: I rewrote the "On expiry" text for the per-attempt model but left the Log-visibility paragraph describing the old Job-level behavior. It now distinguishes the two — a per-attempt expiry terminates the containers and marks the pod Failed with reason DeadlineExceeded in place, so the attempt survives as an ordinary full-log archive; only the Job-level ceiling deletes the active pod, which is the case the last-logs snapshot still exists for.


On CI: build-operator fails on both arches with nvcr.io/nvidia/distroless/static:v4.0.1: not found. That's unrelated to this PR — #400 and #401 fail identically on the same base image, and this branch touches no Dockerfile or workflow. Everything substantive is green, including the new e2e assertions: unit-tests, helm-tests, cli-e2e, deployment-policy, and all four e2e pools across k8s 1.33–1.36. e2e/lifecycle covers failure-nodewright, so the new park assertion (terminal BackoffLimitExceeded, failed: 4, state-recorded, 24h failure TTL) passed on all four versions inside its 300s window.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/designs/2026-07-10-package-execution-as-jobs.md (1)

40-40: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document the Job-level ceiling as a second terminal path.

The summary says that only exhausted retry budgets produce parked erroring stages. Per-attempt DeadlineExceeded failures are retried, but the derived Job-level DeadlineExceeded also parks the stage when pods never receive StartTime. Rewrite this sentence to distinguish per-attempt deadline retries from Job-level ceiling parking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/designs/2026-07-10-package-execution-as-jobs.md` at line 40, Update the
lifecycle summary to distinguish per-attempt DeadlineExceeded failures, which
are killed and retried, from the derived Job-level DeadlineExceeded ceiling,
which parks the stage when pods never receive StartTime; identify both
retry-budget exhaustion and the Job-level ceiling as terminal paths producing an
erroring parked stage.
operator/internal/controller/skyhook_controller.go (1)

2483-2483: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve unprocessed terminal Failed Jobs in the AlreadyExists path.

When existing is finished, a Failed Job without state-recorded reaches the deletion branch unless its entry is already erroring. Return without deletion for every unprocessed finished Job. Add a regression test for this AlreadyExists case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/skyhook_controller.go` at line 2483, Update the
AlreadyExists handling around the finished-job logic in
operator/internal/controller/skyhook_controller.go:2483 so every finished Job
lacking state-recorded, including Failed Jobs, is returned without deletion
regardless of whether its entry is erroring; add a regression test covering this
AlreadyExists case. The related documentation at
docs/designs/2026-07-10-package-execution-as-jobs.md:213-223 requires no direct
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@operator/internal/controller/skyhook_controller.go`:
- Around line 151-153: Update the documentation at
operator/internal/controller/skyhook_controller.go:151-153,
chart/values.yaml:92-96, operator/config/manager/manager.yaml:114-115, and
docs/designs/2026-07-10-package-execution-as-jobs.md:113-115 to separate
retry-budget counting from parking classification. Preserve JobBackoffLimit+1
and zero-value semantics, clarify that ignored disruptions do not count while
admission-rejected attempts may consume the budget, and state that parking
requires genuine failure evidence rather than occurring unconditionally after
exhaustion.

---

Outside diff comments:
In `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Line 40: Update the lifecycle summary to distinguish per-attempt
DeadlineExceeded failures, which are killed and retried, from the derived
Job-level DeadlineExceeded ceiling, which parks the stage when pods never
receive StartTime; identify both retry-budget exhaustion and the Job-level
ceiling as terminal paths producing an erroring parked stage.

In `@operator/internal/controller/skyhook_controller.go`:
- Line 2483: Update the AlreadyExists handling around the finished-job logic in
operator/internal/controller/skyhook_controller.go:2483 so every finished Job
lacking state-recorded, including Failed Jobs, is returned without deletion
regardless of whether its entry is erroring; add a regression test covering this
AlreadyExists case. The related documentation at
docs/designs/2026-07-10-package-execution-as-jobs.md:213-223 requires no direct
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 802e440d-f389-4350-aafd-c2d3e557b172

📥 Commits

Reviewing files that changed from the base of the PR and between 6f61a9b and a31ccbe.

📒 Files selected for processing (5)
  • chart/values.yaml
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • k8s-tests/chainsaw/nodewright/simple-nodewright/assert_jobs.yaml
  • operator/config/manager/manager.yaml
  • operator/internal/controller/skyhook_controller.go

Comment thread operator/internal/controller/skyhook_controller.go Outdated
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Addressed in 83741d3 — valid, and it's about the distinction this PR introduces.

The comments conflated two decisions the code deliberately keeps apart: backoffLimit counts Failed pods, but exhausting it only takes the Job terminal. The stage parks as erroring solely when a retained attempt genuinely failed (jobFailureIsGenuine). Every flagged site said or implied exhaustion ⇒ park.

A Failed pod now falls into one of three classes, stated explicitly in the design doc's backoffLimit paragraph:

Class Spends an attempt? Parks the stage?
Ignored disruption (DisruptionTarget) no no
Kubelet refused to admit it (OutOfpods etc.) yes no
Genuine step failure or per-attempt timeout yes yes

That paragraph also still carried the pre-PR claim that the Ignore rule means "only genuine step failures count" — untrue once the limit is finite, since the middle row counts too. Fixed there and in the options docstring, manager.yaml, and values.yaml.

ayuskauskas added a commit that referenced this pull request Aug 3, 2026
CodeRabbit review on #402. The comments conflated two decisions the code
deliberately keeps apart: backoffLimit counts Failed pods, but exhausting
it only takes the Job terminal — the stage parks as erroring solely when a
retained attempt genuinely failed.

A Failed pod falls into one of three classes: an ignored disruption spends
nothing; an attempt the kubelet refused to admit spends an attempt without
being the package's failure; a genuine step failure or per-attempt timeout
spends an attempt and is. Only the third parks. Say so in the options
docstring, manager.yaml, values.yaml, and the design doc's backoffLimit
paragraph, which still claimed only genuine failures could count at all.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/designs/2026-07-10-package-execution-as-jobs.md (1)

161-163: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align stageTimeout documentation with the API validation comment.

stageTimeout controls package Pod attempt deadlines, not the Job-level deadline. Update the webhook comment to describe the derived Job ceiling and the separate interrupt Job deadline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/designs/2026-07-10-package-execution-as-jobs.md` around lines 161 - 163,
Update the stageTimeout API validation comment to clarify that it derives the
package Pod attempt deadline and the corresponding Job ceiling, while the
interrupt Job retains a separate deadline. Keep the documented defaults,
zero-value behavior, and retry-budget semantics aligned with the implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Around line 113-115: Update the sentence in the retry and failed-attempt
explanation so the genuine step failure or per-attempt timeout clause ends with
“and is genuine failure evidence.” Preserve the surrounding distinctions between
ignored disruptions, admission failures, and genuine failures.

---

Outside diff comments:
In `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Around line 161-163: Update the stageTimeout API validation comment to clarify
that it derives the package Pod attempt deadline and the corresponding Job
ceiling, while the interrupt Job retains a separate deadline. Keep the
documented defaults, zero-value behavior, and retry-budget semantics aligned
with the implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 9a8c451a-376e-4ee5-8ce5-1b9ceca0e344

📥 Commits

Reviewing files that changed from the base of the PR and between a31ccbe and 83741d3.

📒 Files selected for processing (4)
  • chart/values.yaml
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • operator/config/manager/manager.yaml
  • operator/internal/controller/skyhook_controller.go

Comment thread docs/designs/2026-07-10-package-execution-as-jobs.md Outdated
ayuskauskas added a commit that referenced this pull request Aug 3, 2026
…it paragraph

CodeRabbit review on #402: the elliptical "and is" left the third failure
class without a predicate. Spell it out, keeping the parallelism with the
preceding "is not the package's failure" rather than switching terms.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Fixed in e399d4b. Took the point but not the exact wording — "and is the package's failure" keeps the parallelism with the preceding "is not the package's failure", where "is genuine failure evidence" switches terms mid-sentence.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/designs/2026-07-10-package-execution-as-jobs.md (1)

124-125: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persist genuine-failure evidence before child Pods can disappear.

jobFailureIsGenuine returns false when no retained failed child Pod remains. The Job-tracking finalizer protects Kubernetes Job accounting, but not the later operator classification. Pod GC or pruneFailedAttempts can remove the only genuine-failure archive, causing a real BackoffLimitExceeded Job to be marked and swept instead of parked. Persist the verdict on the Job before terminal processing, or add a durable fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/designs/2026-07-10-package-execution-as-jobs.md` around lines 124 - 125,
Update the terminal Job-processing flow around jobFailureIsGenuine so the
genuine-failure verdict is persisted on the Job before Pod GC or
pruneFailedAttempts can remove the last failed archive. Ensure
BackoffLimitExceeded with genuine evidence remains classified as erroring and
parked, while kubelet-admission cases retain the existing marker, TTL, and sweep
behavior by using the persisted verdict or another durable fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/designs/2026-07-10-package-execution-as-jobs.md`:
- Around line 124-125: Update the terminal Job-processing flow around
jobFailureIsGenuine so the genuine-failure verdict is persisted on the Job
before Pod GC or pruneFailedAttempts can remove the last failed archive. Ensure
BackoffLimitExceeded with genuine evidence remains classified as erroring and
parked, while kubelet-admission cases retain the existing marker, TTL, and sweep
behavior by using the persisted verdict or another durable fallback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: e7f1fa21-7a84-4abe-b31c-71f06b0fa699

📥 Commits

Reviewing files that changed from the base of the PR and between 83741d3 and e399d4b.

📒 Files selected for processing (1)
  • docs/designs/2026-07-10-package-execution-as-jobs.md

ayuskauskas added a commit that referenced this pull request Aug 3, 2026
CodeRabbit review on #402 read jobFailureIsGenuine as the sole gate on
parking and concluded that losing the archives to pod GC loses the park.
It is not the sole gate, but nothing said so, so write it down.

The park predicate is (terminal Failed, entry at (stage, erroring)) in both
places that evaluate it. jobFailureIsGenuine only decides whether the
terminal path is the one to write that entry; the Pod watch writes it live,
while the archive still exists, using the same classification. Each covers
the other's blind spot — the Pod watch survives archives being GC'd, the
terminal path survives the operator being down through the retries. Both
must miss to lose a park, and the stage then re-runs and parks on the next
cycle rather than churning.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Pushed c85ee6e — but as documentation, not the suggested fix. The mechanism is real; the conclusion isn't, and I'd rather not add a persisted verdict for a gap that mostly doesn't exist.

jobFailureIsGenuine is not the gate on parking. The park predicate, in both places that evaluate it (skyhook_controller.go:2506 and shouldDeleteFinishedJob), is:

terminal Failed Job  ∧  node-state entry at (stage, erroring)

Neither reads a verdict. jobFailureIsGenuine only decides whether the terminal Job path is the one that writes that entry — and it is the second of two writers. The Pod watch is the first, and it writes erroring live, while the failed attempt still exists. Both use the same classification (podFailureIsGenuine / pod-level DeadlineExceeded), so they agree; each covers the other's blind spot:

Writer Runs Blind spot it covers
Pod watch live, per failed attempt archives later reaped by terminated-pod GC
Terminal Job path at terminal, from archives operator down for the whole retry window

So a genuine failure that loses its archives to pod GC still parks — the Pod watch already wrote the entry. Both writers must miss, which needs the operator down through the entire retry window and the archives gone.

And that case is bounded, not churn: the Job is swept, the stage re-runs, and the next cycle's Pod-watch evidence (operator now up) parks it. One extra retry cycle.

Two smaller corrections: pruneFailedAttempts returns early at len(archives) <= 2 (job_controller.go:662), so the pruner can never remove the last archive — only external pod GC can. And the sandwiched-failure case the pruner can hit was already called out in the function comment, erring toward re-run rather than park.

What was genuinely missing is that none of this was written down, which is why it read as a single-gate design. Now in the jobFailureIsGenuine doc comment and as a "Two writers, one park predicate" entry in the design doc's edge-cases section. Happy to add the persisted verdict if a maintainer wants the residual window closed, but it buys one retry cycle in a scenario that needs two independent failures to coincide.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
operator/internal/controller/job_controller.go (2)

786-796: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Retain unprocessed Failed Jobs until state-recorded is written.

isParkedJob now recognizes every terminal Failed Job, but the existing-Job path only retains it when the stage entry is already erroring. If that path runs before terminal Job reconciliation, it can delete the Job and its retained failure evidence.

  • operator/internal/controller/job_controller.go#L786-L796: defer JobFailed && !jobProcessed Jobs until terminal reconciliation writes state-recorded.
  • docs/designs/2026-07-10-package-execution-as-jobs.md#L216-L217: keep the lifecycle rule aligned with the controller and add a regression test for concurrent reconcile ordering.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/job_controller.go` around lines 786 - 796, The
existing-Job retention logic must defer terminal Failed Jobs until terminal
reconciliation records state-recorded, rather than relying only on the stage
being erroring. Update isParkedJob and its callers in
operator/internal/controller/job_controller.go (lines 786-796) to preserve
JobFailed && !jobProcessed Jobs until that marker exists; update
docs/designs/2026-07-10-package-execution-as-jobs.md (lines 216-217) to document
the aligned lifecycle rule and add a regression test covering concurrent
reconcile ordering.

439-442: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Wrap propagated errors with operation context.

The changed failure path returns bare err at Lines [442], [465], and [500]. These functions perform multiple operations. The errors do not identify whether classification, state recording, or child-pod inspection failed.

Wrap each error with fmt.Errorf("...: %w", err).

Suggested wrapping
-		return ctrl.Result{}, err
+		return ctrl.Result{}, fmt.Errorf("classifying failed Job %s: %w", job.Name, err)

-		return ctrl.Result{}, err
+		return ctrl.Result{}, fmt.Errorf("recording failure for Job %s: %w", job.Name, err)

-		return false, err
+		return false, fmt.Errorf("checking failure evidence for Job %s: %w", job.Name, err)

As per coding guidelines, wrap propagated errors with fmt.Errorf("...: %w", err) and do not return a bare err from functions that perform more than one operation.

Also applies to: 464-466, 498-500

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/internal/controller/job_controller.go` around lines 439 - 442,
Update handleFailedJob and the corresponding error paths around
jobFailureIsGenuine, state recording, and child-pod inspection to wrap each
propagated error with fmt.Errorf using descriptive operation context and %w.
Replace the bare err returns at all three referenced paths while preserving the
existing control flow.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@operator/internal/controller/job_controller.go`:
- Around line 786-796: The existing-Job retention logic must defer terminal
Failed Jobs until terminal reconciliation records state-recorded, rather than
relying only on the stage being erroring. Update isParkedJob and its callers in
operator/internal/controller/job_controller.go (lines 786-796) to preserve
JobFailed && !jobProcessed Jobs until that marker exists; update
docs/designs/2026-07-10-package-execution-as-jobs.md (lines 216-217) to document
the aligned lifecycle rule and add a regression test covering concurrent
reconcile ordering.
- Around line 439-442: Update handleFailedJob and the corresponding error paths
around jobFailureIsGenuine, state recording, and child-pod inspection to wrap
each propagated error with fmt.Errorf using descriptive operation context and
%w. Replace the bare err returns at all three referenced paths while preserving
the existing control flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: b9ad03b8-2e9b-43f4-aa7c-ccf82cdce67d

📥 Commits

Reviewing files that changed from the base of the PR and between e399d4b and c85ee6e.

📒 Files selected for processing (2)
  • docs/designs/2026-07-10-package-execution-as-jobs.md
  • operator/internal/controller/job_controller.go

ayuskauskas added a commit that referenced this pull request Aug 4, 2026
…th too

CodeRabbit review on #402. shouldDeleteFinishedJob was taught to wait for
the state-recorded marker on both outcomes, but handleExistingJob — its
mirror, and the path that reaches the window first — still carved out only
unprocessed Complete Jobs. A finished Job does not satisfy JobExists, so the
next pass creates over its deterministic name; landing there before
JobReconcile processed a terminal Failed Job deleted it, taking the retained
attempts with it and restarting the stage on a fresh budget.

Generalize the carve-out to any unprocessed finished Job so both paths reach
the same verdict, and cover the ordering with specs on handleExistingJob,
which had none.

Also wrap the two propagated errors in handleFailedJob so a failure names
whether classification or the state write broke. Left jobFailureIsGenuine's
childPods error bare: childPods already contextualizes it, and its two
siblings in this file (snapshotFailureLogs, pruneFailedAttempts) pass it
through the same way.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Both addressed in 3e74f0d. The first was a real bug — thanks.

Retain unprocessed Failed Jobs (Major). Correct, and it's an asymmetry I introduced: I taught shouldDeleteFinishedJob to wait for the state-recorded marker on both outcomes, then left its mirror handleExistingJob carving out only unprocessed Complete Jobs. And handleExistingJob is the path that reaches the window first — a finished Job doesn't satisfy JobExists, so the next pass creates over its deterministic name; landing there before JobReconcile processed a terminal Failed Job deleted it, taking the retained attempts with it and restarting the stage on a fresh budget.

Generalized the carve-out to any unprocessed finished Job, so both paths now reach the same verdict for the same Job. Added three specs on handleExistingJob, which had none at all:

  • keeps a terminal Failed Job JobReconcile has not processed yet
  • keeps a parked Job while its entry sits erroring
  • deletes a processed Failed Job whose entry never reached erroring (the kubelet-refused case — nothing parked it, so the name frees and the stage re-runs)

Design doc rule 3 updated to match.

Error wrapping (Minor). Wrapped the two in handleFailedJob — "classifying failed job %s" vs "recording failure for job %s" is exactly the distinction that was missing.

Left jobFailureIsGenuine's childPods error bare, deliberately: childPods already wraps it as "listing child pods for job %s", and its two siblings in the same file (snapshotFailureLogs, pruneFailedAttempts) pass it through identically. Wrapping only this one would read as "listing child pods for job X" nested inside "checking failure evidence for job X" while its neighbours don't — consistency within the file seemed worth more than the extra frame. Happy to change it if you disagree.

make unit-tests: 298 controller specs pass, including the three new ones. golangci-lint: 0 issues.

Comment thread operator/internal/controller/job_controller.go Outdated
ayuskauskas added a commit that referenced this pull request Aug 4, 2026
Review feedback on #402. The API group is nodewright.nvidia.com on this
branch, so user-visible event text should not say skyhook.

Only the event this PR added is changed. Six pre-existing [skyhook:%s]
event strings remain in pod_controller.go and skyhook_controller.go; those
are untouched by this PR and sweeping them here would collide with other
in-flight branches in the epic.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@lockwobr
lockwobr force-pushed the feature/package-as-jobs branch from 7d065f7 to a3dd68c Compare August 5, 2026 20:44
#316 set the stage deadline on JobSpec.ActiveDeadlineSeconds, which is
terminal: exceeding it fails the Job permanently with no replacement pod,
so the first expiry parked. That made a timeout the one failure class with
no retry. Move the bound to the pod template so an expired attempt is Failed
and replaced like any other failure, and give up on a finite backoffLimit
instead of math.MaxInt32. No operator-side retry counter to persist.

Package Jobs now carry three bounds:

  spec.template.spec.activeDeadlineSeconds = stageTimeout   (per attempt)
  spec.backoffLimit                        = JOB_BACKOFF_LIMIT (default 3)
  spec.activeDeadlineSeconds               = derived ceiling

The ceiling is (backoffLimit+1) * (stageTimeout + gracefulShutdown + 10m),
clamped to MaxInt32. It exists only for the case a per-attempt clock cannot
bound: that clock runs from pod.Status.StartTime, which a pod the kubelet
never acknowledges never gets. gracefulShutdown is in the formula because
podReplacementPolicy Failed waits out every shutdown; without it a slow
shutdown could truncate the retry budget into a DeadlineExceeded that reads
as a hang. Deriving it rather than adding a fourth knob makes a ceiling
below the retry budget unrepresentable.

Interrupt Jobs are deliberately unchanged. Under OnFailure backoffLimit
counts container restarts, so a finite budget would be spent by the in-place
restart that is the reboot recovery, and the bound must span the reboot,
which a per-attempt clock cannot.

The park signal inverts, as #373 anticipated: BackoffLimitExceeded becomes
the park and DeadlineExceeded becomes a routine retry. Two consequences that
were not in the issue and are worth review attention:

- BackoffLimitExceeded alone is not sufficient to park. These pods carry
  spec.nodeName rather than going through the scheduler, so kubelet
  admission is the only gate they face; a node at capacity or returning
  from a reboot can reject several node-pinned replacements in a row, each
  Failed with no container statuses and no DisruptionTarget for the Ignore
  rule to match. At MaxInt32 that cost an archive slot; at 3 it exhausts the
  budget in ~70s and would park a package that never ran a line of script.
  Terminal failure is now believed only when a retained archive really
  failed. The Job-level ceiling needs no such evidence.

- pod_controller.go could not stay untouched. A pod killed by its own
  deadline whose container never started (unpullable image, missing
  configmap - the hang the deadline exists for) has no exit code, and
  podFailureIsGenuine rejects both shapes it can take. The Pod watch now
  also keys on the pod-level DeadlineExceeded reason, which nothing else
  sets; without it a hang would read in_progress for the whole retry budget,
  worse than the single deadline this replaces.

Also: shouldDeleteFinishedJob now requires the state-recorded marker for
Failed Jobs, not just Complete. A finite backoffLimit takes a Job from first
failure to terminal in about a minute, so the sweep would otherwise race the
erroring write into a fresh, equally doomed attempt.

Behavior note for the changelog: backoffLimit bounds every failure class,
not just timeouts. A crash-looping package parks after 4 attempts (~70s)
where it previously retried for the whole stageTimeout (~1h, ~10 attempts).
Packages that ride out transient environment flakiness lose that hour of
self-healing, which is why the limit is an operator knob rather than a
constant. Default stays 3 per the issue.

Docs: stageTimeout docstring and both CRD copies, the design doc's Job
shape / retry / stage-deadline / pause / finished-Job-rules / rejected-
alternatives sections and its stale skyhook_types.go path, and the erroring
row in operator-status-definitions.md (it now also means "gave up"). Chart
and kustomize both carry JOB_BACKOFF_LIMIT.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
Comment on lines +786 to +793
// isParkedJob reports whether a Job is a parked failure — a terminal Failed Job that the
// finished-Job rules deliberately leave in place while its stage sits erroring, so nothing
// recreates the stage until a rerun/reset/config-change/TTL clears it.
//
// Every terminal failure qualifies, deliberately: with a finite backoffLimit the reason no longer
// separates a real failure from a backstop. jobFailureIsGenuine draws that line instead, and only
// a genuine failure leaves the entry at (stage, erroring) — the other half of every park
// predicate, without which a non-genuine failure is swept and the stage re-runs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we find a new name? Parked seems like a human controlled name, like paused. I feel like timedout is more clear.

spec.BackoffLimit = ptr(opts.JobBackoffLimit)
if timeout > 0 {
spec.Template.Spec.ActiveDeadlineSeconds = ptr(deadlineSeconds(timeout))
spec.ActiveDeadlineSeconds = ptr(jobCeilingSeconds(opts, _package, timeout))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sort of wonder if we need this set?

If have n retry, and a try has a timeout, do we need a global retry?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess would cover some edge cases, like were the pod can schedule or run maybe

// Without this the first timeout would write nothing and a hang would read in_progress until the
// entire retry budget burned down.
func podDeadlineExceeded(pod *corev1.Pod) bool {
return pod.Status.Phase == corev1.PodFailed && pod.Status.Reason == podReasonDeadlineExceeded

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the sdk have this const? podReasonDeadlineExceeded would stay in sync better if so

@lockwobr lockwobr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cross-review summary

Automated multi-reviewer analysis of b27c75f6, cross-reviewed to consensus with adversarial verification of every confirmed finding. Nothing was run against a cluster and no tests were executed; every claim comes from reading the pinned commit. CI is green on this commit (all 34 checks, including all four e2e pools across k8s 1.33-1.36).

Findings fall into two groups. Confirmed ones reached agreement across independent reviewers and then survived a fresh reviewer whose only job was to refute them. Unadjudicated ones were raised late in the process and never cross-evaluated, so each carries a single reporter's position: read them, don't treat them as agreed. Two further findings were dropped after verification showed the arithmetic behind them was wrong, and are not reproduced here.

Findings that anchor to changed lines are inline. The rest follow.


Confirmed

operator/internal/controller/job_controller.go:667 - the pruner and the classifier disagree on "genuine"

pruneFailedAttempts builds its archive set filtering only on Status.Phase == PodFailed && !hasDisruptionTarget(...), then keeps first+last and deletes the middle. jobFailureIsGenuine applies a stricter predicate to whatever survived: podDeadlineExceeded(...) || podFailureIsGenuine(...). A kubelet-admission rejection satisfies the pruner's filter but not the classifier's, so a real step failure sandwiched between two rejections is prunable while both rejections survive.

When the terminal classifier is the only writer that ran (an operator restart or leadership change spanning the Pod-watch window), a genuinely failing stage is then classified non-genuine: handleFailedJob marks the Job processed without recording erroring, and the sweep clears it so the stage re-runs. Repeated host-side work, and the real failure stays invisible until a later cycle parks it.

The dual-writer design bounds this, and the docstring at 482-484 acknowledges the gap as erring toward re-running rather than parking. But the pruner could select its keep-pair with the same podFailureIsGenuine/podDeadlineExceeded predicate and close it outright, which its own comment ("the first genuine failure") suggests was the intent.

operator/RELEASE_NOTES.md - the declared behavior change has no release note

The PR description has a section titled "Behavior change worth a changelog line", but no hand-authored note landed. operator/CHANGELOG.md:3 is marked generated, with hand-authored behavior/upgrade notes directed to RELEASE_NOTES.md; that file's ## Unreleased / ### Changed section is untouched by this diff, as is chart/RELEASE_NOTES.md despite chart/values.yaml gaining a new user-settable jobBackoffLimit.

An operator upgrading gets a self-heal window shortened from roughly an hour to roughly 70 seconds for packages that ride out transient environment flakiness, with nothing telling them the mitigation is to raise JOB_BACKOFF_LIMIT.

Worth noting this is an epic-wide pattern rather than a one-off: #348 / #350 / #351 / #390 also added jobTtlSucceeded / jobStageTimeout / stageTimeout with no RELEASE_NOTES entry. If the intent is to write these once at the epic's landing PR, that is reasonable, but it is worth being deliberate about.


Unadjudicated (single reporter, never cross-evaluated)

operator/internal/controller/job_controller.go:614 - a prior admission rejection suppresses the last-logs snapshot

snapshotFailureLogs returns as soon as it sees any PodFailed non-disruption child pod, on the reasoning that "a genuine failed archive already holds full logs". That is the same loose predicate as the pruner finding above. A pod with no init-container statuses (the kubelet-admission rejection this PR adds handling for) is not genuine and carries no logs at all.

So on a Job that reaches FailureTarget via the Job-level ceiling after an earlier admission rejection, the snapshot is skipped: the surviving archive has no logs and no container statuses, and nodewright.nvidia.com/last-logs is never written. The parked tombstone names nothing, in exactly the never-started-container case the waiting-reason fallback exists for. Best-effort evidence only, no state-machine effect.


Open questions

  • Interrupt gating loosens as a side effect. HasRunningPackages counts unfinished Jobs only, and a crash-looping package Job now goes terminal in ~70s instead of holding Active for the whole stageTimeout. Another package's, or another NodeWright's, interrupt (cordon / drain / reboot) can now proceed on a node where a parked erroring package sits, where previously the Active Job held the gate. Intended?
  • DeploymentPolicy thresholds trip much sooner for a crash-looping package, for the same reason. Nothing asserts the old timing, but rollout shaping on large fleets will behave differently after upgrade.
  • Should pruneFailedAttempts use the classifier's predicate? That closes the first confirmed finding entirely.
  • Is the unbounded hang on stageTimeout: 0 an accepted trade-off? If so, the CRD field doc and chart/values.yaml should say so explicitly rather than implying the retry budget bounds it. See the inline comment on the field.
  • spec.suspend and the failure count. The design doc's "suspend-deletions are excluded from the failure count upstream (verified empirically)" is load-bearing now that the budget is finite: were it false, four pause/resume cycles would spend the whole budget and park every in-flight stage. Could not be verified from this repo (k8s.io/kubernetes is not vendored) and nothing was run.
  • Pod-template activeDeadlineSeconds bounds. jobCeilingSeconds clamps to MaxInt32, but deadlineSeconds does not, so a stageTimeout above roughly 68 years emits an unclamped pod-template value the apiserver may reject, turning every Create into a non-AlreadyExists error loop. The webhook only rejects negatives. Unrealistic input, noted for completeness.
  • chart/README.md's values table omits jobBackoffLimit - though it already omits jobTtlSucceeded / jobTtlFailed / jobStageTimeout from the prior chart PR. Is that table meant to be exhaustive, or a curated subset?
  • docs/designs/2026-07-10-package-execution-as-jobs.md:130 (an unchanged context line) still says the operator "prunes failed attempts to one archive", while the same file's archive bullet and pruneFailedAttempts keep two. Pre-existing, but this PR revises that file heavily.

// killed and retried like any other failed attempt, and the package surfaces as erroring
// once the operator's retry budget (JOB_BACKOFF_LIMIT) is spent. Interrupt stages are the
// exception: their attempt must span a reboot, so it bounds the whole stage instead.
// Unset uses the operator default (JOB_STAGE_TIMEOUT); "0" removes the time bound for this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Confirmed finding. This says 0 leaves "the retry budget as its only limit", but with 0 there is no limit at all for one failure class.

job_builder.go:177-179 gates both spec.template.spec.activeDeadlineSeconds and the Job-level spec.activeDeadlineSeconds behind if timeout > 0, so zero omits both. The design doc in this same commit explains that the Job-level ceiling exists precisely because the per-attempt clock runs from pod.Status.StartTime, which a pod the kubelet never acknowledges never gets, so "a Job whose pods never start would sit Active forever". With stageTimeout: 0 that ceiling is gone too, and such a pod stays Pending rather than becoming Failed, so it never consumes a retry either.

A user who sets stageTimeout: 0 (or the operator-wide jobStageTimeout: "0") on the strength of this wording gets no bound whatsoever on an unacknowledged or unpullable attempt: the package sits in_progress indefinitely, with no erroring signal and no DeploymentPolicy failure accounting.

The same wording appears in chart/values.yaml:88-90 for the operator-wide knob. Either the zero case should keep the ceiling, or both texts should name the exception.

// only for the case a per-attempt clock cannot see.
spec.BackoffLimit = ptr(opts.JobBackoffLimit)
if timeout > 0 {
spec.Template.Spec.ActiveDeadlineSeconds = ptr(deadlineSeconds(timeout))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Confirmed finding (about a file not in this diff). This line moves the package-Job bound to the pod template, and the PR correspondingly rewrote the stageTimeout description in five places: nodewright_types.go, config/crd/bases/nodewright.nvidia.com_nodewrights.yaml, chart/templates/nodewright-crd.yaml, chart/values.yaml, and config/manager/manager.yaml.

operator/api/nodewright/v1alpha1/nodewright_webhook.go:339 was missed and still reads:

// stageTimeout maps to a Job activeDeadlineSeconds; 0 disables it, negatives are meaningless.

Both halves are now false for package Jobs: it maps to the pod template's activeDeadlineSeconds, and 0 no longer disables the bound outright since the retry budget remains. No runtime impact, the >= 0 validation is unchanged and still correct. But leaving one copy behind reintroduces the two-sources-of-truth problem the rest of the sweep was closing.

// shouldDeleteFinishedJob applies: a Complete one holds an unrecorded completion, and a
// Failed one has not yet had its chance to write erroring. Deleting the Failed case here
// would take its retained attempts with it and restart the stage on a fresh budget, and
// this path is reachable in exactly that window — a finished Job does not satisfy

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unadjudicated, raised late, single reporter - but the highest-severity thing surfaced, so worth a careful look.

Claim: a parked stage is never unparked by a package spec change, and the finite backoffLimit makes the park reachable in ~70s instead of after the 1h stage deadline, so "edit the NodeWright to fix a broken package" now usually does nothing until the 24h failure TTL.

The chain as reported:

  • Spec-drift invalidation is scoped to unfinished Jobs. ValidateRunningPackages returns early for any finished Job (2710-2719), so jobIsStale / jobMatchesPackage - the only code comparing a Job to the current package spec (env, image, resources) - never sees a terminal Job.
  • The two paths that do see it both keep it: shouldDeleteFinishedJob returns false when the entry sits at (stage, erroring), and handleExistingJob absorbs the recreate for the same pair just above this line.
  • Job names are deterministic with no config or spec hash, so the fixed spec collides with the parked name.
  • The remaining unpark paths are the orphaned-node sweep, the reboot-reset sweep, a node-state change from rerun/reset, deleteConfigUpdateExecutors - which HandleConfigUpdates calls only for Config/Interrupt/PostInterrupt, not Apply/Upgrade/Uninstall - and TTL expiry.

If that holds, docs/designs/2026-07-10-package-execution-as-jobs.md:165 promises something the code does not do: "The park clears on any explicit signal - package rerun/reset, a config update, a spec change, or failure-TTL expiry."

The carve-out predates this PR, but previously it needed a 1h JobSpec.activeDeadlineSeconds to fire, so in practice a spec fix always landed while the Job was still Active. At ~70s that is no longer true.

The reporter also claims k8s-tests/chainsaw/nodewright/uninstall-fix-config exercises exactly this shape (fixing EXIT_CODE 1 -> 0 at the same package version and asserting the uninstall completes) and becomes a race against the ~70s park. That test is currently passing in CI on this commit, which is evidence against the strong form of the claim - but a race that passes is still a race, so it is worth confirming which of the two readings is right.

failed, reason := jobFailure(job)
return failed && reason == batchv1.JobReasonDeadlineExceeded
failed, _ := jobFailure(job)
return failed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unadjudicated, single reporter. Naming nit with a trap in it: isParkedJob no longer checks anything about parking. It is now exactly jobFailure(job)'s first return value.

Two callers pair it with the entry check that actually makes a Job parked (handleExistingJob with entryErroringAtStage, shouldDeleteFinishedJob with the inline stage/state comparison). deleteConfigUpdateExecutors calls !jobFinished(job) || isParkedJob(job) with no entry check, where it can only mean "terminally failed" - including the all-kubelet-rejections case that jobFailureIsGenuine deliberately classifies as not parked.

Current behavior is correct and matches the design doc's "unfinished or terminally failed". The risk is a future caller trusting the name. Renaming to something like jobTerminallyFailed, and letting the two real park sites compose it with the entry check they already perform, removes the trap.

// handleExistingJob resolves an AlreadyExists on create against the deterministic Job name. It
// never records in_progress (the create did not happen this pass): an unfinished Job that matches
// is a benign race won by another pass; a parked deadline failure whose entry sits erroring is
// is a benign race won by another pass; a parked failure whose entry sits erroring is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unadjudicated, single reporter. This comment block was partially updated: line 2486 changed "a parked deadline failure" to "a parked failure", but the next clause still says "an unprocessed completion is left for JobReconcile; and a stale-spec unfinished Job or a processed finished Job is foreground-deleted".

The body now returns early for any unprocessed finished Job (if !jobProcessed(existing) { return nil }), Complete or Failed. Since the adjacent line in the same block was updated by this diff, this reads as a partial sweep rather than a deliberate carve-out - and the case it omits (Failed-but-unprocessed) is the one the new inline comment just below calls out as the risky one. No runtime effect.

… running

Item 4 of #411, resolved as documentation.

Editing stageTimeout, JOB_STAGE_TIMEOUT or JOB_BACKOFF_LIMIT changes what the
next Job is built with and does not reach a Job already running. jobMatchesPackage
compares only the pod-template subset podMatchesPackage looks at — labels and
per-init-container name/image/env/resources — so the changed value is not
staleness and nothing is replaced on the strength of it.

Making it reach in-flight work is not a small fix, and Kubernetes does not offer a
clean one. The per-attempt bound lives on the Job's pod template, which is
immutable, so it cannot be patched — and a template edit would only reach pods
created after it anyway. The running pod's own activeDeadlineSeconds is mutable
but may only be DECREASED, which is backwards from the edit that motivates the
change: people raise a timeout because a stage needs longer. Applying an increase
means replacing the Job, which kills the in-flight attempt in order to give it
more time.

So the contract is stated rather than engineered around: the new value applies at
the package's next stage, and to apply it to work already under way the user
clears the Job — `kubectl nodewright package rerun`, or deleting it — and the
stage restarts under the new value.

Covers the CRD docstring (regenerated into both CRD copies), the chart's
jobStageTimeout comment, and the design doc's stage-deadline section, which also
records why the Job-level ceiling is left unpatched even though it alone is
mutable: patching it would leave the ceiling and the per-attempt bound
disagreeing.

Refs #411 (item 4).

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
The previous commit picked these up by accident. make manifests and make
unit-tests both run addlicense, which strips the Apache header from the
generated deepcopy files and drops one blank comment line from the header of
every touched file under operator/config/. I reverted that churn before
committing, then ran the test gate, which reintroduced it — and git add -A
caught it the second time.

No content change: only the headers, back to what is committed on the branch.
The stageTimeout description added to nodewright.nvidia.com_nodewrights.yaml
in the previous commit is untouched.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
ayuskauskas added a commit that referenced this pull request Aug 7, 2026
…two unfounded claims

CodeRabbit review on #421; all three findings were right.

FailureTarget is a later threshold than podFailurePolicy, not the same one.
podFailurePolicy and DisruptionTarget are beta-on from 1.26, but 1.29-1.30
report a deadline expiry directly as JobFailed — the delayed-terminal behaviour
that raises FailureTarget first arrives in 1.31. Everything the operator hangs
off that condition therefore breaks a full five minors earlier than the table
claimed, and it is more than the log snapshot: failureTargetStale, the path
that surfaces erroring for a Job wedged on an unreachable node, keys on it too.
Split into its own row, removed from the 1.26 row, and the intro band split
accordingly.

The "race, not corruption" line was a guarantee I had no business making. Flag
files make re-execution idempotent; they are not a lock and do not order two
concurrent cp -r runs into the same copyDir, so a step script can read a file
another attempt is mid-way through overwriting. Now described as a
possible-corruption configuration.

The 1.26 row cited a finite JOB_BACKOFF_LIMIT defaulting to 3. That knob does
not exist on this branch — it arrives with #402, and here BackoffLimit is
MaxInt32. Rephrased to hold in both regimes rather than forward-referencing an
unmerged PR.

Also de-duplicated the snapshot loss out of the 1.27 row: the table is
cumulative, and by 1.27 it has already been lost at 1.31.

Refs #411 (item 8).

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Cross-link from #434 (#411 item 9): that PR adds jobTtlSucceeded, jobTtlFailed, jobStageTimeout, and legacyCleanupDelay to the settings table in chart/README.md, which had none of them. It deliberately does not add a row for jobBackoffLimit, since that knob arrives here.

Whichever of the two merges second should add the missing row — one line in the same table. Both PRs also touch chart/values.yaml, but in different blocks (this one edits the jobStageTimeout comment and appends jobBackoffLimit; #434 adds the one-minute floor note above jobTtlSucceeded), so expect at most a trivial adjacent-line conflict.

pruneFailedAttempts selected archives on Failed-and-not-disrupted, while
jobFailureIsGenuine applied the stricter podDeadlineExceeded ||
podFailureIsGenuine to whatever survived. A kubelet admission rejection
satisfies the first and not the second, so a real step failure sandwiched
between two rejections was prunable while both rejections survived. When
the terminal classifier is the only writer that ran — an operator restart
or leadership change spanning the Pod-watch window — it then read only
rejections, called a genuinely failing stage non-genuine, and let the
sweep clear it to run the whole thing again.

snapshotFailureLogs had the same looseness from the other side: it
returned early on any Failed non-disrupted pod, on the reasoning that a
genuine archive already holds the logs. A rejected attempt has no
container statuses and no logs, so an earlier rejection suppressed the
snapshot and left the timed-out stage with no evidence at all.

Both now call podFailedGenuinely, alongside the classifier. Non-genuine
Failed pods are consequently never pruned either; they are bounded by
backoffLimit+1 and go with the Job at its TTL.

Also records why podReasonDeadlineExceeded is declared locally: the SDK
has no constant for the pod-level reason, and batchv1's same-valued
JobReasonDeadlineExceeded is the Job controller's condition reason on a
different object.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
Spec-drift invalidation was scoped to unfinished Jobs, so a stage that
had spent its retry budget sat behind its terminal Job until the failure
TTL — up to 24h in which editing the package to fix exactly what broke it
did nothing. With a finite backoffLimit that state is now reachable in
about 70 seconds, so the window is no longer theoretical.

The carve-out that keeps a timed-out Job in place now also requires the
Job to still match the package it was built from, in both places that
apply it: the sweep's rerun predicate and the AlreadyExists-on-create
path. An edited package therefore deletes the Job and the stage re-runs
from the entry that is already sitting at (stage, erroring) — the same
position any other erroring package is in.

The spec comparison used by the unfinished-Job staleness check is
extracted so both arms ask the identical question.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
Two clocks over the same work can only disagree. Derived from the retry
budget the ceiling said nothing the budget did not; set independently it
could sit below the budget and truncate it into a DeadlineExceeded that
reads as a hang. Package Jobs now carry exactly two bounds: the
per-attempt deadline on the pod template, and backoffLimit.

The one case the ceiling covered goes with it: the per-attempt clock runs
from pod.Status.StartTime, so a pod the kubelet never acknowledges never
starts it, never fails, and never spends an attempt. That is now
unbounded and documented as node health rather than stage health — no
field the operator can set bounds a pod the kubelet has not accepted, and
activeDeadlineSeconds on the pod is exactly the clock that never starts.

Interrupt Jobs are unchanged: their whole-stage deadline has to span a
reboot, which a per-attempt clock cannot.

deadlineSeconds now clamps to int32. The apiserver rejects anything
larger, so a stageTimeout past ~68 years turned every Create into an
error loop instead of a value the user could see was wrong.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
The field said "0 removes the time bound, leaving the retry budget as its
only limit", which reads as though something still bounds the stage. A
retry budget is only spent by attempts that FAIL, so with 0 an attempt
that hangs hangs forever, and a pod the kubelet never acknowledges is
unbounded at any value.

Also fixes the webhook comment missed when the bound moved to the pod
template: it still claimed stageTimeout maps to a Job
activeDeadlineSeconds and that 0 disables it, both now false for package
Jobs.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
"Parked" reads as something a human did, like paused, when it is the
operator giving up on a stage that spent its deadline or its retry
budget. Renamed through the design doc, the status definitions, the
chainsaw fixtures and the code comments.

isParkedJob becomes jobFailedTerminally rather than isTimedOutJob: it
checks a terminal Failed condition and nothing else, so naming it for the
conclusion its callers draw was the trap the review flagged. A timed-out
stage is that plus the entry sitting at (stage, erroring), which is what
the two callers that mean it actually test; deleteConfigUpdateExecutors
means only "terminally failed" and now reads correctly.

The design doc also drops the claim that the pruner can lose a real
failure to the classifier, which is no longer true.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
The epic has been landing user-visible changes for several PRs — Jobs as
the execution unit, retained failure logs, stageTimeout, the pause
cascade, four new env knobs — with nothing in either RELEASE_NOTES.

The behavior change that most needs saying: a crash-looping package now
gives up after roughly 70 seconds where it used to retry for up to an
hour, so anything that self-healed through a registry blip or a slow
mount needs JOB_BACKOFF_LIMIT raised. The TTL floor of 1m is called out
too, since "0" reads as "no retention" and is actually a startup crash.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

All eleven items from the cross-review are addressed in 11ad797f..64ef1bd1. Two you asked me to explain rather than change are answered below; the rest are code or docs.

Confirmed

1 — pruner vs classifier (job_controller.go). Fixed in 11ad797f. Both now call one podFailedGenuinely predicate (Failed, no DisruptionTarget, and either podDeadlineExceeded or podFailureIsGenuine), so a real failure between two rejections can no longer be pruned out from under the classifier. Consequence worth naming: non-genuine Failed pods are now never pruned either. They are bounded by backoffLimit+1 and go with the Job at its TTL, and keeping them is useful evidence for why the budget burned.

2 — no release note. Written in 64ef1bd1, for the epic rather than just this PR, since you were right that this is a pattern: #348/#350/#351/#390 all landed user-visible surface with nothing in either file. operator/RELEASE_NOTES.md now covers Jobs-as-executor, retained failure logs, stageTimeout, the pause cascade, the TTL knobs, and the ~1h → ~70s self-heal change with JOB_BACKOFF_LIMIT named as the mitigation. chart/RELEASE_NOTES.md gets the four env values and the 1m TTL floor. Going forward I will keep these current per PR rather than deferring to #305.

Unadjudicated

3 — snapshot suppressed by a prior rejection. Confirmed and fixed in the same commit: same predicate, so an admission rejection no longer counts as "the archive already holds the logs". The old spec for this asserted nothing (no stuck pod was present, so nothing would have been snapshotted either way); it now has one, plus a new spec for the rejection case.

4 — a timed-out stage is never unparked by a spec change. Fixed in cf00aee5. The carve-out that keeps a terminal Job in place now also requires the Job to still match the package it was built from, in both the sweep and the AlreadyExists path. Editing the package deletes the Job and the stage re-runs from its existing (stage, erroring) entry — the same position as any other erroring package, per your instruction. Covered for an image change, a version bump, and a package leaving the spec.

5 — isParkedJob no longer checks parking. You are right, and I did not rename it to isTimedOutJob, because that repeats the error under a new word: the function tests a terminal Failed condition and nothing else. It is now jobFailedTerminally, named for what it tests. "Timed out" is now the name of the pair — terminal Failed plus the entry at (stage, erroring) — which is what the two callers that mean it actually check, and deleteConfigUpdateExecutors, which pairs it with nothing, now reads correctly as "unfinished or terminally failed".

6 — partially-updated comment block (skyhook_controller.go:2486). Your read was right: the first clause had been updated and the rest described the old body. The block is rewritten in cf00aee5 and 4ab9067b as part of the surrounding changes — it now states the current three rules (unprocessed finished Jobs are left alone; a timed-out Job is kept only while it matches the spec; everything else is foreground-deleted). No behavior hid behind the stale half; it was documentation drift.

Inline

7 — "parked" → timed out. Done in 4ab9067b, through the design doc, status definitions, chainsaw fixtures, and comments.

8 — remove the Job-level ceiling. Done in 4961fc03, per your call that mismatched timeouts are the worse problem. Package Jobs now carry the per-attempt deadline and backoffLimit, nothing else. The tradeoff is real and now documented rather than solved: the ceiling existed for the pod the kubelet never acknowledges, whose per-attempt clock never starts, so that case is unbounded at any stageTimeout — including the default. It is written up in the design doc and the field docs as node health (a stage stuck in_progress with a Pending pod) rather than stage health, on the reasoning that nothing the operator can set bounds a pod the kubelet has not accepted.

9 — SDK constant for DeadlineExceeded. Checked: there is none for the pod-level reason. core/v1 exports PodReason* only for the PodScheduled and DisruptionTarget conditions, and the kubelet's active-deadline reason is not among them. batchv1.JobReasonDeadlineExceeded carries the same string but is the Job controller's condition reason on a different object, so binding to it would couple this check to an unrelated surface that is free to diverge. Kept the local constant, with that recorded on it.

10 — stageTimeout: 0 is unbounded. Documented in d4779e4c across the field docs, both CRD copies and values.yaml: the retry budget is only spent by attempts that fail, so with 0 an attempt that hangs hangs forever.

11 — stale webhook comment. Fixed in the same commit.

Also from the open questions

  • deadlineSeconds now clamps to int32, so a stageTimeout past ~68 years is a value the apiserver accepts rather than an error loop on every Create.
  • The design doc line claiming the pruner "prunes failed attempts to one archive" is corrected — the code keeps two.
  • chart/README.md: docs: document the Jobs migration's remaining user-facing surfaces (#430) #434 adds the three existing Job knobs to the values table; jobBackoffLimit still needs its row here, as noted above.

Left as they stand: interrupt gating and DeploymentPolicy thresholds tripping sooner are the intended consequence of a bounded budget, and the upstream suspend-deletion claim is still unverified from this tree.

Verification: controller suite 302/302, webhook 43/43 and 130/130, wrappers 84/84, make lint 0 issues, go build ./... clean. Not run: chainsaw e2e (no cluster here) — CI covers it.

Removing the ceiling left this fixture asserting activeDeadlineSeconds:
16800 on both Jobs, so e2e/core failed on every k8s version. The
per-attempt assertion on the pod template stays — that is the bound that
still exists.

Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
@ayuskauskas

Copy link
Copy Markdown
Collaborator Author

Correction to the verification note above: e2e/core failed on all four k8s versions after that push, and it was my miss, not a flake. k8s-tests/chainsaw/nodewright/simple-nodewright/assert_jobs.yaml still asserted activeDeadlineSeconds: 16800 on both Jobs — the ceiling that 4961fc03 removed. I updated that file's comments during the rename and did not notice it also pinned the value.

Fixed in d86c27c8 by dropping both assertions; the per-attempt activeDeadlineSeconds: 3600 on the pod template stays, since that bound still exists. grep -rn activeDeadlineSeconds k8s-tests/ now shows only the two template assertions.

Worth naming the gap: the unit suites and lint were green on the previous push precisely because no unit test can see a chainsaw fixture. For a change that removes a field from a generated object, the e2e fixtures are part of the blast radius and I should have grepped them before claiming verification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/chart Helm chart component/ci CI workflows, GitHub Actions, and repo tooling component/operator Skyhook operator (controller-manager) component/tests End-to-end / chainsaw test suites (k8s-tests) doc Documentation change (PR path label; doc issues use the Documentation type)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants