From 08bf9ea485f3383988008e8666444e337c2cb4da Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Tue, 22 Sep 2026 17:10:36 +0200 Subject: [PATCH] Add 3 tier revision engine Signed-off-by: Per G. da Silva --- api/v1/clusterobjectset_types.go | 11 +- api/v1/zz_generated.deepcopy.go | 5 +- applyconfigurations/api/v1/observedphase.go | 18 +- applyconfigurations/internal/internal.go | 3 + cmd/operator-controller/main.go | 3 +- ...peratorframework.io_clusterobjectsets.yaml | 23 +- .../clusterobjectset_controller.go | 23 +- .../clusterobjectset_controller_test.go | 19 +- internal/object-controller/revision/engine.go | 185 +++++++++++++++ .../revision/engine_internal_test.go | 218 ++++++++++++++++++ .../factory.go} | 50 ++-- internal/object-controller/revision/result.go | 78 +++++++ .../controllers/mocks_object_controller.go | 76 +++--- internal/testutil/mock/generate.go | 2 +- manifests/experimental-e2e.yaml | 23 +- manifests/experimental.yaml | 23 +- 16 files changed, 670 insertions(+), 90 deletions(-) create mode 100644 internal/object-controller/revision/engine.go create mode 100644 internal/object-controller/revision/engine_internal_test.go rename internal/object-controller/{controllers/revision_engine_factory.go => revision/factory.go} (61%) create mode 100644 internal/object-controller/revision/result.go diff --git a/api/v1/clusterobjectset_types.go b/api/v1/clusterobjectset_types.go index 56fe210bd9..e66a6e84f5 100644 --- a/api/v1/clusterobjectset_types.go +++ b/api/v1/clusterobjectset_types.go @@ -518,7 +518,7 @@ type ClusterObjectSetStatus struct { // different content. Each entry covers all fully-resolved object // manifests within a phase, making it source-agnostic. // - // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf.size() == 0",message="observedPhases is immutable" + // +kubebuilder:validation:XValidation:rule="oldSelf.size() == 0 || (self.size() == oldSelf.size() && oldSelf.all(o, self.exists(n, n.name == o.name)))",message="observedPhases: phases cannot be added or removed once set" // +kubebuilder:validation:MaxItems=20 // +listType=map // +listMapKey=name @@ -527,6 +527,7 @@ type ClusterObjectSetStatus struct { } // ObservedPhase records the observed content digest of a resolved phase. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.completedAt) || (has(self.completedAt) && self.completedAt == oldSelf.completedAt)",message="completedAt is immutable once set" type ObservedPhase struct { // name is the phase name matching a phase in spec.phases. // @@ -534,8 +535,15 @@ type ObservedPhase struct { // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=63 // +kubebuilder:validation:XValidation:rule=`!format.dns1123Label().validate(self).hasValue()`,message="the value must consist of only lowercase alphanumeric characters and hyphens, and must start and end with an alphanumeric character." + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="name is immutable" Name string `json:"name"` + // completedAt is the timestamp when this phase first became Complete. + // Set once and never cleared. Zero value means the phase has never been + // Complete. + // +optional + CompletedAt metav1.Time `json:"completedAt,omitzero"` + // digest is the digest of the phase's resolved object content // at first successful resolution, in the format ":". // @@ -543,6 +551,7 @@ type ObservedPhase struct { // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=256 // +kubebuilder:validation:XValidation:rule=`self.matches('^[a-z0-9]+:[a-f0-9]+$')`,message="digest must be in the format ':'" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="digest is immutable" Digest string `json:"digest"` } diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 6836216378..7f00f1bc54 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -563,7 +563,9 @@ func (in *ClusterObjectSetStatus) DeepCopyInto(out *ClusterObjectSetStatus) { if in.ObservedPhases != nil { in, out := &in.ObservedPhases, &out.ObservedPhases *out = make([]ObservedPhase, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -677,6 +679,7 @@ func (in *ObjectSourceRef) DeepCopy() *ObjectSourceRef { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObservedPhase) DeepCopyInto(out *ObservedPhase) { *out = *in + in.CompletedAt.DeepCopyInto(&out.CompletedAt) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedPhase. diff --git a/applyconfigurations/api/v1/observedphase.go b/applyconfigurations/api/v1/observedphase.go index 75f5fb1d60..cbeda79c96 100644 --- a/applyconfigurations/api/v1/observedphase.go +++ b/applyconfigurations/api/v1/observedphase.go @@ -13,10 +13,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by controller-gen-v0.20. DO NOT EDIT. +// Code generated by controller-gen-v0.21. DO NOT EDIT. package v1 +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + // ObservedPhaseApplyConfiguration represents a declarative configuration of the ObservedPhase type for use // with apply. // @@ -24,6 +28,10 @@ package v1 type ObservedPhaseApplyConfiguration struct { // name is the phase name matching a phase in spec.phases. Name *string `json:"name,omitempty"` + // completedAt is the timestamp when this phase first became Complete. + // Set once and never cleared. Zero value means the phase has never been + // Complete. + CompletedAt *metav1.Time `json:"completedAt,omitempty"` // digest is the digest of the phase's resolved object content // at first successful resolution, in the format ":". Digest *string `json:"digest,omitempty"` @@ -43,6 +51,14 @@ func (b *ObservedPhaseApplyConfiguration) WithName(value string) *ObservedPhaseA return b } +// WithCompletedAt sets the CompletedAt field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CompletedAt field is set to the value of the last call. +func (b *ObservedPhaseApplyConfiguration) WithCompletedAt(value metav1.Time) *ObservedPhaseApplyConfiguration { + b.CompletedAt = &value + return b +} + // WithDigest sets the Digest field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Digest field is set to the value of the last call. diff --git a/applyconfigurations/internal/internal.go b/applyconfigurations/internal/internal.go index dde5aaf513..a068f4b7f1 100644 --- a/applyconfigurations/internal/internal.go +++ b/applyconfigurations/internal/internal.go @@ -408,6 +408,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: com.github.operator-framework.operator-controller.api.v1.ObservedPhase map: fields: + - name: completedAt + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time - name: digest type: scalar: string diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 5203db1ac4..18b82e85c4 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -62,6 +62,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" clusterobjctrl "github.com/operator-framework/operator-controller/internal/object-controller/controllers" + "github.com/operator-framework/operator-controller/internal/object-controller/revision" "github.com/operator-framework/operator-controller/internal/operator-controller/action" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/cache" @@ -673,7 +674,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl // Wrap the discovery client with caching to reduce memory usage from repeated OpenAPI schema fetches discoveryClient := memory.NewMemCacheClient(baseDiscoveryClient) - revisionEngineFactory, err := clusterobjctrl.NewDefaultRevisionEngineFactory( + revisionEngineFactory, err := revision.NewDefaultRevisionEngineFactory( c.mgr.GetScheme(), c.trackingCache, discoveryClient, diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml index 0c94049181..d1f90dbad5 100644 --- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml +++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.21.0 olm.operatorframework.io/generator: experimental name: clusterobjectsets.olm.operatorframework.io spec: @@ -632,6 +632,13 @@ spec: description: ObservedPhase records the observed content digest of a resolved phase. properties: + completedAt: + description: |- + completedAt is the timestamp when this phase first became Complete. + Set once and never cleared. Zero value means the phase has never been + Complete. + format: date-time + type: string digest: description: |- digest is the digest of the phase's resolved object content @@ -642,6 +649,8 @@ spec: x-kubernetes-validations: - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') + - message: digest is immutable + rule: self == oldSelf name: description: name is the phase name matching a phase in spec.phases. maxLength: 63 @@ -652,18 +661,26 @@ spec: characters and hyphens, and must start and end with an alphanumeric character. rule: '!format.dns1123Label().validate(self).hasValue()' + - message: name is immutable + rule: self == oldSelf required: - digest - name type: object + x-kubernetes-validations: + - message: completedAt is immutable once set + rule: '!has(oldSelf.completedAt) || (has(self.completedAt) && + self.completedAt == oldSelf.completedAt)' maxItems: 20 type: array x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 + - message: 'observedPhases: phases cannot be added or removed once + set' + rule: oldSelf.size() == 0 || (self.size() == oldSelf.size() && oldSelf.all(o, + self.exists(n, n.name == o.name))) type: object type: object served: true diff --git a/internal/object-controller/controllers/clusterobjectset_controller.go b/internal/object-controller/controllers/clusterobjectset_controller.go index e42e3c6144..e180b86530 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller.go +++ b/internal/object-controller/controllers/clusterobjectset_controller.go @@ -42,6 +42,7 @@ import ( "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/revision" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) @@ -53,7 +54,7 @@ const ( // as part of the boxcutter integration. type ClusterObjectSetReconciler struct { Client client.Client - RevisionEngineFactory RevisionEngineFactory + RevisionEngineFactory revision.EngineFactory TrackingCache trackingCache Clock clock.Clock } @@ -150,13 +151,13 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl return ctrl.Result{}, nil } - revisionEngine, err := c.RevisionEngineFactory.CreateRevisionEngine(ctx, cos) + revisionEngine, err := c.RevisionEngineFactory.New(ctx, cos) if err != nil { setRetryingConditions(l, cos, err.Error(), isDeadlineExceeded) return ctrl.Result{}, fmt.Errorf("failed to create revision engine: %v", err) } - revision := boxcutter.NewRevisionWithOwner( + bcRevision := boxcutter.NewRevisionWithOwner( cos.Name, cos.Spec.Revision, phases, @@ -169,20 +170,20 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl markAsAvailableUnknown(cos, ocv1.ClusterObjectSetReasonReconciling, err.Error()) return ctrl.Result{}, fmt.Errorf("error stopping informers: %v", err) } - return c.archive(ctx, revisionEngine, cos, revision) + return c.archive(ctx, revisionEngine, cos, bcRevision) } 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 { + if err := c.establishWatch(ctx, cos, bcRevision); err != nil { werr := fmt.Errorf("establish watch: %v", err) setRetryingConditions(l, cos, werr.Error(), isDeadlineExceeded) return ctrl.Result{}, werr } - rres, err := revisionEngine.Reconcile(ctx, revision, opts...) + rres, err := revisionEngine.Reconcile(ctx, bcRevision, opts...) if err != nil { if rres != nil { // Log detailed reconcile reports only in debug mode (V(1)) to reduce verbosity. @@ -200,6 +201,14 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } + // Set phase completedAt + now := metav1.NewTime(time.Now()) + for i, pres := range rres.GetPhases() { + if pres.IsComplete() && cos.Status.ObservedPhases[i].CompletedAt.IsZero() { + cos.Status.ObservedPhases[i].CompletedAt = now + } + } + for i, pres := range rres.GetPhases() { if verr := pres.GetValidationError(); verr != nil { l.Error(fmt.Errorf("%w", verr), "phase preflight validation failed, retrying after 10s", "phase", i) @@ -308,7 +317,7 @@ func (c *ClusterObjectSetReconciler) delete(ctx context.Context, cos *ocv1.Clust return ctrl.Result{}, nil } -func (c *ClusterObjectSetReconciler) archive(ctx context.Context, revisionEngine RevisionEngine, cos *ocv1.ClusterObjectSet, revision boxcutter.RevisionBuilder) (ctrl.Result, error) { +func (c *ClusterObjectSetReconciler) archive(ctx context.Context, revisionEngine revision.Engine, cos *ocv1.ClusterObjectSet, revision boxcutter.RevisionBuilder) (ctrl.Result, error) { l := log.FromContext(ctx) tdres, err := revisionEngine.Teardown(ctx, revision) if err != nil { diff --git a/internal/object-controller/controllers/clusterobjectset_controller_test.go b/internal/object-controller/controllers/clusterobjectset_controller_test.go index e70fbb1c88..9b98174608 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller_test.go +++ b/internal/object-controller/controllers/clusterobjectset_controller_test.go @@ -29,6 +29,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/object-controller/controllers" + "github.com/operator-framework/operator-controller/internal/object-controller/revision" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" mockcontrollers "github.com/operator-framework/operator-controller/internal/testutil/mock/controllers" mockmachinery "github.com/operator-framework/operator-controller/internal/testutil/mock/machinery" @@ -1269,8 +1270,8 @@ func newMockTrackingCache(ctrl *gomock.Controller, cl client.Client, freeFn func // newNoopMockRevisionEngine creates a MockRevisionEngine with no expectations set. // Useful for tests where the engine is never called (e.g., error paths that fail before reaching the engine). -func newNoopMockRevisionEngine(ctrl *gomock.Controller) *mockcontrollers.MockRevisionEngine { - return mockcontrollers.NewMockRevisionEngine(ctrl) +func newNoopMockRevisionEngine(ctrl *gomock.Controller) *mockcontrollers.MockEngine { + return mockcontrollers.NewMockEngine(ctrl) } // newMockRevisionEngineWithReconcile creates a MockRevisionEngine with a Reconcile expectation. @@ -1279,8 +1280,8 @@ func newMockRevisionEngineWithReconcile( ctrl *gomock.Controller, reconcileFn func(context.Context, machinerytypes.Revision, ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error), teardownFn func(context.Context, machinerytypes.Revision, ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error), -) *mockcontrollers.MockRevisionEngine { - m := mockcontrollers.NewMockRevisionEngine(ctrl) +) *mockcontrollers.MockEngine { + m := mockcontrollers.NewMockEngine(ctrl) m.EXPECT().Reconcile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(reconcileFn).AnyTimes() if teardownFn != nil { m.EXPECT().Teardown(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(teardownFn).AnyTimes() @@ -1292,12 +1293,12 @@ func newMockRevisionEngineWithReconcile( // that returns the given engine and error. func newMockRevisionEngineFactoryWithEngine( ctrl *gomock.Controller, - engine controllers.RevisionEngine, + engine revision.Engine, createErr error, -) *mockcontrollers.MockRevisionEngineFactory { - m := mockcontrollers.NewMockRevisionEngineFactory(ctrl) - m.EXPECT().CreateRevisionEngine(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, rev *ocv1.ClusterObjectSet) (controllers.RevisionEngine, error) { +) *mockcontrollers.MockEngineFactory { + m := mockcontrollers.NewMockEngineFactory(ctrl) + m.EXPECT().New(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, rev *ocv1.ClusterObjectSet) (revision.Engine, error) { if createErr != nil { return nil, createErr } diff --git a/internal/object-controller/revision/engine.go b/internal/object-controller/revision/engine.go new file mode 100644 index 0000000000..fb8b963397 --- /dev/null +++ b/internal/object-controller/revision/engine.go @@ -0,0 +1,185 @@ +package revision + +import ( + "context" + + "pkg.package-operator.run/boxcutter" + "pkg.package-operator.run/boxcutter/machinery" + "pkg.package-operator.run/boxcutter/machinery/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +type ThreeTierEngine struct { + revision *boxcutter.RevisionEngine + phase *machinery.PhaseEngine + reader client.Reader + completedPhaseNames map[string]bool +} + +// New returns a new 3-Tier Boxcutter revision reconciler that extends the behavior of the default +// Boxcutter Engine to provide reconciliation results for each of the phases by: +// - Executing the standard revision reconciliation process and collecting results for phases up to the first probe failure +// - Reconciling previously completed phases to correct any drift in phase object spec +// - Reconciles remaining phases in read-only mode (with the WithPaused option) gathering status information +func New(observedPhases []ocv1.ObservedPhase, opts boxcutter.RevisionEngineOptions) (*ThreeTierEngine, error) { + re, err := boxcutter.NewRevisionEngine(opts) + if err != nil { + return nil, err + } + pe, err := boxcutter.NewPhaseEngine(opts) + if err != nil { + return nil, err + } + return &ThreeTierEngine{ + revision: re, + phase: pe, + reader: opts.Reader, + completedPhaseNames: completedPhaseNames(observedPhases), + }, nil +} + +func (e *ThreeTierEngine) Reconcile(ctx context.Context, rev types.Revision, opts ...types.RevisionReconcileOption) (machinery.RevisionResult, error) { + gatedResult, err := e.revision.Reconcile(ctx, rev, opts...) + if err != nil { + return gatedResult, err + } + if gatedResult.GetValidationError() != nil || gatedResult.HasProgressed() { + return gatedResult, nil + } + + gatedPhaseNames := make(map[string]struct{}, len(gatedResult.GetPhases())) + for _, pr := range gatedResult.GetPhases() { + gatedPhaseNames[pr.GetName()] = struct{}{} + } + + var revOpts types.RevisionReconcileOptions + for _, o := range opts { + o.ApplyToRevisionReconcileOptions(&revOpts) + } + + driftPhases, readOnlyPhases := splitPhases(rev, gatedPhaseNames, e.completedPhaseNames) + + var driftResults []machinery.PhaseResult + var driftErr error + for _, phase := range driftPhases { + phaseOpts := revOpts.ForPhase(phase.GetName()) + pr, pErr := e.phase.Reconcile(ctx, rev.GetRevisionNumber(), phase, phaseOpts...) //nolint:staticcheck + if pr != nil { //nolint:staticcheck // defensive: boxcutter may return nil in future versions + driftResults = append(driftResults, pr) + } + if pErr != nil { + driftErr = pErr + break + } + } + + var readOnlyResults []machinery.PhaseResult + if driftErr == nil { + for _, phase := range readOnlyPhases { + phaseOpts := append(revOpts.ForPhase(phase.GetName()), types.WithPaused{}) + pr, pErr := e.phase.Reconcile(ctx, rev.GetRevisionNumber(), phase, phaseOpts...) //nolint:staticcheck + if pr != nil { //nolint:staticcheck // defensive: boxcutter may return nil in future versions + readOnlyResults = append(readOnlyResults, pr) + } + if pErr != nil { + break + } + } + } + + return &result{ + gated: gatedResult, + driftResults: driftResults, + readOnlyResults: readOnlyResults, + }, driftErr +} + +func (e *ThreeTierEngine) Teardown(ctx context.Context, rev types.Revision, opts ...types.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + result, err := e.revision.Teardown(ctx, rev, opts...) + if err != nil || result == nil || len(result.GetWaitingPhaseNames()) == 0 { + return result, err + } + + waitingNames := make(map[string]struct{}, len(result.GetWaitingPhaseNames())) + for _, name := range result.GetWaitingPhaseNames() { + waitingNames[name] = struct{}{} + } + + var readOnlyPhases []machinery.PhaseTeardownResult + for _, phase := range rev.GetPhases() { + if _, ok := waitingNames[phase.GetName()]; !ok { + continue + } + var present []types.ObjectRef + for _, obj := range phase.GetObjects() { + actual := obj.DeepCopyObject().(client.Object) + if getErr := e.reader.Get(ctx, client.ObjectKeyFromObject(actual), actual); getErr == nil { + present = append(present, types.ToObjectRef(actual)) + } + } + readOnlyPhases = append(readOnlyPhases, &readOnlyPhaseTeardownResult{ + name: phase.GetName(), + waiting: present, + }) + } + + return &teardownResultWithReadOnly{ + RevisionTeardownResult: result, + readOnlyPhases: readOnlyPhases, + }, nil +} + +func completedPhaseNames(observedPhases []ocv1.ObservedPhase) map[string]bool { + m := make(map[string]bool, len(observedPhases)) + for _, op := range observedPhases { + if !op.CompletedAt.IsZero() { + m[op.Name] = true + } + } + return m +} + +func splitPhases(rev types.Revision, gatedPhaseNames map[string]struct{}, completedPhases map[string]bool) ([]types.Phase, []types.Phase) { + var drift, readOnly []types.Phase + sawCompleted := false + for _, phase := range rev.GetPhases() { + if _, inGated := gatedPhaseNames[phase.GetName()]; inGated { + continue + } + isCompleted := completedPhases[phase.GetName()] + if !isCompleted && !sawCompleted { + readOnly = append(readOnly, phase) + readOnly = append(readOnly, phasesAfter(rev, gatedPhaseNames, phase.GetName())...) + return drift, readOnly + } + sawCompleted = true + drift = append(drift, phase) + if !isCompleted { + readOnly = append(readOnly, phasesAfter(rev, gatedPhaseNames, phase.GetName())...) + return drift, readOnly + } + } + return drift, readOnly +} + +// phasesAfter returns all non-gated phases that follow the phase named afterName in the revision's phase order. +func phasesAfter(rev types.Revision, gatedPhaseNames map[string]struct{}, afterName string) []types.Phase { + var result []types.Phase + found := false + for _, phase := range rev.GetPhases() { + if phase.GetName() == afterName { + found = true + continue + } + if !found { + continue + } + if _, inGated := gatedPhaseNames[phase.GetName()]; inGated { + continue + } + result = append(result, phase) + } + return result +} diff --git a/internal/object-controller/revision/engine_internal_test.go b/internal/object-controller/revision/engine_internal_test.go new file mode 100644 index 0000000000..8b5cd9e0b8 --- /dev/null +++ b/internal/object-controller/revision/engine_internal_test.go @@ -0,0 +1,218 @@ +package revision + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "pkg.package-operator.run/boxcutter/machinery/types" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// newRevision builds a types.Revision whose phases are ordered as given. +func newRevision(phaseNames ...string) types.Revision { + phases := make([]types.Phase, 0, len(phaseNames)) + for _, n := range phaseNames { + phases = append(phases, types.NewPhase(n, nil)) + } + return types.NewRevision("test-rev", 1, phases) +} + +// gatedSet builds the set of gated phase names used by splitPhases/phasesAfter. +func gatedSet(names ...string) map[string]struct{} { + m := make(map[string]struct{}, len(names)) + for _, n := range names { + m[n] = struct{}{} + } + return m +} + +// completedSet builds the completed-phase lookup used by splitPhases. +func completedSet(names ...string) map[string]bool { + m := make(map[string]bool, len(names)) + for _, n := range names { + m[n] = true + } + return m +} + +// names extracts phase names for order-sensitive comparison. Always returns a +// non-nil slice so nil and empty results compare equal in assertions. +func names(phases []types.Phase) []string { + out := make([]string, 0, len(phases)) + for _, p := range phases { + out = append(out, p.GetName()) + } + return out +} + +func Test_completedPhaseNames(t *testing.T) { + setAt := metav1.Now() + + for _, tc := range []struct { + name string + observed []ocv1.ObservedPhase + want map[string]bool + }{ + { + name: "nil input yields empty map", + observed: nil, + want: map[string]bool{}, + }, + { + name: "phase with zero completedAt is excluded", + observed: []ocv1.ObservedPhase{ + {Name: "a", CompletedAt: metav1.Time{}}, + }, + want: map[string]bool{}, + }, + { + name: "phase with set completedAt is included", + observed: []ocv1.ObservedPhase{ + {Name: "a", CompletedAt: setAt}, + }, + want: map[string]bool{"a": true}, + }, + { + name: "only phases with a set completedAt are included", + observed: []ocv1.ObservedPhase{ + {Name: "a", CompletedAt: setAt}, + {Name: "b", CompletedAt: metav1.Time{}}, + {Name: "c", CompletedAt: setAt}, + }, + want: map[string]bool{"a": true, "c": true}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, completedPhaseNames(tc.observed)) + }) + } +} + +func Test_phasesAfter(t *testing.T) { + for _, tc := range []struct { + name string + rev types.Revision + gated map[string]struct{} + afterName string + want []string + }{ + { + name: "returns phases following the named phase in order", + rev: newRevision("a", "b", "c"), + gated: gatedSet(), + afterName: "a", + want: []string{"b", "c"}, + }, + { + name: "returns empty when the named phase is last", + rev: newRevision("a", "b", "c"), + gated: gatedSet(), + afterName: "c", + want: []string{}, + }, + { + name: "returns empty when the named phase is absent", + rev: newRevision("a", "b", "c"), + gated: gatedSet(), + afterName: "missing", + want: []string{}, + }, + { + name: "skips gated phases that follow", + rev: newRevision("a", "b", "c", "d"), + gated: gatedSet("c"), + afterName: "a", + want: []string{"b", "d"}, + }, + { + name: "the named phase itself is excluded even when gated", + rev: newRevision("a", "b", "c"), + gated: gatedSet("b"), + afterName: "b", + want: []string{"c"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, names(phasesAfter(tc.rev, tc.gated, tc.afterName))) + }) + } +} + +func Test_splitPhases(t *testing.T) { + for _, tc := range []struct { + name string + rev types.Revision + gated map[string]struct{} + completed map[string]bool + wantDrift []string + wantReadOnly []string + }{ + { + name: "all phases completed go to drift", + rev: newRevision("a", "b", "c"), + gated: gatedSet(), + completed: completedSet("a", "b", "c"), + wantDrift: []string{"a", "b", "c"}, + wantReadOnly: []string{}, + }, + { + name: "all phases gated yields nothing", + rev: newRevision("a", "b"), + gated: gatedSet("a", "b"), + completed: completedSet(), + wantDrift: []string{}, + wantReadOnly: []string{}, + }, + { + name: "leading non-completed phase makes everything read-only", + rev: newRevision("a", "b", "c"), + gated: gatedSet(), + completed: completedSet(), + wantDrift: []string{}, + wantReadOnly: []string{"a", "b", "c"}, + }, + { + name: "first non-completed phase after completed run joins drift, rest read-only", + rev: newRevision("a", "b", "c"), + // a is completed; b is the boundary phase (drift), c is read-only. + gated: gatedSet(), + completed: completedSet("a"), + wantDrift: []string{"a", "b"}, + wantReadOnly: []string{"c"}, + }, + { + name: "gated phases are skipped while splitting completed and remaining", + rev: newRevision("a", "b", "c", "d"), + gated: gatedSet("b"), + completed: completedSet("a", "c"), + wantDrift: []string{"a", "c", "d"}, + wantReadOnly: []string{}, + }, + { + name: "boundary read-only respects gated phases", + rev: newRevision("a", "b", "c", "d"), + // a completed -> drift; b boundary non-completed -> drift; c gated -> skipped; d -> read-only. + gated: gatedSet("c"), + completed: completedSet("a"), + wantDrift: []string{"a", "b"}, + wantReadOnly: []string{"d"}, + }, + { + name: "leading read-only respects gated phases", + rev: newRevision("a", "b", "c", "d"), + // a gated -> skipped; b non-completed with none completed yet -> read-only + rest. + gated: gatedSet("a"), + completed: completedSet(), + wantDrift: []string{}, + wantReadOnly: []string{"b", "c", "d"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + drift, readOnly := splitPhases(tc.rev, tc.gated, tc.completed) + assert.Equal(t, tc.wantDrift, names(drift), "drift phases") + assert.Equal(t, tc.wantReadOnly, names(readOnly), "read-only phases") + }) + } +} \ No newline at end of file diff --git a/internal/object-controller/controllers/revision_engine_factory.go b/internal/object-controller/revision/factory.go similarity index 61% rename from internal/object-controller/controllers/revision_engine_factory.go rename to internal/object-controller/revision/factory.go index d361644ca0..77db4e95f3 100644 --- a/internal/object-controller/controllers/revision_engine_factory.go +++ b/internal/object-controller/revision/factory.go @@ -5,7 +5,7 @@ // The experimental build includes BoxcutterRuntime which requires these factories // for serviceAccount-scoped client creation and RevisionEngine instantiation. -package controllers +package revision import ( "context" @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" "k8s.io/client-go/rest" + "pkg.package-operator.run/boxcutter" "pkg.package-operator.run/boxcutter/machinery" machinerytypes "pkg.package-operator.run/boxcutter/machinery/types" "pkg.package-operator.run/boxcutter/managedcache" @@ -24,15 +25,21 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" ) -// RevisionEngine defines the interface for reconciling and tearing down revisions. -type RevisionEngine interface { +// Engine defines the interface for reconciling and tearing down revisions. +type Engine interface { Teardown(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) Reconcile(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) } -// RevisionEngineFactory creates a RevisionEngine for a ClusterObjectSet. -type RevisionEngineFactory interface { - CreateRevisionEngine(ctx context.Context, rev *ocv1.ClusterObjectSet) (RevisionEngine, error) +// PhaseEngine defines the interface for reconciling and tearing down revision phases +type PhaseEngine interface { + Reconcile(ctx context.Context, revision int64, phase machinerytypes.Phase, opts ...machinerytypes.PhaseReconcileOption) (machinery.PhaseResult, error) + Teardown(ctx context.Context, revision int64, phase machinerytypes.Phase, opts ...machinerytypes.PhaseTeardownOption) (machinery.PhaseTeardownResult, error) +} + +// EngineFactory creates an Engine for a ClusterObjectSet. +type EngineFactory interface { + New(ctx context.Context, rev *ocv1.ClusterObjectSet) (Engine, error) } // defaultRevisionEngineFactory creates boxcutter RevisionEngines. @@ -45,21 +52,20 @@ type defaultRevisionEngineFactory struct { Client client.Client } -// CreateRevisionEngine constructs a boxcutter RevisionEngine for the given ClusterObjectSet. -func (f *defaultRevisionEngineFactory) CreateRevisionEngine(_ context.Context, rev *ocv1.ClusterObjectSet) (RevisionEngine, error) { - return machinery.NewRevisionEngine( - machinery.NewPhaseEngine( - machinery.NewObjectEngine( - f.Scheme, f.TrackingCache, f.Client, - machinery.NewComparator(f.DiscoveryClient, f.Scheme, f.FieldOwnerPrefix), - f.FieldOwnerPrefix, f.FieldOwnerPrefix, - f.FieldOwnerPrefix, // managedBy - f.Client, - ), - validation.NewClusterPhaseValidator(f.RESTMapper, f.Client), - ), - validation.NewRevisionValidator(), f.Client, - ), nil +// N constructs a boxcutter Engine for the given ClusterObjectSet. +func (f *defaultRevisionEngineFactory) New(_ context.Context, rev *ocv1.ClusterObjectSet) (Engine, error) { + return New(rev.Status.ObservedPhases, boxcutter.RevisionEngineOptions{ + Scheme: f.Scheme, + FieldOwner: f.FieldOwnerPrefix, + SystemPrefix: f.FieldOwnerPrefix, + DiscoveryClient: f.DiscoveryClient, + RestMapper: f.RESTMapper, + Writer: f.Client, + Reader: f.TrackingCache, + ManagedBy: f.FieldOwnerPrefix, + PhaseValidator: validation.NewClusterPhaseValidator(f.RESTMapper, f.Client), + UnfilteredReader: f.Client, + }) } // NewDefaultRevisionEngineFactory creates a new defaultRevisionEngineFactory. @@ -70,7 +76,7 @@ func NewDefaultRevisionEngineFactory( restMapper meta.RESTMapper, fieldOwnerPrefix string, baseConfig *rest.Config, -) (RevisionEngineFactory, error) { +) (EngineFactory, error) { if baseConfig == nil { return nil, fmt.Errorf("baseConfig is required but not provided") } diff --git a/internal/object-controller/revision/result.go b/internal/object-controller/revision/result.go new file mode 100644 index 0000000000..7e343ae79b --- /dev/null +++ b/internal/object-controller/revision/result.go @@ -0,0 +1,78 @@ +package revision + +import ( + "pkg.package-operator.run/boxcutter/machinery" + "pkg.package-operator.run/boxcutter/machinery/types" + "pkg.package-operator.run/boxcutter/validation" +) + +type result struct { + gated machinery.RevisionResult + driftResults []machinery.PhaseResult + readOnlyResults []machinery.PhaseResult +} + +func (r *result) GetValidationError() *validation.RevisionValidationError { + return r.gated.GetValidationError() +} + +func (r *result) GetPhases() []machinery.PhaseResult { + result := append(r.gated.GetPhases(), r.driftResults...) + return append(result, r.readOnlyResults...) +} + +func (r *result) InTransition() bool { + if r.gated.InTransition() { + return true + } + for _, dr := range r.driftResults { + if !dr.IsComplete() { + return true + } + } + return false +} + +func (r *result) IsComplete() bool { + if !r.gated.IsComplete() { + return false + } + for _, dr := range r.driftResults { + if !dr.IsComplete() { + return false + } + } + return true +} + +func (r *result) HasProgressed() bool { + return r.gated.HasProgressed() +} + +func (r *result) String() string { + return r.gated.String() +} + +type teardownResultWithReadOnly struct { + machinery.RevisionTeardownResult + readOnlyPhases []machinery.PhaseTeardownResult +} + +func (r *teardownResultWithReadOnly) GetPhases() []machinery.PhaseTeardownResult { + return append(r.RevisionTeardownResult.GetPhases(), r.readOnlyPhases...) +} + +func (r *teardownResultWithReadOnly) GetWaitingPhaseNames() []string { + return nil +} + +type readOnlyPhaseTeardownResult struct { + name string + waiting []types.ObjectRef +} + +func (r *readOnlyPhaseTeardownResult) GetName() string { return r.name } +func (r *readOnlyPhaseTeardownResult) IsComplete() bool { return false } +func (r *readOnlyPhaseTeardownResult) Gone() []types.ObjectRef { return nil } +func (r *readOnlyPhaseTeardownResult) Waiting() []types.ObjectRef { return r.waiting } +func (r *readOnlyPhaseTeardownResult) String() string { return r.name } diff --git a/internal/testutil/mock/controllers/mocks_object_controller.go b/internal/testutil/mock/controllers/mocks_object_controller.go index ff48d2e4de..a21cef291d 100644 --- a/internal/testutil/mock/controllers/mocks_object_controller.go +++ b/internal/testutil/mock/controllers/mocks_object_controller.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/operator-framework/operator-controller/internal/object-controller/controllers (interfaces: RevisionEngine,RevisionEngineFactory) +// Source: github.com/operator-framework/operator-controller/internal/object-controller/revision (interfaces: Engine,EngineFactory) // // Generated by this command: // -// mockgen -destination=controllers/mocks_object_controller.go -package=controllers github.com/operator-framework/operator-controller/internal/object-controller/controllers RevisionEngine,RevisionEngineFactory +// mockgen -destination=controllers/mocks_object_controller.go -package=controllers github.com/operator-framework/operator-controller/internal/object-controller/revision Engine,EngineFactory // // Package controllers is a generated GoMock package. @@ -14,38 +14,38 @@ import ( reflect "reflect" v1 "github.com/operator-framework/operator-controller/api/v1" - controllers "github.com/operator-framework/operator-controller/internal/object-controller/controllers" + revision "github.com/operator-framework/operator-controller/internal/object-controller/revision" gomock "go.uber.org/mock/gomock" machinery "pkg.package-operator.run/boxcutter/machinery" types "pkg.package-operator.run/boxcutter/machinery/types" ) -// MockRevisionEngine is a mock of RevisionEngine interface. -type MockRevisionEngine struct { +// MockEngine is a mock of Engine interface. +type MockEngine struct { ctrl *gomock.Controller - recorder *MockRevisionEngineMockRecorder + recorder *MockEngineMockRecorder isgomock struct{} } -// MockRevisionEngineMockRecorder is the mock recorder for MockRevisionEngine. -type MockRevisionEngineMockRecorder struct { - mock *MockRevisionEngine +// MockEngineMockRecorder is the mock recorder for MockEngine. +type MockEngineMockRecorder struct { + mock *MockEngine } -// NewMockRevisionEngine creates a new mock instance. -func NewMockRevisionEngine(ctrl *gomock.Controller) *MockRevisionEngine { - mock := &MockRevisionEngine{ctrl: ctrl} - mock.recorder = &MockRevisionEngineMockRecorder{mock} +// NewMockEngine creates a new mock instance. +func NewMockEngine(ctrl *gomock.Controller) *MockEngine { + mock := &MockEngine{ctrl: ctrl} + mock.recorder = &MockEngineMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockRevisionEngine) EXPECT() *MockRevisionEngineMockRecorder { +func (m *MockEngine) EXPECT() *MockEngineMockRecorder { return m.recorder } // Reconcile mocks base method. -func (m *MockRevisionEngine) Reconcile(ctx context.Context, rev types.Revision, opts ...types.RevisionReconcileOption) (machinery.RevisionResult, error) { +func (m *MockEngine) Reconcile(ctx context.Context, rev types.Revision, opts ...types.RevisionReconcileOption) (machinery.RevisionResult, error) { m.ctrl.T.Helper() varargs := []any{ctx, rev} for _, a := range opts { @@ -58,14 +58,14 @@ func (m *MockRevisionEngine) Reconcile(ctx context.Context, rev types.Revision, } // Reconcile indicates an expected call of Reconcile. -func (mr *MockRevisionEngineMockRecorder) Reconcile(ctx, rev any, opts ...any) *gomock.Call { +func (mr *MockEngineMockRecorder) Reconcile(ctx, rev any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, rev}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reconcile", reflect.TypeOf((*MockRevisionEngine)(nil).Reconcile), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reconcile", reflect.TypeOf((*MockEngine)(nil).Reconcile), varargs...) } // Teardown mocks base method. -func (m *MockRevisionEngine) Teardown(ctx context.Context, rev types.Revision, opts ...types.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { +func (m *MockEngine) Teardown(ctx context.Context, rev types.Revision, opts ...types.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { m.ctrl.T.Helper() varargs := []any{ctx, rev} for _, a := range opts { @@ -78,47 +78,47 @@ func (m *MockRevisionEngine) Teardown(ctx context.Context, rev types.Revision, o } // Teardown indicates an expected call of Teardown. -func (mr *MockRevisionEngineMockRecorder) Teardown(ctx, rev any, opts ...any) *gomock.Call { +func (mr *MockEngineMockRecorder) Teardown(ctx, rev any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{ctx, rev}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Teardown", reflect.TypeOf((*MockRevisionEngine)(nil).Teardown), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Teardown", reflect.TypeOf((*MockEngine)(nil).Teardown), varargs...) } -// MockRevisionEngineFactory is a mock of RevisionEngineFactory interface. -type MockRevisionEngineFactory struct { +// MockEngineFactory is a mock of EngineFactory interface. +type MockEngineFactory struct { ctrl *gomock.Controller - recorder *MockRevisionEngineFactoryMockRecorder + recorder *MockEngineFactoryMockRecorder isgomock struct{} } -// MockRevisionEngineFactoryMockRecorder is the mock recorder for MockRevisionEngineFactory. -type MockRevisionEngineFactoryMockRecorder struct { - mock *MockRevisionEngineFactory +// MockEngineFactoryMockRecorder is the mock recorder for MockEngineFactory. +type MockEngineFactoryMockRecorder struct { + mock *MockEngineFactory } -// NewMockRevisionEngineFactory creates a new mock instance. -func NewMockRevisionEngineFactory(ctrl *gomock.Controller) *MockRevisionEngineFactory { - mock := &MockRevisionEngineFactory{ctrl: ctrl} - mock.recorder = &MockRevisionEngineFactoryMockRecorder{mock} +// NewMockEngineFactory creates a new mock instance. +func NewMockEngineFactory(ctrl *gomock.Controller) *MockEngineFactory { + mock := &MockEngineFactory{ctrl: ctrl} + mock.recorder = &MockEngineFactoryMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockRevisionEngineFactory) EXPECT() *MockRevisionEngineFactoryMockRecorder { +func (m *MockEngineFactory) EXPECT() *MockEngineFactoryMockRecorder { return m.recorder } -// CreateRevisionEngine mocks base method. -func (m *MockRevisionEngineFactory) CreateRevisionEngine(ctx context.Context, rev *v1.ClusterObjectSet) (controllers.RevisionEngine, error) { +// New mocks base method. +func (m *MockEngineFactory) New(ctx context.Context, rev *v1.ClusterObjectSet) (revision.Engine, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateRevisionEngine", ctx, rev) - ret0, _ := ret[0].(controllers.RevisionEngine) + ret := m.ctrl.Call(m, "New", ctx, rev) + ret0, _ := ret[0].(revision.Engine) ret1, _ := ret[1].(error) return ret0, ret1 } -// CreateRevisionEngine indicates an expected call of CreateRevisionEngine. -func (mr *MockRevisionEngineFactoryMockRecorder) CreateRevisionEngine(ctx, rev any) *gomock.Call { +// New indicates an expected call of New. +func (mr *MockEngineFactoryMockRecorder) New(ctx, rev any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRevisionEngine", reflect.TypeOf((*MockRevisionEngineFactory)(nil).CreateRevisionEngine), ctx, rev) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "New", reflect.TypeOf((*MockEngineFactory)(nil).New), ctx, rev) } diff --git a/internal/testutil/mock/generate.go b/internal/testutil/mock/generate.go index 35338a0dd4..f69e48de3d 100644 --- a/internal/testutil/mock/generate.go +++ b/internal/testutil/mock/generate.go @@ -43,7 +43,7 @@ package mock //go:generate mockgen -destination=controllers/mocks_operator_controller.go -package=controllers github.com/operator-framework/operator-controller/internal/operator-controller/controllers CatalogCache,CatalogCachePopulator,RevisionStatesGetter,Applier // Internal interfaces — cluster-object-controller controllers -//go:generate mockgen -destination=controllers/mocks_object_controller.go -package=controllers github.com/operator-framework/operator-controller/internal/object-controller/controllers RevisionEngine,RevisionEngineFactory +//go:generate mockgen -destination=controllers/mocks_object_controller.go -package=controllers github.com/operator-framework/operator-controller/internal/object-controller/revision Engine,EngineFactory // Internal interfaces — rukpak render //go:generate mockgen -destination=render/mock_certprovider.go -package=render github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render CertificateProvider diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 6d9346b4ae..f410f7600d 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1351,7 +1351,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.21.0 olm.operatorframework.io/generator: experimental name: clusterobjectsets.olm.operatorframework.io spec: @@ -1980,6 +1980,13 @@ spec: description: ObservedPhase records the observed content digest of a resolved phase. properties: + completedAt: + description: |- + completedAt is the timestamp when this phase first became Complete. + Set once and never cleared. Zero value means the phase has never been + Complete. + format: date-time + type: string digest: description: |- digest is the digest of the phase's resolved object content @@ -1990,6 +1997,8 @@ spec: x-kubernetes-validations: - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') + - message: digest is immutable + rule: self == oldSelf name: description: name is the phase name matching a phase in spec.phases. maxLength: 63 @@ -2000,18 +2009,26 @@ spec: characters and hyphens, and must start and end with an alphanumeric character. rule: '!format.dns1123Label().validate(self).hasValue()' + - message: name is immutable + rule: self == oldSelf required: - digest - name type: object + x-kubernetes-validations: + - message: completedAt is immutable once set + rule: '!has(oldSelf.completedAt) || (has(self.completedAt) && + self.completedAt == oldSelf.completedAt)' maxItems: 20 type: array x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 + - message: 'observedPhases: phases cannot be added or removed once + set' + rule: oldSelf.size() == 0 || (self.size() == oldSelf.size() && oldSelf.all(o, + self.exists(n, n.name == o.name))) type: object type: object served: true diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index f8c3add53b..878acbf00a 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1312,7 +1312,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.21.0 olm.operatorframework.io/generator: experimental name: clusterobjectsets.olm.operatorframework.io spec: @@ -1941,6 +1941,13 @@ spec: description: ObservedPhase records the observed content digest of a resolved phase. properties: + completedAt: + description: |- + completedAt is the timestamp when this phase first became Complete. + Set once and never cleared. Zero value means the phase has never been + Complete. + format: date-time + type: string digest: description: |- digest is the digest of the phase's resolved object content @@ -1951,6 +1958,8 @@ spec: x-kubernetes-validations: - message: digest must be in the format ':' rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') + - message: digest is immutable + rule: self == oldSelf name: description: name is the phase name matching a phase in spec.phases. maxLength: 63 @@ -1961,18 +1970,26 @@ spec: characters and hyphens, and must start and end with an alphanumeric character. rule: '!format.dns1123Label().validate(self).hasValue()' + - message: name is immutable + rule: self == oldSelf required: - digest - name type: object + x-kubernetes-validations: + - message: completedAt is immutable once set + rule: '!has(oldSelf.completedAt) || (has(self.completedAt) && + self.completedAt == oldSelf.completedAt)' maxItems: 20 type: array x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 + - message: 'observedPhases: phases cannot be added or removed once + set' + rule: oldSelf.size() == 0 || (self.size() == oldSelf.size() && oldSelf.all(o, + self.exists(n, n.name == o.name))) type: object type: object served: true