diff --git a/api/v1/clusterextensionrevision_types.go b/api/v1/clusterextensionrevision_types.go new file mode 100644 index 000000000..66382730c --- /dev/null +++ b/api/v1/clusterextensionrevision_types.go @@ -0,0 +1,123 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +*/ + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +const ClusterExtensionRevisionKind = "ClusterExtensionRevision" + +// ClusterExtensionRevisionSpec defines the desired state of ClusterExtensionRevision. +type ClusterExtensionRevisionSpec struct { + // Specifies the lifecycle state of the ClusterExtensionRevision. + // +kubebuilder:default="Active" + // +kubebuilder:validation:Enum=Active;Paused;Archived + // +kubebuilder:validation:XValidation:rule="oldSelf == 'Active' || oldSelf == 'Paused' || oldSelf == 'Archived' && oldSelf == self", message="can not un-archive" + LifecycleState ClusterExtensionRevisionLifecycleState `json:"lifecycleState,omitempty"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf", message="revision is immutable" + Revision int64 `json:"revision"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf.size() == 0", message="phases is immutable" + Phases []ClusterExtensionRevisionPhase `json:"phases"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf", message="previous is immutable" + Previous []ClusterExtensionRevisionPrevious `json:"previous,omitempty"` +} + +// ClusterExtensionRevisionLifecycleState specifies the lifecycle state of the ClusterExtensionRevision. +type ClusterExtensionRevisionLifecycleState string + +const ( + // ClusterExtensionRevisionLifecycleStateActive / "Active" is the default lifecycle state. + ClusterExtensionRevisionLifecycleStateActive ClusterExtensionRevisionLifecycleState = "Active" + // ClusterExtensionRevisionLifecycleStatePaused / "Paused" disables reconciliation of the ClusterExtensionRevision. + // Only Status updates will still propagated, but object changes will not be reconciled. + ClusterExtensionRevisionLifecycleStatePaused ClusterExtensionRevisionLifecycleState = "Paused" + // ClusterExtensionRevisionLifecycleStateArchived / "Archived" disables reconciliation while also "scaling to zero", + // which deletes all objects that are not excluded via the pausedFor property and + // removes itself from the owner list of all other objects previously under management. + ClusterExtensionRevisionLifecycleStateArchived ClusterExtensionRevisionLifecycleState = "Archived" +) + +type ClusterExtensionRevisionPhase struct { + Name string `json:"name"` + Objects []ClusterExtensionRevisionObject `json:"objects"` + Slices []string `json:"slices,omitempty"` +} + +type ClusterExtensionRevisionObject struct { + // +kubebuilder:validation:EmbeddedResource + // +kubebuilder:pruning:PreserveUnknownFields + Object unstructured.Unstructured `json:"object"` +} + +type ClusterExtensionRevisionPrevious struct { + // +kubebuilder:validation:Required + Name string `json:"name"` + // +kubebuilder:validation:Required + UID types.UID `json:"uid"` +} + +// ClusterExtensionRevisionStatus defines the observed state of a ClusterExtensionRevision. +type ClusterExtensionRevisionStatus struct { + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:subresource:status + +// ClusterExtensionRevision is the Schema for the clusterextensionrevisions API +type ClusterExtensionRevision struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // spec is an optional field that defines the desired state of the ClusterExtension. + // +optional + Spec ClusterExtensionRevisionSpec `json:"spec,omitempty"` + + // status is an optional field that defines the observed state of the ClusterExtension. + // +optional + Status ClusterExtensionRevisionStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ClusterExtensionRevisionList contains a list of ClusterExtensionRevision +type ClusterExtensionRevisionList struct { + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + // items is a required list of ClusterExtensionRevision objects. + // + // +kubebuilder:validation:Required + Items []ClusterExtensionRevision `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ClusterExtensionRevision{}, &ClusterExtensionRevisionList{}) +} diff --git a/api/v1/common_types.go b/api/v1/common_types.go index 5478039c9..6ab5336ac 100644 --- a/api/v1/common_types.go +++ b/api/v1/common_types.go @@ -20,12 +20,18 @@ const ( TypeInstalled = "Installed" TypeProgressing = "Progressing" + // Installed reasons + ReasonAbsent = "Absent" + // Progressing reasons - ReasonSucceeded = "Succeeded" - ReasonRetrying = "Retrying" - ReasonBlocked = "Blocked" + ReasonRolloutInProgress = "RolloutInProgress" + ReasonRetrying = "Retrying" + ReasonBlocked = "Blocked" - // Terminal reasons + // Deprecation reasons ReasonDeprecated = "Deprecated" - ReasonFailed = "Failed" + + // Common reasons + ReasonSucceeded = "Succeeded" + ReasonFailed = "Failed" ) diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 23fcf7d85..94d6e1dc7 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -320,6 +320,172 @@ func (in *ClusterExtensionList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevision) DeepCopyInto(out *ClusterExtensionRevision) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevision. +func (in *ClusterExtensionRevision) DeepCopy() *ClusterExtensionRevision { + if in == nil { + return nil + } + out := new(ClusterExtensionRevision) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterExtensionRevision) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionList) DeepCopyInto(out *ClusterExtensionRevisionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterExtensionRevision, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionList. +func (in *ClusterExtensionRevisionList) DeepCopy() *ClusterExtensionRevisionList { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterExtensionRevisionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionObject) DeepCopyInto(out *ClusterExtensionRevisionObject) { + *out = *in + in.Object.DeepCopyInto(&out.Object) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionObject. +func (in *ClusterExtensionRevisionObject) DeepCopy() *ClusterExtensionRevisionObject { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionObject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionPhase) DeepCopyInto(out *ClusterExtensionRevisionPhase) { + *out = *in + if in.Objects != nil { + in, out := &in.Objects, &out.Objects + *out = make([]ClusterExtensionRevisionObject, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Slices != nil { + in, out := &in.Slices, &out.Slices + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionPhase. +func (in *ClusterExtensionRevisionPhase) DeepCopy() *ClusterExtensionRevisionPhase { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionPhase) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionPrevious) DeepCopyInto(out *ClusterExtensionRevisionPrevious) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionPrevious. +func (in *ClusterExtensionRevisionPrevious) DeepCopy() *ClusterExtensionRevisionPrevious { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionPrevious) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionSpec) DeepCopyInto(out *ClusterExtensionRevisionSpec) { + *out = *in + if in.Phases != nil { + in, out := &in.Phases, &out.Phases + *out = make([]ClusterExtensionRevisionPhase, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Previous != nil { + in, out := &in.Previous, &out.Previous + *out = make([]ClusterExtensionRevisionPrevious, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionSpec. +func (in *ClusterExtensionRevisionSpec) DeepCopy() *ClusterExtensionRevisionSpec { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionStatus) DeepCopyInto(out *ClusterExtensionRevisionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionStatus. +func (in *ClusterExtensionRevisionStatus) DeepCopy() *ClusterExtensionRevisionStatus { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterExtensionSpec) DeepCopyInto(out *ClusterExtensionSpec) { *out = *in diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index d426793d4..243c7dfaa 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -31,21 +31,29 @@ import ( "github.com/containers/image/v5/types" "github.com/spf13/cobra" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" k8slabels "k8s.io/apimachinery/pkg/labels" k8stypes "k8s.io/apimachinery/pkg/types" apimachineryrand "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/discovery" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/klog/v2" "k8s.io/utils/ptr" + "pkg.package-operator.run/boxcutter/machinery" + "pkg.package-operator.run/boxcutter/managedcache" + "pkg.package-operator.run/boxcutter/ownerhandling" + "pkg.package-operator.run/boxcutter/validation" ctrl "sigs.k8s.io/controller-runtime" crcache "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/certwatcher" "sigs.k8s.io/controller-runtime/pkg/client" + crcontroller "sigs.k8s.io/controller-runtime/pkg/controller" crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -219,6 +227,12 @@ func run() error { DefaultLabelSelector: k8slabels.Nothing(), } + if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + cacheOptions.ByObject[&ocv1.ClusterExtensionRevision{}] = crcache.ByObject{ + Label: k8slabels.Everything(), + } + } + saKey, err := sautil.GetServiceAccount() if err != nil { setupLog.Error(err, "Failed to extract serviceaccount from JWT") @@ -272,7 +286,8 @@ func run() error { "Metrics will not be served since the TLS certificate and key file are not provided.") } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + restConfig := ctrl.GetConfigOrDie() + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ Scheme: scheme.Scheme, Metrics: metricsServerOptions, PprofBindAddress: cfg.pprofAddr, @@ -304,38 +319,6 @@ func run() error { return err } - coreClient, err := corev1client.NewForConfig(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "unable to create core client") - return err - } - tokenGetter := authentication.NewTokenGetter(coreClient, authentication.WithExpirationDuration(1*time.Hour)) - clientRestConfigMapper := action.ServiceAccountRestConfigMapper(tokenGetter) - if features.OperatorControllerFeatureGate.Enabled(features.SyntheticPermissions) { - clientRestConfigMapper = action.SyntheticUserRestConfigMapper(clientRestConfigMapper) - } - - cfgGetter, err := helmclient.NewActionConfigGetter(mgr.GetConfig(), mgr.GetRESTMapper(), - helmclient.StorageDriverMapper(action.ChunkedStorageDriverMapper(coreClient, mgr.GetAPIReader(), cfg.systemNamespace)), - helmclient.ClientNamespaceMapper(func(obj client.Object) (string, error) { - ext := obj.(*ocv1.ClusterExtension) - return ext.Spec.Namespace, nil - }), - helmclient.ClientRestConfigMapper(clientRestConfigMapper), - ) - if err != nil { - setupLog.Error(err, "unable to config for creating helm client") - return err - } - - acg, err := action.NewWrappedActionClientGetter(cfgGetter, - helmclient.WithFailureRollbacks(false), - ) - if err != nil { - setupLog.Error(err, "unable to create helm client") - return err - } - certPoolWatcher, err := httputil.NewCertPoolWatcher(cfg.catalogdCasDir, ctrl.Log.WithName("cert-pool")) if err != nil { setupLog.Error(err, "unable to create CA certificate pool") @@ -421,58 +404,31 @@ func run() error { crdupgradesafety.NewPreflight(aeClient.CustomResourceDefinitions()), } - // determine if PreAuthorizer should be enabled based on feature gate - var preAuth authorization.PreAuthorizer - if features.OperatorControllerFeatureGate.Enabled(features.PreflightPermissions) { - preAuth = authorization.NewRBACPreAuthorizer(mgr.GetClient()) + var ctrlBuilderOpts []controllers.ControllerBuilderOption + if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithOwns(&ocv1.ClusterExtensionRevision{})) } - // determine if a certificate provider should be set in the bundle renderer and feature support for the provider - // based on the feature flag - var certProvider render.CertificateProvider - var isWebhookSupportEnabled bool - if features.OperatorControllerFeatureGate.Enabled(features.WebhookProviderCertManager) { - certProvider = certproviders.CertManagerCertificateProvider{} - isWebhookSupportEnabled = true - } else if features.OperatorControllerFeatureGate.Enabled(features.WebhookProviderOpenshiftServiceCA) { - certProvider = certproviders.OpenshiftServiceCaCertificateProvider{} - isWebhookSupportEnabled = true - } - - // now initialize the helmApplier, assigning the potentially nil preAuth - helmApplier := &applier.Helm{ - ActionClientGetter: acg, - Preflights: preflights, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{ - BundleRenderer: registryv1.Renderer, - CertificateProvider: certProvider, - IsWebhookSupportEnabled: isWebhookSupportEnabled, - }, - PreAuthorizer: preAuth, + ceReconciler := &controllers.ClusterExtensionReconciler{ + Client: cl, + Resolver: resolver, + ImageCache: imageCache, + ImagePuller: imagePuller, + Finalizers: clusterExtensionFinalizers, } - - cm := contentmanager.NewManager(clientRestConfigMapper, mgr.GetConfig(), mgr.GetRESTMapper()) - err = clusterExtensionFinalizers.Register(controllers.ClusterExtensionCleanupContentManagerCacheFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) { - ext := obj.(*ocv1.ClusterExtension) - err := cm.Delete(ext) - return crfinalizer.Result{}, err - })) + ceController, err := ceReconciler.SetupWithManager(mgr, ctrlBuilderOpts...) if err != nil { - setupLog.Error(err, "unable to register content manager cleanup finalizer") + setupLog.Error(err, "unable to create controller", "controller", "ClusterExtension") return err } - if err = (&controllers.ClusterExtensionReconciler{ - Client: cl, - Resolver: resolver, - ImageCache: imageCache, - ImagePuller: imagePuller, - Applier: helmApplier, - InstalledBundleGetter: &controllers.DefaultInstalledBundleGetter{ActionClientGetter: acg}, - Finalizers: clusterExtensionFinalizers, - Manager: cm, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "ClusterExtension") + if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + err = setupBoxcutter(mgr, ceReconciler, preflights) + } else { + err = setupHelm(mgr, ceReconciler, preflights, ceController, clusterExtensionFinalizers) + } + if err != nil { + setupLog.Error(err, "unable to setup lifecycler") return err } @@ -521,6 +477,153 @@ func run() error { return nil } +func getCertificateProvider() render.CertificateProvider { + if features.OperatorControllerFeatureGate.Enabled(features.WebhookProviderCertManager) { + return certproviders.CertManagerCertificateProvider{} + } else if features.OperatorControllerFeatureGate.Enabled(features.WebhookProviderOpenshiftServiceCA) { + return certproviders.OpenshiftServiceCaCertificateProvider{} + } + return nil +} + +func setupBoxcutter(mgr manager.Manager, ceReconciler *controllers.ClusterExtensionReconciler, preflights []applier.Preflight) error { + certProvider := getCertificateProvider() + + // TODO: add support for preflight checks + // TODO: better scheme handling - which types do we want to support? + _ = apiextensionsv1.AddToScheme(mgr.GetScheme()) + ceReconciler.Applier = &applier.Boxcutter{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + RevisionGenerator: &applier.SimpleRevisionGenerator{ + Scheme: mgr.GetScheme(), + BundleRenderer: &applier.RegistryV1BundleRenderer{ + BundleRenderer: registryv1.Renderer, + CertificateProvider: certProvider, + }, + }, + Preflights: preflights, + } + ceReconciler.RevisionStatesGetter = &controllers.BoxcutterRevisionStatesGetter{Reader: mgr.GetClient()} + + // Boxcutter + const ( + boxcutterSystemPrefixFieldOwner = "olm.operatorframework.io" + ) + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(mgr.GetConfig()) + if err != nil { + return fmt.Errorf("unable to create discovery client: %w", err) + } + + trackingCache, err := managedcache.NewTrackingCache( + ctrl.Log.WithName("trackingCache"), + mgr.GetConfig(), + crcache.Options{ + Scheme: mgr.GetScheme(), Mapper: mgr.GetRESTMapper(), + }, + ) + if err != nil { + return fmt.Errorf("unable to create boxcutter tracking cache: %v", err) + } + if err := mgr.Add(trackingCache); err != nil { + return fmt.Errorf("unable to add tracking cache to manager: %v", err) + } + + if err = (&controllers.ClusterExtensionRevisionReconciler{ + Client: mgr.GetClient(), + RevisionEngine: machinery.NewRevisionEngine( + machinery.NewPhaseEngine( + machinery.NewObjectEngine( + mgr.GetScheme(), trackingCache, mgr.GetClient(), + ownerhandling.NewNative(mgr.GetScheme()), + machinery.NewComparator(ownerhandling.NewNative(mgr.GetScheme()), discoveryClient, mgr.GetScheme(), boxcutterSystemPrefixFieldOwner), + boxcutterSystemPrefixFieldOwner, boxcutterSystemPrefixFieldOwner, + ), + validation.NewClusterPhaseValidator(mgr.GetRESTMapper(), mgr.GetClient()), + ), + validation.NewRevisionValidator(), mgr.GetClient(), + ), + TrackingCache: trackingCache, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to setup ClusterExtensionRevision controller: %w", err) + } + return nil +} + +func setupHelm( + mgr manager.Manager, + ceReconciler *controllers.ClusterExtensionReconciler, + preflights []applier.Preflight, + ceController crcontroller.Controller, + clusterExtensionFinalizers crfinalizer.Registerer, +) error { + coreClient, err := corev1client.NewForConfig(mgr.GetConfig()) + if err != nil { + return fmt.Errorf("unable to create core client: %w", err) + } + tokenGetter := authentication.NewTokenGetter(coreClient, authentication.WithExpirationDuration(1*time.Hour)) + clientRestConfigMapper := action.ServiceAccountRestConfigMapper(tokenGetter) + if features.OperatorControllerFeatureGate.Enabled(features.SyntheticPermissions) { + clientRestConfigMapper = action.SyntheticUserRestConfigMapper(clientRestConfigMapper) + } + + cfgGetter, err := helmclient.NewActionConfigGetter(mgr.GetConfig(), mgr.GetRESTMapper(), + helmclient.StorageDriverMapper(action.ChunkedStorageDriverMapper(coreClient, mgr.GetAPIReader(), cfg.systemNamespace)), + helmclient.ClientNamespaceMapper(func(obj client.Object) (string, error) { + ext := obj.(*ocv1.ClusterExtension) + return ext.Spec.Namespace, nil + }), + helmclient.ClientRestConfigMapper(clientRestConfigMapper), + ) + if err != nil { + return fmt.Errorf("unable to create helm action config getter: %w", err) + } + + acg, err := action.NewWrappedActionClientGetter(cfgGetter, + helmclient.WithFailureRollbacks(false), + ) + if err != nil { + return fmt.Errorf("unable to create helm action client getter: %w", err) + } + + // determine if PreAuthorizer should be enabled based on feature gate + var preAuth authorization.PreAuthorizer + if features.OperatorControllerFeatureGate.Enabled(features.PreflightPermissions) { + preAuth = authorization.NewRBACPreAuthorizer(mgr.GetClient()) + } + + cm := contentmanager.NewManager(clientRestConfigMapper, mgr.GetConfig(), mgr.GetRESTMapper()) + err = clusterExtensionFinalizers.Register(controllers.ClusterExtensionCleanupContentManagerCacheFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) { + ext := obj.(*ocv1.ClusterExtension) + err := cm.Delete(ext) + return crfinalizer.Result{}, err + })) + if err != nil { + setupLog.Error(err, "unable to register content manager cleanup finalizer") + return err + } + + certProvider := getCertificateProvider() + + // now initialize the helmApplier, assigning the potentially nil preAuth + ceReconciler.Applier = &applier.Helm{ + ActionClientGetter: acg, + Preflights: preflights, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{ + BundleRenderer: registryv1.Renderer, + CertificateProvider: certProvider, + IsWebhookSupportEnabled: certProvider != nil, + }, + HelmReleaseToObjectsConverter: &applier.HelmReleaseToObjectsConverter{}, + PreAuthorizer: preAuth, + Watcher: ceController, + Manager: cm, + } + ceReconciler.RevisionStatesGetter = &controllers.HelmRevisionStatesGetter{ActionClientGetter: acg} + return nil +} + func main() { if err := operatorControllerCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/config/base/operator-controller/crd/experimental/kustomization.yaml b/config/base/operator-controller/crd/experimental/kustomization.yaml index 1c4db41af..f0315ce34 100644 --- a/config/base/operator-controller/crd/experimental/kustomization.yaml +++ b/config/base/operator-controller/crd/experimental/kustomization.yaml @@ -1,2 +1,3 @@ resources: - olm.operatorframework.io_clusterextensions.yaml +- olm.operatorframework.io_clusterextensionrevisions.yaml diff --git a/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml b/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml new file mode 100644 index 000000000..4eba98633 --- /dev/null +++ b/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml @@ -0,0 +1,182 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + olm.operatorframework.io/generator: experimental + name: clusterextensionrevisions.olm.operatorframework.io +spec: + group: olm.operatorframework.io + names: + kind: ClusterExtensionRevision + listKind: ClusterExtensionRevisionList + plural: clusterextensionrevisions + singular: clusterextensionrevision + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: ClusterExtensionRevision is the Schema for the clusterextensionrevisions + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec is an optional field that defines the desired state + of the ClusterExtension. + properties: + lifecycleState: + default: Active + description: Specifies the lifecycle state of the ClusterExtensionRevision. + enum: + - Active + - Paused + - Archived + type: string + x-kubernetes-validations: + - message: can not un-archive + rule: oldSelf == 'Active' || oldSelf == 'Paused' || oldSelf == 'Archived' + && oldSelf == self + phases: + items: + properties: + name: + type: string + objects: + items: + properties: + object: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + required: + - object + type: object + type: array + slices: + items: + type: string + type: array + required: + - name + - objects + type: object + type: array + x-kubernetes-validations: + - message: phases is immutable + rule: self == oldSelf || oldSelf.size() == 0 + previous: + items: + properties: + name: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - uid + type: object + type: array + x-kubernetes-validations: + - message: previous is immutable + rule: self == oldSelf + revision: + format: int64 + type: integer + x-kubernetes-validations: + - message: revision is immutable + rule: self == oldSelf + required: + - phases + - revision + type: object + status: + description: status is an optional field that defines the observed state + of the ClusterExtension. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/base/operator-controller/rbac/experimental/kustomization.yaml b/config/base/operator-controller/rbac/experimental/kustomization.yaml index 52a91a8e1..7d430c538 100644 --- a/config/base/operator-controller/rbac/experimental/kustomization.yaml +++ b/config/base/operator-controller/rbac/experimental/kustomization.yaml @@ -3,5 +3,5 @@ kind: Kustomization namespace: olmv1-system namePrefix: operator-controller- resources: -- ../common -- role.yaml + - ../common + - role.yaml diff --git a/config/base/operator-controller/rbac/experimental/role.yaml b/config/base/operator-controller/rbac/experimental/role.yaml index bb1cbe626..ea0d24fd0 100644 --- a/config/base/operator-controller/rbac/experimental/role.yaml +++ b/config/base/operator-controller/rbac/experimental/role.yaml @@ -27,8 +27,10 @@ rules: - apiGroups: - olm.operatorframework.io resources: - - clusterextensions + - clusterextensionrevisions verbs: + - create + - delete - get - list - patch @@ -37,16 +39,28 @@ rules: - apiGroups: - olm.operatorframework.io resources: + - clusterextensionrevisions/finalizers - clusterextensions/finalizers verbs: - update - apiGroups: - olm.operatorframework.io resources: + - clusterextensionrevisions/status - clusterextensions/status verbs: - patch - update +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensions + verbs: + - get + - list + - patch + - update + - watch - apiGroups: - rbac.authorization.k8s.io resources: diff --git a/config/components/base/experimental/kustomization.yaml b/config/components/base/experimental/kustomization.yaml index ab4eac1f7..f69e0e973 100644 --- a/config/components/base/experimental/kustomization.yaml +++ b/config/components/base/experimental/kustomization.yaml @@ -16,5 +16,6 @@ components: - ../../features/preflight-permissions - ../../features/apiv1-metas-handler - ../../features/helm-chart +- ../../features/boxcutter-runtime # This one is downstream only, so we shant use it # - ../../features/webhook-provider-openshift-serviceca diff --git a/config/components/features/boxcutter-runtime/cluster_role_binding.yaml b/config/components/features/boxcutter-runtime/cluster_role_binding.yaml new file mode 100644 index 000000000..e4a77f41f --- /dev/null +++ b/config/components/features/boxcutter-runtime/cluster_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: operator-controller-boxcutter-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: operator-controller-controller-manager + namespace: olmv1-system \ No newline at end of file diff --git a/config/components/features/boxcutter-runtime/kustomization.yaml b/config/components/features/boxcutter-runtime/kustomization.yaml new file mode 100644 index 000000000..bb8922d09 --- /dev/null +++ b/config/components/features/boxcutter-runtime/kustomization.yaml @@ -0,0 +1,11 @@ +# DO NOT ADD A NAMESPACE HERE +--- +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component +resources: + - cluster_role_binding.yaml +patches: + - target: + kind: Deployment + name: operator-controller-controller-manager + path: patches/enable-featuregate.yaml diff --git a/config/components/features/boxcutter-runtime/patches/enable-featuregate.yaml b/config/components/features/boxcutter-runtime/patches/enable-featuregate.yaml new file mode 100644 index 000000000..97f8b89be --- /dev/null +++ b/config/components/features/boxcutter-runtime/patches/enable-featuregate.yaml @@ -0,0 +1,4 @@ +# enable Boxcutter runtime feature gate +- op: add + path: /spec/template/spec/containers/0/args/- + value: "--feature-gates=BoxcutterRuntime=true" diff --git a/config/samples/olm_v1_clusterextension.yaml b/config/samples/olm_v1_clusterextension.yaml index 4438dfb76..14c8e167e 100644 --- a/config/samples/olm_v1_clusterextension.yaml +++ b/config/samples/olm_v1_clusterextension.yaml @@ -33,6 +33,10 @@ rules: resources: [clusterextensions/finalizers] verbs: [update] resourceNames: [argocd] +# Allow ClusterExtensionRevisions to set blockOwnerDeletion ownerReferences +- apiGroups: [olm.operatorframework.io] + resources: [clusterextensionrevisions/finalizers] + verbs: [update] # Manage ArgoCD CRDs - apiGroups: [apiextensions.k8s.io] resources: [customresourcedefinitions] diff --git a/docs/api-reference/crd-ref-docs-gen-config.yaml b/docs/api-reference/crd-ref-docs-gen-config.yaml index f6394fdf6..c8efa15c1 100644 --- a/docs/api-reference/crd-ref-docs-gen-config.yaml +++ b/docs/api-reference/crd-ref-docs-gen-config.yaml @@ -1,5 +1,5 @@ processor: - ignoreTypes: [] + ignoreTypes: [ClusterExtensionRevision, ClusterExtensionRevisionList] ignoreFields: [] render: diff --git a/go.mod b/go.mod index 60e3d8118..3e2a102d7 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/cert-manager/cert-manager v1.18.2 github.com/containerd/containerd v1.7.28 github.com/containers/image/v5 v5.36.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.3 github.com/golang-jwt/jwt/v5 v5.3.0 @@ -42,6 +43,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kubernetes v1.33.2 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 + pkg.package-operator.run/boxcutter v0.5.1 sigs.k8s.io/controller-runtime v0.21.0 sigs.k8s.io/controller-tools v0.18.0 sigs.k8s.io/crdify v0.4.1-0.20250613143457-398e4483fb58 @@ -88,7 +90,6 @@ require ( github.com/containers/storage v1.59.1 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v28.3.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect @@ -96,13 +97,13 @@ require ( github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect @@ -110,7 +111,7 @@ require ( github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonpointer v0.21.2 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/gobuffalo/flect v1.0.3 // indirect @@ -178,7 +179,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/proglottis/gpgme v0.1.4 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -213,7 +214,7 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect @@ -222,13 +223,13 @@ require ( golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 92d3d2f4e..611349e49 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -148,8 +148,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -169,8 +169,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= +github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= @@ -406,8 +406,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/redis/go-redis/extra/rediscmd/v9 v9.10.0 h1:uTiEyEyfLhkw678n6EulHVto8AkcXVr8zUcBJNZ0ark= github.com/redis/go-redis/extra/rediscmd/v9 v9.10.0/go.mod h1:eFYL/99JvdLP4T9/3FZ5t2pClnv7mMskc+WstTcyVr4= github.com/redis/go-redis/extra/redisotel/v9 v9.10.0 h1:4z7/hCJ9Jft8EBb2tDmK38p2WjyIEJ1ShhhwAhjOCps= @@ -549,8 +549,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= -go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -676,8 +676,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -705,13 +705,13 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= @@ -759,6 +759,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +pkg.package-operator.run/boxcutter v0.5.1 h1:oZ68bI8wQ5nUsn6VqFFYNKJpnHLrys9Uc36yeNgedKE= +pkg.package-operator.run/boxcutter v0.5.1/go.mod h1:yJu14WhAywcr2rvt/MEfDCT14t8cTFdYGZWxdSMA5QY= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= diff --git a/hack/tools/update-crds.sh b/hack/tools/update-crds.sh index 8627784fe..744d7f09e 100755 --- a/hack/tools/update-crds.sh +++ b/hack/tools/update-crds.sh @@ -7,11 +7,12 @@ set -e # The names of the generated CRDs CE="olm.operatorframework.io_clusterextensions.yaml" CC="olm.operatorframework.io_clustercatalogs.yaml" +CR="olm.operatorframework.io_clusterextensionrevisions.yaml" # order for modules and crds must match # each item in crds must be unique, and should be associated with a module -modules=("operator-controller" "catalogd") -crds=("${CE}" "${CC}") +modules=("operator-controller" "catalogd" "operator-controller") +crds=("${CE}" "${CC}" "${CR}") # Channels must much those in the generator channels=("standard" "experimental") diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go new file mode 100644 index 000000000..231331d7f --- /dev/null +++ b/internal/operator-controller/applier/boxcutter.go @@ -0,0 +1,292 @@ +package applier + +import ( + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "hash" + "io/fs" + "maps" + "slices" + + "github.com/davecgh/go-spew/spew" + "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" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" +) + +const ( + RevisionHashAnnotation = "olm.operatorframework.io/hash" +) + +type ClusterExtensionRevisionGenerator interface { + GenerateRevision(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) +} + +type SimpleRevisionGenerator struct { + Scheme *runtime.Scheme + BundleRenderer BundleRenderer +} + +func (r *SimpleRevisionGenerator) GenerateRevision(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + // extract plain manifests + plain, err := r.BundleRenderer.Render(bundleFS, ext) + if err != nil { + return nil, err + } + + // objectLabels + objs := make([]ocv1.ClusterExtensionRevisionObject, 0, len(plain)) + for _, obj := range plain { + if len(obj.GetLabels()) > 0 { + labels := maps.Clone(obj.GetLabels()) + if labels == nil { + labels = map[string]string{} + } + maps.Copy(labels, objectLabels) + obj.SetLabels(labels) + } + + gvk, err := apiutil.GVKForObject(obj, r.Scheme) + if err != nil { + return nil, err + } + + unstrObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + unstr := unstructured.Unstructured{Object: unstrObj} + unstr.SetGroupVersionKind(gvk) + + objs = append(objs, ocv1.ClusterExtensionRevisionObject{ + Object: unstr, + }) + } + + if revisionAnnotations == nil { + revisionAnnotations = map[string]string{} + } + + // Build desired revision + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: revisionAnnotations, + Labels: map[string]string{ + controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Phases: PhaseSort(objs), + }, + }, nil +} + +type Boxcutter struct { + Client client.Client + Scheme *runtime.Scheme + RevisionGenerator ClusterExtensionRevisionGenerator + Preflights []Preflight +} + +func (bc *Boxcutter) Apply(ctx context.Context, bundle bundle.Bundle, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) { + return bc.apply(ctx, bundle, ext, objectLabels, revisionAnnotations) +} + +func (bc *Boxcutter) getObjects(rev *ocv1.ClusterExtensionRevision) []client.Object { + var objs []client.Object + for _, phase := range rev.Spec.Phases { + for _, phaseObject := range phase.Objects { + objs = append(objs, &phaseObject.Object) + } + } + return objs +} + +func (bc *Boxcutter) apply(ctx context.Context, bundle bundle.Bundle, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) { + // Generate desired revision + desiredRevision, err := bc.RevisionGenerator.GenerateRevision(bundle.FS(), ext, objectLabels, revisionAnnotations) + if err != nil { + return false, "", err + } + + // List all existing revisions + existingRevisions, err := bc.getExistingRevisions(ctx, ext.GetName()) + if err != nil { + return false, "", err + } + desiredHash := computeSHA256Hash(desiredRevision.Spec.Phases) + + // Sort into current and previous revisions. + var ( + currentRevision *ocv1.ClusterExtensionRevision + ) + state := StateNeedsInstall + if len(existingRevisions) > 0 { + maybeCurrentRevision := existingRevisions[len(existingRevisions)-1] + annotations := maybeCurrentRevision.GetAnnotations() + if annotations != nil { + if revisionHash, ok := annotations[RevisionHashAnnotation]; ok && revisionHash == desiredHash { + currentRevision = &maybeCurrentRevision + } + } + state = StateNeedsUpgrade + } + + // Preflights + plainObjs := bc.getObjects(desiredRevision) + for _, preflight := range bc.Preflights { + if shouldSkipPreflight(ctx, preflight, ext, state) { + continue + } + switch state { + case StateNeedsInstall: + err := preflight.Install(ctx, plainObjs) + if err != nil { + return false, "", err + } + // TODO: jlanford's IDE says that "StateNeedsUpgrade" condition is always true, but + // it isn't immediately obvious why that is. Perhaps len(existingRevisions) is + // always greater than 0 (seems unlikely), or shouldSkipPreflight always returns + // true (and we continue) when state == StateNeedsInstall? + case StateNeedsUpgrade: + err := preflight.Upgrade(ctx, plainObjs) + if err != nil { + return false, "", err + } + } + } + + if currentRevision == nil { + // all Revisions are outdated => create a new one. + prevRevisions := existingRevisions + revisionNumber := latestRevisionNumber(prevRevisions) + 1 + + newRevision := desiredRevision + newRevision.Name = fmt.Sprintf("%s-%d", ext.Name, revisionNumber) + if newRevision.GetAnnotations() == nil { + newRevision.Annotations = map[string]string{} + } + newRevision.Annotations[RevisionHashAnnotation] = desiredHash + newRevision.Spec.Revision = revisionNumber + for _, prevRevision := range prevRevisions { + newRevision.Spec.Previous = append(newRevision.Spec.Previous, ocv1.ClusterExtensionRevisionPrevious{ + Name: prevRevision.Name, + UID: prevRevision.UID, + }) + } + + if err := controllerutil.SetControllerReference(ext, newRevision, bc.Scheme); err != nil { + return false, "", fmt.Errorf("set ownerref: %w", err) + } + if err := bc.Client.Create(ctx, newRevision); err != nil { + return false, "", fmt.Errorf("creating new Revision: %w", err) + } + currentRevision = newRevision + } + + // TODO: Delete archived previous revisions over a certain revision limit + + // TODO: Define constants for the ClusterExtensionRevision condition types. + progressingCondition := meta.FindStatusCondition(currentRevision.Status.Conditions, "Progressing") + availableCondition := meta.FindStatusCondition(currentRevision.Status.Conditions, "Available") + succeededCondition := meta.FindStatusCondition(currentRevision.Status.Conditions, "Succeeded") + + if progressingCondition == nil && availableCondition == nil && succeededCondition == nil { + return false, "New revision created", nil + } else if progressingCondition != nil && progressingCondition.Status == metav1.ConditionTrue { + return false, progressingCondition.Message, nil + } else if availableCondition != nil && availableCondition.Status != metav1.ConditionTrue { + return false, "", errors.New(availableCondition.Message) + } else if succeededCondition != nil && succeededCondition.Status != metav1.ConditionTrue { + return false, succeededCondition.Message, nil + } + return true, "", nil +} + +// getExistingRevisions returns the list of ClusterExtensionRevisions for a ClusterExtension with name extName in revision order (oldest to newest) +func (bc *Boxcutter) getExistingRevisions(ctx context.Context, extName string) ([]ocv1.ClusterExtensionRevision, error) { + existingRevisionList := &ocv1.ClusterExtensionRevisionList{} + if err := bc.Client.List(ctx, existingRevisionList, client.MatchingLabels{ + controllers.ClusterExtensionRevisionOwnerLabel: extName, + }); err != nil { + return nil, fmt.Errorf("listing revisions: %w", err) + } + slices.SortFunc(existingRevisionList.Items, func(a, b ocv1.ClusterExtensionRevision) int { + return cmp.Compare(a.Spec.Revision, b.Spec.Revision) + }) + return existingRevisionList.Items, nil +} + +// computeSHA256Hash returns a sha236 hash value calculated from object. +func computeSHA256Hash(obj any) string { + hasher := sha256.New() + deepHashObject(hasher, obj) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// deepHashObject writes specified object to hash using the spew library +// which follows pointers and prints actual values of the nested objects +// ensuring the hash does not change when a pointer changes. +func deepHashObject(hasher hash.Hash, objectToWrite any) { + hasher.Reset() + + // TODO: change this out to `json.Marshal`. Pretty sure we found issues in the past where + // spew would produce different output when internal structures changed without the + // external public API changing. + printer := spew.ConfigState{ + Indent: " ", + SortKeys: true, + DisableMethods: true, + SpewKeys: true, + } + if _, err := printer.Fprintf(hasher, "%#v", objectToWrite); err != nil { + panic(err) + } +} + +func latestRevisionNumber(prevRevisions []ocv1.ClusterExtensionRevision) int64 { + if len(prevRevisions) == 0 { + return 0 + } + return prevRevisions[len(prevRevisions)-1].Spec.Revision +} + +type BundleRenderer interface { + Render(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) +} + +type RegistryV1BundleRenderer struct { + BundleRenderer render.BundleRenderer + CertificateProvider render.CertificateProvider +} + +func (r *RegistryV1BundleRenderer) Render(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) { + reg, err := source.FromFS(bundleFS).GetBundle() + if err != nil { + return nil, err + } + + if len(reg.CSV.Spec.WebhookDefinitions) > 0 && r.CertificateProvider == nil { + return nil, fmt.Errorf("unsupported bundle: webhookDefinitions are not supported") + } + + watchNamespace, err := GetWatchNamespace(ext) + if err != nil { + return nil, err + } + return r.BundleRenderer.Render(reg, ext.Spec.Namespace, render.WithTargetNamespaces(watchNamespace), render.WithCertificateProvider(r.CertificateProvider)) +} diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go new file mode 100644 index 000000000..758451a6d --- /dev/null +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -0,0 +1,540 @@ +package applier_test + +import ( + "context" + "errors" + "fmt" + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + k8scheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" + regv1 "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + testutils "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" +) + +func Test_RegistryV1BundleRenderer_Render_Success(t *testing.T) { + expectedObjs := []client.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + }, + }, + } + r := applier.RegistryV1BundleRenderer{ + BundleRenderer: render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *regv1.RegistryV1, opts render.Options) ([]client.Object, error) { + require.Equal(t, []string{""}, opts.TargetNamespaces) + require.Equal(t, "some-namespace", opts.InstallNamespace) + return expectedObjs, nil + }, + }, + }, + } + + objs, err := r.Render(testutils.NewBundleFS(), &ocv1.ClusterExtension{ + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "some-namespace", + }, + }) + require.NoError(t, err) + require.Equal(t, expectedObjs, objs) +} + +func Test_RegistryV1BundleRenderer_Render_Failure(t *testing.T) { + var expectedObjs []client.Object + r := applier.RegistryV1BundleRenderer{ + BundleRenderer: render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *regv1.RegistryV1, opts render.Options) ([]client.Object, error) { + return expectedObjs, fmt.Errorf("some-error") + }, + }, + }, + } + + objs, err := r.Render(testutils.NewBundleFS(), &ocv1.ClusterExtension{ + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "some-namespace", + }, + }) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "some-error") +} + +func Test_SimpleRevisionGenerator_Success(t *testing.T) { + var r mockBundleRenderer = func(_ fs.FS, _ *ocv1.ClusterExtension) ([]client.Object, error) { + return []client.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + }, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + }, + }, + }, nil + } + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + BundleRenderer: r, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + } + + rev, err := b.GenerateRevision(fstest.MapFS{}, ext, map[string]string{}, map[string]string{}) + require.NoError(t, err) + + t.Log("by checking the olm.operatorframework.io/owner label is set to the name of the ClusterExtension") + require.Equal(t, map[string]string{ + controllers.ClusterExtensionRevisionOwnerLabel: "test-extension", + }, rev.Labels) + t.Log("by checking there are no annotations") + require.Empty(t, rev.Annotations) + t.Log("by checking the revision number is 0") + require.Equal(t, int64(0), rev.Spec.Revision) + t.Log("by checking the rendered objects are present in the correct phases") + require.Equal(t, []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "creationTimestamp": nil, + "name": "test-service", + }, + "spec": map[string]interface{}{}, + "status": map[string]interface{}{ + "loadBalancer": map[string]interface{}{}, + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "creationTimestamp": nil, + "name": "test-deployment", + }, + "spec": map[string]interface{}{ + "selector": nil, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "creationTimestamp": nil, + }, + "spec": map[string]interface{}{ + "containers": nil, + }, + }, + "strategy": map[string]interface{}{}, + }, + "status": map[string]interface{}{}, + }, + }, + }, + }, + }, + }, rev.Spec.Phases) +} + +func Test_SimpleRevisionGenerator_Renderer_Integration(t *testing.T) { + bundleFS := fstest.MapFS{} + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + } + var r mockBundleRenderer = func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) { + t.Log("by checking renderer was called with the correct parameters") + require.Equal(t, bundleFS, b) + require.Equal(t, ext, e) + return nil, nil + } + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + BundleRenderer: r, + } + + _, err := b.GenerateRevision(bundleFS, ext, map[string]string{}, map[string]string{}) + require.NoError(t, err) +} + +func Test_SimpleRevisionGenerator_AppliesObjectLabelsAndRevisionAnnotations(t *testing.T) { + renderedObjs := []client.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Labels: map[string]string{ + "app": "test-obj", + }, + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Labels: map[string]string{ + "app": "test-obj", + }, + }, + }, + } + var r mockBundleRenderer = func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) { + return renderedObjs, nil + } + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + BundleRenderer: r, + } + + revAnnotations := map[string]string{ + "other": "value", + } + + rev, err := b.GenerateRevision(fstest.MapFS{}, &ocv1.ClusterExtension{}, map[string]string{ + "some": "value", + }, revAnnotations) + require.NoError(t, err) + t.Log("by checking the rendered objects contain the given object labels") + for _, phase := range rev.Spec.Phases { + for _, revObj := range phase.Objects { + require.Equal(t, map[string]string{ + "app": "test-obj", + "some": "value", + }, revObj.Object.GetLabels()) + } + } + t.Log("by checking the generated revision contain the given annotations") + require.Equal(t, revAnnotations, rev.Annotations) +} + +func Test_SimpleRevisionGenerator_Failure(t *testing.T) { + var r mockBundleRenderer = func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) { + return nil, fmt.Errorf("some-error") + } + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + BundleRenderer: r, + } + + rev, err := b.GenerateRevision(fstest.MapFS{}, &ocv1.ClusterExtension{}, map[string]string{}, map[string]string{}) + require.Nil(t, rev) + t.Log("by checking rendering errors are propagated") + require.Error(t, err) + require.Contains(t, err.Error(), "some-error") +} + +func TestBoxcutter_Apply(t *testing.T) { + testScheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(testScheme)) + + // This is the revision that the mock builder will produce by default. + // We calculate its hash to use in the tests. + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext", + UID: "test-uid", + }, + } + defaultDesiredHash := "faaeb52a1cb7c968c96278bc1cd804e50d3ae9faae08807c9279a5e569933ea0" + defaultDesiredRevision := &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext-1", + UID: "rev-uid-1", + Annotations: map[string]string{ + applier.RevisionHashAnnotation: defaultDesiredHash, + }, + Labels: map[string]string{ + controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Revision: 1, + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-cm", + }, + }, + }, + }, + }, + }, + }, + }, + } + + testCases := []struct { + name string + mockBuilder applier.ClusterExtensionRevisionGenerator + existingObjs []client.Object + expectedErr string + validate func(t *testing.T, c client.Client) + }{ + { + name: "first revision", + mockBuilder: &mockBundleRevisionBuilder{ + makeRevisionFunc: func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: revisionAnnotations, + Labels: map[string]string{ + controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-cm", + }, + }, + }, + }, + }, + }, + }, + }, + }, nil + }, + }, + validate: func(t *testing.T, c client.Client) { + revList := &ocv1.ClusterExtensionRevisionList{} + err := c.List(t.Context(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + require.NoError(t, err) + require.Len(t, revList.Items, 1) + + rev := revList.Items[0] + assert.Equal(t, "test-ext-1", rev.Name) + assert.Equal(t, int64(1), rev.Spec.Revision) + assert.Equal(t, defaultDesiredHash, rev.Annotations[applier.RevisionHashAnnotation]) + assert.Len(t, rev.OwnerReferences, 1) + assert.Equal(t, ext.Name, rev.OwnerReferences[0].Name) + assert.Equal(t, ext.UID, rev.OwnerReferences[0].UID) + }, + }, + { + name: "no change, revision exists", + mockBuilder: &mockBundleRevisionBuilder{ + makeRevisionFunc: func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: revisionAnnotations, + Labels: map[string]string{ + controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-cm", + }, + }, + }, + }, + }, + }, + }, + }, + }, nil + }, + }, + existingObjs: []client.Object{ + defaultDesiredRevision, + }, + validate: func(t *testing.T, c client.Client) { + revList := &ocv1.ClusterExtensionRevisionList{} + err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + require.NoError(t, err) + // No new revision should be created + require.Len(t, revList.Items, 1) + assert.Equal(t, "test-ext-1", revList.Items[0].Name) + }, + }, + { + name: "new revision created when hash differs", + mockBuilder: &mockBundleRevisionBuilder{ + makeRevisionFunc: func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: revisionAnnotations, + Labels: map[string]string{ + controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "new-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, nil + }, + }, + existingObjs: []client.Object{ + defaultDesiredRevision, + }, + validate: func(t *testing.T, c client.Client) { + revList := &ocv1.ClusterExtensionRevisionList{} + err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + require.NoError(t, err) + require.Len(t, revList.Items, 2) + + // Find the new revision (rev 2) + var newRev ocv1.ClusterExtensionRevision + for _, r := range revList.Items { + if r.Spec.Revision == 2 { + newRev = r + break + } + } + require.NotNil(t, newRev) + + assert.Equal(t, "test-ext-2", newRev.Name) + assert.Equal(t, int64(2), newRev.Spec.Revision) + assert.Equal(t, "ec8213d4061a75b55cd67a009d9cdeb1bdd6f503d4b3bb7b6cfea3a5233aad43", newRev.Annotations[applier.RevisionHashAnnotation]) + require.Len(t, newRev.Spec.Previous, 1) + assert.Equal(t, "test-ext-1", newRev.Spec.Previous[0].Name) + assert.Equal(t, types.UID("rev-uid-1"), newRev.Spec.Previous[0].UID) + }, + }, + { + name: "error from GenerateRevision", + mockBuilder: &mockBundleRevisionBuilder{ + makeRevisionFunc: func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + return nil, errors.New("render boom") + }, + }, + expectedErr: "render boom", + validate: func(t *testing.T, c client.Client) { + // Ensure no revisions were created + revList := &ocv1.ClusterExtensionRevisionList{} + err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + require.NoError(t, err) + assert.Empty(t, revList.Items) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup + fakeClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(tc.existingObjs...).Build() + + boxcutter := &applier.Boxcutter{ + Client: fakeClient, + Scheme: testScheme, + RevisionGenerator: tc.mockBuilder, + } + + // We need a dummy bundle + dummyBundle := bundle.FromFS(fstest.MapFS{}) + + // Execute + installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), dummyBundle, ext, nil, nil) + + // Assert + if tc.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + assert.False(t, installSucceeded) + assert.Empty(t, installStatus) + } else { + require.NoError(t, err) + assert.False(t, installSucceeded) + assert.Equal(t, "New revision created", installStatus) + } + + if tc.validate != nil { + // For the client create error, we need a client that *will* error. + // Since we can't do that easily, we will skip validation for that specific path + // as the state won't be what we expect. + if tc.name != "error from client create" { + tc.validate(t, fakeClient) + } + } + }) + } +} + +// mockBundleRevisionBuilder is a mock implementation of the ClusterExtensionRevisionGenerator for testing. +type mockBundleRevisionBuilder struct { + makeRevisionFunc func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotation map[string]string) (*ocv1.ClusterExtensionRevision, error) +} + +func (m *mockBundleRevisionBuilder) GenerateRevision(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + return m.makeRevisionFunc(bundleFS, ext, objectLabels, revisionAnnotations) +} + +type mockBundleRenderer func(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) + +func (f mockBundleRenderer) Render(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) { + return f(bundleFS, ext) +} diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index ecfb3fdc2..694806e98 100644 --- a/internal/operator-controller/applier/helm.go +++ b/internal/operator-controller/applier/helm.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "io/fs" "slices" "strings" @@ -19,72 +18,54 @@ import ( rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" apimachyaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" + crcontroller "sigs.k8s.io/controller-runtime/pkg/controller" helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/authorization" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/contentmanager" "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" ) -const ( - StateNeedsInstall string = "NeedsInstall" - StateNeedsUpgrade string = "NeedsUpgrade" - StateUnchanged string = "Unchanged" - StateError string = "Error" - maxHelmReleaseHistory = 10 -) - -// Preflight is a check that should be run before making any changes to the cluster -type Preflight interface { - // Install runs checks that should be successful prior - // to installing the Helm release. It is provided - // a Helm release and returns an error if the - // check is unsuccessful - Install(context.Context, *release.Release) error - - // Upgrade runs checks that should be successful prior - // to upgrading the Helm release. It is provided - // a Helm release and returns an error if the - // check is unsuccessful - Upgrade(context.Context, *release.Release) error -} - type BundleToHelmChartConverter interface { ToHelmChart(bundle source.BundleSource, installNamespace string, watchNamespace string) (*chart.Chart, error) } -type Helm struct { - ActionClientGetter helmclient.ActionClientGetter - Preflights []Preflight - PreAuthorizer authorization.PreAuthorizer - BundleToHelmChartConverter BundleToHelmChartConverter +type HelmReleaseToObjectsConverter struct { } -// shouldSkipPreflight is a helper to determine if the preflight check is CRDUpgradeSafety AND -// if it is set to enforcement None. -func shouldSkipPreflight(ctx context.Context, preflight Preflight, ext *ocv1.ClusterExtension, state string) bool { - l := log.FromContext(ctx) - hasCRDUpgradeSafety := ext.Spec.Install != nil && ext.Spec.Install.Preflight != nil && ext.Spec.Install.Preflight.CRDUpgradeSafety != nil - _, isCRDUpgradeSafetyInstance := preflight.(*crdupgradesafety.Preflight) +type HelmReleaseToObjectsConverterInterface interface { + GetObjectsFromRelease(rel *release.Release) ([]client.Object, error) +} - if hasCRDUpgradeSafety && isCRDUpgradeSafetyInstance { - if state == StateNeedsInstall || state == StateNeedsUpgrade { - l.Info("crdUpgradeSafety ", "policy", ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement) - } - if ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement == ocv1.CRDUpgradeSafetyEnforcementNone { - // Skip this preflight check because it is of type *crdupgradesafety.Preflight and the CRD Upgrade Safety - // policy is set to None - return true - } +func (h HelmReleaseToObjectsConverter) GetObjectsFromRelease(rel *release.Release) ([]client.Object, error) { + if rel == nil { + return nil, nil } - return false + + relObjects, err := util.ManifestObjects(strings.NewReader(rel.Manifest), fmt.Sprintf("%s-release-manifest", rel.Name)) + if err != nil { + return nil, fmt.Errorf("parsing release %q objects: %w", rel.Name, err) + } + return relObjects, nil +} + +type Helm struct { + ActionClientGetter helmclient.ActionClientGetter + Preflights []Preflight + PreAuthorizer authorization.PreAuthorizer + BundleToHelmChartConverter BundleToHelmChartConverter + HelmReleaseToObjectsConverter HelmReleaseToObjectsConverterInterface + + Manager contentmanager.Manager + Watcher crcontroller.Controller } // runPreAuthorizationChecks performs pre-authorization checks for a Helm release @@ -121,10 +102,10 @@ func (h *Helm) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterE return nil } -func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels map[string]string, storageLabels map[string]string) ([]client.Object, string, error) { - chrt, err := h.buildHelmChart(contentFS, ext) +func (h *Helm) Apply(ctx context.Context, bundle bundle.Bundle, ext *ocv1.ClusterExtension, objectLabels map[string]string, storageLabels map[string]string) (bool, string, error) { + chrt, err := h.buildHelmChart(bundle, ext) if err != nil { - return nil, "", err + return false, "", err } values := chartutil.Values{} @@ -136,18 +117,22 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte err := h.runPreAuthorizationChecks(ctx, ext, chrt, values, post) if err != nil { // Return the pre-authorization error directly - return nil, "", err + return false, "", err } } ac, err := h.ActionClientGetter.ActionClientFor(ctx, ext) if err != nil { - return nil, "", err + return false, "", err } rel, desiredRel, state, err := h.getReleaseState(ac, ext, chrt, values, post) if err != nil { - return nil, "", fmt.Errorf("failed to get release state using server-side dry-run: %w", err) + return false, "", fmt.Errorf("failed to get release state using server-side dry-run: %w", err) + } + objs, err := h.HelmReleaseToObjectsConverter.GetObjectsFromRelease(desiredRel) + if err != nil { + return false, "", err } for _, preflight := range h.Preflights { @@ -156,14 +141,14 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte } switch state { case StateNeedsInstall: - err := preflight.Install(ctx, desiredRel) + err := preflight.Install(ctx, objs) if err != nil { - return nil, state, err + return false, "", err } case StateNeedsUpgrade: - err := preflight.Upgrade(ctx, desiredRel) + err := preflight.Upgrade(ctx, objs) if err != nil { - return nil, state, err + return false, "", err } } } @@ -176,7 +161,7 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte return nil }, helmclient.AppendInstallPostRenderer(post)) if err != nil { - return nil, state, err + return false, "", err } case StateNeedsUpgrade: rel, err = ac.Upgrade(ext.GetName(), ext.Spec.Namespace, chrt, values, func(upgrade *action.Upgrade) error { @@ -185,28 +170,38 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte return nil }, helmclient.AppendUpgradePostRenderer(post)) if err != nil { - return nil, state, err + return false, "", err } case StateUnchanged: if err := ac.Reconcile(rel); err != nil { - return nil, state, err + return false, "", err } default: - return nil, state, fmt.Errorf("unexpected release state %q", state) + return false, "", fmt.Errorf("unexpected release state %q", state) } relObjects, err := util.ManifestObjects(strings.NewReader(rel.Manifest), fmt.Sprintf("%s-release-manifest", rel.Name)) if err != nil { - return nil, state, err + return true, "", err + } + klog.FromContext(ctx).Info("watching managed objects") + cache, err := h.Manager.Get(ctx, ext) + if err != nil { + return true, "", err + } + + if err := cache.Watch(ctx, h.Watcher, relObjects...); err != nil { + return true, "", err } - return relObjects, state, nil + return true, "", nil } -func (h *Helm) buildHelmChart(bundleFS fs.FS, ext *ocv1.ClusterExtension) (*chart.Chart, error) { +func (h *Helm) buildHelmChart(bundle bundle.Bundle, ext *ocv1.ClusterExtension) (*chart.Chart, error) { if h.BundleToHelmChartConverter == nil { return nil, errors.New("BundleToHelmChartConverter is nil") } + bundleFS := bundle.FS() watchNamespace, err := GetWatchNamespace(ext) if err != nil { return nil, err diff --git a/internal/operator-controller/applier/helm_test.go b/internal/operator-controller/applier/helm_test.go index 89c94df88..42dbe959c 100644 --- a/internal/operator-controller/applier/helm_test.go +++ b/internal/operator-controller/applier/helm_test.go @@ -8,7 +8,6 @@ import ( "testing" "testing/fstest" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" @@ -25,11 +24,52 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "github.com/operator-framework/operator-controller/internal/operator-controller/authorization" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/contentmanager" + cmcache "github.com/operator-framework/operator-controller/internal/operator-controller/contentmanager/cache" "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/convert" ) +var _ contentmanager.Manager = (*mockManagedContentCacheManager)(nil) + +type mockManagedContentCacheManager struct { + err error + cache cmcache.Cache +} + +func (m *mockManagedContentCacheManager) Get(_ context.Context, _ *ocv1.ClusterExtension) (cmcache.Cache, error) { + if m.err != nil { + return nil, m.err + } + return m.cache, nil +} + +func (m *mockManagedContentCacheManager) Delete(_ *ocv1.ClusterExtension) error { + return m.err +} + +type mockManagedContentCache struct { + err error +} + +var _ cmcache.Cache = (*mockManagedContentCache)(nil) + +func (m *mockManagedContentCache) Close() error { + if m.err != nil { + return m.err + } + return nil +} + +func (m *mockManagedContentCache) Watch(_ context.Context, _ cmcache.Watcher, _ ...client.Object) error { + if m.err != nil { + return m.err + } + return nil +} + type mockPreflight struct { installErr error upgradeErr error @@ -48,14 +88,21 @@ func (p *mockPreAuthorizer) PreAuthorize( return p.missingRules, p.returnError } -func (mp *mockPreflight) Install(context.Context, *release.Release) error { +func (mp *mockPreflight) Install(context.Context, []client.Object) error { return mp.installErr } -func (mp *mockPreflight) Upgrade(context.Context, *release.Release) error { +func (mp *mockPreflight) Upgrade(context.Context, []client.Object) error { return mp.upgradeErr } +type mockHelmReleaseToObjectsConverter struct { +} + +func (mockHelmReleaseToObjectsConverter) GetObjectsFromRelease(*release.Release) ([]client.Object, error) { + return nil, nil +} + type mockActionGetter struct { actionClientForErr error getClientErr error @@ -115,8 +162,8 @@ func (mag *mockActionGetter) Reconcile(rel *release.Release) error { } var ( - // required for unmockable call to convert.RegistryV1ToHelmChart - validFS = fstest.MapFS{ + // required for unmockable call to bundle + validBundle = bundle.FromFS(fstest.MapFS{ "metadata/annotations.yaml": &fstest.MapFile{Data: []byte(`annotations: operators.operatorframework.io.bundle.package.v1: my-package`)}, "manifests": &fstest.MapFile{Data: []byte(`apiVersion: operators.operatorframework.io/v1alpha1 @@ -133,7 +180,7 @@ spec: supported: true - type: AllNamespaces supported: true`)}, - } + }) // required for unmockable call to util.ManifestObjects validManifest = `apiVersion: v1 @@ -188,10 +235,10 @@ func TestApply_Base(t *testing.T) { t.Run("fails converting content FS to helm chart", func(t *testing.T) { helmApplier := applier.Helm{} - objs, state, err := helmApplier.Apply(context.TODO(), os.DirFS("/"), testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), bundle.FromFS(os.DirFS("/")), testCE, testObjectLabels, testStorageLabels) require.Error(t, err) - require.Nil(t, objs) - require.Empty(t, state) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails trying to obtain an action client", func(t *testing.T) { @@ -201,11 +248,11 @@ func TestApply_Base(t *testing.T) { BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "getting action client") - require.Nil(t, objs) - require.Empty(t, state) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails getting current release and !driver.ErrReleaseNotFound", func(t *testing.T) { @@ -215,11 +262,11 @@ func TestApply_Base(t *testing.T) { BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "getting current release") - require.Nil(t, objs) - require.Empty(t, state) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) } @@ -234,11 +281,11 @@ func TestApply_Installation(t *testing.T) { BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "attempting to dry-run install chart") - require.Nil(t, objs) - require.Empty(t, state) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during pre-flight installation", func(t *testing.T) { @@ -248,16 +295,17 @@ func TestApply_Installation(t *testing.T) { } mockPf := &mockPreflight{installErr: errors.New("failed during install pre-flight check")} helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - Preflights: []applier.Preflight{mockPf}, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + Preflights: []applier.Preflight{mockPf}, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "install pre-flight check") - require.Equal(t, applier.StateNeedsInstall, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during installation", func(t *testing.T) { @@ -266,15 +314,16 @@ func TestApply_Installation(t *testing.T) { installErr: errors.New("failed installing chart"), } helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "installing chart") - require.Equal(t, applier.StateNeedsInstall, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("successful installation", func(t *testing.T) { @@ -286,16 +335,18 @@ func TestApply_Installation(t *testing.T) { }, } helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, + Manager: &mockManagedContentCacheManager{ + cache: &mockManagedContentCache{}, + }, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.NoError(t, err) - require.Equal(t, applier.StateNeedsInstall, state) - require.NotNil(t, objs) - assert.Equal(t, "service-a", objs[0].GetName()) - assert.Equal(t, "service-b", objs[1].GetName()) + require.Empty(t, installStatus) + require.True(t, installSucceeded) }) } @@ -310,11 +361,11 @@ func TestApply_InstallationWithPreflightPermissionsEnabled(t *testing.T) { BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "attempting to dry-run install chart") - require.Nil(t, objs) - require.Empty(t, state) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during pre-flight installation", func(t *testing.T) { @@ -328,17 +379,18 @@ func TestApply_InstallationWithPreflightPermissionsEnabled(t *testing.T) { } mockPf := &mockPreflight{installErr: errors.New("failed during install pre-flight check")} helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - Preflights: []applier.Preflight{mockPf}, - PreAuthorizer: &mockPreAuthorizer{nil, nil}, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + Preflights: []applier.Preflight{mockPf}, + PreAuthorizer: &mockPreAuthorizer{nil, nil}, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "install pre-flight check") - require.Equal(t, applier.StateNeedsInstall, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during installation because of pre-authorization failure", func(t *testing.T) { @@ -363,11 +415,12 @@ func TestApply_InstallationWithPreflightPermissionsEnabled(t *testing.T) { }, }, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, validCE, testObjectLabels, testStorageLabels) + + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, validCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "problem running preauthorization") - require.Empty(t, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during installation due to missing RBAC rules", func(t *testing.T) { @@ -392,11 +445,12 @@ func TestApply_InstallationWithPreflightPermissionsEnabled(t *testing.T) { }, }, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, validCE, testObjectLabels, testStorageLabels) + + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, validCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, errMissingRBAC) - require.Empty(t, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("successful installation", func(t *testing.T) { @@ -408,9 +462,13 @@ func TestApply_InstallationWithPreflightPermissionsEnabled(t *testing.T) { }, } helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - PreAuthorizer: &mockPreAuthorizer{nil, nil}, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + PreAuthorizer: &mockPreAuthorizer{nil, nil}, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, + Manager: &mockManagedContentCacheManager{ + cache: &mockManagedContentCache{}, + }, } // Use a ClusterExtension with valid Spec fields. @@ -423,12 +481,10 @@ func TestApply_InstallationWithPreflightPermissionsEnabled(t *testing.T) { }, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, validCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, validCE, testObjectLabels, testStorageLabels) require.NoError(t, err) - require.Equal(t, applier.StateNeedsInstall, state) - require.NotNil(t, objs) - assert.Equal(t, "service-a", objs[0].GetName()) - assert.Equal(t, "service-b", objs[1].GetName()) + require.Empty(t, installStatus) + require.True(t, installSucceeded) }) } @@ -446,11 +502,11 @@ func TestApply_Upgrade(t *testing.T) { BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "attempting to dry-run upgrade chart") - require.Nil(t, objs) - require.Empty(t, state) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during pre-flight upgrade", func(t *testing.T) { @@ -464,16 +520,17 @@ func TestApply_Upgrade(t *testing.T) { } mockPf := &mockPreflight{upgradeErr: errors.New("failed during upgrade pre-flight check")} helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - Preflights: []applier.Preflight{mockPf}, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + Preflights: []applier.Preflight{mockPf}, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "upgrade pre-flight check") - require.Equal(t, applier.StateNeedsUpgrade, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during upgrade", func(t *testing.T) { @@ -488,14 +545,15 @@ func TestApply_Upgrade(t *testing.T) { mockPf := &mockPreflight{} helmApplier := applier.Helm{ ActionClientGetter: mockAcg, Preflights: []applier.Preflight{mockPf}, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "upgrading chart") - require.Equal(t, applier.StateNeedsUpgrade, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("fails during upgrade reconcile (StateUnchanged)", func(t *testing.T) { @@ -509,16 +567,17 @@ func TestApply_Upgrade(t *testing.T) { } mockPf := &mockPreflight{} helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - Preflights: []applier.Preflight{mockPf}, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + Preflights: []applier.Preflight{mockPf}, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.Error(t, err) require.ErrorContains(t, err, "reconciling charts") - require.Equal(t, applier.StateUnchanged, state) - require.Nil(t, objs) + require.False(t, installSucceeded) + require.Empty(t, installStatus) }) t.Run("successful upgrade", func(t *testing.T) { @@ -530,16 +589,18 @@ func TestApply_Upgrade(t *testing.T) { desiredRel: &testDesiredRelease, } helmApplier := applier.Helm{ - ActionClientGetter: mockAcg, - BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + ActionClientGetter: mockAcg, + BundleToHelmChartConverter: &convert.BundleToHelmChartConverter{}, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, + Manager: &mockManagedContentCacheManager{ + cache: &mockManagedContentCache{}, + }, } - objs, state, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + installSucceeded, installStatus, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) require.NoError(t, err) - require.Equal(t, applier.StateNeedsUpgrade, state) - require.NotNil(t, objs) - assert.Equal(t, "service-a", objs[0].GetName()) - assert.Equal(t, "service-b", objs[1].GetName()) + require.True(t, installSucceeded) + require.Empty(t, installStatus) }) } @@ -562,6 +623,10 @@ func TestApply_InstallationWithSingleOwnNamespaceInstallSupportEnabled(t *testin return nil, nil }, }, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, + Manager: &mockManagedContentCacheManager{ + cache: &mockManagedContentCache{}, + }, } testExt := &ocv1.ClusterExtension{ @@ -573,7 +638,7 @@ func TestApply_InstallationWithSingleOwnNamespaceInstallSupportEnabled(t *testin }, } - _, _, _ = helmApplier.Apply(context.TODO(), validFS, testExt, testObjectLabels, testStorageLabels) + _, _, _ = helmApplier.Apply(context.TODO(), validBundle, testExt, testObjectLabels, testStorageLabels) }) } @@ -595,9 +660,13 @@ func TestApply_RegistryV1ToChartConverterIntegration(t *testing.T) { return nil, nil }, }, + HelmReleaseToObjectsConverter: mockHelmReleaseToObjectsConverter{}, + Manager: &mockManagedContentCacheManager{ + cache: &mockManagedContentCache{}, + }, } - _, _, _ = helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) + _, _, _ = helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) }) t.Run("surfaces chart generation errors", func(t *testing.T) { @@ -614,10 +683,13 @@ func TestApply_RegistryV1ToChartConverterIntegration(t *testing.T) { return nil, errors.New("some error") }, }, + Manager: &mockManagedContentCacheManager{ + cache: &mockManagedContentCache{}, + }, } - _, _, err := helmApplier.Apply(context.TODO(), validFS, testCE, testObjectLabels, testStorageLabels) - require.Error(t, err) + _, _, err := helmApplier.Apply(context.TODO(), validBundle, testCE, testObjectLabels, testStorageLabels) + require.ErrorContains(t, err, "some error") }) } diff --git a/internal/operator-controller/applier/phase.go b/internal/operator-controller/applier/phase.go new file mode 100644 index 000000000..9ae31db6a --- /dev/null +++ b/internal/operator-controller/applier/phase.go @@ -0,0 +1,136 @@ +package applier + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// The following, with modifications, is taken from: +// https://github.com/package-operator/package-operator/blob/v1.18.2/internal/packages/internal/packagekickstart/presets/phases.go +// +// Determines a phase using the objects Group Kind from a list of presets. +// Defaults to the `deploy` phase if no preset was found. Runtimes that +// depend on a custom resource to start i.e. certmanager's Certificate +// will require this. +func determinePhase(gk schema.GroupKind) Phase { + phase, ok := gkPhaseMap[gk] + if !ok { + return PhaseDeploy + } + return phase +} + +// Phase represents a well-known phase. +type Phase string + +const ( + PhaseNamespaces Phase = "namespaces" + PhasePolicies Phase = "policies" + PhaseRBAC Phase = "rbac" + PhaseCRDs Phase = "crds" + PhaseStorage Phase = "storage" + PhaseDeploy Phase = "deploy" + PhasePublish Phase = "publish" +) + +// Well known phases ordered. +var defaultPhaseOrder = []Phase{ + PhaseNamespaces, + PhasePolicies, + PhaseRBAC, + PhaseCRDs, + PhaseStorage, + PhaseDeploy, + PhasePublish, +} + +var ( + // This will be populated from `phaseGKMap` in an init func! + gkPhaseMap = map[schema.GroupKind]Phase{} + phaseGKMap = map[Phase][]schema.GroupKind{ + PhaseNamespaces: { + {Kind: "Namespace"}, + }, + + PhasePolicies: { + {Kind: "ResourceQuota"}, + {Kind: "LimitRange"}, + {Kind: "PriorityClass", Group: "scheduling.k8s.io"}, + {Kind: "NetworkPolicy", Group: "networking.k8s.io"}, + {Kind: "HorizontalPodAutoscaler", Group: "autoscaling"}, + {Kind: "PodDisruptionBudget", Group: "policy"}, + }, + + PhaseRBAC: { + {Kind: "ServiceAccount"}, + {Kind: "Role", Group: "rbac.authorization.k8s.io"}, + {Kind: "RoleBinding", Group: "rbac.authorization.k8s.io"}, + {Kind: "ClusterRole", Group: "rbac.authorization.k8s.io"}, + {Kind: "ClusterRoleBinding", Group: "rbac.authorization.k8s.io"}, + }, + + PhaseCRDs: { + {Kind: "CustomResourceDefinition", Group: "apiextensions.k8s.io"}, + }, + + PhaseStorage: { + {Kind: "PersistentVolume"}, + {Kind: "PersistentVolumeClaim"}, + {Kind: "StorageClass", Group: "storage.k8s.io"}, + }, + + PhaseDeploy: { + {Kind: "Deployment", Group: "apps"}, + {Kind: "DaemonSet", Group: "apps"}, + {Kind: "StatefulSet", Group: "apps"}, + {Kind: "ReplicaSet"}, + {Kind: "Pod"}, // probing complicated, may be either Completed or Available. + {Kind: "Job", Group: "batch"}, + {Kind: "CronJob", Group: "batch"}, + {Kind: "Service"}, + {Kind: "Secret"}, + {Kind: "ConfigMap"}, + }, + + PhasePublish: { + {Kind: "Ingress", Group: "networking.k8s.io"}, + {Kind: "APIService", Group: "apiregistration.k8s.io"}, + {Kind: "Route", Group: "route.openshift.io"}, + {Kind: "MutatingWebhookConfiguration", Group: "admissionregistration.k8s.io"}, + {Kind: "ValidatingWebhookConfiguration", Group: "admissionregistration.k8s.io"}, + }, + } +) + +func init() { + for phase, gks := range phaseGKMap { + for _, gk := range gks { + gkPhaseMap[gk] = phase + } + } +} + +// PhaseSort takes an unsorted list of objects and organizes them into sorted phases. +// Each phase will be applied in order according to DefaultPhaseOrder. Objects +// within a single phase are applied simultaneously. +func PhaseSort(unsortedObjs []ocv1.ClusterExtensionRevisionObject) []ocv1.ClusterExtensionRevisionPhase { + phasesSorted := make([]ocv1.ClusterExtensionRevisionPhase, 0) + phaseMap := make(map[Phase][]ocv1.ClusterExtensionRevisionObject, 0) + + for _, obj := range unsortedObjs { + phase := determinePhase(obj.Object.GroupVersionKind().GroupKind()) + phaseMap[phase] = append(phaseMap[phase], obj) + } + + for _, phaseName := range defaultPhaseOrder { + if objs, ok := phaseMap[phaseName]; ok { + phasesSorted = append(phasesSorted, ocv1.ClusterExtensionRevisionPhase{ + Name: string(phaseName), + Objects: objs, + }) + } + } + + return phasesSorted +} diff --git a/internal/operator-controller/applier/phase_test.go b/internal/operator-controller/applier/phase_test.go new file mode 100644 index 000000000..3f2d85d0b --- /dev/null +++ b/internal/operator-controller/applier/phase_test.go @@ -0,0 +1,292 @@ +package applier_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + v1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" +) + +func Test_PhaseSort(t *testing.T) { + for _, tt := range []struct { + name string + objs []v1.ClusterExtensionRevisionObject + want []v1.ClusterExtensionRevisionPhase + }{ + { + name: "single deploy obj", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + }, + }, + }, + }, + }, + }, + { + name: "all phases", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apiregistration.k8s.io/v1", + "kind": "APIService", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "some.api/v1", + "kind": "SomeCustomResource", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "PersistentVolume", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseNamespaces), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + }, + }, + }, + }, + }, + { + Name: string(applier.PhasePolicies), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + }, + }, + }, + }, + }, + { + Name: string(applier.PhaseRBAC), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + }, + }, + }, + }, + }, + { + Name: string(applier.PhaseCRDs), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + }, + }, + }, + }, + }, + { + Name: string(applier.PhaseStorage), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "PersistentVolume", + }, + }, + }, + }, + }, + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "some.api/v1", + "kind": "SomeCustomResource", + }, + }, + }, + }, + }, + { + Name: string(applier.PhasePublish), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apiregistration.k8s.io/v1", + "kind": "APIService", + }, + }, + }, + }, + }, + }, + }, + { + name: "sorted and batched", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ServiceAccount", + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseRBAC), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ServiceAccount", + }, + }, + }, + }, + }, + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + }, + }, + }, + }, + }, + }, + { + name: "no objects", + objs: []v1.ClusterExtensionRevisionObject{}, + want: []v1.ClusterExtensionRevisionPhase{}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, applier.PhaseSort(tt.objs)) + }) + } +} diff --git a/internal/operator-controller/applier/preflight.go b/internal/operator-controller/applier/preflight.go new file mode 100644 index 000000000..7b5d91e27 --- /dev/null +++ b/internal/operator-controller/applier/preflight.go @@ -0,0 +1,54 @@ +package applier + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" +) + +const ( + StateNeedsInstall string = "NeedsInstall" + StateNeedsUpgrade string = "NeedsUpgrade" + StateUnchanged string = "Unchanged" + StateError string = "Error" + maxHelmReleaseHistory = 10 +) + +// Preflight is a check that should be run before making any changes to the cluster +type Preflight interface { + // Install runs checks that should be successful prior + // to installing the Helm release. It is provided + // a Helm release and returns an error if the + // check is unsuccessful + Install(context.Context, []client.Object) error + + // Upgrade runs checks that should be successful prior + // to upgrading the Helm release. It is provided + // a Helm release and returns an error if the + // check is unsuccessful + Upgrade(context.Context, []client.Object) error +} + +// shouldSkipPreflight is a helper to determine if the preflight check is CRDUpgradeSafety AND +// if it is set to enforcement None. +func shouldSkipPreflight(ctx context.Context, preflight Preflight, ext *ocv1.ClusterExtension, state string) bool { + l := log.FromContext(ctx) + hasCRDUpgradeSafety := ext.Spec.Install != nil && ext.Spec.Install.Preflight != nil && ext.Spec.Install.Preflight.CRDUpgradeSafety != nil + _, isCRDUpgradeSafetyInstance := preflight.(*crdupgradesafety.Preflight) + + if hasCRDUpgradeSafety && isCRDUpgradeSafetyInstance { + if state == StateNeedsInstall || state == StateNeedsUpgrade { + l.Info("crdUpgradeSafety ", "policy", ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement) + } + if ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement == ocv1.CRDUpgradeSafetyEnforcementNone { + // Skip this preflight check because it is of type *crdupgradesafety.Preflight and the CRD Upgrade Safety + // policy is set to None + return true + } + } + return false +} diff --git a/internal/operator-controller/bundle/bundle.go b/internal/operator-controller/bundle/bundle.go new file mode 100644 index 000000000..f576345b1 --- /dev/null +++ b/internal/operator-controller/bundle/bundle.go @@ -0,0 +1,19 @@ +package bundle + +import "io/fs" + +type Bundle interface { + FS() fs.FS +} + +type fsBundle struct { + bundleFS fs.FS +} + +func (f fsBundle) FS() fs.FS { + return f.bundleFS +} + +func FromFS(bundleFS fs.FS) Bundle { + return fsBundle{bundleFS} +} diff --git a/internal/operator-controller/conditionsets/conditionsets.go b/internal/operator-controller/conditionsets/conditionsets.go index c69aff421..0d63e1abb 100644 --- a/internal/operator-controller/conditionsets/conditionsets.go +++ b/internal/operator-controller/conditionsets/conditionsets.go @@ -39,4 +39,6 @@ var ConditionReasons = []string{ ocv1.ReasonFailed, ocv1.ReasonBlocked, ocv1.ReasonRetrying, + ocv1.ReasonAbsent, + ocv1.ReasonRolloutInProgress, } diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index 24824bfd1..d156dfa47 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -17,10 +17,11 @@ limitations under the License. package controllers import ( + "cmp" "context" "errors" "fmt" - "io/fs" + "slices" "strings" "time" @@ -34,7 +35,6 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" crcontroller "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/event" @@ -50,9 +50,9 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/authentication" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" "github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets" - "github.com/operator-framework/operator-controller/internal/operator-controller/contentmanager" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/resolve" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" @@ -71,23 +71,20 @@ type ClusterExtensionReconciler struct { ImageCache imageutil.Cache ImagePuller imageutil.Puller - Applier Applier - Manager contentmanager.Manager - controller crcontroller.Controller - cache cache.Cache - InstalledBundleGetter InstalledBundleGetter - Finalizers crfinalizer.Finalizers + Applier Applier + RevisionStatesGetter RevisionStatesGetter + Finalizers crfinalizer.Finalizers } type Applier interface { // Apply applies the content in the provided fs.FS using the configuration of the provided ClusterExtension. // It also takes in a map[string]string to be applied to all applied resources as labels and another // map[string]string used to create a unique identifier for a stored reference to the resources created. - Apply(context.Context, fs.FS, *ocv1.ClusterExtension, map[string]string, map[string]string) ([]client.Object, string, error) + Apply(context.Context, bundle.Bundle, *ocv1.ClusterExtension, map[string]string, map[string]string) (bool, string, error) } -type InstalledBundleGetter interface { - GetInstalledBundle(ctx context.Context, ext *ocv1.ClusterExtension) (*InstalledBundle, error) +type RevisionStatesGetter interface { + GetRevisionStates(ctx context.Context, ext *ocv1.ClusterExtension) (*RevisionStates, error) } //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensions,verbs=get;list;watch;update;patch @@ -216,7 +213,7 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl } l.Info("getting installed bundle") - installedBundle, err := r.InstalledBundleGetter.GetInstalledBundle(ctx, ext) + revisionStates, err := r.RevisionStatesGetter.GetRevisionStates(ctx, ext) if err != nil { setInstallStatus(ext, nil) var saerr *authentication.ServiceAccountNotFoundError @@ -230,47 +227,54 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl return ctrl.Result{}, err } - // run resolution - l.Info("resolving bundle") - var bm *ocv1.BundleMetadata - if installedBundle != nil { - bm = &installedBundle.BundleMetadata - } - resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolver.Resolve(ctx, ext, bm) - if err != nil { - // Note: We don't distinguish between resolution-specific errors and generic errors - setStatusProgressing(ext, err) - setInstalledStatusFromBundle(ext, installedBundle) - ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error()) - return ctrl.Result{}, err - } + var resolvedRevisionMetadata *RevisionMetadata + if len(revisionStates.RollingOut) == 0 { + l.Info("resolving bundle") + var bm *ocv1.BundleMetadata + if revisionStates.Installed != nil { + bm = &revisionStates.Installed.BundleMetadata + } + resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolver.Resolve(ctx, ext, bm) + if err != nil { + // Note: We don't distinguish between resolution-specific errors and generic errors + setStatusProgressing(ext, err) + setInstalledStatusFromRevisionStates(ext, revisionStates) + ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error()) + return ctrl.Result{}, err + } - // set deprecation status after _successful_ resolution - // TODO: - // 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved - // bundle. So perhaps we should set package and channel deprecations directly after resolution, but - // defer setting the bundle deprecation until we successfully install the bundle. - // 2. If resolution fails because it can't find a bundle, that doesn't mean we wouldn't be able to find - // a deprecation for the ClusterExtension's spec.packageName. Perhaps we should check for a non-nil - // resolvedDeprecation even if resolution returns an error. If present, we can still update some of - // our deprecation status. - // - Open question though: what if different catalogs have different opinions of what's deprecated. - // If we can't resolve a bundle, how do we know which catalog to trust for deprecation information? - // Perhaps if the package shows up in multiple catalogs and deprecations don't match, we can set - // the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from - // all catalogs? - SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation) - - resolvedBundleMetadata := bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion) + // set deprecation status after _successful_ resolution + // TODO: + // 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved + // bundle. So perhaps we should set package and channel deprecations directly after resolution, but + // defer setting the bundle deprecation until we successfully install the bundle. + // 2. If resolution fails because it can't find a bundle, that doesn't mean we wouldn't be able to find + // a deprecation for the ClusterExtension's spec.packageName. Perhaps we should check for a non-nil + // resolvedDeprecation even if resolution returns an error. If present, we can still update some of + // our deprecation status. + // - Open question though: what if different catalogs have different opinions of what's deprecated. + // If we can't resolve a bundle, how do we know which catalog to trust for deprecation information? + // Perhaps if the package shows up in multiple catalogs and deprecations don't match, we can set + // the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from + // all catalogs? + SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation) + resolvedRevisionMetadata = &RevisionMetadata{ + Package: resolvedBundle.Package, + Image: resolvedBundle.Image, + BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion), + } + } else { + resolvedRevisionMetadata = revisionStates.RollingOut[0] + } l.Info("unpacking resolved bundle") - imageFS, _, _, err := r.ImagePuller.Pull(ctx, ext.GetName(), resolvedBundle.Image, r.ImageCache) + imageFS, _, _, err := r.ImagePuller.Pull(ctx, ext.GetName(), resolvedRevisionMetadata.Image, r.ImageCache) if err != nil { // Wrap the error passed to this with the resolution information until we have successfully // installed since we intend for the progressing condition to replace the resolved condition // and will be removing the .status.resolution field from the ClusterExtension status API - setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedBundleMetadata, err)) - setInstalledStatusFromBundle(ext, installedBundle) + setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedRevisionMetadata.BundleMetadata, err)) + setInstalledStatusFromRevisionStates(ext, revisionStates) return ctrl.Result{}, err } @@ -280,10 +284,10 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl } storeLbls := map[string]string{ - labels.BundleNameKey: resolvedBundle.Name, - labels.PackageNameKey: resolvedBundle.Package, - labels.BundleVersionKey: resolvedBundleVersion.String(), - labels.BundleReferenceKey: resolvedBundle.Image, + labels.BundleNameKey: resolvedRevisionMetadata.Name, + labels.PackageNameKey: resolvedRevisionMetadata.Package, + labels.BundleVersionKey: resolvedRevisionMetadata.Version, + labels.BundleReferenceKey: resolvedRevisionMetadata.Image, } l.Info("applying bundle contents") @@ -296,41 +300,32 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl // to ensure exponential backoff can occur: // - Permission errors (it is not possible to watch changes to permissions. // The only way to eventually recover from permission errors is to keep retrying). - managedObjs, _, err := r.Applier.Apply(ctx, imageFS, ext, objLbls, storeLbls) - if err != nil { - setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedBundleMetadata, err)) - // Now that we're actually trying to install, use the error - setInstalledStatusFromBundle(ext, installedBundle) - return ctrl.Result{}, err - } + rolloutSucceeded, rolloutStatus, err := r.Applier.Apply(ctx, bundle.FromFS(imageFS), ext, objLbls, storeLbls) - newInstalledBundle := &InstalledBundle{ - BundleMetadata: resolvedBundleMetadata, - Image: resolvedBundle.Image, + // Set installed status + if rolloutSucceeded { + revisionStates = &RevisionStates{Installed: resolvedRevisionMetadata} + } else if err == nil && revisionStates.Installed == nil && len(revisionStates.RollingOut) == 0 { + revisionStates = &RevisionStates{RollingOut: []*RevisionMetadata{resolvedRevisionMetadata}} } - // Successful install - setInstalledStatusFromBundle(ext, newInstalledBundle) + setInstalledStatusFromRevisionStates(ext, revisionStates) - l.Info("watching managed objects") - cache, err := r.Manager.Get(ctx, ext) + // If there was an error applying the resolved bundle, + // report the error via the Progressing condition. if err != nil { - // No need to wrap error with resolution information here (or beyond) since the - // bundle was successfully installed and the information will be present in - // the .status.installed field - setStatusProgressing(ext, err) - return ctrl.Result{}, err - } - - if err := cache.Watch(ctx, r.controller, managedObjs...); err != nil { - setStatusProgressing(ext, err) + setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedRevisionMetadata.BundleMetadata, err)) return ctrl.Result{}, err + } else if !rolloutSucceeded { + apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ + Type: ocv1.TypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonRolloutInProgress, + Message: rolloutStatus, + ObservedGeneration: ext.GetGeneration(), + }) + } else { + setStatusProgressing(ext, nil) } - - // If we made it here, we have successfully reconciled the ClusterExtension - // and have reached the desired state. Since the Progressing status should reflect - // our progress towards the desired state, we also set it when we have reached - // the desired state by providing a nil error value. - setStatusProgressing(ext, nil) return ctrl.Result{}, nil } @@ -338,13 +333,14 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl // based on the provided bundle func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) { deprecations := map[string][]declcfg.DeprecationEntry{} - channelSet := sets.New[string]() - if ext.Spec.Source.Catalog != nil { - for _, channel := range ext.Spec.Source.Catalog.Channels { - channelSet.Insert(channel) - } - } if deprecation != nil { + channelSet := sets.New[string]() + if ext.Spec.Source.Catalog != nil { + for _, channel := range ext.Spec.Source.Catalog.Channels { + channelSet.Insert(channel) + } + } + for _, entry := range deprecation.Entries { switch entry.Reference.Schema { case declcfg.SchemaPackage: @@ -413,9 +409,17 @@ func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, depreca } } +type ControllerBuilderOption func(builder *ctrl.Builder) + +func WithOwns(obj client.Object) ControllerBuilderOption { + return func(builder *ctrl.Builder) { + builder.Owns(obj) + } +} + // SetupWithManager sets up the controller with the Manager. -func (r *ClusterExtensionReconciler) SetupWithManager(mgr ctrl.Manager) error { - controller, err := ctrl.NewControllerManagedBy(mgr). +func (r *ClusterExtensionReconciler) SetupWithManager(mgr ctrl.Manager, opts ...ControllerBuilderOption) (crcontroller.Controller, error) { + ctrlBuilder := ctrl.NewControllerManagedBy(mgr). For(&ocv1.ClusterExtension{}). Named("controller-operator-cluster-extension-controller"). Watches(&ocv1.ClusterCatalog{}, @@ -436,15 +440,13 @@ func (r *ClusterExtensionReconciler) SetupWithManager(mgr ctrl.Manager) error { } return true }, - })). - Build(r) - if err != nil { - return err + })) + + for _, applyOpt := range opts { + applyOpt(ctrlBuilder) } - r.controller = controller - r.cache = mgr.GetCache() - return nil + return ctrlBuilder.Build(r) } func wrapErrorWithResolutionInfo(resolved ocv1.BundleMetadata, err error) error { @@ -475,16 +477,22 @@ func clusterExtensionRequestsForCatalog(c client.Reader, logger logr.Logger) crh } } -type DefaultInstalledBundleGetter struct { - helmclient.ActionClientGetter +type RevisionMetadata struct { + Package string + Image string + ocv1.BundleMetadata } -type InstalledBundle struct { - ocv1.BundleMetadata - Image string +type RevisionStates struct { + Installed *RevisionMetadata + RollingOut []*RevisionMetadata } -func (d *DefaultInstalledBundleGetter) GetInstalledBundle(ctx context.Context, ext *ocv1.ClusterExtension) (*InstalledBundle, error) { +type HelmRevisionStatesGetter struct { + helmclient.ActionClientGetter +} + +func (d *HelmRevisionStatesGetter) GetRevisionStates(ctx context.Context, ext *ocv1.ClusterExtension) (*RevisionStates, error) { cl, err := d.ActionClientFor(ctx, ext) if err != nil { return nil, err @@ -494,22 +502,70 @@ func (d *DefaultInstalledBundleGetter) GetInstalledBundle(ctx context.Context, e if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return nil, err } + rs := &RevisionStates{} if len(relhis) == 0 { - return nil, nil + return rs, nil } // relhis[0].Info.Status is the status of the most recent install attempt. // But we need to look for the most-recent _Deployed_ release for _, rel := range relhis { if rel.Info != nil && rel.Info.Status == release.StatusDeployed { - return &InstalledBundle{ + rs.Installed = &RevisionMetadata{ + Package: rel.Labels[labels.PackageNameKey], + Image: rel.Labels[labels.BundleReferenceKey], BundleMetadata: ocv1.BundleMetadata{ Name: rel.Labels[labels.BundleNameKey], Version: rel.Labels[labels.BundleVersionKey], }, - Image: rel.Labels[labels.BundleReferenceKey], - }, nil + } + break + } + } + return rs, nil +} + +type BoxcutterRevisionStatesGetter struct { + Reader client.Reader +} + +func (d *BoxcutterRevisionStatesGetter) GetRevisionStates(ctx context.Context, ext *ocv1.ClusterExtension) (*RevisionStates, error) { + // TODO: boxcutter applier has a nearly identical bit of code for listing and sorting revisions + // only difference here is that it sorts in reverse order to start iterating with the most + // recent revisions. We should consolidate to avoid code duplication. + existingRevisionList := &ocv1.ClusterExtensionRevisionList{} + if err := d.Reader.List(ctx, existingRevisionList, client.MatchingLabels{ + ClusterExtensionRevisionOwnerLabel: ext.Name, + }); err != nil { + return nil, fmt.Errorf("listing revisions: %w", err) + } + slices.SortFunc(existingRevisionList.Items, func(a, b ocv1.ClusterExtensionRevision) int { + return cmp.Compare(b.Spec.Revision, a.Spec.Revision) + }) + + rs := &RevisionStates{} + for _, rev := range existingRevisionList.Items { + if rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateActive { + // TODO: the setting of these annotations (happens in boxcutter applier when we pass in "storageLabels") + // is fairly decoupled from this code where we get the annotations back out. We may want to co-locate + // the set/get logic a bit better to make it more maintainable and less likely to get out of sync. + rm := &RevisionMetadata{ + Package: rev.Labels[labels.PackageNameKey], + Image: rev.Annotations[labels.BundleReferenceKey], + BundleMetadata: ocv1.BundleMetadata{ + Name: rev.Annotations[labels.BundleNameKey], + Version: rev.Annotations[labels.BundleVersionKey], + }, + } + + // TODO: we should make constants for the ClusterExtensionRevision condition types. + if installedCondition := apimeta.FindStatusCondition(rev.Status.Conditions, "Succeeded"); installedCondition == nil || installedCondition.Status != metav1.ConditionTrue { + rs.RollingOut = append(rs.RollingOut, rm) + } else { + rs.Installed = rm + break + } } } - return nil, nil + return rs, nil } diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go index 4072d8030..437f62dce 100644 --- a/internal/operator-controller/controllers/clusterextension_controller_test.go +++ b/internal/operator-controller/controllers/clusterextension_controller_test.go @@ -51,12 +51,12 @@ func TestClusterExtensionDoesNotExist(t *testing.T) { func TestClusterExtensionShortCircuitsReconcileDuringDeletion(t *testing.T) { cl, reconciler := newClientAndReconciler(t) - installedBundleGetterCalledErr := errors.New("installed bundle getter called") + installedBundleGetterCalledErr := errors.New("revision states getter called") checkInstalledBundleGetterCalled := func(t require.TestingT, err error, args ...interface{}) { require.Equal(t, installedBundleGetterCalledErr, err) } - reconciler.InstalledBundleGetter = &MockInstalledBundleGetter{ - err: installedBundleGetterCalledErr, + reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{ + Err: installedBundleGetterCalledErr, } type testCase struct { @@ -348,8 +348,8 @@ func TestClusterExtensionResolutionAndUnpackSuccessfulApplierFails(t *testing.T) func TestClusterExtensionServiceAccountNotFound(t *testing.T) { cl, reconciler := newClientAndReconciler(t) - reconciler.InstalledBundleGetter = &MockInstalledBundleGetter{ - err: &authentication.ServiceAccountNotFoundError{ + reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{ + Err: &authentication.ServiceAccountNotFoundError{ ServiceAccountName: "missing-sa", ServiceAccountNamespace: "default", }} @@ -448,17 +448,16 @@ func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) { }, &v, nil, nil }) - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{}, - } - reconciler.InstalledBundleGetter = &MockInstalledBundleGetter{ - bundle: &controllers.InstalledBundle{ - BundleMetadata: ocv1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{ + RevisionStates: &controllers.RevisionStates{ + Installed: &controllers.RevisionMetadata{ + BundleMetadata: ocv1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, + Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + }, }, } reconciler.Applier = &MockApplier{ - objs: []client.Object{}, + installCompleted: true, } res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) @@ -544,10 +543,8 @@ func TestClusterExtensionManagerFailed(t *testing.T) { }, &v, nil, nil }) reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - err: errors.New("manager fail"), + installCompleted: true, + err: errors.New("manager fail"), } res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) require.Equal(t, ctrl.Result{}, res) @@ -623,12 +620,8 @@ func TestClusterExtensionManagedContentCacheWatchFail(t *testing.T) { }, &v, nil, nil }) reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{ - err: errors.New("watch error"), - }, + installCompleted: true, + err: errors.New("watch error"), } res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) require.Equal(t, ctrl.Result{}, res) @@ -703,10 +696,7 @@ func TestClusterExtensionInstallationSucceeds(t *testing.T) { }, &v, nil, nil }) reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{}, + installCompleted: true, } res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) require.Equal(t, ctrl.Result{}, res) @@ -782,15 +772,14 @@ func TestClusterExtensionDeleteFinalizerFails(t *testing.T) { fakeFinalizer := "fake.testfinalizer.io" finalizersMessage := "still have finalizers" reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{}, + installCompleted: true, } - reconciler.InstalledBundleGetter = &MockInstalledBundleGetter{ - bundle: &controllers.InstalledBundle{ - BundleMetadata: ocv1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{ + RevisionStates: &controllers.RevisionStates{ + Installed: &controllers.RevisionMetadata{ + BundleMetadata: ocv1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, + Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + }, }, } err = reconciler.Finalizers.Register(fakeFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) { @@ -1448,11 +1437,11 @@ func TestSetDeprecationStatus(t *testing.T) { } type MockActionGetter struct { - description string - rels []*release.Release - err error - expectedBundle *controllers.InstalledBundle - expectedError error + description string + rels []*release.Release + err error + expectedInstalled *controllers.RevisionMetadata + expectedError error } func (mag *MockActionGetter) ActionClientFor(ctx context.Context, obj client.Object) (helmclient.ActionInterface, error) { @@ -1485,7 +1474,7 @@ func (mag *MockActionGetter) Reconcile(rel *release.Release) error { } func TestGetInstalledBundleHistory(t *testing.T) { - getter := controllers.DefaultInstalledBundleGetter{} + getter := controllers.HelmRevisionStatesGetter{} ext := ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ @@ -1525,7 +1514,7 @@ func TestGetInstalledBundleHistory(t *testing.T) { }, }, nil, - &controllers.InstalledBundle{ + &controllers.RevisionMetadata{ BundleMetadata: ocv1.BundleMetadata{ Name: "test-ext", Version: "1.0", @@ -1560,7 +1549,7 @@ func TestGetInstalledBundleHistory(t *testing.T) { }, }, nil, - &controllers.InstalledBundle{ + &controllers.RevisionMetadata{ BundleMetadata: ocv1.BundleMetadata{ Name: "test-ext", Version: "1.0", @@ -1573,8 +1562,14 @@ func TestGetInstalledBundleHistory(t *testing.T) { for _, tst := range mag { t.Log(tst.description) getter.ActionClientGetter = &tst - md, err := getter.GetInstalledBundle(context.Background(), &ext) - require.Equal(t, tst.expectedError, err) - require.Equal(t, tst.expectedBundle, md) + md, err := getter.GetRevisionStates(context.Background(), &ext) + if tst.expectedError != nil { + require.Equal(t, tst.expectedError, err) + require.Nil(t, md) + } else { + require.NoError(t, err) + require.Equal(t, tst.expectedInstalled, md.Installed) + require.Nil(t, md.RollingOut) + } } } diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller.go b/internal/operator-controller/controllers/clusterextensionrevision_controller.go new file mode 100644 index 000000000..027da8354 --- /dev/null +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller.go @@ -0,0 +1,428 @@ +//go:build !standard + +package controllers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "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" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "pkg.package-operator.run/boxcutter" + "pkg.package-operator.run/boxcutter/machinery" + machinerytypes "pkg.package-operator.run/boxcutter/machinery/types" + 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/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" +) + +const ( + ClusterExtensionRevisionOwnerLabel = "olm.operatorframework.io/owner" + clusterExtensionRevisionTeardownFinalizer = "olm.operatorframework.io/teardown" +) + +// ClusterExtensionRevisionReconciler actions individual snapshots of ClusterExtensions, +// as part of the boxcutter integration. +type ClusterExtensionRevisionReconciler struct { + Client client.Client + RevisionEngine RevisionEngine + TrackingCache trackingCache +} + +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 +} + +type RevisionEngine 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) +} + +//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions,verbs=get;list;watch;update;patch;create;delete +//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions/status,verbs=update;patch +//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions/finalizers,verbs=update + +func (c *ClusterExtensionRevisionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx).WithName("cluster-extension-revision") + ctx = log.IntoContext(ctx, l) + + rev := &ocv1.ClusterExtensionRevision{} + if err := c.Client.Get(ctx, req.NamespacedName, rev); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + l = l.WithValues("key", req.String()) + l.Info("reconcile starting") + defer l.Info("reconcile ending") + + return c.reconcile(ctx, rev) +} + +func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (ctrl.Result, error) { + l := log.FromContext(ctx) + + revision, opts, previous := toBoxcutterRevision(rev) + + if !rev.DeletionTimestamp.IsZero() || + rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived { + // + // Teardown + // + tres, err := c.RevisionEngine.Teardown(ctx, *revision) + if err != nil { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ReconcileFailure", + Message: err.Error(), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{}, fmt.Errorf("revision teardown: %w", errors.Join(err, c.Client.Status().Update(ctx, rev))) + } + + l.Info("teardown report", "report", tres.String()) + if !tres.IsComplete() { + return ctrl.Result{}, nil + } + + if err := c.TrackingCache.Free(ctx, rev); err != nil { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ReconcileFailure", + Message: err.Error(), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{}, fmt.Errorf("free cache informers: %w", errors.Join(err, c.Client.Status().Update(ctx, rev))) + } + return ctrl.Result{}, c.removeFinalizer(ctx, rev, clusterExtensionRevisionTeardownFinalizer) + } + + // + // Reconcile + // + if err := c.ensureFinalizer(ctx, rev, clusterExtensionRevisionTeardownFinalizer); err != nil { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ReconcileFailure", + Message: err.Error(), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{}, fmt.Errorf("ensure finalizer: %w", errors.Join(err, c.Client.Status().Update(ctx, rev))) + } + if err := c.establishWatch(ctx, rev, revision); err != nil { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ReconcileFailure", + Message: err.Error(), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{}, fmt.Errorf("establish watch: %w", errors.Join(err, c.Client.Status().Update(ctx, rev))) + } + rres, err := c.RevisionEngine.Reconcile(ctx, *revision, opts...) + if err != nil { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ReconcileFailure", + Message: err.Error(), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{}, fmt.Errorf("revision reconcile: %w", errors.Join(err, c.Client.Status().Update(ctx, rev))) + } + l.Info("reconcile report", "report", rres.String()) + + // Retry failing preflight checks with a flat 10s retry. + // TODO: report status, backoff? + if verr := rres.GetValidationError(); verr != nil { + l.Info("preflight error, retrying after 10s", "err", verr.String()) + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "RevisionValidationFailure", + Message: fmt.Sprintf("revision validation error: %s", verr), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{RequeueAfter: 10 * time.Second}, c.Client.Status().Update(ctx, rev) + } + for i, pres := range rres.GetPhases() { + if verr := pres.GetValidationError(); verr != nil { + l.Info("preflight error, retrying after 10s", "err", verr.String()) + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "PhaseValidationError", + Message: fmt.Sprintf("phase %d validation error: %s", i, verr), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{RequeueAfter: 10 * time.Second}, c.Client.Status().Update(ctx, rev) + } + var collidingObjs []string + for _, ores := range pres.GetObjects() { + if ores.Action() == machinery.ActionCollision { + collidingObjs = append(collidingObjs, ores.String()) + } + } + if len(collidingObjs) > 0 { + l.Info("object collision error, retrying after 10s", "collisions", collidingObjs) + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ObjectCollisions", + Message: fmt.Sprintf("revision object collisions in phase %d\n%s", i, strings.Join(collidingObjs, "\n\n")), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{RequeueAfter: 10 * time.Second}, c.Client.Status().Update(ctx, rev) + } + } + + //nolint:nestif + if rres.IsComplete() { + // Archive other revisions. + for _, a := range previous { + if err := c.Client.Patch(ctx, a, client.RawPatch( + types.MergePatchType, []byte(`{"spec":{"lifecycleState":"Archived"}}`))); err != nil { + return ctrl.Result{}, fmt.Errorf("archive previous Revision: %w", err) + } + } + + // Report status. + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionTrue, + Reason: "Available", + Message: "Object is available and passes all probes.", + ObservedGeneration: rev.Generation, + }) + if !meta.IsStatusConditionTrue(rev.Status.Conditions, "Succeeded") { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Succeeded", + Status: metav1.ConditionTrue, + Reason: "RolloutSuccess", + Message: "Revision succeeded rolling out.", + ObservedGeneration: rev.Generation, + }) + } + } else { + var probeFailureMsgs []string + for _, pres := range rres.GetPhases() { + if pres.IsComplete() { + continue + } + for _, ores := range pres.GetObjects() { + pr := ores.Probes()[boxcutter.ProgressProbeType] + if pr.Success { + continue + } + + obj := ores.Object() + gvk := obj.GetObjectKind().GroupVersionKind() + probeFailureMsgs = append(probeFailureMsgs, fmt.Sprintf( + "Object %s.%s %s/%s: %v", + gvk.Kind, gvk.GroupVersion().String(), + obj.GetNamespace(), obj.GetName(), strings.Join(pr.Messages, " and "), + )) + break + } + } + if len(probeFailureMsgs) > 0 { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "ProbeFailure", + Message: strings.Join(probeFailureMsgs, "\n"), + ObservedGeneration: rev.Generation, + }) + } else { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Available", + Status: metav1.ConditionFalse, + Reason: "Incomplete", + Message: "Revision has not been rolled out completely.", + ObservedGeneration: rev.Generation, + }) + } + } + if rres.InTransistion() { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: "Progressing", + Status: metav1.ConditionTrue, + Reason: "Progressing", + Message: "Rollout in progress.", + ObservedGeneration: rev.Generation, + }) + } else { + meta.RemoveStatusCondition(&rev.Status.Conditions, "Progressing") + } + + return ctrl.Result{}, c.Client.Status().Update(ctx, rev) +} + +type Sourcerer interface { + Source(handler handler.EventHandler, predicates ...predicate.Predicate) source.Source +} + +func (c *ClusterExtensionRevisionReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For( + &ocv1.ClusterExtensionRevision{}, + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + WatchesRawSource( + c.TrackingCache.Source( + handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &ocv1.ClusterExtensionRevision{}), + predicate.ResourceVersionChangedPredicate{}, + ), + ). + Complete(c) +} + +func (c *ClusterExtensionRevisionReconciler) establishWatch( + ctx context.Context, rev *ocv1.ClusterExtensionRevision, + boxcutterRev *boxcutter.Revision, +) error { + gvks := sets.New[schema.GroupVersionKind]() + for _, phase := range boxcutterRev.Phases { + for _, obj := range phase.Objects { + gvks.Insert(obj.GroupVersionKind()) + } + } + + return c.TrackingCache.Watch(ctx, rev, gvks) +} + +func (c *ClusterExtensionRevisionReconciler) 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 *ClusterExtensionRevisionReconciler) 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 +} + +func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revision, []boxcutter.RevisionReconcileOption, []client.Object) { + r := &boxcutter.Revision{ + Name: rev.Name, + Owner: rev, + Revision: rev.Spec.Revision, + } + for _, specPhase := range rev.Spec.Phases { + phase := boxcutter.Phase{Name: specPhase.Name} + for _, specObj := range specPhase.Objects { + obj := specObj.Object + + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[ClusterExtensionRevisionOwnerLabel] = rev.Labels[ClusterExtensionRevisionOwnerLabel] + obj.SetLabels(labels) + + phase.Objects = append(phase.Objects, obj) + } + r.Phases = append(r.Phases, phase) + } + + previous := make([]client.Object, 0, len(rev.Spec.Previous)) + for _, specPrevious := range rev.Spec.Previous { + prev := &unstructured.Unstructured{} + prev.SetName(specPrevious.Name) + prev.SetUID(specPrevious.UID) + prev.SetGroupVersionKind(ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionRevisionKind)) + previous = append(previous, prev) + } + + opts := []boxcutter.RevisionReconcileOption{ + boxcutter.WithPreviousOwners(previous), + boxcutter.WithProbe(boxcutter.ProgressProbeType, boxcutter.ProbeFunc(func(obj client.Object) (bool, []string) { + deployGK := schema.GroupKind{ + Group: "apps", Kind: "Deployment", + } + if obj.GetObjectKind().GroupVersionKind().GroupKind() != deployGK { + return true, nil + } + ustrObj := obj.(*unstructured.Unstructured) + depl := &appsv1.Deployment{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(ustrObj.Object, depl); err != nil { + return false, []string{err.Error()} + } + + if depl.Status.ObservedGeneration != depl.Generation { + return false, []string{".status.observedGeneration outdated"} + } + for _, cond := range depl.Status.Conditions { + if cond.Type == "Available" && + cond.Status == corev1.ConditionTrue && + depl.Status.UpdatedReplicas == *depl.Spec.Replicas { + return true, nil + } + } + return false, []string{"not available or not fully updated"} + })), + } + if rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStatePaused { + opts = append(opts, boxcutter.WithPaused{}) + } + return r, opts, previous +} diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go b/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go new file mode 100644 index 000000000..0275b7a12 --- /dev/null +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go @@ -0,0 +1,861 @@ +package controllers_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "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" + "pkg.package-operator.run/boxcutter" + "pkg.package-operator.run/boxcutter/machinery" + machinerytypes "pkg.package-operator.run/boxcutter/machinery/types" + "pkg.package-operator.run/boxcutter/validation" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "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/operator-controller/controllers" +) + +func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *testing.T) { + const ( + clusterExtensionRevisionName = "test-ext-1" + ) + + testScheme := newScheme(t) + + for _, tc := range []struct { + name string + existingObjs func() []client.Object + revisionResult machinery.RevisionResult + validate func(*testing.T, client.Client) + }{ + { + name: "sets teardown finalizer", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + require.Contains(t, rev.Finalizers, "olm.operatorframework.io/teardown") + }, + }, + { + name: "set Available:False:InComplete status condition during rollout when no probe failures are detected", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + cond := meta.FindStatusCondition(rev.Status.Conditions, "Available") + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, "Incomplete", cond.Reason) + require.Equal(t, "Revision has not been rolled out completely.", cond.Message) + require.Equal(t, int64(1), cond.ObservedGeneration) + }, + }, + { + name: "set Available:False:ProbeFailure condition when probe failures are detected", + revisionResult: mockRevisionResult{ + phases: []machinery.PhaseResult{ + mockPhaseResult{ + name: "somephase", + isComplete: false, + objects: []machinery.ObjectResult{ + mockObjectResult{ + success: true, + probes: map[string]machinery.ObjectProbeResult{ + boxcutter.ProgressProbeType: { + Success: true, + }, + }, + }, + mockObjectResult{ + success: false, + object: func() client.Object { + obj := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "my-namespace", + }, + } + obj.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Service")) + return obj + }(), + probes: map[string]machinery.ObjectProbeResult{ + boxcutter.ProgressProbeType: { + Success: false, + Messages: []string{ + "something bad happened", + "something worse happened", + }, + }, + }, + }, + }, + }, + mockPhaseResult{ + name: "someotherphase", + isComplete: false, + objects: []machinery.ObjectResult{ + mockObjectResult{ + success: false, + object: func() client.Object { + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-configmap", + Namespace: "my-namespace", + }, + } + obj.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + return obj + }(), + probes: map[string]machinery.ObjectProbeResult{ + boxcutter.ProgressProbeType: { + Success: false, + Messages: []string{ + "we have a problem", + }, + }, + }, + }, + }, + }, + }, + }, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + cond := meta.FindStatusCondition(rev.Status.Conditions, "Available") + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, "ProbeFailure", cond.Reason) + require.Equal(t, "Object Service.v1 my-namespace/my-service: something bad happened and something worse happened\nObject ConfigMap.v1 my-namespace/my-configmap: we have a problem", cond.Message) + require.Equal(t, int64(1), cond.ObservedGeneration) + }, + }, + { + name: "set Progressing:True:Progressing condition while revision is transitioning", + revisionResult: mockRevisionResult{ + inTransition: true, + }, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + cond := meta.FindStatusCondition(rev.Status.Conditions, "Progressing") + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, "Progressing", cond.Reason) + require.Equal(t, "Rollout in progress.", cond.Message) + require.Equal(t, int64(1), cond.ObservedGeneration) + }, + }, + { + name: "remove Progressing condition once transition rollout is finished", + revisionResult: mockRevisionResult{ + inTransition: false, + }, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + meta.SetStatusCondition(&rev1.Status.Conditions, metav1.Condition{ + Type: "Progressing", + Status: metav1.ConditionTrue, + Reason: "Progressing", + Message: "some message", + ObservedGeneration: 1, + }) + return []client.Object{ext, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + cond := meta.FindStatusCondition(rev.Status.Conditions, "Progressing") + require.Nil(t, cond) + }, + }, + { + name: "set Available:True:Available and Succeeded:True:RolloutSuccess conditions on successful revision rollout", + revisionResult: mockRevisionResult{ + isComplete: true, + }, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + cond := meta.FindStatusCondition(rev.Status.Conditions, "Available") + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, "Available", cond.Reason) + require.Equal(t, "Object is available and passes all probes.", cond.Message) + require.Equal(t, int64(1), cond.ObservedGeneration) + + cond = meta.FindStatusCondition(rev.Status.Conditions, "Succeeded") + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, "RolloutSuccess", cond.Reason) + require.Equal(t, "Revision succeeded rolling out.", cond.Message) + require.Equal(t, int64(1), cond.ObservedGeneration) + }, + }, + { + name: "archive previous revisions on successful rollout", + revisionResult: mockRevisionResult{ + isComplete: true, + }, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + prevRev1 := newTestClusterExtensionRevision("prev-rev-1") + require.NoError(t, controllerutil.SetControllerReference(ext, prevRev1, testScheme)) + prevRev2 := newTestClusterExtensionRevision("prev-rev-2") + require.NoError(t, controllerutil.SetControllerReference(ext, prevRev2, testScheme)) + rev1 := newTestClusterExtensionRevision("test-ext-1") + rev1.Spec.Previous = []ocv1.ClusterExtensionRevisionPrevious{ + { + Name: "prev-rev-1", + UID: "prev-rev-1", + }, { + Name: "prev-rev-2", + UID: "prev-rev-2", + }, + } + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, prevRev1, prevRev2, rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: "prev-rev-1", + }, rev) + require.NoError(t, err) + require.Equal(t, ocv1.ClusterExtensionRevisionLifecycleStateArchived, rev.Spec.LifecycleState) + + err = c.Get(t.Context(), client.ObjectKey{ + Name: "prev-rev-2", + }, rev) + require.NoError(t, err) + require.Equal(t, ocv1.ClusterExtensionRevisionLifecycleStateArchived, rev.Spec.LifecycleState) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // create extension and cluster extension + testClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithStatusSubresource(&ocv1.ClusterExtensionRevision{}). + WithObjects(tc.existingObjs()...). + Build() + + // reconcile cluster extension revision + result, err := (&controllers.ClusterExtensionRevisionReconciler{ + Client: testClient, + RevisionEngine: &mockRevisionEngine{ + reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) { + return tc.revisionResult, nil + }, + }, + TrackingCache: &mockTrackingCache{}, + }).Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: clusterExtensionRevisionName, + }, + }) + + // reconcile cluster extensionr evision + require.Equal(t, ctrl.Result{}, result) + require.NoError(t, err) + + // validate test case + tc.validate(t, testClient) + }) + } +} + +func Test_ClusterExtensionRevisionReconciler_Reconcile_ValidationError_Retries(t *testing.T) { + const ( + clusterExtensionName = "test-ext" + clusterExtensionRevisionName = "test-ext-1" + ) + + testScheme := newScheme(t) + + for _, tc := range []struct { + name string + revisionResult machinery.RevisionResult + }{ + { + name: "retries on revision result validation error", + revisionResult: mockRevisionResult{ + validationError: &validation.RevisionValidationError{ + RevisionName: "test-ext-1", + RevisionNumber: 1, + Phases: []validation.PhaseValidationError{ + { + PhaseName: "everything", + PhaseError: fmt.Errorf("some error"), + Objects: []validation.ObjectValidationError{ + { + ObjectRef: machinerytypes.ObjectRef{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + ObjectKey: client.ObjectKey{ + Name: "my-configmap", + Namespace: "my-namespace", + }, + }, + Errors: []error{ + fmt.Errorf("is not a config"), + fmt.Errorf("is not a map"), + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "retries on revision result phase validation error", + revisionResult: mockRevisionResult{ + phases: []machinery.PhaseResult{ + mockPhaseResult{ + validationError: &validation.PhaseValidationError{ + PhaseName: "everything", + PhaseError: fmt.Errorf("some error"), + Objects: []validation.ObjectValidationError{ + { + ObjectRef: machinerytypes.ObjectRef{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + ObjectKey: client.ObjectKey{ + Name: "my-configmap", + Namespace: "my-namespace", + }, + }, + Errors: []error{ + fmt.Errorf("is not a config"), + fmt.Errorf("is not a map"), + }, + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + + // create extension and cluster extension + testClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithStatusSubresource(&ocv1.ClusterExtensionRevision{}). + WithObjects(ext, rev1). + Build() + + // reconcile cluster extension revision + result, err := (&controllers.ClusterExtensionRevisionReconciler{ + Client: testClient, + RevisionEngine: &mockRevisionEngine{ + reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) { + return tc.revisionResult, nil + }, + }, + TrackingCache: &mockTrackingCache{}, + }).Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: clusterExtensionRevisionName, + }, + }) + + // reconcile cluster extensionr evision + require.Equal(t, ctrl.Result{ + RequeueAfter: 10 * time.Second, + }, result) + require.NoError(t, err) + }) + } +} + +func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { + const ( + clusterExtensionRevisionName = "test-ext-1" + ) + + testScheme := newScheme(t) + require.NoError(t, corev1.AddToScheme(testScheme)) + + for _, tc := range []struct { + name string + existingObjs func() []client.Object + revisionResult machinery.RevisionResult + revisionEngineTeardownFn func(*testing.T) func(context.Context, machinerytypes.Revision, ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) + validate func(*testing.T, client.Client) + expectedErr string + }{ + { + name: "teardown finalizer is removed", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1.Finalizers = []string{ + "olm.operatorframework.io/teardown", + } + return []client.Object{rev1} + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + require.NotContains(t, "olm.operatorframework.io/teardown", rev.Finalizers) + }, + revisionEngineTeardownFn: func(t *testing.T) func(context.Context, machinerytypes.Revision, ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return nil + }, + }, + { + name: "revision is torn down and deleted when deleted", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1.Finalizers = []string{ + "olm.operatorframework.io/teardown", + } + rev1.DeletionTimestamp = &metav1.Time{Time: time.Now()} + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{rev1, ext} + }, + revisionEngineTeardownFn: func(t *testing.T) func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return &mockRevisionTeardownResult{ + isComplete: true, + }, nil + } + }, + validate: func(t *testing.T, c client.Client) { + t.Log("cluster revision is deleted") + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.Error(t, err) + require.True(t, errors.IsNotFound(err)) + }, + }, + { + name: "surfaces tear down errors when deleted", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1.Finalizers = []string{ + "olm.operatorframework.io/teardown", + } + rev1.DeletionTimestamp = &metav1.Time{Time: time.Now()} + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{rev1, ext} + }, + revisionEngineTeardownFn: func(t *testing.T) func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return nil, fmt.Errorf("some teardown error") + } + }, + expectedErr: "some teardown error", + validate: func(t *testing.T, c client.Client) { + t.Log("cluster revision is not deleted and still contains finalizer") + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + require.NotContains(t, "olm.operatorframework.io/teardown", rev.Finalizers) + }, + }, + { + name: "revision is torn down when in archived state and finalizer is removed", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1.Finalizers = []string{ + "olm.operatorframework.io/teardown", + } + rev1.Spec.LifecycleState = ocv1.ClusterExtensionRevisionLifecycleStateArchived + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{rev1, ext} + }, + revisionEngineTeardownFn: func(t *testing.T) func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return &mockRevisionTeardownResult{ + isComplete: true, + }, nil + } + }, + validate: func(t *testing.T, c client.Client) { + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + require.NotContains(t, "olm.operatorframework.io/teardown", rev.Finalizers) + }, + }, + { + name: "surfaces revision teardown error when in archived state", + revisionResult: mockRevisionResult{}, + existingObjs: func() []client.Object { + ext := newTestClusterExtension() + rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1.Finalizers = []string{ + "olm.operatorframework.io/teardown", + } + rev1.Spec.LifecycleState = ocv1.ClusterExtensionRevisionLifecycleStateArchived + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{rev1, ext} + }, + revisionEngineTeardownFn: func(t *testing.T) func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return nil, fmt.Errorf("some teardown error") + } + }, + expectedErr: "some teardown error", + validate: func(t *testing.T, c client.Client) { + t.Log("cluster revision is not deleted and still contains finalizer") + rev := &ocv1.ClusterExtensionRevision{} + err := c.Get(t.Context(), client.ObjectKey{ + Name: clusterExtensionRevisionName, + }, rev) + require.NoError(t, err) + require.NotContains(t, "olm.operatorframework.io/teardown", rev.Finalizers) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // create extension and cluster extension + testClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithStatusSubresource(&ocv1.ClusterExtensionRevision{}). + WithObjects(tc.existingObjs()...). + Build() + + // reconcile cluster extension revision + result, err := (&controllers.ClusterExtensionRevisionReconciler{ + Client: testClient, + RevisionEngine: &mockRevisionEngine{ + reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) { + return tc.revisionResult, nil + }, + teardown: tc.revisionEngineTeardownFn(t), + }, + TrackingCache: &mockTrackingCache{}, + }).Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: clusterExtensionRevisionName, + }, + }) + + // reconcile cluster extension revision + require.Equal(t, ctrl.Result{}, result) + if tc.expectedErr != "" { + require.Contains(t, err.Error(), tc.expectedErr) + } else { + require.NoError(t, err) + } + + // validate test case + tc.validate(t, testClient) + }) + } +} + +func newTestClusterExtension() *ocv1.ClusterExtension { + return &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext", + UID: "test-ext", + }, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "some-namespace", + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "service-account", + }, + Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeCatalog, + Catalog: &ocv1.CatalogFilter{ + PackageName: "some-package", + }, + }, + }, + } +} + +func newTestClusterExtensionRevision(name string) *ocv1.ClusterExtensionRevision { + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + UID: types.UID(name), + Generation: int64(1), + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: "everything", + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "data": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +type mockRevisionEngine struct { + teardown func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) + reconcile func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) +} + +func (m mockRevisionEngine) Teardown(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionTeardownOption) (machinery.RevisionTeardownResult, error) { + return m.teardown(ctx, rev) +} + +func (m mockRevisionEngine) Reconcile(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) { + return m.reconcile(ctx, rev) +} + +type mockRevisionResult struct { + validationError *validation.RevisionValidationError + phases []machinery.PhaseResult + inTransition bool + isComplete bool + hasProgressed bool + string string +} + +func (m mockRevisionResult) GetValidationError() *validation.RevisionValidationError { + return m.validationError +} + +func (m mockRevisionResult) GetPhases() []machinery.PhaseResult { + return m.phases +} + +func (m mockRevisionResult) InTransistion() bool { + return m.inTransition +} + +func (m mockRevisionResult) IsComplete() bool { + return m.isComplete +} + +func (m mockRevisionResult) HasProgressed() bool { + return m.hasProgressed +} + +func (m mockRevisionResult) String() string { + return m.string +} + +type mockPhaseResult struct { + name string + validationError *validation.PhaseValidationError + objects []machinery.ObjectResult + inTransition bool + isComplete bool + hasProgressed bool + string string +} + +func (m mockPhaseResult) GetName() string { + return m.name +} + +func (m mockPhaseResult) GetValidationError() *validation.PhaseValidationError { + return m.validationError +} + +func (m mockPhaseResult) GetObjects() []machinery.ObjectResult { + return m.objects +} + +func (m mockPhaseResult) InTransistion() bool { + return m.inTransition +} + +func (m mockPhaseResult) IsComplete() bool { + return m.isComplete +} + +func (m mockPhaseResult) HasProgressed() bool { + return m.hasProgressed +} + +func (m mockPhaseResult) String() string { + return m.string +} + +type mockObjectResult struct { + action machinery.Action + object machinery.Object + success bool + probes map[string]machinery.ObjectProbeResult + string string +} + +func (m mockObjectResult) Action() machinery.Action { + return m.action +} + +func (m mockObjectResult) Object() machinery.Object { + return m.object +} + +func (m mockObjectResult) Success() bool { + return m.success +} + +func (m mockObjectResult) Probes() map[string]machinery.ObjectProbeResult { + return m.probes +} + +func (m mockObjectResult) String() string { + return m.string +} + +type mockRevisionTeardownResult struct { + phases []machinery.PhaseTeardownResult + isComplete bool + waitingPhaseNames []string + activePhaseName string + phaseIsActive bool + gonePhaseNames []string + string string +} + +func (m mockRevisionTeardownResult) GetPhases() []machinery.PhaseTeardownResult { + return m.phases +} + +func (m mockRevisionTeardownResult) IsComplete() bool { + return m.isComplete +} + +func (m mockRevisionTeardownResult) GetWaitingPhaseNames() []string { + return m.waitingPhaseNames +} + +func (m mockRevisionTeardownResult) GetActivePhaseName() (string, bool) { + return m.activePhaseName, m.phaseIsActive +} + +func (m mockRevisionTeardownResult) GetGonePhaseNames() []string { + return m.gonePhaseNames +} + +func (m mockRevisionTeardownResult) String() string { + return m.string +} + +type mockTrackingCache struct{} + +func (m *mockTrackingCache) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + panic("not implemented") +} + +func (m *mockTrackingCache) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + panic("not implemented") +} + +func (m *mockTrackingCache) Source(handler handler.EventHandler, predicates ...predicate.Predicate) source.Source { + panic("not implemented") +} + +func (m *mockTrackingCache) Watch(ctx context.Context, user client.Object, gvks sets.Set[schema.GroupVersionKind]) error { + return nil +} + +func (m *mockTrackingCache) Free(ctx context.Context, user client.Object) error { + return nil +} diff --git a/internal/operator-controller/controllers/common_controller.go b/internal/operator-controller/controllers/common_controller.go index 7cee10c10..79f0c8371 100644 --- a/internal/operator-controller/controllers/common_controller.go +++ b/internal/operator-controller/controllers/common_controller.go @@ -27,20 +27,24 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" ) -// setInstalledStatusFromBundle sets the installed status based on the given installedBundle. -func setInstalledStatusFromBundle(ext *ocv1.ClusterExtension, installedBundle *InstalledBundle) { +// setInstalledStatusFromRevisionStates sets the installed status based on the given installedBundle. +func setInstalledStatusFromRevisionStates(ext *ocv1.ClusterExtension, revisionStates *RevisionStates) { // Nothing is installed - if installedBundle == nil { + if revisionStates.Installed == nil { setInstallStatus(ext, nil) - setInstalledStatusConditionFailed(ext, "No bundle installed") + if len(revisionStates.RollingOut) == 0 { + setInstalledStatusConditionFalse(ext, ocv1.ReasonFailed, "No bundle installed") + } else { + setInstalledStatusConditionFalse(ext, ocv1.ReasonAbsent, "No bundle installed") + } return } // Something is installed installStatus := &ocv1.ClusterExtensionInstallStatus{ - Bundle: installedBundle.BundleMetadata, + Bundle: revisionStates.Installed.BundleMetadata, } setInstallStatus(ext, installStatus) - setInstalledStatusConditionSuccess(ext, fmt.Sprintf("Installed bundle %s successfully", installedBundle.Image)) + setInstalledStatusConditionSuccess(ext, fmt.Sprintf("Installed bundle %s successfully", revisionStates.Installed.Image)) } // setInstalledStatusConditionSuccess sets the installed status condition to success. @@ -55,11 +59,11 @@ func setInstalledStatusConditionSuccess(ext *ocv1.ClusterExtension, message stri } // setInstalledStatusConditionFailed sets the installed status condition to failed. -func setInstalledStatusConditionFailed(ext *ocv1.ClusterExtension, message string) { +func setInstalledStatusConditionFalse(ext *ocv1.ClusterExtension, reason string, message string) { apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ Type: ocv1.TypeInstalled, Status: metav1.ConditionFalse, - Reason: ocv1.ReasonFailed, + Reason: reason, Message: message, ObservedGeneration: ext.GetGeneration(), }) @@ -85,7 +89,7 @@ func setStatusProgressing(ext *ocv1.ClusterExtension, err error) { Type: ocv1.TypeProgressing, Status: metav1.ConditionTrue, Reason: ocv1.ReasonSucceeded, - Message: "desired state reached", + Message: "Desired state reached", ObservedGeneration: ext.GetGeneration(), } diff --git a/internal/operator-controller/controllers/common_controller_test.go b/internal/operator-controller/controllers/common_controller_test.go index 7b644172d..792e8b6d9 100644 --- a/internal/operator-controller/controllers/common_controller_test.go +++ b/internal/operator-controller/controllers/common_controller_test.go @@ -29,7 +29,7 @@ func TestSetStatusProgressing(t *testing.T) { Type: ocv1.TypeProgressing, Status: metav1.ConditionTrue, Reason: ocv1.ReasonSucceeded, - Message: "desired state reached", + Message: "Desired state reached", }, }, { diff --git a/internal/operator-controller/controllers/suite_test.go b/internal/operator-controller/controllers/suite_test.go index f287982ec..d936eca7f 100644 --- a/internal/operator-controller/controllers/suite_test.go +++ b/internal/operator-controller/controllers/suite_test.go @@ -18,14 +18,12 @@ package controllers_test import ( "context" - "io/fs" "log" "os" "path/filepath" "testing" "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/meta" apimachineryruntime "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/rest" @@ -33,112 +31,71 @@ import ( "sigs.k8s.io/controller-runtime/pkg/envtest" crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer" - helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" - ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/contentmanager" - cmcache "github.com/operator-framework/operator-controller/internal/operator-controller/contentmanager/cache" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" ) +func newScheme(t *testing.T) *apimachineryruntime.Scheme { + sch := apimachineryruntime.NewScheme() + require.NoError(t, ocv1.AddToScheme(sch)) + return sch +} + func newClient(t *testing.T) client.Client { // TODO: this is a live client, which behaves differently than a cache client. // We may want to use a caching client instead to get closer to real behavior. - sch := apimachineryruntime.NewScheme() - require.NoError(t, ocv1.AddToScheme(sch)) - cl, err := client.New(config, client.Options{Scheme: sch}) + cl, err := client.New(config, client.Options{Scheme: newScheme(t)}) require.NoError(t, err) require.NotNil(t, cl) return cl } -type MockInstalledBundleGetter struct { - bundle *controllers.InstalledBundle - err error -} +var _ controllers.RevisionStatesGetter = (*MockRevisionStatesGetter)(nil) -func (m *MockInstalledBundleGetter) SetBundle(bundle *controllers.InstalledBundle) { - m.bundle = bundle +type MockRevisionStatesGetter struct { + *controllers.RevisionStates + Err error } -func (m *MockInstalledBundleGetter) GetInstalledBundle(ctx context.Context, ext *ocv1.ClusterExtension) (*controllers.InstalledBundle, error) { - return m.bundle, m.err +func (m *MockRevisionStatesGetter) GetRevisionStates(ctx context.Context, ext *ocv1.ClusterExtension) (*controllers.RevisionStates, error) { + if m.Err != nil { + return nil, m.Err + } + return m.RevisionStates, nil } var _ controllers.Applier = (*MockApplier)(nil) type MockApplier struct { - err error - objs []client.Object - state string + installCompleted bool + installStatus string + err error } -func (m *MockApplier) Apply(_ context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _ map[string]string, _ map[string]string) ([]client.Object, string, error) { - if m.err != nil { - return nil, m.state, m.err - } - - return m.objs, m.state, nil -} - -var _ contentmanager.Manager = (*MockManagedContentCacheManager)(nil) - -type MockManagedContentCacheManager struct { - err error - cache cmcache.Cache -} - -func (m *MockManagedContentCacheManager) Get(_ context.Context, _ *ocv1.ClusterExtension) (cmcache.Cache, error) { - if m.err != nil { - return nil, m.err - } - return m.cache, nil -} - -func (m *MockManagedContentCacheManager) Delete(_ *ocv1.ClusterExtension) error { - return m.err -} - -type MockManagedContentCache struct { - err error -} - -var _ cmcache.Cache = (*MockManagedContentCache)(nil) - -func (m *MockManagedContentCache) Close() error { - if m.err != nil { - return m.err - } - return nil -} - -func (m *MockManagedContentCache) Watch(_ context.Context, _ cmcache.Watcher, _ ...client.Object) error { - if m.err != nil { - return m.err - } - return nil +func (m *MockApplier) Apply(_ context.Context, _ bundle.Bundle, _ *ocv1.ClusterExtension, _ map[string]string, _ map[string]string) (bool, string, error) { + return m.installCompleted, m.installStatus, m.err } func newClientAndReconciler(t *testing.T) (client.Client, *controllers.ClusterExtensionReconciler) { cl := newClient(t) reconciler := &controllers.ClusterExtensionReconciler{ - Client: cl, - InstalledBundleGetter: &MockInstalledBundleGetter{}, - Finalizers: crfinalizer.NewFinalizers(), + Client: cl, + RevisionStatesGetter: &MockRevisionStatesGetter{ + RevisionStates: &controllers.RevisionStates{}, + }, + Finalizers: crfinalizer.NewFinalizers(), } return cl, reconciler } -var ( - config *rest.Config - helmClientGetter helmclient.ActionClientGetter -) +var config *rest.Config func TestMain(m *testing.M) { testEnv := &envtest.Environment{ CRDDirectoryPaths: []string{ - filepath.Join("..", "..", "..", "config", "base", "operator-controller", "crd", "standard"), + filepath.Join("..", "..", "..", "config", "base", "operator-controller", "crd", "experimental"), }, ErrorIfCRDPathMissing: true, } @@ -165,12 +122,6 @@ func TestMain(m *testing.M) { log.Panic("expected cfg to not be nil") } - rm := meta.NewDefaultRESTMapper(nil) - cfgGetter, err := helmclient.NewActionConfigGetter(config, rm) - utilruntime.Must(err) - helmClientGetter, err = helmclient.NewActionClientGetter(cfgGetter) - utilruntime.Must(err) - code := m.Run() utilruntime.Must(testEnv.Stop()) os.Exit(code) diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 41bad3cf7..1abdf0a18 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -17,6 +17,7 @@ const ( WebhookProviderCertManager featuregate.Feature = "WebhookProviderCertManager" WebhookProviderOpenshiftServiceCA featuregate.Feature = "WebhookProviderOpenshiftServiceCA" HelmChartSupport featuregate.Feature = "HelmChartSupport" + BoxcutterRuntime featuregate.Feature = "BoxcutterRuntime" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -72,6 +73,13 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // BoxcutterRuntime configures OLM to use the Boxcutter runtime for extension lifecycling + BoxcutterRuntime: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() diff --git a/internal/operator-controller/rukpak/bundle/source/source_test.go b/internal/operator-controller/rukpak/bundle/source/source_test.go index 2ee948638..cf7b1cb90 100644 --- a/internal/operator-controller/rukpak/bundle/source/source_test.go +++ b/internal/operator-controller/rukpak/bundle/source/source_test.go @@ -10,14 +10,11 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" ) const ( olmProperties = "olm.properties" - - bundlePathAnnotations = "metadata/annotations.yaml" - bundlePathProperties = "metadata/properties.yaml" - bundlePathCSV = "manifests/csv.yaml" ) func Test_FromBundle_Success(t *testing.T) { @@ -30,7 +27,7 @@ func Test_FromBundle_Success(t *testing.T) { } func Test_FromFS_Success(t *testing.T) { - rv1, err := source.FromFS(newBundleFS()).GetBundle() + rv1, err := source.FromFS(NewBundleFS()).GetBundle() require.NoError(t, err) t.Log("Check package name is correctly taken from metadata/annotations.yaml") @@ -47,16 +44,16 @@ func Test_FromFS_Fails(t *testing.T) { }{ { name: "bundle missing ClusterServiceVersion manifest", - FS: removePaths(newBundleFS(), bundlePathCSV), + FS: removePaths(NewBundleFS(), BundlePathCSV), }, { name: "bundle missing metadata/annotations.yaml", - FS: removePaths(newBundleFS(), bundlePathAnnotations), + FS: removePaths(NewBundleFS(), BundlePathAnnotations), }, { name: "bundle missing metadata/ directory", - FS: removePaths(newBundleFS(), "metadata/"), + FS: removePaths(NewBundleFS(), "metadata/"), }, { name: "bundle missing manifests/ directory", - FS: removePaths(newBundleFS(), "manifests/"), + FS: removePaths(NewBundleFS(), "manifests/"), }, } { t.Run(tt.name, func(t *testing.T) { @@ -66,39 +63,6 @@ func Test_FromFS_Fails(t *testing.T) { } } -func newBundleFS() fstest.MapFS { - annotationsYml := ` -annotations: - operators.operatorframework.io.bundle.mediatype.v1: registry+v1 - operators.operatorframework.io.bundle.package.v1: test -` - - propertiesYml := ` -properties: - - type: "from-file-key" - value: "from-file-value" -` - - csvYml := ` -apiVersion: operators.operatorframework.io/v1alpha1 -kind: ClusterServiceVersion -metadata: - name: test.v1.0.0 - annotations: - olm.properties: '[{"type":"from-csv-annotations-key", "value":"from-csv-annotations-value"}]' -spec: - installModes: - - type: AllNamespaces - supported: true -` - - return fstest.MapFS{ - bundlePathAnnotations: &fstest.MapFile{Data: []byte(strings.Trim(annotationsYml, "\n"))}, - bundlePathProperties: &fstest.MapFile{Data: []byte(strings.Trim(propertiesYml, "\n"))}, - bundlePathCSV: &fstest.MapFile{Data: []byte(strings.Trim(csvYml, "\n"))}, - } -} - func removePaths(mapFs fstest.MapFS, paths ...string) fstest.MapFS { for k := range mapFs { for _, path := range paths { diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go index fadc85873..8c3824eca 100644 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go @@ -4,19 +4,16 @@ import ( "context" "errors" "fmt" - "strings" - "helm.sh/helm/v3/pkg/release" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/crdify/pkg/config" "sigs.k8s.io/crdify/pkg/runner" "sigs.k8s.io/crdify/pkg/validations" - - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) type Option func(p *Preflight) @@ -53,24 +50,18 @@ func NewPreflight(crdCli apiextensionsv1client.CustomResourceDefinitionInterface return p } -func (p *Preflight) Install(ctx context.Context, rel *release.Release) error { - return p.runPreflight(ctx, rel) +func (p *Preflight) Install(ctx context.Context, objs []client.Object) error { + return p.runPreflight(ctx, objs) } -func (p *Preflight) Upgrade(ctx context.Context, rel *release.Release) error { - return p.runPreflight(ctx, rel) +func (p *Preflight) Upgrade(ctx context.Context, objs []client.Object) error { + return p.runPreflight(ctx, objs) } -func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) error { - if rel == nil { +func (p *Preflight) runPreflight(ctx context.Context, relObjects []client.Object) error { + if len(relObjects) == 0 { return nil } - - relObjects, err := util.ManifestObjects(strings.NewReader(rel.Manifest), fmt.Sprintf("%s-release-manifest", rel.Name)) - if err != nil { - return fmt.Errorf("parsing release %q objects: %w", rel.Name, err) - } - runner, err := runner.New(p.config, p.registry) if err != nil { return fmt.Errorf("creating CRD validation runner: %w", err) diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go index 73db9673b..ba065dd2b 100644 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go @@ -16,6 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) @@ -186,7 +187,10 @@ func TestInstall(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { preflight := newMockPreflight(getCrdFromManifestFile(t, tc.oldCrdPath), tc.wantCrdGetErr) - err := preflight.Install(context.Background(), tc.release) + objs, err := applier.HelmReleaseToObjectsConverter{}.GetObjectsFromRelease(tc.release) + if err == nil { + err = preflight.Install(context.Background(), objs) + } if len(tc.wantErrMsgs) != 0 { for _, expectedErrMsg := range tc.wantErrMsgs { require.ErrorContains(t, err, expectedErrMsg) @@ -335,7 +339,10 @@ func TestUpgrade(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { preflight := newMockPreflight(getCrdFromManifestFile(t, tc.oldCrdPath), tc.wantCrdGetErr) - err := preflight.Upgrade(context.Background(), tc.release) + objs, err := applier.HelmReleaseToObjectsConverter{}.GetObjectsFromRelease(tc.release) + if err == nil { + err = preflight.Upgrade(context.Background(), objs) + } if len(tc.wantErrMsgs) != 0 { for _, expectedErrMsg := range tc.wantErrMsgs { require.ErrorContains(t, err, expectedErrMsg) diff --git a/internal/operator-controller/rukpak/util/testing/bundlefs.go b/internal/operator-controller/rukpak/util/testing/bundlefs.go new file mode 100644 index 000000000..6c5f58837 --- /dev/null +++ b/internal/operator-controller/rukpak/util/testing/bundlefs.go @@ -0,0 +1,64 @@ +package testing + +import ( + "fmt" + "path/filepath" + "strings" + "testing/fstest" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" +) + +const ( + BundlePathAnnotations = "metadata/annotations.yaml" + BundlePathProperties = "metadata/properties.yaml" + BundlePathManifests = "manifests" + BundlePathCSV = BundlePathManifests + "/csv.yaml" +) + +func NewBundleFS() fstest.MapFS { + annotationsYml := ` +annotations: + operators.operatorframework.io.bundle.mediatype.v1: registry+v1 + operators.operatorframework.io.bundle.package.v1: test +` + + propertiesYml := ` +properties: + - type: "from-file-key" + value: "from-file-value" +` + + csvYml := ` +apiVersion: operators.operatorframework.io/v1alpha1 +kind: ClusterServiceVersion +metadata: + name: test.v1.0.0 + annotations: + olm.properties: '[{"type":"from-csv-annotations-key", "value":"from-csv-annotations-value"}]' +spec: + installModes: + - type: AllNamespaces + supported: true +` + + return fstest.MapFS{ + BundlePathAnnotations: &fstest.MapFile{Data: []byte(strings.Trim(annotationsYml, "\n"))}, + BundlePathProperties: &fstest.MapFile{Data: []byte(strings.Trim(propertiesYml, "\n"))}, + BundlePathCSV: &fstest.MapFile{Data: []byte(strings.Trim(csvYml, "\n"))}, + } +} + +func AddManifest(bundleFS fstest.MapFS, obj client.Object) error { + gvk := obj.GetObjectKind().GroupVersionKind() + manifestName := fmt.Sprintf("%s%s_%s_%s%s.yaml", gvk.Group, gvk.Version, gvk.Kind, obj.GetNamespace(), obj.GetName()) + bytes, err := yaml.Marshal(obj) + if err != nil { + return err + } + bundleFS[filepath.Join(BundlePathManifests, manifestName)] = &fstest.MapFile{ + Data: bytes, + } + return nil +} diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index a91833bd7..26e3905f9 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -454,6 +454,189 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + olm.operatorframework.io/feature-set: experimental + olm.operatorframework.io/generator: experimental + name: clusterextensionrevisions.olm.operatorframework.io +spec: + group: olm.operatorframework.io + names: + kind: ClusterExtensionRevision + listKind: ClusterExtensionRevisionList + plural: clusterextensionrevisions + singular: clusterextensionrevision + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: ClusterExtensionRevision is the Schema for the clusterextensionrevisions + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec is an optional field that defines the desired state + of the ClusterExtension. + properties: + lifecycleState: + default: Active + description: Specifies the lifecycle state of the ClusterExtensionRevision. + enum: + - Active + - Paused + - Archived + type: string + x-kubernetes-validations: + - message: can not un-archive + rule: oldSelf == 'Active' || oldSelf == 'Paused' || oldSelf == 'Archived' + && oldSelf == self + phases: + items: + properties: + name: + type: string + objects: + items: + properties: + object: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + required: + - object + type: object + type: array + slices: + items: + type: string + type: array + required: + - name + - objects + type: object + type: array + x-kubernetes-validations: + - message: phases is immutable + rule: self == oldSelf || oldSelf.size() == 0 + previous: + items: + properties: + name: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - uid + type: object + type: array + x-kubernetes-validations: + - message: previous is immutable + rule: self == oldSelf + revision: + format: int64 + type: integer + x-kubernetes-validations: + - message: revision is immutable + rule: self == oldSelf + required: + - phases + - revision + type: object + status: + description: status is an optional field that defines the observed state + of the ClusterExtension. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.18.0 @@ -1331,8 +1514,10 @@ rules: - apiGroups: - olm.operatorframework.io resources: - - clusterextensions + - clusterextensionrevisions verbs: + - create + - delete - get - list - patch @@ -1341,16 +1526,28 @@ rules: - apiGroups: - olm.operatorframework.io resources: + - clusterextensionrevisions/finalizers - clusterextensions/finalizers verbs: - update - apiGroups: - olm.operatorframework.io resources: + - clusterextensionrevisions/status - clusterextensions/status verbs: - patch - update +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensions + verbs: + - get + - list + - patch + - update + - watch - apiGroups: - rbac.authorization.k8s.io resources: @@ -1502,6 +1699,21 @@ subjects: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + name: operator-controller-boxcutter-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: operator-controller-controller-manager + namespace: olmv1-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -1771,6 +1983,7 @@ spec: - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=PreflightPermissions=true - --feature-gates=HelmChartSupport=true + - --feature-gates=BoxcutterRuntime=true - --catalogd-cas-dir=/var/certs - --pull-cas-dir=/var/certs - --tls-cert=/var/certs/tls.cert diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 00dc14153..1b66bd2b7 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -454,6 +454,189 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + olm.operatorframework.io/feature-set: experimental + olm.operatorframework.io/generator: experimental + name: clusterextensionrevisions.olm.operatorframework.io +spec: + group: olm.operatorframework.io + names: + kind: ClusterExtensionRevision + listKind: ClusterExtensionRevisionList + plural: clusterextensionrevisions + singular: clusterextensionrevision + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: ClusterExtensionRevision is the Schema for the clusterextensionrevisions + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec is an optional field that defines the desired state + of the ClusterExtension. + properties: + lifecycleState: + default: Active + description: Specifies the lifecycle state of the ClusterExtensionRevision. + enum: + - Active + - Paused + - Archived + type: string + x-kubernetes-validations: + - message: can not un-archive + rule: oldSelf == 'Active' || oldSelf == 'Paused' || oldSelf == 'Archived' + && oldSelf == self + phases: + items: + properties: + name: + type: string + objects: + items: + properties: + object: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + required: + - object + type: object + type: array + slices: + items: + type: string + type: array + required: + - name + - objects + type: object + type: array + x-kubernetes-validations: + - message: phases is immutable + rule: self == oldSelf || oldSelf.size() == 0 + previous: + items: + properties: + name: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - uid + type: object + type: array + x-kubernetes-validations: + - message: previous is immutable + rule: self == oldSelf + revision: + format: int64 + type: integer + x-kubernetes-validations: + - message: revision is immutable + rule: self == oldSelf + required: + - phases + - revision + type: object + status: + description: status is an optional field that defines the observed state + of the ClusterExtension. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.18.0 @@ -1331,8 +1514,10 @@ rules: - apiGroups: - olm.operatorframework.io resources: - - clusterextensions + - clusterextensionrevisions verbs: + - create + - delete - get - list - patch @@ -1341,16 +1526,28 @@ rules: - apiGroups: - olm.operatorframework.io resources: + - clusterextensionrevisions/finalizers - clusterextensions/finalizers verbs: - update - apiGroups: - olm.operatorframework.io resources: + - clusterextensionrevisions/status - clusterextensions/status verbs: - patch - update +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensions + verbs: + - get + - list + - patch + - update + - watch - apiGroups: - rbac.authorization.k8s.io resources: @@ -1502,6 +1699,21 @@ subjects: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + name: operator-controller-boxcutter-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: operator-controller-controller-manager + namespace: olmv1-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -1737,6 +1949,7 @@ spec: - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=PreflightPermissions=true - --feature-gates=HelmChartSupport=true + - --feature-gates=BoxcutterRuntime=true - --catalogd-cas-dir=/var/certs - --pull-cas-dir=/var/certs - --tls-cert=/var/certs/tls.cert diff --git a/test/e2e/cluster_extension_install_test.go b/test/e2e/cluster_extension_install_test.go index 7c070cb44..26b0cb7a0 100644 --- a/test/e2e/cluster_extension_install_test.go +++ b/test/e2e/cluster_extension_install_test.go @@ -940,14 +940,12 @@ func TestClusterExtensionRecoversFromNoNamespaceWhenFailureFixed(t *testing.T) { require.Equal(ct, ocv1.ReasonRetrying, cond.Reason) }, pollDuration, pollInterval) - t.Log("By eventually failing to install the package successfully due to no namespace") + t.Log("By eventually reporting Installed != True") require.EventuallyWithT(t, func(ct *assert.CollectT) { require.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: clusterExtension.Name}, clusterExtension)) cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled) require.NotNil(ct, cond) - require.Equal(ct, metav1.ConditionUnknown, cond.Status) - require.Equal(ct, ocv1.ReasonFailed, cond.Reason) - require.Contains(ct, cond.Message, fmt.Sprintf("service account %q not found in namespace %q: unable to authenticate with the Kubernetes cluster.", clusterExtension.Name, clusterExtension.Name)) + require.NotEqual(ct, metav1.ConditionTrue, cond.Status) }, pollDuration, pollInterval) t.Log("By creating the Namespace and ServiceAccount") @@ -1066,7 +1064,10 @@ func TestClusterExtensionRecoversFromExistingDeploymentWhenFailureFixed(t *testi cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled) require.NotNil(ct, cond) require.Equal(ct, metav1.ConditionFalse, cond.Status) - require.Equal(ct, ocv1.ReasonFailed, cond.Reason) + // TODO: We probably _should_ be testing the reason here, but helm and boxcutter applier have different reasons. + // Maybe we change helm to use "Absent" rather than "Failed" since the Progressing condition already captures + // the failure? + //require.Equal(ct, ocv1.ReasonFailed, cond.Reason) require.Contains(ct, cond.Message, "No bundle installed") }, pollDuration, pollInterval) diff --git a/test/upgrade-e2e/post_upgrade_test.go b/test/upgrade-e2e/post_upgrade_test.go index b196db356..a9f2fb361 100644 --- a/test/upgrade-e2e/post_upgrade_test.go +++ b/test/upgrade-e2e/post_upgrade_test.go @@ -156,6 +156,11 @@ func TestClusterExtensionAfterOLMUpgrade(t *testing.T) { require.True(ct, clusterCatalog.Status.LastUnpacked.After(catalogdManagerPod.CreationTimestamp.Time)) }, time.Minute, time.Second) + // TODO: if we change the underlying revision storage mechanism, the new version + // will not detect any installed versions, we need to make sure that the upgrade + // test fails across revision storage mechanism changes that are not also accompanied + // by code that automatically migrates the revision storage. + t.Log("Checking that the ClusterExtension is installed") var clusterExtension ocv1.ClusterExtension require.EventuallyWithT(t, func(ct *assert.CollectT) {