Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions api/v1/clusterobjectset_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const (
ClusterObjectSetReasonObjectsNotReady = "ObjectsNotReady"
ClusterObjectSetReasonSuperseded = "Superseded"
ClusterObjectSetReasonObjectCollision = "ObjectCollision"
ClusterObjectSetReasonRetryableError = "RetryableError"
ClusterObjectSetReasonPreconditionsNotMet = "PreconditionsNotMet"
ClusterObjectSetReasonInvalidSpecification = "InvalidSpecification"
ClusterObjectSetReasonAllObjectsReady = "AllObjectsReady"
Expand Down Expand Up @@ -508,7 +507,6 @@ type ClusterObjectSetStatus struct {
// - False with reason PreconditionsNotMet: a precondition for applying the revision is not yet satisfied (a referenced Secret is mutable or has invalid or changed content, or a preflight dry-run was rejected, e.g. an immutable field, a conflicting object, or RBAC/dependency CRDs not yet propagated); the revision keeps retrying and recovers once the precondition is met.
// - False with reason InvalidSpecification: the revision spec is invalid (a malformed revision/phase object or progression probe) and requires a spec change to proceed.
// - False with reason ProgressDeadlineExceeded: the revision did not roll out within spec.progressDeadlineMinutes.
// - False with reason RetryableError: the revision encountered a transient error that may resolve on a subsequent reconciliation.
// - False with reason Archiving: the revision is being archived and its owned objects are still being torn down.
// - False with reason Archived: the revision has been archived and its objects have been torn down.
//
Expand Down
1 change: 0 additions & 1 deletion applyconfigurations/api/v1/clusterobjectsetstatus.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion docs/draft/concepts/clusterobjectsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,12 @@ Indicates whether all objects in the revision have been successfully rolled out
| False | `ObjectCollision` | One or more objects are controlled by another owner, or are unowned and collision protection prevents adoption |
| False | `PreconditionsNotMet` | A precondition for applying the revision is not yet satisfied (a referenced Secret is mutable or has invalid or changed content, or a preflight dry-run was rejected, e.g. an immutable field, a conflicting object, or RBAC/dependency CRDs not yet propagated); the revision keeps retrying and recovers once the precondition is met |
| False | `InvalidSpecification` | The revision spec is invalid (a malformed revision/phase object or progression probe); requires a spec change |
| Unknown | `RetryableError` | Error prevented probe observation |
| False | `Archiving` | The revision is being archived; owned objects are still being torn down |
| False | `Archived` | Objects torn down after archival |
| Unknown | `Migrated` | Migrated from existing release; probes not yet observed |

A transient internal error (e.g. an API read failure) leaves the `Ready` condition unchanged and is retried with backoff; it is not reported as a distinct reason, so on a first reconcile the condition may remain absent (`Unknown`).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

WDYT about setting Ready to Unknown with reason RetryableError? That is the most truthful thing we can say.

Or maybe we can figure out a way for a RetryableError to only cause a condition to flip after a certain number of consecutive failed retries?

I'm worried about a situation where our code has decided that an error is transient, but then it turns out that it is not transient and our status gives no indication of that.

@perdasilva perdasilva Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was thinking of following this PR with generating an event for these transient errors - that way we at least get some kind of external signal. I really think we shouldn't be surfacing controller state through the object - otherwise we're being kinda edge based. But, I agree this leaves that case open =/ ultimately, I think we need to cover this gap through monitoring and metrics.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1 on the eventing idea. Need to be careful there to avoid sending too many duplicate events. There's an OLMv0 bug about that right now, I think.

But on the COS status, if we say Ready/Unknown/Indeterminate/could not evaluate readiness due to transient error, that's not controller state. That's accurate COS state, saying 🤷 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess the question is whether we want to produce a new level on a transient error. It seems like the right thing for a controller to do is eat these kinds of spurious transition changes, and surface sticky reconciliation failures through controller metrics, no?

@perdasilva perdasilva Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

maybe this is the case for the Progressing condition? Or Degraded?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here's what I've found with Claude asking it to check Deployment, ResultSet, StatefulSet, CRD, etc.

The rule holds everywhere I checked: no controller flaps a condition to Unknown on a transient error. Transient = return + requeue, leave status.
Failures that get surfaced use a dedicated condition (ReplicaFailure=True, NamesAccepted=False/reason), cleared/recomputed on recovery. The only
persisted Unknown is the Node controller's, and it's gated on a sustained grace period from the last successful observation — never a single error.

So for the COS:

  • "leave Ready, requeue" on transient plumbing errors is exactly the idiomatic, universally-observed behavior. ✅
  • If you want transient failures visible on the object, the precedent is a dedicated Degraded/*Failure-style condition cleared on recovery, not touching Ready.
  • Ready=Unknown/TransientError per-error: no precedent in any controller checked. The nearest Unknown (Node) is sustained-staleness-gated — a different mechanism you'd have to build deliberately (a grace period), not a per-tick error reaction.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's convincing! I definitely agree that it would not be ideal to flap Ready on a momentary hiccup.

It seems like no other core controllers immediately flap on the first error. So I'm convinced on this change, and we can always add a separate status condition or wait to flip to Unknown until we decide the transient error is sustained.

Thanks for the perseverance!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If Ready is not already set, should we explicitly set it to Unknown (or does this already happen as a fallback somewhere?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in follow-up #3023: the controller now initializes Ready=Unknown/Pending when the condition is absent, so an active revision always reports an explicit Ready instead of relying on absent-reads-as-Unknown. It's done in the outer Reconcile (after any in-reconcile metadata patches), only when Ready is absent (never overwrites an observed state), and skipped for deleting/archived revisions.


### Succeeded

A terminal condition set once the rollout completes. It persists even if the revision later becomes unavailable, marking that this version was successfully deployed at least once.
Expand Down
4 changes: 2 additions & 2 deletions docs/draft/concepts/large-bundle-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,8 @@ The reconciler distinguishes two failure modes:

- A referenced **Secret that does not exist yet** — for example, informer cache lag
after creation, or a partially completed creation sequence — is treated as a
transient error. The ClusterObjectSet reports `Ready=False, Reason=RetryableError`
and the controller retries with backoff.
transient error. The ClusterObjectSet leaves its `Ready` condition unchanged and the
controller returns the error so it retries with backoff.
- **Invalid referenced content** — a missing key, a malformed manifest, or content
that has changed since first rollout — is an unmet precondition. The ClusterObjectSet
reports `Ready=False, Reason=PreconditionsNotMet`. Because referenced Secrets are not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,6 @@ spec:
- False with reason PreconditionsNotMet: a precondition for applying the revision is not yet satisfied (a referenced Secret is mutable or has invalid or changed content, or a preflight dry-run was rejected, e.g. an immutable field, a conflicting object, or RBAC/dependency CRDs not yet propagated); the revision keeps retrying and recovers once the precondition is met.
- False with reason InvalidSpecification: the revision spec is invalid (a malformed revision/phase object or progression probe) and requires a spec change to proceed.
- False with reason ProgressDeadlineExceeded: the revision did not roll out within spec.progressDeadlineMinutes.
- False with reason RetryableError: the revision encountered a transient error that may resolve on a subsequent reconciliation.
- False with reason Archiving: the revision is being archived and its owned objects are still being torn down.
- False with reason Archived: the revision has been archived and its objects have been torn down.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,9 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl
markAsNotReady(cos, ocv1.ClusterObjectSetReasonPreconditionsNotMet, err.Error())
return ctrl.Result{RequeueAfter: validationRetryInterval}, nil
}
// A Secret read error is transient; surface it as retryable and requeue with backoff.
werr := fmt.Errorf("verifying referenced Secrets: %v", err)
setRetryableErrorConditions(cos, werr.Error(), isDeadlineExceeded)
return ctrl.Result{}, werr
// A Secret read error is transient; leave the Ready condition unchanged and
// return the error for rate-limited backoff.
return ctrl.Result{}, fmt.Errorf("verifying referenced Secrets: %v", err)
}

phases, currentPhases, opts, err := c.buildBoxcutterPhases(ctx, cos)
Expand All @@ -175,7 +174,6 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl
markAsNotReady(cos, ocv1.ClusterObjectSetReasonInvalidSpecification, fmt.Sprintf("The object set spec is invalid: %v.", err))
return ctrl.Result{}, nil
}
setRetryableErrorConditions(cos, err.Error(), isDeadlineExceeded)
return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err)
Comment on lines -178 to 179

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How do we know that all other errors from buildBoxcutterPhases are and will always be transient? Should we have another errors.As for a TransientError match?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in follow-up #3023. Audit of buildBoxcutterPhases: every deterministic failure is already a typed configValidationError/referencedContentError except computePhaseDigest's json.Marshal, which #3023 now classifies as configValidationError (→ InvalidSpecification). The fallthrough also gets a comment documenting the invariant that anything reaching it is a transient infra error (and that new deterministic modes must be typed). I kept the controller-runtime idiom (retryable-by-default, explicitly-terminal via typed errors) rather than inverting to an explicit TransientError match.

}

Expand All @@ -192,7 +190,6 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl

revisionEngine, err := c.RevisionEngineFactory.CreateRevisionEngine(ctx, cos)
if err != nil {
setRetryableErrorConditions(cos, err.Error(), isDeadlineExceeded)
return ctrl.Result{}, fmt.Errorf("failed to create revision engine: %v", err)
}

Expand All @@ -206,7 +203,6 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl

if cos.Spec.LifecycleState == ocv1.ClusterObjectSetLifecycleStateArchived {
if err := c.TrackingCache.Free(ctx, cos); err != nil {
markAsNotReady(cos, ocv1.ClusterObjectSetReasonRetryableError, err.Error())
return ctrl.Result{}, fmt.Errorf("error stopping informers: %v", err)
}
return c.archive(ctx, revisionEngine, cos, revision)
Expand All @@ -217,9 +213,7 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl
}

if err := c.establishWatch(ctx, cos, revision); err != nil {
werr := fmt.Errorf("establish watch: %v", err)
setRetryableErrorConditions(cos, werr.Error(), isDeadlineExceeded)
return ctrl.Result{}, werr
return ctrl.Result{}, fmt.Errorf("establish watch: %v", err)
}

rres, err := revisionEngine.Reconcile(ctx, revision, opts...)
Expand All @@ -228,7 +222,6 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl
// Log detailed reconcile reports only in debug mode (V(1)) to reduce verbosity.
l.V(1).Info("reconcile report", "report", rres.String())
}
setRetryableErrorConditions(cos, err.Error(), isDeadlineExceeded)
return ctrl.Result{}, fmt.Errorf("revision reconcile: %v", err)
}

Expand Down Expand Up @@ -393,7 +386,6 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl

func (c *ClusterObjectSetReconciler) delete(ctx context.Context, cos *ocv1.ClusterObjectSet) (ctrl.Result, error) {
if err := c.TrackingCache.Free(ctx, cos); err != nil {
markAsNotReady(cos, ocv1.ClusterObjectSetReasonRetryableError, err.Error())
return ctrl.Result{}, fmt.Errorf("error stopping informers: %v", err)
}
if err := c.removeFinalizer(ctx, cos, clusterObjectSetTeardownFinalizer); err != nil {
Expand All @@ -405,9 +397,9 @@ func (c *ClusterObjectSetReconciler) delete(ctx context.Context, cos *ocv1.Clust
func (c *ClusterObjectSetReconciler) archive(ctx context.Context, revisionEngine RevisionEngine, cos *ocv1.ClusterObjectSet, revision boxcutter.RevisionBuilder) (ctrl.Result, error) {
tdres, err := revisionEngine.Teardown(ctx, revision, machinerytypes.WithObserveAfterIncomplete{})
if err != nil {
err = fmt.Errorf("error archiving revision: %v", err)
setRetryableErrorConditions(cos, err.Error(), false)
return ctrl.Result{}, err
// Transient teardown error: leave the Ready condition unchanged and return
// the error for rate-limited backoff.
return ctrl.Result{}, fmt.Errorf("error archiving revision: %v", err)
}
// Set object counts
status.FromTeardown(cos, tdres)
Expand Down Expand Up @@ -775,10 +767,6 @@ func setReadyWithDeadline(cos *ocv1.ClusterObjectSet, status metav1.ConditionSta
})
}

func setRetryableErrorConditions(cos *ocv1.ClusterObjectSet, message string, isDeadlineExceeded bool) {
setReadyWithDeadline(cos, metav1.ConditionFalse, ocv1.ClusterObjectSetReasonRetryableError, message, isDeadlineExceeded)
}

func markAsReady(cos *ocv1.ClusterObjectSet, reason, message string) bool {
return meta.SetStatusCondition(&cos.Status.Conditions, metav1.Condition{
Type: ocv1.ClusterObjectSetTypeReady,
Expand Down
Loading
Loading