//go:build !standard package controllers import ( "bytes" "compress/gzip" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "strings" "time" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/workqueue" "k8s.io/utils/clock" "pkg.package-operator.run/boxcutter" "pkg.package-operator.run/boxcutter/machinery" machinerytypes "pkg.package-operator.run/boxcutter/machinery/types" "pkg.package-operator.run/boxcutter/ownerhandling" "pkg.package-operator.run/boxcutter/probing" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/source" ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/object-controller/status" "github.com/operator-framework/operator-controller/internal/shared/labels" ) const ( clusterObjectSetTeardownFinalizer = "olm.operatorframework.io/teardown" // validationRetryInterval is how often the reconciler re-checks a revision that is // Ready=False/PreconditionsNotMet. These failures stem from inputs that are not watched // — a mutable or changed referenced Secret, an invalid referenced manifest, or a // preflight dry-run rejected until RBAC or a dependency CRD propagates — and the ref // spec is immutable, so a periodic retry lets the revision recover (once the Secret is // replaced or the cluster state catches up) without a controller restart. validationRetryInterval = 10 * time.Second ) // ClusterObjectSetReconciler manages the Kubernetes objects in a ClusterObjectSet. type ClusterObjectSetReconciler struct { Client client.Client RevisionEngineFactory RevisionEngineFactory TrackingCache trackingCache Clock clock.Clock } //go:generate mockgen -source clusterobjectset_controller.go -destination mock_trackingcache_gen_test.go -package controllers -mock_names trackingCache=MockTrackingCache -exclude_interfaces Sourcoser type trackingCache interface { client.Reader Source(handler handler.EventHandler, predicates ...predicate.Predicate) source.Source Watch(ctx context.Context, user client.Object, gvks sets.Set[schema.GroupVersionKind]) error Free(ctx context.Context, user client.Object) error } //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterobjectsets,verbs=get;list;watch;update;patch;create;delete //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterobjectsets/status,verbs=update;patch //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterobjectsets/finalizers,verbs=update //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch func (c *ClusterObjectSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { l := log.FromContext(ctx).WithName("cluster-extension-revision") ctx = log.IntoContext(ctx, l) if c.Clock == nil { c.Clock = clock.RealClock{} } existingRev := &ocv1.ClusterObjectSet{} if err := c.Client.Get(ctx, req.NamespacedName, existingRev); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } l.Info("reconcile starting") defer l.Info("reconcile ending") reconciledRev := existingRev.DeepCopy() res, reconcileErr := c.reconcile(ctx, reconciledRev) // Ensure an active revision always reports a Ready condition. If reconcile left it // absent — e.g. an early transient error returned before the first evaluation set a // concrete state — initialize it to Unknown/Pending. This runs after reconcile (past // any in-reconcile metadata patches that refresh the object), only sets Ready when // absent so it never overwrites a previously observed state, and is skipped for // revisions that are being deleted or archived. if reconciledRev.DeletionTimestamp.IsZero() && reconciledRev.Spec.LifecycleState != ocv1.ClusterObjectSetLifecycleStateArchived && meta.FindStatusCondition(reconciledRev.Status.Conditions, ocv1.ClusterObjectSetTypeReady) == nil { markAsPending(reconciledRev) } // Do checks before any Update()s, as Update() may modify the resource structure! updateStatus := !equality.Semantic.DeepEqual(existingRev.Status, reconciledRev.Status) unexpectedFieldsChanged := checkForUnexpectedClusterObjectSetFieldChange(*existingRev, *reconciledRev) if unexpectedFieldsChanged { panic("spec or metadata changed by reconciler") } // NOTE: finalizer updates are performed during c.reconcile as patches, so that reconcile can // continue performing logic after successfully setting the finalizer. therefore we only need // to set status here. if updateStatus { if err := c.Client.Status().Update(ctx, reconciledRev); err != nil { reconcileErr = errors.Join(reconcileErr, fmt.Errorf("error updating status: %v", err)) } } return res, reconcileErr } // Compare resources - ignoring status & metadata.finalizers func checkForUnexpectedClusterObjectSetFieldChange(a, b ocv1.ClusterObjectSet) bool { a.Status, b.Status = ocv1.ClusterObjectSetStatus{}, ocv1.ClusterObjectSetStatus{} // when finalizers are updated during reconcile, we expect finalizers, managedFields, and resourceVersion // to be updated, so we ignore changes in these fields. a.Finalizers, b.Finalizers = []string{}, []string{} a.ManagedFields, b.ManagedFields = nil, nil a.ResourceVersion, b.ResourceVersion = "", "" return !equality.Semantic.DeepEqual(a.Spec, b.Spec) } func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.ClusterObjectSet) (ctrl.Result, error) { l := log.FromContext(ctx) if !cos.DeletionTimestamp.IsZero() { return c.delete(ctx, cos) } remaining, hasDeadline := durationUntilDeadline(c.Clock, cos) isDeadlineExceeded := hasDeadline && remaining <= 0 if err := c.verifyReferencedSecretsImmutable(ctx, cos); err != nil { var mutErr *mutableSecretError if errors.As(err, &mutErr) { // A confirmed mutable Secret requires user action (replacing the immutable Secret), // which is not watched, so keep retrying so the revision can recover. // PreconditionsNotMet takes precedence over ProgressDeadlineExceeded: it is more actionable. l.Error(err, "referenced Secret is mutable, marking revision not ready") markAsNotReady(cos, ocv1.ClusterObjectSetReasonPreconditionsNotMet, err.Error()) return ctrl.Result{RequeueAfter: validationRetryInterval}, nil } // 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) if err != nil { var contentErr *referencedContentError if errors.As(err, &contentErr) { // Invalid referenced Secret content (missing key or malformed manifest). The Secret // is not watched and the ref is immutable, so keep retrying so the revision can // recover once the Secret is replaced with valid content. l.Error(err, "invalid referenced content, marking revision not ready") markAsNotReady(cos, ocv1.ClusterObjectSetReasonPreconditionsNotMet, fmt.Sprintf("Referenced content is invalid: %v. Fix the Secret content.", err)) return ctrl.Result{RequeueAfter: validationRetryInterval}, nil } var cfgErr *configValidationError if errors.As(err, &cfgErr) { // Invalid revision spec; a spec edit (or a new revision) re-triggers reconciliation, // so no periodic retry is needed. l.Error(err, "invalid configuration, marking revision not ready") markAsNotReady(cos, ocv1.ClusterObjectSetReasonInvalidSpecification, fmt.Sprintf("The object set spec is invalid: %v.", err)) return ctrl.Result{}, nil } // Invariant: the error sites in buildBoxcutterPhases classify every deterministic // (spec/content) failure as a *configValidationError or *referencedContentError, // handled above. Anything reaching here is therefore a transient infrastructure // error (e.g. listing sibling revisions or reading a referenced Secret failed), so // leave Ready unchanged and return the error for a rate-limited backoff requeue. // New deterministic failure modes added to buildBoxcutterPhases must be wrapped in // one of those typed errors so they are reported rather than retried forever. return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err) } if len(cos.Status.ObservedPhases) == 0 { cos.Status.ObservedPhases = currentPhases } else if err := verifyObservedPhases(cos.Status.ObservedPhases, currentPhases); err != nil { // The resolved content of a referenced Secret changed since first reconciliation. // Secrets are not watched, so keep retrying so the revision can recover once the // original content is restored. l.Error(err, "resolved phases content changed, marking revision not ready") markAsNotReady(cos, ocv1.ClusterObjectSetReasonPreconditionsNotMet, fmt.Sprintf("Referenced Secret content no longer matches the original rollout: %v. Restore the original content.", err)) return ctrl.Result{RequeueAfter: validationRetryInterval}, nil } revisionEngine, err := c.RevisionEngineFactory.CreateRevisionEngine(ctx, cos) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to create revision engine: %v", err) } revision := boxcutter.NewRevisionWithOwner( cos.Name, cos.Spec.Revision, phases, cos, ownerhandling.NewNative(c.Client.Scheme()), ) if cos.Spec.LifecycleState == ocv1.ClusterObjectSetLifecycleStateArchived { if err := c.TrackingCache.Free(ctx, cos); err != nil { return ctrl.Result{}, fmt.Errorf("error stopping informers: %v", err) } return c.archive(ctx, revisionEngine, cos, revision) } if err := c.ensureFinalizer(ctx, cos, clusterObjectSetTeardownFinalizer); err != nil { return ctrl.Result{}, fmt.Errorf("error ensuring teardown finalizer: %v", err) } if err := c.establishWatch(ctx, cos, revision); err != nil { return ctrl.Result{}, fmt.Errorf("establish watch: %v", err) } rres, err := revisionEngine.Reconcile(ctx, revision, opts...) if err != nil { if rres != nil { // Log detailed reconcile reports only in debug mode (V(1)) to reduce verbosity. l.V(1).Info("reconcile report", "report", rres.String()) } return ctrl.Result{}, fmt.Errorf("revision reconcile: %v", err) } // Set object counts status.FromReconcile(cos, rres) // Report revision-level preflight validation failures and re-check on a flat timer: // the dry-run can be rejected for reasons that clear without a spec change (e.g. RBAC // or a dependency CRD still propagating), and those inputs are not watched. if verr := rres.GetValidationError(); verr != nil { l.Error(fmt.Errorf("%w", verr), "preflight validation failed, retrying after 10s") markAsNotReady(cos, ocv1.ClusterObjectSetReasonPreconditionsNotMet, fmt.Sprintf("A pre-apply check rejected the revision: %v.", verr)) return ctrl.Result{RequeueAfter: validationRetryInterval}, nil } // Phases are reconciled in order; once a phase is incomplete, later phases are // only observed read-only (paused). A paused phase's preflight dry-run can fail // for reasons that resolve once earlier phases roll out (e.g. a Namespace a // later phase depends on has not been created yet), so its validation error is // not actionable yet. Only treat a phase's validation error as a terminal // validation failure while every preceding phase is complete; otherwise skip it // and let the rollout status (and the progress deadline) below take over. // Collisions are still reported for paused phases below: an object owned by // another controller does not become ownable just because earlier phases roll // out, so it remains actionable regardless of phase ordering. precedingPhasesComplete := true for _, pres := range rres.GetPhases() { if verr := pres.GetValidationError(); verr != nil { if !precedingPhasesComplete { // Paused downstream phase: an earlier phase is still rolling out. l.V(1).Info("ignoring paused phase preflight validation error while an earlier phase rolls out", "phase", pres.GetName(), "err", verr.Error()) continue } l.Error(fmt.Errorf("%w", verr), "phase preflight validation failed, retrying after 10s", "phase", pres.GetName()) markAsNotReady(cos, ocv1.ClusterObjectSetReasonPreconditionsNotMet, fmt.Sprintf("A pre-apply check rejected phase %q: %v.", pres.GetName(), verr)) return ctrl.Result{RequeueAfter: validationRetryInterval}, nil } if !pres.IsComplete() { precedingPhasesComplete = false } var collidingObjs []string for _, ores := range pres.GetObjects() { if ores.Action() != machinery.ActionCollision { continue } obj := ores.Object() name := obj.GetName() if ns := obj.GetNamespace(); ns != "" { name = ns + "/" + name } gvk := obj.GetObjectKind().GroupVersionKind() desc := fmt.Sprintf("%s.%s %q", gvk.Kind, gvk.GroupVersion().String(), name) if coll, ok := ores.(machinery.ObjectResultCollision); ok { if owner, hasOwner := coll.ConflictingOwner(); hasOwner { desc += fmt.Sprintf(" is owned by %s %q", owner.Kind, owner.Name) } } collidingObjs = append(collidingObjs, desc) } if len(collidingObjs) > 0 { l.Error(fmt.Errorf("object collision detected"), "object collision, retrying after 10s", "phase", pres.GetName(), "collisions", collidingObjs) setReadyWithDeadline(cos, metav1.ConditionFalse, ocv1.ClusterObjectSetReasonObjectCollision, fmt.Sprintf("Cannot take ownership of %d object(s) in phase %q because they are owned by another controller or already exist. Remove the conflicting owner or object, or create a new ClusterObjectSet with a more permissive collisionProtection setting.", len(collidingObjs), pres.GetName()), isDeadlineExceeded) return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } } // A revision whose objects have all been adopted by a newer sibling revision can // report IsComplete() (progressed objects count as complete), but it is superseded, // not successfully rolled out: skip the completion branch so it does not archive // previous revisions, report AllObjectsReady, or record a first completion // (completedAt) that would identify it as the installed revision. superseded := hasSupersededObject(rres) //nolint:nestif if rres.IsComplete() && !superseded { // Archive previous revisions previous, err := c.listPreviousRevisions(ctx, cos) if err != nil { return ctrl.Result{}, fmt.Errorf("listing previous revisions: %v", err) } for _, a := range previous { patch := []byte(`{"spec":{"lifecycleState":"Archived"}}`) if err := c.Client.Patch(ctx, client.Object(a), client.RawPatch(types.MergePatchType, patch)); err != nil { // TODO: It feels like an error here needs to propagate to a status _somewhere_. // Not sure the current COS makes sense? But it also feels off to set the CE // status from outside the CE reconciler. return ctrl.Result{}, fmt.Errorf("archive previous Revision: %w", err) } } markAsReady(cos, ocv1.ClusterObjectSetReasonAllObjectsReady, "All objects are at the desired state") // Record the timestamp of the first time the revision was observed to be // ready. This is set once and never changes for subsequent reconciliations. // It also serves as the signal that the revision has completed its rollout, // which the ClusterExtension controller uses to determine the installed revision. if cos.Status.CompletedAt.IsZero() { cos.Status.CompletedAt = metav1.NewTime(c.Clock.Now()) } } else { var notReadyMsgs []string for _, pres := range rres.GetPhases() { if pres.IsComplete() { continue } for _, ores := range pres.GetObjects() { if ores.IsPaused() && ores.Action() == machinery.ActionCreated { // Skip probe checks of objects not yet created continue } // we probably want an AvailabilityProbeType and run through all of them independently of whether // the revision is complete or not pr := ores.ProbeResults()[boxcutter.ProgressProbeType] if pr.Status == machinerytypes.ProbeStatusTrue { continue } obj := ores.Object() gvk := obj.GetObjectKind().GroupVersionKind() // Count every not-ready object (the condition reports the count); the // per-object detail here is for the V(1) debug log, not the message. notReadyMsgs = append(notReadyMsgs, fmt.Sprintf( "Object %s.%s %s/%s: %v", gvk.Kind, gvk.GroupVersion().String(), obj.GetNamespace(), obj.GetName(), strings.Join(pr.Messages, " and "), )) } } // Generic summary used as the ProgressDeadlineExceeded "last status", kept // stable (not the volatile per-object detail) so the deadline message does not // churn. The non-deadline path reports the count. Per-object detail is kept out // of the condition message and logged at V(1) for debugging. const genericNotReadyMsg = "Objects are not yet ready." if len(notReadyMsgs) > 0 { l.V(1).Info("objects not yet ready", "count", len(notReadyMsgs), "details", notReadyMsgs) } // Precedence: ProgressDeadlineExceeded > Superseded > ObjectsNotReady. switch { case isDeadlineExceeded: setReadyWithDeadline(cos, metav1.ConditionFalse, ocv1.ClusterObjectSetReasonObjectsNotReady, genericNotReadyMsg, true) case superseded: markAsNotReady(cos, ocv1.ClusterObjectSetReasonSuperseded, "Object(s) have been adopted by a newer revision of the object set.") default: notReadyMsg := genericNotReadyMsg if len(notReadyMsgs) > 0 { notReadyMsg = fmt.Sprintf("%d object(s) are not yet ready.", len(notReadyMsgs)) } setReadyWithDeadline(cos, metav1.ConditionFalse, ocv1.ClusterObjectSetReasonObjectsNotReady, notReadyMsg, false) } if hasDeadline && !isDeadlineExceeded { return ctrl.Result{RequeueAfter: remaining}, nil } } return ctrl.Result{}, nil } func (c *ClusterObjectSetReconciler) delete(ctx context.Context, cos *ocv1.ClusterObjectSet) (ctrl.Result, error) { if err := c.TrackingCache.Free(ctx, cos); err != nil { return ctrl.Result{}, fmt.Errorf("error stopping informers: %v", err) } if err := c.removeFinalizer(ctx, cos, clusterObjectSetTeardownFinalizer); err != nil { return ctrl.Result{}, fmt.Errorf("error removing teardown finalizer: %v", err) } return ctrl.Result{}, nil } 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 { // 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) if tdres != nil && !tdres.IsComplete() { markAsArchiving(cos) return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } // Ensure conditions are set before removing the finalizer when archiving if markAsArchived(cos) { return ctrl.Result{}, nil } if err := c.removeFinalizer(ctx, cos, clusterObjectSetTeardownFinalizer); err != nil { return ctrl.Result{}, fmt.Errorf("error removing teardown finalizer: %v", err) } return ctrl.Result{}, nil } type Sourcoser interface { Source(handler handler.EventHandler, predicates ...predicate.Predicate) source.Source } func (c *ClusterObjectSetReconciler) SetupWithManager(mgr ctrl.Manager) error { c.Clock = clock.RealClock{} return ctrl.NewControllerManagedBy(mgr). WithOptions(controller.Options{ RateLimiter: newDeadlineAwareRateLimiter( workqueue.DefaultTypedControllerRateLimiter[ctrl.Request](), mgr.GetClient(), c.Clock, ), }). For( &ocv1.ClusterObjectSet{}, builder.WithPredicates( predicate.ResourceVersionChangedPredicate{}, ), ). WatchesRawSource( c.TrackingCache.Source( handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &ocv1.ClusterObjectSet{}), predicate.ResourceVersionChangedPredicate{}, ), ). Complete(c) } func (c *ClusterObjectSetReconciler) establishWatch(ctx context.Context, cos *ocv1.ClusterObjectSet, revision boxcutter.RevisionBuilder) error { gvks := sets.New[schema.GroupVersionKind]() for _, phase := range revision.GetPhases() { for _, obj := range phase.GetObjects() { gvks.Insert(obj.GetObjectKind().GroupVersionKind()) } } return c.TrackingCache.Watch(ctx, cos, gvks) } func (c *ClusterObjectSetReconciler) ensureFinalizer( ctx context.Context, obj client.Object, finalizer string, ) error { if controllerutil.ContainsFinalizer(obj, finalizer) { return nil } controllerutil.AddFinalizer(obj, finalizer) patch := map[string]any{ "metadata": map[string]any{ "resourceVersion": obj.GetResourceVersion(), "finalizers": obj.GetFinalizers(), }, } patchJSON, err := json.Marshal(patch) if err != nil { return fmt.Errorf("marshalling patch to remove finalizer: %w", err) } if err := c.Client.Patch(ctx, obj, client.RawPatch(types.MergePatchType, patchJSON)); err != nil { return fmt.Errorf("adding finalizer: %w", err) } return nil } func (c *ClusterObjectSetReconciler) removeFinalizer(ctx context.Context, obj client.Object, finalizer string) error { if !controllerutil.ContainsFinalizer(obj, finalizer) { return nil } controllerutil.RemoveFinalizer(obj, finalizer) patch := map[string]any{ "metadata": map[string]any{ "resourceVersion": obj.GetResourceVersion(), "finalizers": obj.GetFinalizers(), }, } patchJSON, err := json.Marshal(patch) if err != nil { return fmt.Errorf("marshalling patch to remove finalizer: %w", err) } if err := c.Client.Patch(ctx, obj, client.RawPatch(types.MergePatchType, patchJSON)); err != nil { return fmt.Errorf("removing finalizer: %w", err) } return nil } // listSiblingRevisions returns all active revisions belonging to the same owner, excluding the current one. // This includes both lower and higher revision numbers, enabling boxcutter to properly classify // sibling owners and avoid reporting false collisions during revision handover. func (c *ClusterObjectSetReconciler) listSiblingRevisions(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]*ocv1.ClusterObjectSet, error) { return c.listOtherActiveRevisions(ctx, cos, func(*ocv1.ClusterObjectSet) bool { return true }) } // listPreviousRevisions returns active revisions belonging to the same owner with lower revision numbers. func (c *ClusterObjectSetReconciler) listPreviousRevisions(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]*ocv1.ClusterObjectSet, error) { return c.listOtherActiveRevisions(ctx, cos, func(r *ocv1.ClusterObjectSet) bool { return r.Spec.Revision < cos.Spec.Revision }) } func (c *ClusterObjectSetReconciler) listOtherActiveRevisions( ctx context.Context, cos *ocv1.ClusterObjectSet, predicate func(*ocv1.ClusterObjectSet) bool, ) ([]*ocv1.ClusterObjectSet, error) { ownerLabel, ok := cos.Labels[labels.OwnerNameKey] if !ok { return nil, nil } revList := &ocv1.ClusterObjectSetList{} if err := c.TrackingCache.List(ctx, revList, client.MatchingLabels{ labels.OwnerNameKey: ownerLabel, }); err != nil { return nil, fmt.Errorf("listing revisions: %w", err) } result := make([]*ocv1.ClusterObjectSet, 0, len(revList.Items)) for i := range revList.Items { r := &revList.Items[i] if r.Name == cos.Name { continue } if r.Spec.LifecycleState == ocv1.ClusterObjectSetLifecycleStateArchived || !r.DeletionTimestamp.IsZero() { continue } if !predicate(r) { continue } result = append(result, r) } return result, nil } // configValidationError marks an error caused by invalid user-provided configuration in the // revision spec (a malformed revision/phase object or a bad progression probe). The spec is // either immutable or watched, so the reconciler reports it as Ready=False/InvalidSpecification // without a periodic retry: a spec edit re-triggers reconciliation. type configValidationError struct{ error } // referencedContentError marks a validation failure caused by the content of a referenced // Secret (a missing key or a malformed manifest). Referenced Secrets are not watched and the // phase/ref spec is immutable, so the failure can only clear when the Secret is replaced. // The reconciler reports it as Ready=False/PreconditionsNotMet but keeps retrying periodically // so the revision can recover without a controller restart. type referencedContentError struct{ error } func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]boxcutter.Phase, []ocv1.ObservedPhase, []boxcutter.RevisionReconcileOption, error) { siblings, err := c.listSiblingRevisions(ctx, cos) if err != nil { return nil, nil, nil, fmt.Errorf("listing sibling revisions: %w", err) } siblingObjs := make([]client.Object, len(siblings)) for i, rev := range siblings { siblingObjs[i] = rev } progressionProbes, err := buildProgressionProbes(cos.Spec.ProgressionProbes) if err != nil { return nil, nil, nil, err } opts := []boxcutter.RevisionReconcileOption{ boxcutter.WithSiblingOwners(siblingObjs), boxcutter.WithProbe(boxcutter.ProgressProbeType, progressionProbes), boxcutter.WithAggregatePhaseReconcileErrors(), boxcutter.WithObserveAfterIncomplete{}, } phases := make([]boxcutter.Phase, 0, len(cos.Spec.Phases)) observedPhases := make([]ocv1.ObservedPhase, 0, len(cos.Spec.Phases)) for _, specPhase := range cos.Spec.Phases { objs := make([]client.Object, 0, len(specPhase.Objects)) for _, specObj := range specPhase.Objects { var obj *unstructured.Unstructured switch { case specObj.Object.Object != nil: obj = specObj.Object.DeepCopy() case specObj.Ref.Name != "": resolved, err := c.resolveObjectRef(ctx, specObj.Ref) if err != nil { return nil, nil, nil, fmt.Errorf("resolving ref in phase %q: %w", specPhase.Name, err) } obj = resolved default: return nil, nil, nil, &configValidationError{fmt.Errorf("an object in phase %q has neither an inline object nor a Secret reference", specPhase.Name)} } objs = append(objs, obj) } // Compute digest from the user-provided objects before controller mutations. digest, err := computePhaseDigest(specPhase.Name, objs) if err != nil { return nil, nil, nil, fmt.Errorf("computing phase digest: %w", err) } observedPhases = append(observedPhases, ocv1.ObservedPhase{Name: specPhase.Name, Digest: digest}) // Apply controller mutations after digest computation. for i, obj := range objs { objLabels := obj.GetLabels() if objLabels == nil { objLabels = map[string]string{} } objLabels[labels.OwnerNameKey] = cos.Labels[labels.OwnerNameKey] obj.SetLabels(objLabels) switch cp := EffectiveCollisionProtection(cos.Spec.CollisionProtection, specPhase.CollisionProtection, specPhase.Objects[i].CollisionProtection); cp { case ocv1.CollisionProtectionIfNoController, ocv1.CollisionProtectionNone: opts = append(opts, boxcutter.WithObjectReconcileOptions( obj, boxcutter.WithCollisionProtection(cp))) } } phases = append(phases, boxcutter.NewPhase(specPhase.Name, objs)) } return phases, observedPhases, opts, nil } // resolveObjectRef fetches the referenced Secret, reads the value at the specified key, // auto-detects gzip compression, and deserializes into an unstructured.Unstructured. func (c *ClusterObjectSetReconciler) resolveObjectRef(ctx context.Context, ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, error) { secret := &corev1.Secret{} key := client.ObjectKey{Name: ref.Name, Namespace: ref.Namespace} if err := c.Client.Get(ctx, key, secret); err != nil { return nil, fmt.Errorf("getting Secret %s/%s: %w", ref.Namespace, ref.Name, err) } data, ok := secret.Data[ref.Key] if !ok { return nil, &referencedContentError{fmt.Errorf("key %q not found in Secret %s/%s", ref.Key, ref.Namespace, ref.Name)} } // Auto-detect gzip compression (magic bytes 0x1f 0x8b) if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { reader, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { return nil, &referencedContentError{fmt.Errorf("creating gzip reader for key %q in Secret %s/%s: %w", ref.Key, ref.Namespace, ref.Name, err)} } defer reader.Close() const maxDecompressedSize = 10 * 1024 * 1024 // 10 MiB limited := io.LimitReader(reader, maxDecompressedSize+1) decompressed, err := io.ReadAll(limited) if err != nil { return nil, &referencedContentError{fmt.Errorf("decompressing key %q in Secret %s/%s: %w", ref.Key, ref.Namespace, ref.Name, err)} } if len(decompressed) > maxDecompressedSize { return nil, &referencedContentError{fmt.Errorf("decompressed data for key %q in Secret %s/%s exceeds maximum size (%d bytes)", ref.Key, ref.Namespace, ref.Name, maxDecompressedSize)} } data = decompressed } obj := &unstructured.Unstructured{} if err := json.Unmarshal(data, &obj.Object); err != nil { return nil, &referencedContentError{fmt.Errorf("unmarshaling object from key %q in Secret %s/%s: %w", ref.Key, ref.Namespace, ref.Name, err)} } return obj, nil } // EffectiveCollisionProtection resolves the collision protection value using // the inheritance hierarchy: object > phase > spec > default ("Prevent"). func EffectiveCollisionProtection(cp ...ocv1.CollisionProtection) ocv1.CollisionProtection { ecp := ocv1.CollisionProtectionPrevent for _, c := range cp { if c != "" { ecp = c } } return ecp } // buildProgressionProbes creates a set of boxcutter probes from the fields provided in the COS's spec.progressionProbes. // Returns nil and an error if encountered while attempting to build the probes. func buildProgressionProbes(progressionProbes []ocv1.ProgressionProbe) (probing.And, error) { userProbes := probing.And{} if len(progressionProbes) < 1 { return userProbes, nil } for _, progressionProbe := range progressionProbes { // Collect all user assertions into a single 'And' assertions := probing.And{} for _, probe := range progressionProbe.Assertions { switch probe.Type { // Switch based on the union discriminator case ocv1.ProbeTypeConditionEqual: conditionProbe := probing.ConditionProbe(probe.ConditionEqual) assertions = append(assertions, &conditionProbe) case ocv1.ProbeTypeFieldsEqual: fieldsEqualProbe := probing.FieldsEqualProbe(probe.FieldsEqual) assertions = append(assertions, &fieldsEqualProbe) case ocv1.ProbeTypeFieldValue: fieldValueProbe := probing.FieldValueProbe(probe.FieldValue) assertions = append(assertions, &fieldValueProbe) default: return nil, &configValidationError{fmt.Errorf("unknown progression probe assertion type %q", probe.Type)} } } // Create the selector probe based on user-requested type and provide the assertions var selectorProbe probing.Prober switch progressionProbe.Selector.Type { // Switch based on the union discriminator case ocv1.SelectorTypeGroupKind: selectorProbe = &probing.GroupKindSelector{ GroupKind: schema.GroupKind(progressionProbe.Selector.GroupKind), Prober: assertions, } case ocv1.SelectorTypeLabel: selector, err := metav1.LabelSelectorAsSelector(&progressionProbe.Selector.Label) if err != nil { return nil, &configValidationError{fmt.Errorf("a progression probe has an invalid label selector (%v): %w", progressionProbe.Selector.Label, err)} } selectorProbe = &probing.LabelSelector{ Selector: selector, Prober: assertions, } default: return nil, &configValidationError{fmt.Errorf("unknown progression probe selector type %q", progressionProbe.Selector.Type)} } userProbes = append(userProbes, &probing.ObservedGenerationProbe{ Prober: selectorProbe, }) } return userProbes, nil } // setReadyWithDeadline sets the Ready condition to the supplied status/reason/message, // unless the progress deadline has been exceeded — in which case it reports // Ready=False/ProgressDeadlineExceeded instead. This centralises the progress // deadline enforcement for the Ready condition. func setReadyWithDeadline(cos *ocv1.ClusterObjectSet, status metav1.ConditionStatus, reason, message string, isDeadlineExceeded bool) { // nolint:unparam if isDeadlineExceeded { markAsNotReady(cos, ocv1.ReasonProgressDeadlineExceeded, fmt.Sprintf("Revision has not rolled out for %d minute(s). Last status: %s", cos.Spec.ProgressDeadlineMinutes, message)) return } meta.SetStatusCondition(&cos.Status.Conditions, metav1.Condition{ Type: ocv1.ClusterObjectSetTypeReady, Status: status, Reason: reason, Message: message, ObservedGeneration: cos.Generation, }) } func markAsReady(cos *ocv1.ClusterObjectSet, reason, message string) bool { return meta.SetStatusCondition(&cos.Status.Conditions, metav1.Condition{ Type: ocv1.ClusterObjectSetTypeReady, Status: metav1.ConditionTrue, Reason: reason, Message: message, ObservedGeneration: cos.Generation, }) } func markAsNotReady(cos *ocv1.ClusterObjectSet, reason, message string) bool { return meta.SetStatusCondition(&cos.Status.Conditions, metav1.Condition{ Type: ocv1.ClusterObjectSetTypeReady, Status: metav1.ConditionFalse, Reason: reason, Message: message, ObservedGeneration: cos.Generation, }) } // hasSupersededObject reports whether any object in the revision has been adopted // by a newer sibling revision (machinery.ActionProgressed). boxcutter only emits // ActionProgressed for a newer sibling (a foreign owner is a collision), so no // revision comparison is needed here. func hasSupersededObject(rres machinery.RevisionResult) bool { for _, pres := range rres.GetPhases() { for _, ores := range pres.GetObjects() { if ores.Action() == machinery.ActionProgressed { return true } } } return false } // markAsPending initializes Ready=Unknown/Pending. It is only used before the first // evaluation (when Ready is absent), so a freshly created revision always reports an // explicit Ready condition rather than an absent one. func markAsPending(cos *ocv1.ClusterObjectSet) { meta.SetStatusCondition(&cos.Status.Conditions, metav1.Condition{ Type: ocv1.ClusterObjectSetTypeReady, Status: metav1.ConditionUnknown, Reason: ocv1.ClusterObjectSetReasonPending, Message: "The revision has not yet been evaluated.", ObservedGeneration: cos.Generation, }) } func markAsArchiving(cos *ocv1.ClusterObjectSet) { const msg = "The object set is being archived; removing owned objects." markAsNotReady(cos, ocv1.ClusterObjectSetReasonArchiving, msg) } func markAsArchived(cos *ocv1.ClusterObjectSet) bool { const msg = "revision is archived" return markAsNotReady(cos, ocv1.ClusterObjectSetReasonArchived, msg) } // computePhaseDigest computes a deterministic SHA-256 digest of a phase's // resolved content (name + objects) before any controller mutations. // JSON serialization of unstructured objects produces a canonical encoding // with sorted map keys. func computePhaseDigest(name string, objects []client.Object) (string, error) { phaseMap := map[string]any{ "name": name, "objects": objects, } data, err := json.Marshal(phaseMap) if err != nil { // A non-marshalable object is invalid spec content, not a transient error; // classify it so the reconciler reports InvalidSpecification instead of // retrying forever. return "", &configValidationError{fmt.Errorf("marshaling phase %q: %w", name, err)} } h := sha256.Sum256(data) return fmt.Sprintf("sha256:%x", h), nil } // verifyObservedPhases compares current per-phase digests against stored // digests. Returns an error listing all mismatched phases. func verifyObservedPhases(stored, current []ocv1.ObservedPhase) error { if len(stored) == 0 { return fmt.Errorf("stored observedPhases is unexpectedly empty") } if len(stored) != len(current) { return fmt.Errorf("number of phases has changed (expected %d phases, got %d)", len(stored), len(current)) } storedMap := make(map[string]string, len(stored)) for _, s := range stored { storedMap[s.Name] = s.Digest } var mismatches []string for _, c := range current { if prev, ok := storedMap[c.Name]; ok && prev != c.Digest { mismatches = append(mismatches, fmt.Sprintf("%q", c.Name)) } } if len(mismatches) > 0 { return fmt.Errorf("resolved content of %d phase(s) has changed since first rollout: %s", len(mismatches), strings.Join(mismatches, ", ")) } return nil } // mutableSecretError is returned by verifyReferencedSecretsImmutable when one or // more referenced Secrets are confirmed to be mutable. It distinguishes a confirmed // precondition failure (reported as PreconditionsNotMet and retried on a timer) from // a transient Secret read error (surfaced as a plain error for backoff requeue). type mutableSecretError struct { msg string } func (e *mutableSecretError) Error() string { return e.msg } // verifyReferencedSecretsImmutable checks that all referenced Secrets // have Immutable set to true. A confirmed mutable Secret is returned as a // *mutableSecretError listing every misconfigured Secret; a failure to read a // Secret is returned as a plain (retryable) error. func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx context.Context, cos *ocv1.ClusterObjectSet) error { type secretRef struct { name string namespace string } seen := make(map[secretRef]struct{}) var refs []secretRef for _, phase := range cos.Spec.Phases { for _, obj := range phase.Objects { if obj.Ref.Name == "" { continue } sr := secretRef{name: obj.Ref.Name, namespace: obj.Ref.Namespace} if _, ok := seen[sr]; !ok { seen[sr] = struct{}{} refs = append(refs, sr) } } } var mutableSecrets []string var readErr error for _, ref := range refs { secret := &corev1.Secret{} key := client.ObjectKey{Name: ref.name, Namespace: ref.namespace} if err := c.Client.Get(ctx, key, secret); err != nil { if apierrors.IsNotFound(err) { // Secret not yet available — skip verification. // resolveObjectRef will handle the not-found with a retryable error. continue } // Remember the first read error but keep checking the remaining refs: a // confirmed mutable Secret is a more actionable failure and takes precedence // over a transient read error. if readErr == nil { readErr = fmt.Errorf("getting Secret %s/%s: %w", ref.namespace, ref.name, err) } continue } if secret.Immutable == nil || !*secret.Immutable { mutableSecrets = append(mutableSecrets, fmt.Sprintf("%s/%s", ref.namespace, ref.name)) } } if len(mutableSecrets) > 0 { return &mutableSecretError{msg: fmt.Sprintf( "Referenced Secret(s) must be immutable but are not: %s. Recreate them with immutable set to true.", strings.Join(mutableSecrets, ", "))} } if readErr != nil { return readErr } return nil }