From e7225122463c09eca1809402834f42340822e3f2 Mon Sep 17 00:00:00 2001 From: Todd Short Date: Tue, 30 Jan 2024 16:29:43 -0500 Subject: [PATCH] :sparkles: Plumb the Extension API Copies ClustersExtension functionality `ServiceAccountName` doesn't do anything yet. Signed-off-by: Todd Short --- api/v1alpha1/clusterextension_types.go | 31 + api/v1alpha1/extension_types.go | 62 +- api/v1alpha1/zz_generated.deepcopy.go | 5 + cmd/manager/main.go | 6 +- cmd/resolutioncli/main.go | 3 +- .../olm.operatorframework.io_extensions.yaml | 45 +- config/crd/kustomization.yaml | 1 + internal/controllers/bundle_provider.go | 29 + .../clusterextension_controller.go | 294 +- .../clusterextension_controller_test.go | 218 +- internal/controllers/common_controller.go | 317 +++ internal/controllers/common_test.go | 150 + internal/controllers/extension_controller.go | 307 +- .../controllers/extension_controller_test.go | 2489 +++++++++++++++++ internal/controllers/suite_test.go | 19 +- internal/controllers/validators/validators.go | 41 +- .../controllers/validators/validators_test.go | 4 +- internal/controllers/variables.go | 4 +- internal/controllers/variables_test.go | 3 +- internal/internal.go | 49 + .../variablesources/installed_package.go | 15 +- .../variablesources/installed_package_test.go | 5 +- .../variablesources/required_package.go | 14 +- .../variablesources/required_package_test.go | 3 +- ...t.go => cluster_extension_install_test.go} | 195 +- test/e2e/e2e_suite_test.go | 192 +- test/e2e/extension_install_test.go | 342 +++ 27 files changed, 4125 insertions(+), 718 deletions(-) create mode 100644 internal/controllers/bundle_provider.go create mode 100644 internal/controllers/common_controller.go create mode 100644 internal/controllers/common_test.go create mode 100644 internal/controllers/extension_controller_test.go create mode 100644 internal/internal.go rename test/e2e/{install_test.go => cluster_extension_install_test.go} (66%) create mode 100644 test/e2e/extension_install_test.go diff --git a/api/v1alpha1/clusterextension_types.go b/api/v1alpha1/clusterextension_types.go index b763cb60a..324220142 100644 --- a/api/v1alpha1/clusterextension_types.go +++ b/api/v1alpha1/clusterextension_types.go @@ -18,6 +18,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "github.com/operator-framework/operator-controller/internal/conditionsets" ) @@ -158,3 +159,33 @@ type ClusterExtensionList struct { func init() { SchemeBuilder.Register(&ClusterExtension{}, &ClusterExtensionList{}) } + +func (r *ClusterExtension) GetPackageSpec() *ExtensionSourcePackage { + p := &ExtensionSourcePackage{} + + p.Channel = r.Spec.Channel + p.Name = r.Spec.PackageName + p.Version = r.Spec.Version + + return p +} + +func (r *ClusterExtension) GetGeneration() int64 { + return r.ObjectMeta.GetGeneration() +} + +func (r *ClusterExtension) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *ClusterExtension) SetInstalledBundleResource(s string) { + r.Status.InstalledBundleResource = s +} + +func (r *ClusterExtension) GetUID() types.UID { + return r.ObjectMeta.GetUID() +} + +func (r *ClusterExtension) GetUpgradeConstraintPolicy() UpgradeConstraintPolicy { + return r.Spec.UpgradeConstraintPolicy +} diff --git a/api/v1alpha1/extension_types.go b/api/v1alpha1/extension_types.go index eba75fce8..54ef93148 100644 --- a/api/v1alpha1/extension_types.go +++ b/api/v1alpha1/extension_types.go @@ -18,20 +18,22 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) type ExtensionManagedState string const ( - // Pause resolution of this Extension + // Peform reconcilliation of this Extension ManagedStateActive ExtensionManagedState = "Active" - // Peform resolution of this Extension + // Pause reconcilliation of this Extension ManagedStatePaused ExtensionManagedState = "Paused" ) type ExtensionSourcePackage struct { //+kubebuilder:validation:MaxLength:=48 //+kubebuilder:validation:Pattern:=^[a-z0-9]+(-[a-z0-9]+)*$ + // name specifies the name of the name of the package Name string `json:"name"` //+kubebuilder:validation:MaxLength:=64 @@ -42,12 +44,20 @@ type ExtensionSourcePackage struct { // Examples: 1.2.3, 1.0.0-alpha, 1.0.0-rc.1 // // For more information on semver, please see https://semver.org/ + // version constraint definition Version string `json:"version,omitempty"` //+kubebuilder:validation:MaxLength:=48 //+kubebuilder:validation:Pattern:=^[a-z0-9]+([\.-][a-z0-9]+)*$ - // Channel constraint definition + // channel constraint definition Channel string `json:"channel,omitempty"` + + //+kubebuilder:validation:Enum:=Enforce;Ignore + //+kubebuilder:default:=Enforce + //+kubebuilder:Optional + // + // upgradeConstraintPolicy Defines the policy for how to handle upgrade constraints + UpgradeConstraintPolicy UpgradeConstraintPolicy `json:"upgradeConstraintPolicy,omitempty"` } // TODO: Implement ExtensionSourceDirect containing a URL or other reference mechanism @@ -66,31 +76,30 @@ type ExtensionSpec struct { //+kubebuilder:default:=Active //+kubebuilder:Optional // - // Pause reconciliation on this Extension + // managed controls the management state of the extension. "Active" means this extension will be reconciled and "Paused" means this extension will be ignored. Managed ExtensionManagedState `json:"managed,omitempty"` - //+kubebuilder:validation:MaxLength:=64 + //+kubebuilder:validation:MaxLength:=253 //+kubebuilder:validation:Pattern:=^[a-z0-9]+([\.-][a-z0-9]+)*$ // - // ServiceAccount name used to install this extension + // serviceAccountName is he name of a service account in the Extension's namespace that will be used to manage the installation and lifecycle of the extension. ServiceAccountName string `json:"serviceAccountName"` - //+kubebuilder:validation:MaxLength:=64 + //+kubebuilder:validation:MaxLength:=253 //+kubebuilder:validation:Pattern:=^[a-z0-9]+([\.-][a-z0-9]+)*$ //+kubebuilder:Optional // - // Location of installation TBD?? + // defaultNamespace is the location of installation if different than the resource namespace DefaultNamespace string `json:"defaultNamespace,omitempty"` - // Source of Extension to be installed + // source of Extension to be installed Source ExtensionSource `json:"source"` - //+kubebuilder:validation:Enum:=Enforce;Ignore - //+kubebuilder:default:=Enforce //+kubebuilder:Optional // - // Defines the policy for how to handle upgrade constraints - UpgradeConstraintPolicy UpgradeConstraintPolicy `json:"upgradeConstraintPolicy,omitempty"` + // watchNamespaces indicates which namespaces the extension should watch. + // This feature is currently supported only with RegistryV1 bundles. + WatchNamespaces []string `json:"watchNamespaces,omitempty"` } // ExtensionStatus defines the observed state of Extension @@ -131,3 +140,30 @@ type ExtensionList struct { func init() { SchemeBuilder.Register(&Extension{}, &ExtensionList{}) } + +func (r *Extension) GetPackageSpec() *ExtensionSourcePackage { + return r.Spec.Source.Package.DeepCopy() +} + +func (r *Extension) GetGeneration() int64 { + return r.ObjectMeta.GetGeneration() +} + +func (r *Extension) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *Extension) SetInstalledBundleResource(s string) { + r.Status.InstalledBundleResource = s +} + +func (r *Extension) GetUID() types.UID { + return r.ObjectMeta.GetUID() +} + +func (r *Extension) GetUpgradeConstraintPolicy() UpgradeConstraintPolicy { + if r.Spec.Source.Package != nil { + return r.Spec.Source.Package.UpgradeConstraintPolicy + } + return "" +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index fbebacf2d..c0e3a2abe 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -225,6 +225,11 @@ func (in *ExtensionSourcePackage) DeepCopy() *ExtensionSourcePackage { func (in *ExtensionSpec) DeepCopyInto(out *ExtensionSpec) { *out = *in in.Source.DeepCopyInto(&out.Source) + if in.WatchNamespaces != nil { + in, out := &in.WatchNamespaces, &out.WatchNamespaces + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtensionSpec. diff --git a/cmd/manager/main.go b/cmd/manager/main.go index df8fa2d49..6ae6dbf6c 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -124,8 +124,10 @@ func main() { os.Exit(1) } if err = (&controllers.ExtensionReconciler{ - Client: cl, - Scheme: mgr.GetScheme(), + Client: cl, + BundleProvider: catalogClient, + Scheme: mgr.GetScheme(), + Resolver: resolver, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Extension") os.Exit(1) diff --git a/cmd/resolutioncli/main.go b/cmd/resolutioncli/main.go index 34815e9a6..17d3eea51 100644 --- a/cmd/resolutioncli/main.go +++ b/cmd/resolutioncli/main.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" "github.com/operator-framework/operator-controller/internal/controllers" olmvariables "github.com/operator-framework/operator-controller/internal/resolution/variables" @@ -162,7 +163,7 @@ func run(ctx context.Context, packageName, packageChannel, packageVersionRange, if err := cl.List(ctx, &bundleDeploymentList); err != nil { return err } - variables, err := controllers.GenerateVariables(allBundles, clusterExtensionList.Items, bundleDeploymentList.Items) + variables, err := controllers.GenerateVariables(allBundles, internal.ClusterExtensionArrayToInterface(clusterExtensionList.Items), bundleDeploymentList.Items) if err != nil { return err } diff --git a/config/crd/bases/olm.operatorframework.io_extensions.yaml b/config/crd/bases/olm.operatorframework.io_extensions.yaml index b3082ac3c..1bc2ac6eb 100644 --- a/config/crd/bases/olm.operatorframework.io_extensions.yaml +++ b/config/crd/bases/olm.operatorframework.io_extensions.yaml @@ -35,38 +35,52 @@ spec: description: ExtensionSpec defines the desired state of Extension properties: defaultNamespace: - description: Location of installation TBD?? - maxLength: 64 + description: defaultNamespace is the location of installation if different + than the resource namespace + maxLength: 253 pattern: ^[a-z0-9]+([\.-][a-z0-9]+)*$ type: string managed: default: Active - description: Pause reconciliation on this Extension + description: managed controls the management state of the extension. + "Active" means this extension will be reconciled and "Paused" means + this extension will be ignored. enum: - Active - Paused type: string serviceAccountName: - description: ServiceAccount name used to install this extension - maxLength: 64 + description: serviceAccountName is he name of a service account in + the Extension's namespace that will be used to manage the installation + and lifecycle of the extension. + maxLength: 253 pattern: ^[a-z0-9]+([\.-][a-z0-9]+)*$ type: string source: - description: Source of Extension to be installed + description: source of Extension to be installed properties: package: description: A source package defined by a name, version and/or channel properties: channel: - description: Channel constraint definition + description: channel constraint definition maxLength: 48 pattern: ^[a-z0-9]+([\.-][a-z0-9]+)*$ type: string name: + description: name specifies the name of the name of the package maxLength: 48 pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ type: string + upgradeConstraintPolicy: + default: Enforce + description: upgradeConstraintPolicy Defines the policy for + how to handle upgrade constraints + enum: + - Enforce + - Ignore + type: string version: description: "Version is an optional semver constraint on the package version. If not specified, the latest version @@ -74,7 +88,8 @@ spec: the specific version of the package will be installed so long as it is available in any of the content sources available. Examples: 1.2.3, 1.0.0-alpha, 1.0.0-rc.1 \n For more information - on semver, please see https://semver.org/" + on semver, please see https://semver.org/ version constraint + definition" maxLength: 64 pattern: ^(\s*(=||!=|>|<|>=|=>|<=|=<|~|~>|\^)\s*(v?(0|[1-9]\d*|[x|X|\*])(\.(0|[1-9]\d*|x|X|\*]))?(\.(0|[1-9]\d*|x|X|\*))?(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?)\s*)((?:\s+|,\s*|\s*\|\|\s*)(=||!=|>|<|>=|=>|<=|=<|~|~>|\^)\s*(v?(0|[1-9]\d*|x|X|\*])(\.(0|[1-9]\d*|x|X|\*))?(\.(0|[1-9]\d*|x|X|\*]))?(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?)\s*)*$ type: string @@ -82,13 +97,13 @@ spec: - name type: object type: object - upgradeConstraintPolicy: - default: Enforce - description: Defines the policy for how to handle upgrade constraints - enum: - - Enforce - - Ignore - type: string + watchNamespaces: + description: watchNamespaces indicates which namespaces the extension + should watch. This feature is currently supported only with RegistryV1 + bundles. + items: + type: string + type: array required: - serviceAccountName - source diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index ec864639d..2e88d28bc 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/olm.operatorframework.io_clusterextensions.yaml +- bases/olm.operatorframework.io_extensions.yaml # the following config is for teaching kustomize how to do kustomization for CRDs. configurations: diff --git a/internal/controllers/bundle_provider.go b/internal/controllers/bundle_provider.go new file mode 100644 index 000000000..924eaed60 --- /dev/null +++ b/internal/controllers/bundle_provider.go @@ -0,0 +1,29 @@ +/* +Copyright 2023. + +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 controllers + +import ( + "context" + + "github.com/operator-framework/operator-controller/internal/catalogmetadata" +) + +// BundleProvider provides the way to retrieve a list of Bundles from a source, +// generally from a catalog client of some kind. +type BundleProvider interface { + Bundles(ctx context.Context) ([]*catalogmetadata.Bundle, error) +} diff --git a/internal/controllers/clusterextension_controller.go b/internal/controllers/clusterextension_controller.go index 126b673f4..aaa9c9f7e 100644 --- a/internal/controllers/clusterextension_controller.go +++ b/internal/controllers/clusterextension_controller.go @@ -19,16 +19,13 @@ package controllers import ( "context" "fmt" - "strings" "github.com/go-logr/logr" catalogd "github.com/operator-framework/catalogd/api/core/v1alpha1" "github.com/operator-framework/deppy/pkg/deppy" "github.com/operator-framework/deppy/pkg/deppy/solver" - "github.com/operator-framework/operator-registry/alpha/declcfg" rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" "k8s.io/apimachinery/pkg/api/equality" - apimeta "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" @@ -42,17 +39,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" "github.com/operator-framework/operator-controller/internal/controllers/validators" olmvariables "github.com/operator-framework/operator-controller/internal/resolution/variables" ) -// BundleProvider provides the way to retrieve a list of Bundles from a source, -// generally from a catalog client of some kind. -type BundleProvider interface { - Bundles(ctx context.Context) ([]*catalogmetadata.Bundle, error) -} - // ClusterExtensionReconciler reconciles a ClusterExtension object type ClusterExtensionReconciler struct { client.Client @@ -71,12 +63,12 @@ type ClusterExtensionReconciler struct { //+kubebuilder:rbac:groups=catalogd.operatorframework.io,resources=catalogmetadata,verbs=list;watch func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - l := log.FromContext(ctx).WithName("operator-controller") + l := log.FromContext(ctx).WithName("clusterextensionerator-controller") l.V(1).Info("starting") defer l.V(1).Info("ending") var existingExt = &ocv1alpha1.ClusterExtension{} - if err := r.Get(ctx, req.NamespacedName, existingExt); err != nil { + if err := r.Client.Get(ctx, req.NamespacedName, existingExt); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -86,10 +78,10 @@ func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Req // Do checks before any Update()s, as Update() may modify the resource structure! updateStatus := !equality.Semantic.DeepEqual(existingExt.Status, reconciledExt.Status) updateFinalizers := !equality.Semantic.DeepEqual(existingExt.Finalizers, reconciledExt.Finalizers) - unexpectedFieldsChanged := checkForUnexpectedFieldChange(*existingExt, *reconciledExt) + unexpectedFieldsChanged := r.checkForUnexpectedFieldChange(*existingExt, *reconciledExt) if updateStatus { - if updateErr := r.Status().Update(ctx, reconciledExt); updateErr != nil { + if updateErr := r.Client.Status().Update(ctx, reconciledExt); updateErr != nil { return res, utilerrors.NewAggregate([]error{reconcileErr, updateErr}) } } @@ -99,7 +91,7 @@ func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Req } if updateFinalizers { - if updateErr := r.Update(ctx, reconciledExt); updateErr != nil { + if updateErr := r.Client.Update(ctx, reconciledExt); updateErr != nil { return res, utilerrors.NewAggregate([]error{reconcileErr, updateErr}) } } @@ -108,7 +100,7 @@ func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Req } // Compare resources - ignoring status & metadata.finalizers -func checkForUnexpectedFieldChange(a, b ocv1alpha1.ClusterExtension) bool { +func (*ClusterExtensionReconciler) checkForUnexpectedFieldChange(a, b ocv1alpha1.ClusterExtension) bool { a.Status, b.Status = ocv1alpha1.ClusterExtensionStatus{}, ocv1alpha1.ClusterExtensionStatus{} a.Finalizers, b.Finalizers = []string{}, []string{} return !equality.Semantic.DeepEqual(a, b) @@ -123,7 +115,7 @@ func checkForUnexpectedFieldChange(a, b ocv1alpha1.ClusterExtension) bool { //nolint:unparam func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alpha1.ClusterExtension) (ctrl.Result, error) { // validate spec - if err := validators.ValidateClusterExtensionSpec(ext); err != nil { + if err := validators.ValidateSpec(ext); err != nil { // Set the TypeInstalled condition to Unknown to indicate that the resolution // hasn't been attempted yet, due to the spec being invalid. ext.Status.InstalledBundleResource = "" @@ -195,7 +187,7 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alp // Ensure a BundleDeployment exists with its bundle source from the bundle // image we just looked up in the solution. dep := r.GenerateExpectedBundleDeployment(*ext, bundle.Image, bundleProvisioner) - if err := r.ensureBundleDeployment(ctx, dep); err != nil { + if err := ensureBundleDeployment(ctx, r.Client, dep); err != nil { // originally Reason: ocv1alpha1.ReasonInstallationFailed ext.Status.InstalledBundleResource = "" setInstalledStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) @@ -237,134 +229,7 @@ func (r *ClusterExtensionReconciler) variables(ctx context.Context) ([]deppy.Var return nil, err } - return GenerateVariables(allBundles, clusterExtensionList.Items, bundleDeploymentList.Items) -} - -func mapBDStatusToInstalledCondition(existingTypedBundleDeployment *rukpakv1alpha2.BundleDeployment, ext *ocv1alpha1.ClusterExtension) { - bundleDeploymentReady := apimeta.FindStatusCondition(existingTypedBundleDeployment.Status.Conditions, rukpakv1alpha2.TypeInstalled) - if bundleDeploymentReady == nil { - ext.Status.InstalledBundleResource = "" - setInstalledStatusConditionUnknown(&ext.Status.Conditions, "bundledeployment status is unknown", ext.GetGeneration()) - return - } - - if bundleDeploymentReady.Status != metav1.ConditionTrue { - ext.Status.InstalledBundleResource = "" - setInstalledStatusConditionFailed( - &ext.Status.Conditions, - fmt.Sprintf("bundledeployment not ready: %s", bundleDeploymentReady.Message), - ext.GetGeneration(), - ) - return - } - - bundleDeploymentSource := existingTypedBundleDeployment.Spec.Source - switch bundleDeploymentSource.Type { - case rukpakv1alpha2.SourceTypeImage: - ext.Status.InstalledBundleResource = bundleDeploymentSource.Image.Ref - setInstalledStatusConditionSuccess( - &ext.Status.Conditions, - fmt.Sprintf("installed from %q", bundleDeploymentSource.Image.Ref), - ext.GetGeneration(), - ) - case rukpakv1alpha2.SourceTypeGit: - resource := bundleDeploymentSource.Git.Repository + "@" + bundleDeploymentSource.Git.Ref.Commit - ext.Status.InstalledBundleResource = resource - setInstalledStatusConditionSuccess( - &ext.Status.Conditions, - fmt.Sprintf("installed from %q", resource), - ext.GetGeneration(), - ) - default: - ext.Status.InstalledBundleResource = "" - setInstalledStatusConditionUnknown( - &ext.Status.Conditions, - fmt.Sprintf("unknown bundledeployment source type %q", bundleDeploymentSource.Type), - ext.GetGeneration(), - ) - } -} - -// setDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension -// based on the provided bundle -func SetDeprecationStatus(ext *ocv1alpha1.ClusterExtension, bundle *catalogmetadata.Bundle) { - // reset conditions to false - conditionTypes := []string{ - ocv1alpha1.TypeDeprecated, - ocv1alpha1.TypePackageDeprecated, - ocv1alpha1.TypeChannelDeprecated, - ocv1alpha1.TypeBundleDeprecated, - } - - for _, conditionType := range conditionTypes { - apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ - Type: conditionType, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - Message: "", - ObservedGeneration: ext.Generation, - }) - } - - // There are two early return scenarios here: - // 1) The bundle is not deprecated (i.e bundle deprecations) - // AND there are no other deprecations associated with the bundle - // 2) The bundle is not deprecated, there are deprecations associated - // with the bundle (i.e at least one channel the bundle is present in is deprecated OR whole package is deprecated), - // and the ClusterExtension does not specify a channel. This is because the channel deprecations - // are a loose deprecation coupling on the bundle. A ClusterExtension installation is only - // considered deprecated by a channel deprecation when a deprecated channel is specified via - // the spec.channel field. - if (!bundle.IsDeprecated() && !bundle.HasDeprecation()) || (!bundle.IsDeprecated() && ext.Spec.Channel == "") { - return - } - - deprecationMessages := []string{} - - for _, deprecation := range bundle.Deprecations { - switch deprecation.Reference.Schema { - case declcfg.SchemaPackage: - apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - Message: deprecation.Message, - ObservedGeneration: ext.Generation, - }) - case declcfg.SchemaChannel: - if ext.Spec.Channel != deprecation.Reference.Name { - continue - } - - apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - Message: deprecation.Message, - ObservedGeneration: ext.Generation, - }) - case declcfg.SchemaBundle: - apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - Message: deprecation.Message, - ObservedGeneration: ext.Generation, - }) - } - - deprecationMessages = append(deprecationMessages, deprecation.Message) - } - - if len(deprecationMessages) > 0 { - apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - Message: strings.Join(deprecationMessages, ";"), - ObservedGeneration: ext.Generation, - }) - } + return GenerateVariables(allBundles, internal.ClusterExtensionArrayToInterface(clusterExtensionList.Items), bundleDeploymentList.Items) } func (r *ClusterExtensionReconciler) bundleFromSolution(selection []deppy.Variable, packageName string) (*catalogmetadata.Bundle, error) { @@ -424,7 +289,6 @@ func (r *ClusterExtensionReconciler) GenerateExpectedBundleDeployment(o ocv1alph return bd } -// SetupWithManager sets up the controller with the Manager. func (r *ClusterExtensionReconciler) SetupWithManager(mgr ctrl.Manager) error { err := ctrl.NewControllerManagedBy(mgr). For(&ocv1alpha1.ClusterExtension{}). @@ -439,144 +303,6 @@ func (r *ClusterExtensionReconciler) SetupWithManager(mgr ctrl.Manager) error { return nil } -func (r *ClusterExtensionReconciler) ensureBundleDeployment(ctx context.Context, desiredBundleDeployment *unstructured.Unstructured) error { - // TODO: what if there happens to be an unrelated BD with the same name as the ClusterExtension? - // we should probably also check to see if there's an owner reference and/or a label set - // that we expect only to ever be used by the operator-controller. That way, we don't - // automatically and silently adopt and change a BD that the user doens't intend to be - // owned by the ClusterExtension. - existingBundleDeployment, err := r.existingBundleDeploymentUnstructured(ctx, desiredBundleDeployment.GetName()) - if client.IgnoreNotFound(err) != nil { - return err - } - - // If the existing BD already has everything that the desired BD has, no need to contact the API server. - // Make sure the status of the existingBD from the server is as expected. - if equality.Semantic.DeepDerivative(desiredBundleDeployment, existingBundleDeployment) { - *desiredBundleDeployment = *existingBundleDeployment - return nil - } - - return r.Client.Patch(ctx, desiredBundleDeployment, client.Apply, client.ForceOwnership, client.FieldOwner("operator-controller")) -} - -func (r *ClusterExtensionReconciler) existingBundleDeploymentUnstructured(ctx context.Context, name string) (*unstructured.Unstructured, error) { - existingBundleDeployment := &rukpakv1alpha2.BundleDeployment{} - err := r.Client.Get(ctx, types.NamespacedName{Name: name}, existingBundleDeployment) - if err != nil { - return nil, err - } - existingBundleDeployment.APIVersion = rukpakv1alpha2.GroupVersion.String() - existingBundleDeployment.Kind = rukpakv1alpha2.BundleDeploymentKind - unstrExistingBundleDeploymentObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(existingBundleDeployment) - if err != nil { - return nil, err - } - return &unstructured.Unstructured{Object: unstrExistingBundleDeploymentObj}, nil -} - -// mapBundleMediaTypeToBundleProvisioner maps an olm.bundle.mediatype property to a -// rukpak bundle provisioner class name that is capable of unpacking the bundle type -func mapBundleMediaTypeToBundleProvisioner(mediaType string) (string, error) { - switch mediaType { - case catalogmetadata.MediaTypePlain: - return "core-rukpak-io-plain", nil - // To ensure compatibility with bundles created with OLMv0 where the - // olm.bundle.mediatype property doesn't exist, we assume that if the - // property is empty (i.e doesn't exist) that the bundle is one created - // with OLMv0 and therefore should use the registry provisioner - case catalogmetadata.MediaTypeRegistry, "": - return "core-rukpak-io-registry", nil - default: - return "", fmt.Errorf("unknown bundle mediatype: %s", mediaType) - } -} - -// setResolvedStatusConditionSuccess sets the resolved status condition to success. -func setResolvedStatusConditionSuccess(conditions *[]metav1.Condition, message string, generation int64) { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: ocv1alpha1.TypeResolved, - Status: metav1.ConditionTrue, - Reason: ocv1alpha1.ReasonSuccess, - Message: message, - ObservedGeneration: generation, - }) -} - -// setResolvedStatusConditionFailed sets the resolved status condition to failed. -func setResolvedStatusConditionFailed(conditions *[]metav1.Condition, message string, generation int64) { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: ocv1alpha1.TypeResolved, - Status: metav1.ConditionFalse, - Reason: ocv1alpha1.ReasonResolutionFailed, - Message: message, - ObservedGeneration: generation, - }) -} - -// setResolvedStatusConditionUnknown sets the resolved status condition to unknown. -func setResolvedStatusConditionUnknown(conditions *[]metav1.Condition, message string, generation int64) { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: ocv1alpha1.TypeResolved, - Status: metav1.ConditionUnknown, - Reason: ocv1alpha1.ReasonResolutionUnknown, - Message: message, - ObservedGeneration: generation, - }) -} - -// setInstalledStatusConditionSuccess sets the installed status condition to success. -func setInstalledStatusConditionSuccess(conditions *[]metav1.Condition, message string, generation int64) { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: ocv1alpha1.TypeInstalled, - Status: metav1.ConditionTrue, - Reason: ocv1alpha1.ReasonSuccess, - Message: message, - ObservedGeneration: generation, - }) -} - -// setInstalledStatusConditionFailed sets the installed status condition to failed. -func setInstalledStatusConditionFailed(conditions *[]metav1.Condition, message string, generation int64) { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: ocv1alpha1.TypeInstalled, - Status: metav1.ConditionFalse, - Reason: ocv1alpha1.ReasonInstallationFailed, - Message: message, - ObservedGeneration: generation, - }) -} - -// setInstalledStatusConditionUnknown sets the installed status condition to unknown. -func setInstalledStatusConditionUnknown(conditions *[]metav1.Condition, message string, generation int64) { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: ocv1alpha1.TypeInstalled, - Status: metav1.ConditionUnknown, - Reason: ocv1alpha1.ReasonInstallationStatusUnknown, - Message: message, - ObservedGeneration: generation, - }) -} - -func setDeprecationStatusesUnknown(conditions *[]metav1.Condition, message string, generation int64) { - conditionTypes := []string{ - ocv1alpha1.TypeDeprecated, - ocv1alpha1.TypePackageDeprecated, - ocv1alpha1.TypeChannelDeprecated, - ocv1alpha1.TypeBundleDeprecated, - } - - for _, conditionType := range conditionTypes { - apimeta.SetStatusCondition(conditions, metav1.Condition{ - Type: conditionType, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionUnknown, - Message: message, - ObservedGeneration: generation, - }) - } -} - // Generate reconcile requests for all cluster extensions affected by a catalog change func clusterExtensionRequestsForCatalog(c client.Reader, logger logr.Logger) handler.MapFunc { return func(ctx context.Context, _ client.Object) []reconcile.Request { diff --git a/internal/controllers/clusterextension_controller_test.go b/internal/controllers/clusterextension_controller_test.go index bfe2d7cd2..3bee8053f 100644 --- a/internal/controllers/clusterextension_controller_test.go +++ b/internal/controllers/clusterextension_controller_test.go @@ -2,14 +2,12 @@ package controllers_test import ( "context" - "encoding/json" "fmt" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/operator-framework/operator-registry/alpha/declcfg" - "github.com/operator-framework/operator-registry/alpha/property" rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,7 +32,7 @@ import ( // Describe: ClusterExtension Controller Test func TestClusterExtensionDoesNotExist(t *testing.T) { - _, reconciler := newClientAndReconciler(t) + _, reconciler := newClientAndClusterExtensionReconciler(t) t.Log("When the cluster extension does not exist") t.Log("It returns no error") @@ -44,7 +42,7 @@ func TestClusterExtensionDoesNotExist(t *testing.T) { } func TestClusterExtensionNonExistentPackage(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -77,13 +75,13 @@ func TestClusterExtensionNonExistentPackage(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) require.Equal(t, fmt.Sprintf("no package %q found", pkgName), cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionNonExistentVersion(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -124,13 +122,13 @@ func TestClusterExtensionNonExistentVersion(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionBundleDeploymentDoesNotExist(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} const pkgName = "prometheus" @@ -179,13 +177,13 @@ func TestClusterExtensionBundleDeploymentDoesNotExist(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "bundledeployment status is unknown", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionBundleDeploymentOutOfDate(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} const pkgName = "prometheus" @@ -264,13 +262,13 @@ func TestClusterExtensionBundleDeploymentOutOfDate(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "bundledeployment status is unknown", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionBundleDeploymentUpToDate(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} const pkgName = "prometheus" @@ -534,13 +532,13 @@ func TestClusterExtensionBundleDeploymentUpToDate(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) require.Equal(t, "bundledeployment not ready: installing", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionExpectedBundleDeployment(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} const pkgName = "prometheus" @@ -603,13 +601,13 @@ func TestClusterExtensionExpectedBundleDeployment(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "bundledeployment status is unknown", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionDuplicatePackage(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} const pkgName = "prometheus" @@ -654,13 +652,13 @@ func TestClusterExtensionDuplicatePackage(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "installation has not been attempted as resolution failed", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionChannelVersionExists(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -713,13 +711,13 @@ func TestClusterExtensionChannelVersionExists(t *testing.T) { require.NotNil(t, bd.Spec.Source.Image) require.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", bd.Spec.Source.Image.Ref) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionChannelExistsNoVersion(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -770,13 +768,13 @@ func TestClusterExtensionChannelExistsNoVersion(t *testing.T) { require.NotNil(t, bd.Spec.Source.Image) require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", bd.Spec.Source.Image.Ref) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionVersionNoChannel(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -821,13 +819,13 @@ func TestClusterExtensionVersionNoChannel(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionNoChannel(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -869,13 +867,13 @@ func TestClusterExtensionNoChannel(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionNoVersion(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -919,13 +917,13 @@ func TestClusterExtensionNoVersion(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionPlainV0Bundle(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -976,13 +974,13 @@ func TestClusterExtensionPlainV0Bundle(t *testing.T) { require.NotNil(t, bd.Spec.Source.Image) require.Equal(t, "quay.io/operatorhub/plain@sha256:plain", bd.Spec.Source.Image.Ref) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionBadBundleMediaType(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -1027,13 +1025,13 @@ func TestClusterExtensionBadBundleMediaType(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) require.Equal(t, "unknown bundle mediatype: badmedia+v1", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } func TestClusterExtensionInvalidSemverPastRegex(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() t.Log("When an invalid semver is provided that bypasses the regex validation") extKey := types.NamespacedName{Name: fmt.Sprintf("clusterextension-validation-test-%s", rand.String(8))} @@ -1079,19 +1077,19 @@ func TestClusterExtensionInvalidSemverPastRegex(t *testing.T) { require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) require.Equal(t, "installation has not been attempted as spec is invalid", cond.Message) - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) + verifyClusterExtensionInvariants(ctx, t, reconciler.Client, clusterExtension) require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) } -func verifyInvariants(ctx context.Context, t *testing.T, c client.Client, ext *ocv1alpha1.ClusterExtension) { +func verifyClusterExtensionInvariants(ctx context.Context, t *testing.T, c client.Client, ext *ocv1alpha1.ClusterExtension) { key := client.ObjectKeyFromObject(ext) require.NoError(t, c.Get(ctx, key, ext)) - verifyConditionsInvariants(t, ext) + verifyClusterExtensionConditionsInvariants(t, ext) } -func verifyConditionsInvariants(t *testing.T, ext *ocv1alpha1.ClusterExtension) { +func verifyClusterExtensionConditionsInvariants(t *testing.T, ext *ocv1alpha1.ClusterExtension) { // Expect that the cluster extension's set of conditions contains all defined // condition types for the ClusterExtension API. Every reconcile should always // ensure every condition type's status/reason/message reflects the state @@ -1106,7 +1104,7 @@ func verifyConditionsInvariants(t *testing.T, ext *ocv1alpha1.ClusterExtension) } } -func TestGeneratedBundleDeployment(t *testing.T) { +func TestClusterExtensionGeneratedBundleDeployment(t *testing.T) { test := []struct { name string clusterExtension ocv1alpha1.ClusterExtension @@ -1157,7 +1155,7 @@ func TestGeneratedBundleDeployment(t *testing.T) { } func TestClusterExtensionUpgrade(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() t.Run("semver upgrade constraints enforcement of upgrades within major version", func(t *testing.T) { @@ -1431,7 +1429,7 @@ func TestClusterExtensionUpgrade(t *testing.T) { } func TestClusterExtensionDowngrade(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) + cl, reconciler := newClientAndClusterExtensionReconciler(t) ctx := context.Background() t.Run("enforce upgrade constraints", func(t *testing.T) { @@ -1598,7 +1596,7 @@ func TestClusterExtensionDowngrade(t *testing.T) { }) } -func TestSetDeprecationStatus(t *testing.T) { +func TestClusterExtensionSetDeprecationStatus(t *testing.T) { for _, tc := range []struct { name string clusterExtension *ocv1alpha1.ClusterExtension @@ -2041,143 +2039,3 @@ func TestSetDeprecationStatus(t *testing.T) { }) } } - -var ( - prometheusAlphaChannel = catalogmetadata.Channel{ - Channel: declcfg.Channel{ - Name: "alpha", - Package: "prometheus", - }, - } - prometheusBetaChannel = catalogmetadata.Channel{ - Channel: declcfg.Channel{ - Name: "beta", - Package: "prometheus", - Entries: []declcfg.ChannelEntry{ - { - Name: "operatorhub/prometheus/beta/1.0.0", - }, - { - Name: "operatorhub/prometheus/beta/1.0.1", - Replaces: "operatorhub/prometheus/beta/1.0.0", - }, - { - Name: "operatorhub/prometheus/beta/1.2.0", - Replaces: "operatorhub/prometheus/beta/1.0.1", - }, - { - Name: "operatorhub/prometheus/beta/2.0.0", - Replaces: "operatorhub/prometheus/beta/1.2.0", - }, - }, - }, - } - plainBetaChannel = catalogmetadata.Channel{ - Channel: declcfg.Channel{ - Name: "beta", - Package: "plain", - }, - } - badmediaBetaChannel = catalogmetadata.Channel{ - Channel: declcfg.Channel{ - Name: "beta", - Package: "badmedia", - }, - } -) - -var testBundleList = []*catalogmetadata.Bundle{ - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/prometheus/alpha/0.37.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@sha256:3e281e587de3d03011440685fc4fb782672beab044c1ebadc42788ce05a21c35", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"0.37.0"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&prometheusAlphaChannel}, - }, - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/prometheus/beta/1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.0.0"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, - }, - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/prometheus/beta/1.0.1", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.1", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.0.1"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, - }, - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/prometheus/beta/1.2.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.2.0", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.2.0"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, - }, - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/prometheus/beta/2.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake2.0.0", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"2.0.0"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, - }, - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/plain/0.1.0", - Package: "plain", - Image: "quay.io/operatorhub/plain@sha256:plain", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"plain","version":"0.1.0"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"plain+v0"`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&plainBetaChannel}, - }, - { - Bundle: declcfg.Bundle{ - Name: "operatorhub/badmedia/0.1.0", - Package: "badmedia", - Image: "quay.io/operatorhub/badmedia@sha256:badmedia", - Properties: []property.Property{ - {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"badmedia","version":"0.1.0"}`)}, - {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, - {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"badmedia+v1"`)}, - }, - }, - CatalogName: "fake-catalog", - InChannels: []*catalogmetadata.Channel{&badmediaBetaChannel}, - }, -} diff --git a/internal/controllers/common_controller.go b/internal/controllers/common_controller.go new file mode 100644 index 000000000..c76c1aee1 --- /dev/null +++ b/internal/controllers/common_controller.go @@ -0,0 +1,317 @@ +/* +Copyright 2023. + +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 controllers + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/equality" + apimeta "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/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" + + ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" + "github.com/operator-framework/operator-controller/internal/catalogmetadata" +) + +type ExtensionInterface interface { + GetPackageSpec() *ocv1alpha1.ExtensionSourcePackage + GetGeneration() int64 + GetConditions() *[]metav1.Condition + SetInstalledBundleResource(string) +} + +func ensureBundleDeployment(ctx context.Context, c client.Client, desiredBundleDeployment *unstructured.Unstructured) error { + // TODO: what if there happens to be an unrelated BD with the same name as the ClusterExtension/Extension? + // we should probably also check to see if there's an owner reference and/or a label set + // that we expect only to ever be used by the operator-controller. That way, we don't + // automatically and silently adopt and change a BD that the user doens't intend to be + // owned by the ClusterExtension/Extension. + existingBundleDeployment, err := existingBundleDeploymentUnstructured(ctx, c, desiredBundleDeployment.GetName()) + if client.IgnoreNotFound(err) != nil { + return err + } + + // If the existing BD already has everything that the desired BD has, no need to contact the API server. + // Make sure the status of the existingBD from the server is as expected. + if equality.Semantic.DeepDerivative(desiredBundleDeployment, existingBundleDeployment) { + *desiredBundleDeployment = *existingBundleDeployment + return nil + } + + return c.Patch(ctx, desiredBundleDeployment, client.Apply, client.ForceOwnership, client.FieldOwner("operator-controller")) +} + +func existingBundleDeploymentUnstructured(ctx context.Context, c client.Client, name string) (*unstructured.Unstructured, error) { + existingBundleDeployment := &rukpakv1alpha2.BundleDeployment{} + err := c.Get(ctx, types.NamespacedName{Name: name}, existingBundleDeployment) + if err != nil { + return nil, err + } + existingBundleDeployment.APIVersion = rukpakv1alpha2.GroupVersion.String() + existingBundleDeployment.Kind = rukpakv1alpha2.BundleDeploymentKind + unstrExistingBundleDeploymentObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(existingBundleDeployment) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: unstrExistingBundleDeploymentObj}, nil +} + +func mapBDStatusToInstalledCondition(existingTypedBundleDeployment *rukpakv1alpha2.BundleDeployment, ext internal.ExtensionInterface) { + bundleDeploymentReady := apimeta.FindStatusCondition(existingTypedBundleDeployment.Status.Conditions, rukpakv1alpha2.TypeInstalled) + if bundleDeploymentReady == nil { + ext.SetInstalledBundleResource("") + setInstalledStatusConditionUnknown(ext.GetConditions(), "bundledeployment status is unknown", ext.GetGeneration()) + return + } + + if bundleDeploymentReady.Status != metav1.ConditionTrue { + ext.SetInstalledBundleResource("") + setInstalledStatusConditionFailed( + ext.GetConditions(), + fmt.Sprintf("bundledeployment not ready: %s", bundleDeploymentReady.Message), + ext.GetGeneration(), + ) + return + } + + bundleDeploymentSource := existingTypedBundleDeployment.Spec.Source + switch bundleDeploymentSource.Type { + case rukpakv1alpha2.SourceTypeImage: + ext.SetInstalledBundleResource(bundleDeploymentSource.Image.Ref) + setInstalledStatusConditionSuccess( + ext.GetConditions(), + fmt.Sprintf("installed from %q", bundleDeploymentSource.Image.Ref), + ext.GetGeneration(), + ) + case rukpakv1alpha2.SourceTypeGit: + resource := bundleDeploymentSource.Git.Repository + "@" + bundleDeploymentSource.Git.Ref.Commit + ext.SetInstalledBundleResource(resource) + setInstalledStatusConditionSuccess( + ext.GetConditions(), + fmt.Sprintf("installed from %q", resource), + ext.GetGeneration(), + ) + default: + ext.SetInstalledBundleResource("") + setInstalledStatusConditionUnknown( + ext.GetConditions(), + fmt.Sprintf("unknown bundledeployment source type %q", bundleDeploymentSource.Type), + ext.GetGeneration(), + ) + } +} + +// SetDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension/Extension +// based on the provided bundle +func SetDeprecationStatus(ext internal.ExtensionInterface, bundle *catalogmetadata.Bundle) { + // reset conditions to false + conditionTypes := []string{ + ocv1alpha1.TypeDeprecated, + ocv1alpha1.TypePackageDeprecated, + ocv1alpha1.TypeChannelDeprecated, + ocv1alpha1.TypeBundleDeprecated, + } + + conditions := ext.GetConditions() + generation := ext.GetGeneration() + channel := "" + if pkg := ext.GetPackageSpec(); pkg != nil { + channel = pkg.Channel + } + + for _, conditionType := range conditionTypes { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: conditionType, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + Message: "", + ObservedGeneration: generation, + }) + } + + // There are two early return scenarios here: + // 1) The bundle is not deprecated (i.e bundle deprecations) + // AND there are no other deprecations associated with the bundle + // 2) The bundle is not deprecated, there are deprecations associated + // with the bundle (i.e at least one channel the bundle is present in is deprecated OR whole package is deprecated), + // and the ClusterExtension/Extension does not specify a channel. This is because the channel deprecations + // are a loose deprecation coupling on the bundle. A ClusterExtension/Extension installation is only + // considered deprecated by a channel deprecation when a deprecated channel is specified via + // the spec.channel field. + if (!bundle.IsDeprecated() && !bundle.HasDeprecation()) || (!bundle.IsDeprecated() && channel == "") { + return + } + + deprecationMessages := []string{} + + for _, deprecation := range bundle.Deprecations { + switch deprecation.Reference.Schema { + case declcfg.SchemaPackage: + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + Message: deprecation.Message, + ObservedGeneration: generation, + }) + case declcfg.SchemaChannel: + if channel != deprecation.Reference.Name { + continue + } + + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + Message: deprecation.Message, + ObservedGeneration: generation, + }) + case declcfg.SchemaBundle: + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + Message: deprecation.Message, + ObservedGeneration: generation, + }) + } + + deprecationMessages = append(deprecationMessages, deprecation.Message) + } + + if len(deprecationMessages) > 0 { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + Message: strings.Join(deprecationMessages, ";"), + ObservedGeneration: generation, + }) + } +} + +// mapBundleMediaTypeToBundleProvisioner maps an olm.bundle.mediatype property to a +// rukpak bundle provisioner class name that is capable of unpacking the bundle type +func mapBundleMediaTypeToBundleProvisioner(mediaType string) (string, error) { + switch mediaType { + case catalogmetadata.MediaTypePlain: + return "core-rukpak-io-plain", nil + // To ensure compatibility with bundles created with OLMv0 where the + // olm.bundle.mediatype property doesn't exist, we assume that if the + // property is empty (i.e doesn't exist) that the bundle is one created + // with OLMv0 and therefore should use the registry provisioner + case catalogmetadata.MediaTypeRegistry, "": + return "core-rukpak-io-registry", nil + default: + return "", fmt.Errorf("unknown bundle mediatype: %s", mediaType) + } +} + +// setResolvedStatusConditionSuccess sets the resolved status condition to success. +func setResolvedStatusConditionSuccess(conditions *[]metav1.Condition, message string, generation int64) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeResolved, + Status: metav1.ConditionTrue, + Reason: ocv1alpha1.ReasonSuccess, + Message: message, + ObservedGeneration: generation, + }) +} + +// setInstalledStatusConditionUnknown sets the installed status condition to unknown. +func setInstalledStatusConditionUnknown(conditions *[]metav1.Condition, message string, generation int64) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeInstalled, + Status: metav1.ConditionUnknown, + Reason: ocv1alpha1.ReasonInstallationStatusUnknown, + Message: message, + ObservedGeneration: generation, + }) +} + +// setResolvedStatusConditionFailed sets the resolved status condition to failed. +func setResolvedStatusConditionFailed(conditions *[]metav1.Condition, message string, generation int64) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeResolved, + Status: metav1.ConditionFalse, + Reason: ocv1alpha1.ReasonResolutionFailed, + Message: message, + ObservedGeneration: generation, + }) +} + +// setResolvedStatusConditionUnknown sets the resolved status condition to unknown. +func setResolvedStatusConditionUnknown(conditions *[]metav1.Condition, message string, generation int64) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeResolved, + Status: metav1.ConditionUnknown, + Reason: ocv1alpha1.ReasonResolutionUnknown, + Message: message, + ObservedGeneration: generation, + }) +} + +// setInstalledStatusConditionSuccess sets the installed status condition to success. +func setInstalledStatusConditionSuccess(conditions *[]metav1.Condition, message string, generation int64) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1alpha1.ReasonSuccess, + Message: message, + ObservedGeneration: generation, + }) +} + +// setInstalledStatusConditionFailed sets the installed status condition to failed. +func setInstalledStatusConditionFailed(conditions *[]metav1.Condition, message string, generation int64) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: ocv1alpha1.TypeInstalled, + Status: metav1.ConditionFalse, + Reason: ocv1alpha1.ReasonInstallationFailed, + Message: message, + ObservedGeneration: generation, + }) +} + +func setDeprecationStatusesUnknown(conditions *[]metav1.Condition, message string, generation int64) { + conditionTypes := []string{ + ocv1alpha1.TypeDeprecated, + ocv1alpha1.TypePackageDeprecated, + ocv1alpha1.TypeChannelDeprecated, + ocv1alpha1.TypeBundleDeprecated, + } + + for _, conditionType := range conditionTypes { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: conditionType, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionUnknown, + Message: message, + ObservedGeneration: generation, + }) + } +} diff --git a/internal/controllers/common_test.go b/internal/controllers/common_test.go new file mode 100644 index 000000000..99fbd0e5f --- /dev/null +++ b/internal/controllers/common_test.go @@ -0,0 +1,150 @@ +package controllers_test + +import ( + "encoding/json" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" + + "github.com/operator-framework/operator-controller/internal/catalogmetadata" +) + +var ( + prometheusAlphaChannel = catalogmetadata.Channel{ + Channel: declcfg.Channel{ + Name: "alpha", + Package: "prometheus", + }, + } + prometheusBetaChannel = catalogmetadata.Channel{ + Channel: declcfg.Channel{ + Name: "beta", + Package: "prometheus", + Entries: []declcfg.ChannelEntry{ + { + Name: "operatorhub/prometheus/beta/1.0.0", + }, + { + Name: "operatorhub/prometheus/beta/1.0.1", + Replaces: "operatorhub/prometheus/beta/1.0.0", + }, + { + Name: "operatorhub/prometheus/beta/1.2.0", + Replaces: "operatorhub/prometheus/beta/1.0.1", + }, + { + Name: "operatorhub/prometheus/beta/2.0.0", + Replaces: "operatorhub/prometheus/beta/1.2.0", + }, + }, + }, + } + plainBetaChannel = catalogmetadata.Channel{ + Channel: declcfg.Channel{ + Name: "beta", + Package: "plain", + }, + } + badmediaBetaChannel = catalogmetadata.Channel{ + Channel: declcfg.Channel{ + Name: "beta", + Package: "badmedia", + }, + } +) + +var testBundleList = []*catalogmetadata.Bundle{ + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/alpha/0.37.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@sha256:3e281e587de3d03011440685fc4fb782672beab044c1ebadc42788ce05a21c35", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"0.37.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusAlphaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/1.0.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.0.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/1.0.1", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.0.1", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.0.1"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/1.2.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.2.0", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.2.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/2.0.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake2.0.0", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"2.0.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/plain/0.1.0", + Package: "plain", + Image: "quay.io/operatorhub/plain@sha256:plain", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"plain","version":"0.1.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"plain+v0"`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&plainBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/badmedia/0.1.0", + Package: "badmedia", + Image: "quay.io/operatorhub/badmedia@sha256:badmedia", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"badmedia","version":"0.1.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"badmedia+v1"`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&badmediaBetaChannel}, + }, +} diff --git a/internal/controllers/extension_controller.go b/internal/controllers/extension_controller.go index 513874ec6..3e873d63c 100644 --- a/internal/controllers/extension_controller.go +++ b/internal/controllers/extension_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2022. +Copyright 2024. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,19 +18,40 @@ package controllers import ( "context" + "fmt" + "k8s.io/apimachinery/pkg/api/equality" + 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" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/go-logr/logr" + catalogd "github.com/operator-framework/catalogd/api/core/v1alpha1" + "github.com/operator-framework/deppy/pkg/deppy" + "github.com/operator-framework/deppy/pkg/deppy/solver" + rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" + "github.com/operator-framework/operator-controller/internal/catalogmetadata" + "github.com/operator-framework/operator-controller/internal/controllers/validators" + olmvariables "github.com/operator-framework/operator-controller/internal/resolution/variables" ) // ExtensionReconciler reconciles a Extension object type ExtensionReconciler struct { client.Client - Scheme *runtime.Scheme + BundleProvider BundleProvider + Scheme *runtime.Scheme + Resolver *solver.Solver } //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=extensions,verbs=get;list;watch;create;update;patch;delete @@ -39,24 +60,288 @@ type ExtensionReconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the Extension object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.1/pkg/reconcile func (r *ExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) + l := log.FromContext(ctx).WithName("extension-controller") + l.V(1).Info("starting") + defer l.V(1).Info("ending") + + var existingExt = &ocv1alpha1.Extension{} + if err := r.Client.Get(ctx, req.NamespacedName, existingExt); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + reconciledExt := existingExt.DeepCopy() + res, reconcileErr := r.reconcile(ctx, reconciledExt) + + // Do checks before any Update()s, as Update() may modify the resource structure! + updateStatus := !equality.Semantic.DeepEqual(existingExt.Status, reconciledExt.Status) + updateFinalizers := !equality.Semantic.DeepEqual(existingExt.Finalizers, reconciledExt.Finalizers) + unexpectedFieldsChanged := r.checkForUnexpectedFieldChange(*existingExt, *reconciledExt) + + if updateStatus { + if updateErr := r.Client.Status().Update(ctx, reconciledExt); updateErr != nil { + return res, utilerrors.NewAggregate([]error{reconcileErr, updateErr}) + } + } + + if unexpectedFieldsChanged { + panic("spec or metadata changed by reconciler") + } + + if updateFinalizers { + if updateErr := r.Client.Update(ctx, reconciledExt); updateErr != nil { + return res, utilerrors.NewAggregate([]error{reconcileErr, updateErr}) + } + } + + return res, reconcileErr +} + +// Compare resources - ignoring status & metadata.finalizers +func (*ExtensionReconciler) checkForUnexpectedFieldChange(a, b ocv1alpha1.Extension) bool { + a.Status, b.Status = ocv1alpha1.ExtensionStatus{}, ocv1alpha1.ExtensionStatus{} + a.Finalizers, b.Finalizers = []string{}, []string{} + return !equality.Semantic.DeepEqual(a, b) +} + +// Helper function to do the actual reconcile +// +// Today we always return ctrl.Result{} and an error. +// But in the future we might update this function +// to return different results (e.g. requeue). +// +//nolint:unparam +func (r *ExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alpha1.Extension) (ctrl.Result, error) { + // validate spec + if err := validators.ValidateSpec(ext); err != nil { + // Set the TypeInstalled condition to Unknown to indicate that the resolution + // hasn't been attempted yet, due to the spec being invalid. + ext.Status.InstalledBundleResource = "" + setInstalledStatusConditionUnknown(&ext.Status.Conditions, "installation has not been attempted as spec is invalid", ext.GetGeneration()) + // Set the TypeResolved condition to Unknown to indicate that the resolution + // hasn't been attempted yet, due to the spec being invalid. + ext.Status.ResolvedBundleResource = "" + setResolvedStatusConditionUnknown(&ext.Status.Conditions, "validation has not been attempted as spec is invalid", ext.GetGeneration()) + + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as spec is invalid", ext.GetGeneration()) + return ctrl.Result{}, nil + } + + // gather vars for resolution + vars, err := r.variables(ctx) + if err != nil { + ext.Status.InstalledBundleResource = "" + setInstalledStatusConditionUnknown(&ext.Status.Conditions, "installation has not been attempted due to failure to gather data for resolution", ext.GetGeneration()) + ext.Status.ResolvedBundleResource = "" + setResolvedStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) + + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted due to failure to gather data for resolution", ext.GetGeneration()) + return ctrl.Result{}, err + } + + // run resolution + selection, err := r.Resolver.Solve(vars) + if err != nil { + ext.Status.InstalledBundleResource = "" + setInstalledStatusConditionUnknown(&ext.Status.Conditions, "installation has not been attempted as resolution failed", ext.GetGeneration()) + ext.Status.ResolvedBundleResource = "" + setResolvedStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) + + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as resolution failed", ext.GetGeneration()) + return ctrl.Result{}, err + } + + // lookup the bundle in the solution that corresponds to the + // ClusterExtension's desired package name. + bundle, err := r.bundleFromSolution(selection, ext.Spec.Source.Package.Name) + if err != nil { + ext.Status.InstalledBundleResource = "" + setInstalledStatusConditionUnknown(&ext.Status.Conditions, "installation has not been attempted as resolution failed", ext.GetGeneration()) + ext.Status.ResolvedBundleResource = "" + setResolvedStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) - // TODO(user): your logic here + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as resolution failed", ext.GetGeneration()) + return ctrl.Result{}, err + } + // Now we can set the Resolved Condition, and the resolvedBundleSource field to the bundle.Image value. + ext.Status.ResolvedBundleResource = bundle.Image + setResolvedStatusConditionSuccess(&ext.Status.Conditions, fmt.Sprintf("resolved to %q", bundle.Image), ext.GetGeneration()) + + // TODO: Question - Should we set the deprecation statuses after we have successfully resolved instead of after a successful installation? + + mediaType, err := bundle.MediaType() + if err != nil { + setInstalledStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as installation has failed", ext.GetGeneration()) + return ctrl.Result{}, err + } + bundleProvisioner, err := mapBundleMediaTypeToBundleProvisioner(mediaType) + if err != nil { + setInstalledStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as installation has failed", ext.GetGeneration()) + return ctrl.Result{}, err + } + // Ensure a BundleDeployment exists with its bundle source from the bundle + // image we just looked up in the solution. + dep := r.GenerateExpectedBundleDeployment(*ext, bundle.Image, bundleProvisioner) + if err := ensureBundleDeployment(ctx, r.Client, dep); err != nil { + // originally Reason: ocv1alpha1.ReasonInstallationFailed + ext.Status.InstalledBundleResource = "" + setInstalledStatusConditionFailed(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as installation has failed", ext.GetGeneration()) + return ctrl.Result{}, err + } + + // convert existing unstructured object into bundleDeployment for easier mapping of status. + existingTypedBundleDeployment := &rukpakv1alpha2.BundleDeployment{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(dep.UnstructuredContent(), existingTypedBundleDeployment); err != nil { + // originally Reason: ocv1alpha1.ReasonInstallationStatusUnknown + ext.Status.InstalledBundleResource = "" + setInstalledStatusConditionUnknown(&ext.Status.Conditions, err.Error(), ext.GetGeneration()) + setDeprecationStatusesUnknown(&ext.Status.Conditions, "deprecation checks have not been attempted as installation has failed", ext.GetGeneration()) + return ctrl.Result{}, err + } + + // Let's set the proper Installed condition and InstalledBundleResource field based on the + // existing BundleDeployment object status. + mapBDStatusToInstalledCondition(existingTypedBundleDeployment, ext) + + SetDeprecationStatus(ext, bundle) + + // set the status of the cluster extension based on the respective bundle deployment status conditions. return ctrl.Result{}, nil } +func (r *ExtensionReconciler) variables(ctx context.Context) ([]deppy.Variable, error) { + allBundles, err := r.BundleProvider.Bundles(ctx) + if err != nil { + return nil, err + } + extensionList := ocv1alpha1.ExtensionList{} + if err := r.Client.List(ctx, &extensionList); err != nil { + return nil, err + } + bundleDeploymentList := rukpakv1alpha2.BundleDeploymentList{} + if err := r.Client.List(ctx, &bundleDeploymentList); err != nil { + return nil, err + } + + return GenerateVariables(allBundles, internal.ExtensionArrayToInterface(extensionList.Items), bundleDeploymentList.Items) +} + +func (r *ExtensionReconciler) bundleFromSolution(selection []deppy.Variable, packageName string) (*catalogmetadata.Bundle, error) { + for _, variable := range selection { + switch v := variable.(type) { + case *olmvariables.BundleVariable: + bundlePkgName := v.Bundle().Package + if packageName == bundlePkgName { + return v.Bundle(), nil + } + } + } + return nil, fmt.Errorf("bundle for package %q not found in solution", packageName) +} + +func (r *ExtensionReconciler) GenerateExpectedBundleDeployment(o ocv1alpha1.Extension, bundlePath string, bundleProvisioner string) *unstructured.Unstructured { + // We use unstructured here to avoid problems of serializing default values when sending patches to the apiserver. + // If you use a typed object, any default values from that struct get serialized into the JSON patch, which could + // cause unrelated fields to be patched back to the default value even though that isn't the intention. Using an + // unstructured ensures that the patch contains only what is specified. Using unstructured like this is basically + // identical to "kubectl apply -f" + + spec := map[string]interface{}{ + // TODO: Don't assume plain provisioner + "provisionerClassName": bundleProvisioner, + "source": map[string]interface{}{ + // TODO: Don't assume image type + "type": string(rukpakv1alpha2.SourceTypeImage), + "image": map[string]interface{}{ + "ref": bundlePath, + }, + }, + } + + if len(o.Spec.WatchNamespaces) > 0 { + spec["watchNamespaces"] = o.Spec.WatchNamespaces + } + + bd := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": rukpakv1alpha2.GroupVersion.String(), + "kind": rukpakv1alpha2.BundleDeploymentKind, + "metadata": map[string]interface{}{ + "name": o.GetName(), + }, + "spec": spec, + }} + // TODO: Should this be annotations? + // Will need to update extension_controller_test.go when that is done. + bd.SetOwnerReferences([]metav1.OwnerReference{ + { + APIVersion: ocv1alpha1.GroupVersion.String(), + Kind: "Extension", + Name: o.Name, + UID: o.UID, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }) + return bd +} + // SetupWithManager sets up the controller with the Manager. func (r *ExtensionReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&ocv1alpha1.Extension{}). + Watches(&catalogd.Catalog{}, + handler.EnqueueRequestsFromMapFunc(extensionRequestsForCatalog(mgr.GetClient(), mgr.GetLogger()))). + Watches(&rukpakv1alpha2.BundleDeployment{}, + handler.EnqueueRequestsFromMapFunc(extensionRequestsForBundleDeployment(mgr.GetClient(), mgr.GetLogger()))). Complete(r) } + +// Generate reconcile requests for all extensions affected by a catalog change +func extensionRequestsForCatalog(c client.Reader, logger logr.Logger) handler.MapFunc { + return func(ctx context.Context, _ client.Object) []reconcile.Request { + // no way of associating an extension to a catalog so create reconcile requests for everything + extensions := ocv1alpha1.ExtensionList{} + err := c.List(ctx, &extensions) + if err != nil { + logger.Error(err, "unable to enqueue extensions for catalog reconcile") + return nil + } + var requests []reconcile.Request + for _, ext := range extensions.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: ext.GetNamespace(), + Name: ext.GetName(), + }, + }) + } + return requests + } +} + +// Generate reconcile requests for all extensions affected by a catalog change +func extensionRequestsForBundleDeployment(c client.Reader, logger logr.Logger) handler.MapFunc { + return func(ctx context.Context, _ client.Object) []reconcile.Request { + // no way of associating an extension to a bundleDeployment so create reconcile requests for everything + extensions := ocv1alpha1.ExtensionList{} + err := c.List(ctx, &extensions) + if err != nil { + logger.Error(err, "unable to enqueue extensions for bundleDeployment reconcile") + return nil + } + var requests []reconcile.Request + for _, ext := range extensions.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: ext.GetNamespace(), + Name: ext.GetName(), + }, + }) + } + return requests + } +} diff --git a/internal/controllers/extension_controller_test.go b/internal/controllers/extension_controller_test.go new file mode 100644 index 000000000..907afba79 --- /dev/null +++ b/internal/controllers/extension_controller_test.go @@ -0,0 +1,2489 @@ +package controllers_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/operator-framework/operator-registry/alpha/declcfg" + rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apimeta "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/types" + "k8s.io/apimachinery/pkg/util/rand" + featuregatetesting "k8s.io/component-base/featuregate/testing" + "k8s.io/utils/pointer" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal/catalogmetadata" + "github.com/operator-framework/operator-controller/internal/conditionsets" + "github.com/operator-framework/operator-controller/internal/controllers" + "github.com/operator-framework/operator-controller/pkg/features" +) + +// Describe: Extension Controller Test +func TestExtensionDoesNotExist(t *testing.T) { + _, reconciler := newClientAndExtensionReconciler(t) + + t.Log("When the extension does not exist") + t.Log("It returns no error") + res, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "non-existent", Namespace: "non-existent"}}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) +} + +func TestExtensionBadResources(t *testing.T) { + cl, _ := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + badExtensions := []ocv1alpha1.Extension{ + { + ObjectMeta: metav1.ObjectMeta{Name: "no-package-name", Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "", + }, + }, + ServiceAccountName: "default", + }, + }, { + ObjectMeta: metav1.ObjectMeta{Name: "no-namespace"}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: fmt.Sprintf("non-existent-%s", rand.String(6)), + }, + }, + ServiceAccountName: "default", + }, + }, { + ObjectMeta: metav1.ObjectMeta{Name: "no-service-account", Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: fmt.Sprintf("non-existent-%s", rand.String(6)), + }, + }, + }, + }, + } + + for _, e := range badExtensions { + require.Error(t, cl.Create(ctx, &e), fmt.Sprintf("Failed on %q", e.ObjectMeta.GetName())) + } + + invalidExtensions := []ocv1alpha1.Extension{ + { + ObjectMeta: metav1.ObjectMeta{Name: "no-source", Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: "default", + }, + }, { + ObjectMeta: metav1.ObjectMeta{Name: "no-package", Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{}, + ServiceAccountName: "default", + }, + }, + } + + for _, e := range invalidExtensions { + require.NoError(t, cl.Create(ctx, &e), fmt.Sprintf("Create failed on %q", e.GetObjectMeta().GetName())) + ext := &ocv1alpha1.Extension{} + name := types.NamespacedName{Name: e.GetObjectMeta().GetName(), Namespace: e.GetObjectMeta().GetNamespace()} + + cl, reconciler := newClientAndExtensionReconciler(t) + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: name}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + require.NoError(t, cl.Get(ctx, name, ext), fmt.Sprintf("Get failed on %q", e.ObjectMeta.GetName())) + cond := apimeta.FindStatusCondition(*ext.GetConditions(), ocv1alpha1.TypeResolved) + require.NotNil(t, cond, fmt.Sprintf("Get condition failed on %q", ext.ObjectMeta.GetName())) + require.Equal(t, metav1.ConditionUnknown, cond.Status, fmt.Sprintf("Get status check failed on %q", ext.ObjectMeta.GetName())) + require.Equal(t, ocv1alpha1.ReasonResolutionUnknown, cond.Reason, fmt.Sprintf("Get status reason failed on %q", ext.ObjectMeta.GetName())) + require.Equal(t, "validation has not been attempted as spec is invalid", cond.Message) + } + + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionNonExistentPackage(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a non-existent package") + t.Log("By initializing cluster state") + pkgName := fmt.Sprintf("non-existent-%s", rand.String(6)) + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q found", pkgName)) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q found", pkgName), cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionNonExistentVersion(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a version that does not exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: "0.50.0", // this version of the package does not exist + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf(`no package %q matching version "0.50.0" found`, pkgName)) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf(`no package %q matching version "0.50.0" found`, pkgName), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionBundleDeploymentDoesNotExist(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + const pkgName = "prometheus" + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("When the BundleDeployment does not exist") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("It results in the expected BundleDeployment") + bd := &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, cl.Get(ctx, types.NamespacedName{Name: extKey.Name, Namespace: extKey.Namespace}, bd)) + require.Equal(t, "core-rukpak-io-registry", bd.Spec.ProvisionerClassName) + require.Equal(t, rukpakv1alpha2.SourceTypeImage, bd.Spec.Source.Type) + require.NotNil(t, bd.Spec.Source.Image) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", bd.Spec.Source.Image.Ref) + + t.Log("It sets the resolvedBundleResource status field") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + + t.Log("It sets the InstalledBundleResource status field") + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("It sets the status on the extension") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionBundleDeploymentOutOfDate(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + const pkgName = "prometheus" + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("When the expected BundleDeployment already exists") + t.Log("When the BundleDeployment spec is out of date") + t.Log("By patching the existing BD") + bd := &rukpakv1alpha2.BundleDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Name, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: ocv1alpha1.GroupVersion.String(), + Kind: "Extension", + Name: extension.Name, + UID: extension.UID, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Spec: rukpakv1alpha2.BundleDeploymentSpec{ + ProvisionerClassName: "core-rukpak-io-registry", + Source: rukpakv1alpha2.BundleSource{ + Type: rukpakv1alpha2.SourceTypeImage, + Image: &rukpakv1alpha2.ImageSource{ + Ref: "quay.io/operatorhubio/prometheus@fake2.0.0", + }, + }, + }, + } + + t.Log("By modifying the BD spec and creating the object") + bd.Spec.ProvisionerClassName = "core-rukpak-io-helm" + require.NoError(t, cl.Create(ctx, bd)) + + t.Log("It results in the expected BundleDeployment") + + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the expected BD spec") + bd = &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, cl.Get(ctx, types.NamespacedName{Name: extKey.Name, Namespace: extKey.Namespace}, bd)) + require.Equal(t, "core-rukpak-io-registry", bd.Spec.ProvisionerClassName) + require.Equal(t, rukpakv1alpha2.SourceTypeImage, bd.Spec.Source.Type) + require.NotNil(t, bd.Spec.Source.Image) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", bd.Spec.Source.Image.Ref) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected status conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionBundleDeploymentUpToDate(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + const pkgName = "prometheus" + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("When the expected BundleDeployment already exists") + t.Log("When the BundleDeployment spec is up-to-date") + t.Log("By patching the existing BD") + bd := &rukpakv1alpha2.BundleDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Name, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: ocv1alpha1.GroupVersion.String(), + Kind: "Extension", + Name: extension.Name, + UID: extension.UID, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Spec: rukpakv1alpha2.BundleDeploymentSpec{ + ProvisionerClassName: "core-rukpak-io-registry", + Source: rukpakv1alpha2.BundleSource{ + Type: rukpakv1alpha2.SourceTypeImage, + Image: &rukpakv1alpha2.ImageSource{ + Ref: "quay.io/operatorhubio/prometheus@fake2.0.0", + }, + }, + }, + } + + require.NoError(t, cl.Create(ctx, bd)) + bd.Status.ObservedGeneration = bd.GetGeneration() + + t.Log("When the BundleDeployment status is mapped to the expected Extension status") + t.Log("It verifies extension status when bundle deployment is waiting to be created") + t.Log("By updating the status of bundleDeployment") + require.NoError(t, cl.Status().Update(ctx, bd)) + + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated extension after reconcile") + ext := &ocv1alpha1.Extension{} + require.NoError(t, cl.Get(ctx, extKey, ext)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Empty(t, ext.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + t.Log("It verifies extension status when `Unpacked` condition of rukpak is false") + apimeta.SetStatusCondition(&bd.Status.Conditions, metav1.Condition{ + Type: rukpakv1alpha2.TypeUnpacked, + Status: metav1.ConditionFalse, + Message: "failed to unpack", + Reason: rukpakv1alpha2.ReasonUnpackFailed, + }) + + t.Log("By updating the status of bundleDeployment") + require.NoError(t, cl.Status().Update(ctx, bd)) + + t.Log("By running reconcile") + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated extension after reconcile") + ext = &ocv1alpha1.Extension{} + require.NoError(t, cl.Get(ctx, extKey, ext)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Equal(t, "", ext.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + t.Log("It verifies extension status when `InstallReady` condition of rukpak is false") + apimeta.SetStatusCondition(&bd.Status.Conditions, metav1.Condition{ + Type: rukpakv1alpha2.TypeInstalled, + Status: metav1.ConditionFalse, + Message: "failed to install", + Reason: rukpakv1alpha2.ReasonInstallFailed, + }) + + t.Log("By updating the status of bundleDeployment") + require.NoError(t, cl.Status().Update(ctx, bd)) + + t.Log("By running reconcile") + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated extension after reconcile") + ext = &ocv1alpha1.Extension{} + err = cl.Get(ctx, extKey, ext) + require.NoError(t, err) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Empty(t, ext.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) + require.Contains(t, cond.Message, `failed to install`) + + t.Log("It verifies extension status when `InstallReady` condition of rukpak is true") + apimeta.SetStatusCondition(&bd.Status.Conditions, metav1.Condition{ + Type: rukpakv1alpha2.TypeInstalled, + Status: metav1.ConditionTrue, + Message: "extension installed successfully", + Reason: rukpakv1alpha2.ReasonInstallationSucceeded, + }) + + t.Log("By updating the status of bundleDeployment") + require.NoError(t, cl.Status().Update(ctx, bd)) + + t.Log("By running reconcile") + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated extension after reconcile") + ext = &ocv1alpha1.Extension{} + require.NoError(t, cl.Get(ctx, extKey, ext)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "installed from \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + + t.Log("It verifies any other unknown status of bundledeployment") + apimeta.SetStatusCondition(&bd.Status.Conditions, metav1.Condition{ + Type: rukpakv1alpha2.TypeUnpacked, + Status: metav1.ConditionUnknown, + Message: "unpacking", + Reason: rukpakv1alpha2.ReasonUnpackSuccessful, + }) + + apimeta.SetStatusCondition(&bd.Status.Conditions, metav1.Condition{ + Type: rukpakv1alpha2.TypeInstalled, + Status: metav1.ConditionUnknown, + Message: "installing", + Reason: rukpakv1alpha2.ReasonInstallationSucceeded, + }) + + t.Log("By updating the status of bundleDeployment") + require.NoError(t, cl.Status().Update(ctx, bd)) + + t.Log("By running reconcile") + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated extension after reconcile") + ext = &ocv1alpha1.Extension{} + require.NoError(t, cl.Get(ctx, extKey, ext)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Empty(t, ext.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) + require.Equal(t, "bundledeployment not ready: installing", cond.Message) + + t.Log("It verifies extension status when bundleDeployment installation status is unknown") + apimeta.SetStatusCondition(&bd.Status.Conditions, metav1.Condition{ + Type: rukpakv1alpha2.TypeInstalled, + Status: metav1.ConditionUnknown, + Message: "installing", + Reason: rukpakv1alpha2.ReasonInstallationSucceeded, + }) + + t.Log("By updating the status of bundleDeployment") + require.NoError(t, cl.Status().Update(ctx, bd)) + + t.Log("running reconcile") + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated extension after reconcile") + ext = &ocv1alpha1.Extension{} + require.NoError(t, cl.Get(ctx, extKey, ext)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Empty(t, ext.Status.InstalledBundleResource) + + t.Log("By cchecking the expected conditions") + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) + require.Equal(t, "bundledeployment not ready: installing", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionExpectedBundleDeployment(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + const pkgName = "prometheus" + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("When an out-of-date BundleDeployment exists") + t.Log("By creating the expected BD") + bd := &rukpakv1alpha2.BundleDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: rukpakv1alpha2.BundleDeploymentSpec{ + ProvisionerClassName: "bar", + Source: rukpakv1alpha2.BundleSource{ + Type: rukpakv1alpha2.SourceTypeHTTP, + HTTP: &rukpakv1alpha2.HTTPSource{ + URL: "http://localhost:8080/", + }, + }, + }, + } + require.NoError(t, cl.Create(ctx, bd)) + + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("It results in the expected BundleDeployment") + bd = &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, cl.Get(ctx, types.NamespacedName{Name: extKey.Name, Namespace: extKey.Namespace}, bd)) + require.Equal(t, "core-rukpak-io-registry", bd.Spec.ProvisionerClassName) + require.Equal(t, rukpakv1alpha2.SourceTypeImage, bd.Spec.Source.Type) + require.NotNil(t, bd.Spec.Source.Image) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", bd.Spec.Source.Image.Ref) + + t.Log("It sets the resolvedBundleResource status field") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + + t.Log("It sets the InstalledBundleResource status field") + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("It sets resolution to unknown status") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionDuplicatePackage(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + const pkgName = "prometheus" + + t.Log("When the extension specifies a duplicate package") + t.Log("By initializing cluster state") + dupExtension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("orig-%s", extKey.Name), Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, dupExtension)) + + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, `duplicate identifier "required package prometheus" in input`) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, `duplicate identifier "required package prometheus" in input`, cond.Message) + + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted as resolution failed", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionChannelVersionExists(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a channel with version that exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "1.0.0" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + err := cl.Create(ctx, extension) + require.NoError(t, err) + + t.Log("It sets resolution success status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + t.Log("By fetching the bundled deployment") + bd := &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, cl.Get(ctx, types.NamespacedName{Name: extKey.Name, Namespace: extKey.Namespace}, bd)) + require.Equal(t, "core-rukpak-io-registry", bd.Spec.ProvisionerClassName) + require.Equal(t, rukpakv1alpha2.SourceTypeImage, bd.Spec.Source.Type) + require.NotNil(t, bd.Spec.Source.Image) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", bd.Spec.Source.Image.Ref) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionChannelExistsNoVersion(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a package that exists within a channel but no version specified") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution success status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + t.Log("By fetching the bundledeployment") + bd := &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, cl.Get(ctx, types.NamespacedName{Name: extKey.Name, Namespace: extKey.Namespace}, bd)) + require.Equal(t, "core-rukpak-io-registry", bd.Spec.ProvisionerClassName) + require.Equal(t, rukpakv1alpha2.SourceTypeImage, bd.Spec.Source.Type) + require.NotNil(t, bd.Spec.Source.Image) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", bd.Spec.Source.Image.Ref) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionVersionNoChannel(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a package version in a channel that does not exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "0.47.0" + pkgChan := "alpha" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan)) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionNoChannel(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a package in a channel that does not exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgChan := "non-existent" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q found in channel %q", pkgName, pkgChan)) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q found in channel %q", pkgName, pkgChan), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionNoVersion(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a package version that does not exist in the channel") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "0.57.0" + pkgChan := "non-existent" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan)) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted due to failure to gather data for resolution", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionPlainV0Bundle(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a package with a plain+v0 bundle") + t.Log("By initializing cluster state") + pkgName := "plain" + pkgVer := "0.1.0" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution success status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhub/plain@sha256:plain", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhub/plain@sha256:plain\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "bundledeployment status is unknown", cond.Message) + + t.Log("By fetching the bundled deployment") + bd := &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, cl.Get(ctx, types.NamespacedName{Name: extKey.Name, Namespace: extKey.Namespace}, bd)) + require.Equal(t, "core-rukpak-io-plain", bd.Spec.ProvisionerClassName) + require.Equal(t, rukpakv1alpha2.SourceTypeImage, bd.Spec.Source.Type) + require.NotNil(t, bd.Spec.Source.Image) + require.Equal(t, "quay.io/operatorhub/plain@sha256:plain", bd.Spec.Source.Image.Ref) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionBadBundleMediaType(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("When the extension specifies a package with a bad bundle mediatype") + t.Log("By initializing cluster state") + pkgName := "badmedia" + pkgVer := "0.1.0" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + require.NoError(t, cl.Create(ctx, extension)) + + t.Log("It sets resolution success status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.Error(t, err) + require.ErrorContains(t, err, "unknown bundle mediatype: badmedia+v1") + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhub/badmedia@sha256:badmedia", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhub/badmedia@sha256:badmedia\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) + require.Equal(t, "unknown bundle mediatype: badmedia+v1", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func TestExtensionInvalidSemverPastRegex(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + t.Log("When an invalid semver is provided that bypasses the regex validation") + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + t.Log("By injecting creating a client with the bad clusterextension CR") + pkgName := fmt.Sprintf("exists-%s", rand.String(6)) + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: "1.2.3-123abc_def", // bad semver that matches the regex on the CR validation + }, + }, + ServiceAccountName: "default", + }, + } + + // this bypasses client/server-side CR validation and allows us to test the reconciler's validation + fakeClient := fake.NewClientBuilder().WithScheme(sch).WithObjects(extension).WithStatusSubresource(extension).Build() + + t.Log("By changing the reconciler client to the fake client") + reconciler.Client = fakeClient + + t.Log("It should add an invalid spec condition and *not* re-enqueue for reconciliation") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, fakeClient.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionUnknown, cond.Reason) + require.Equal(t, "validation has not been attempted as spec is invalid", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted as spec is invalid", cond.Message) + + verifyExtensionInvariants(ctx, t, reconciler.Client, extension) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + require.NoError(t, cl.Delete(ctx, namespace)) +} + +func verifyExtensionInvariants(ctx context.Context, t *testing.T, c client.Client, ext *ocv1alpha1.Extension) { + key := client.ObjectKeyFromObject(ext) + require.NoError(t, c.Get(ctx, key, ext)) + + verifyExtensionConditionsInvariants(t, ext) +} + +func verifyExtensionConditionsInvariants(t *testing.T, ext *ocv1alpha1.Extension) { + // Expect that the extension's set of conditions contains all defined + // condition types for the Extension API. Every reconcile should always + // ensure every condition type's status/reason/message reflects the state + // read during _this_ reconcile call. + require.Len(t, ext.Status.Conditions, len(conditionsets.ConditionTypes)) + for _, tt := range conditionsets.ConditionTypes { + cond := apimeta.FindStatusCondition(ext.Status.Conditions, tt) + require.NotNil(t, cond) + require.NotEmpty(t, cond.Status) + require.Contains(t, conditionsets.ConditionReasons, cond.Reason) + require.Equal(t, ext.GetGeneration(), cond.ObservedGeneration) + } +} + +func TestExtensionGeneratedBundleDeployment(t *testing.T) { + test := []struct { + name string + extension ocv1alpha1.Extension + bundlePath string + bundleProvisioner string + expectedBundleDeployment *unstructured.Unstructured + }{ + { + name: "when all the specs are provided.", + extension: ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bd", + UID: types.UID("test"), + }, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: "default", + WatchNamespaces: []string{"alpha", "beta", "gamma"}, + }, + }, + bundlePath: "testpath", + bundleProvisioner: "foo", + expectedBundleDeployment: &unstructured.Unstructured{}, + }, + { + name: "when watchNamespaces are not provided.", + extension: ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bd", + UID: types.UID("test"), + }, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: "default", + }, + }, + bundlePath: "testpath", + bundleProvisioner: "foo", + expectedBundleDeployment: &unstructured.Unstructured{}, + }, + } + + for _, tt := range test { + fakeReconciler := &controllers.ExtensionReconciler{} + objUnstructured := fakeReconciler.GenerateExpectedBundleDeployment(tt.extension, tt.bundlePath, tt.bundleProvisioner) + resultBundleDeployment := &rukpakv1alpha2.BundleDeployment{} + require.NoError(t, runtime.DefaultUnstructuredConverter.FromUnstructured(objUnstructured.Object, resultBundleDeployment)) + // Verify the fields that have are being taken from extension. + require.Equal(t, tt.extension.GetName(), resultBundleDeployment.GetName()) + require.Equal(t, tt.bundlePath, resultBundleDeployment.Spec.Source.Image.Ref) + require.Equal(t, tt.bundleProvisioner, resultBundleDeployment.Spec.ProvisionerClassName) + require.Equal(t, tt.extension.Spec.WatchNamespaces, resultBundleDeployment.Spec.WatchNamespaces) + } +} + +func TestExtensionUpgrade(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + + t.Run("semver upgrade constraints enforcement of upgrades within major version", func(t *testing.T) { + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.ForceSemverUpgradeConstraints, true)() + defer func() { + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + }() + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + pkgName := "prometheus" + pkgVer := "1.0.0" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + // Create a extension + err := cl.Create(ctx, extension) + require.NoError(t, err) + + // Run reconcile + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.0.0"`, cond.Message) + + // Invalid update: can not go to the next major version + extension.Spec.Source.Package.Version = "2.0.0" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Error(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + // TODO: https://github.com/operator-framework/operator-controller/issues/320 + assert.Equal(t, "", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + assert.Contains(t, cond.Message, "constraints not satisfiable") + assert.Regexp(t, "installed package prometheus requires at least one of fake-catalog-prometheus-operatorhub/prometheus/beta/1.2.0, fake-catalog-prometheus-operatorhub/prometheus/beta/1.0.1, fake-catalog-prometheus-operatorhub/prometheus/beta/1.0.0$", cond.Message) + + // Valid update skipping one version + extension.Spec.Source.Package.Version = "1.2.0" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.2.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.2.0"`, cond.Message) + }) + + t.Run("legacy semantics upgrade constraints enforcement", func(t *testing.T) { + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.ForceSemverUpgradeConstraints, false)() + defer func() { + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + }() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + pkgName := "prometheus" + pkgVer := "1.0.0" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }, + ServiceAccountName: "default", + }, + } + // Create a extension + err := cl.Create(ctx, extension) + require.NoError(t, err) + + // Run reconcile + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.0.0"`, cond.Message) + + // Invalid update: can not upgrade by skipping a version in the replaces chain + extension.Spec.Source.Package.Version = "1.2.0" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Error(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + // TODO: https://github.com/operator-framework/operator-controller/issues/320 + assert.Equal(t, "", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + assert.Contains(t, cond.Message, "constraints not satisfiable") + assert.Contains(t, cond.Message, "installed package prometheus requires at least one of fake-catalog-prometheus-operatorhub/prometheus/beta/1.0.1, fake-catalog-prometheus-operatorhub/prometheus/beta/1.0.0\n") + + // Valid update skipping one version + extension.Spec.Source.Package.Version = "1.0.1" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.1", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.0.1"`, cond.Message) + }) + + t.Run("ignore upgrade constraints", func(t *testing.T) { + for _, tt := range []struct { + name string + flagState bool + }{ + { + name: "ForceSemverUpgradeConstraints feature gate enabled", + flagState: true, + }, + { + name: "ForceSemverUpgradeConstraints feature gate disabled", + flagState: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.ForceSemverUpgradeConstraints, tt.flagState)() + defer func() { + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + }() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "prometheus", + Version: "1.0.0", + Channel: "beta", + UpgradeConstraintPolicy: ocv1alpha1.UpgradeConstraintPolicyIgnore, + }, + }, + ServiceAccountName: "default", + }, + } + // Create a extension + err := cl.Create(ctx, extension) + require.NoError(t, err) + + // Run reconcile + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.0.0"`, cond.Message) + + // We can go to the next major version when using semver + // as well as to the version which is not next in the channel + // when using legacy constraints + extension.Spec.Source.Package.Version = "2.0.0" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake2.0.0"`, cond.Message) + }) + } + }) +} + +func TestExtensionDowngrade(t *testing.T) { + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + + t.Run("enforce upgrade constraints", func(t *testing.T) { + for _, tt := range []struct { + name string + flagState bool + }{ + { + name: "ForceSemverUpgradeConstraints feature gate enabled", + flagState: true, + }, + { + name: "ForceSemverUpgradeConstraints feature gate disabled", + flagState: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.ForceSemverUpgradeConstraints, tt.flagState)() + defer func() { + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + }() + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "prometheus", + Version: "1.0.1", + Channel: "beta", + }, + }, + ServiceAccountName: "default", + }, + } + // Create a extension + require.NoError(t, cl.Create(ctx, extension)) + + // Run reconcile + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.1", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.0.1"`, cond.Message) + + // Invalid operation: can not downgrade + extension.Spec.Source.Package.Version = "1.0.0" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Error(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + // TODO: https://github.com/operator-framework/operator-controller/issues/320 + assert.Equal(t, "", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + assert.Contains(t, cond.Message, "constraints not satisfiable") + assert.Contains(t, cond.Message, "installed package prometheus requires at least one of fake-catalog-prometheus-operatorhub/prometheus/beta/1.2.0, fake-catalog-prometheus-operatorhub/prometheus/beta/1.0.1\n") + }) + } + }) + + t.Run("ignore upgrade constraints", func(t *testing.T) { + for _, tt := range []struct { + name string + flagState bool + }{ + { + name: "ForceSemverUpgradeConstraints feature gate enabled", + flagState: true, + }, + { + name: "ForceSemverUpgradeConstraints feature gate disabled", + flagState: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + extKey := types.NamespacedName{ + Name: fmt.Sprintf("extension-test-%s", rand.String(8)), + Namespace: fmt.Sprintf("namespace-%s", rand.String(8)), + } + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.ForceSemverUpgradeConstraints, tt.flagState)() + defer func() { + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.Extension{}, client.InNamespace(extKey.Namespace))) + require.NoError(t, cl.DeleteAllOf(ctx, &rukpakv1alpha2.BundleDeployment{})) + }() + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: extKey.Namespace, + }, + } + require.NoError(t, cl.Create(ctx, namespace)) + + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "prometheus", + Version: "2.0.0", + Channel: "beta", + UpgradeConstraintPolicy: ocv1alpha1.UpgradeConstraintPolicyIgnore, + }, + }, + ServiceAccountName: "default", + }, + } + // Create a extension + err := cl.Create(ctx, extension) + require.NoError(t, err) + + // Run reconcile + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake2.0.0"`, cond.Message) + + // We downgrade + extension.Spec.Source.Package.Version = "1.0.0" + err = cl.Update(ctx, extension) + require.NoError(t, err) + + // Run reconcile again + res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, res) + + // Refresh the extension after reconcile + err = cl.Get(ctx, extKey, extension) + require.NoError(t, err) + + // Checking the status fields + assert.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", extension.Status.ResolvedBundleResource) + + // checking the expected conditions + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Equal(t, `resolved to "quay.io/operatorhubio/prometheus@fake1.0.0"`, cond.Message) + }) + } + }) +} + +func TestExtensionSetDeprecationStatus(t *testing.T) { + for _, tc := range []struct { + name string + extension *ocv1alpha1.Extension + expectedExtension *ocv1alpha1.Extension + bundle *catalogmetadata.Bundle + }{ + { + name: "non-deprecated bundle, no deprecations associated with bundle, all deprecation statuses set to False", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{}, + }, + { + name: "non-deprecated bundle, olm.channel deprecations associated with bundle, no channel specified, all deprecation statuses set to False", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{ + Deprecations: []declcfg.DeprecationEntry{ + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaChannel, + Name: "badchannel", + }, + }, + }, + }, + }, + { + name: "non-deprecated bundle, olm.channel deprecations associated with bundle, non-deprecated channel specified, all deprecation statuses set to False", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "nondeprecated", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "nondeprecated", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{ + Deprecations: []declcfg.DeprecationEntry{ + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaChannel, + Name: "badchannel", + }, + }, + }, + }, + }, + { + name: "non-deprecated bundle, olm.channel deprecations associated with bundle, deprecated channel specified, ChannelDeprecated and Deprecated status set to true", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{ + Deprecations: []declcfg.DeprecationEntry{ + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaChannel, + Name: "badchannel", + }, + Message: "bad channel!", + }, + }, + }, + }, + { + name: "deprecated package + bundle, olm.channel deprecations associated with bundle, deprecated channel specified, all deprecation statuses set to true", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{ + Deprecations: []declcfg.DeprecationEntry{ + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaChannel, + Name: "badchannel", + }, + Message: "bad channel!", + }, + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaPackage, + }, + Message: "bad package!", + }, + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaBundle, + Name: "badbundle", + }, + Message: "bad bundle!", + }, + }, + }, + }, + { + name: "deprecated bundle, olm.channel deprecations associated with bundle, deprecated channel specified, all deprecation statuses set to true except PackageDeprecated", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{ + Deprecations: []declcfg.DeprecationEntry{ + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaChannel, + Name: "badchannel", + }, + Message: "bad channel!", + }, + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaBundle, + Name: "badbundle", + }, + Message: "bad bundle!", + }, + }, + }, + }, + { + name: "deprecated package, olm.channel deprecations associated with bundle, deprecated channel specified, all deprecation statuses set to true except BundleDeprecated", + extension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedExtension: &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Channel: "badchannel", + }, + }, + }, + Status: ocv1alpha1.ExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1alpha1.TypeDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypePackageDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeChannelDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }, + { + Type: ocv1alpha1.TypeBundleDeprecated, + Reason: ocv1alpha1.ReasonDeprecated, + Status: metav1.ConditionFalse, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &catalogmetadata.Bundle{ + Deprecations: []declcfg.DeprecationEntry{ + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaChannel, + Name: "badchannel", + }, + Message: "bad channel!", + }, + { + Reference: declcfg.PackageScopedReference{ + Schema: declcfg.SchemaPackage, + }, + Message: "bad package!", + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + controllers.SetDeprecationStatus(tc.extension, tc.bundle) + assert.Equal(t, "", cmp.Diff(tc.expectedExtension, tc.extension, cmpopts.IgnoreFields(metav1.Condition{}, "Message", "LastTransitionTime"))) + }) + } +} diff --git a/internal/controllers/suite_test.go b/internal/controllers/suite_test.go index 8ee4abcd3..6ff53175c 100644 --- a/internal/controllers/suite_test.go +++ b/internal/controllers/suite_test.go @@ -22,6 +22,7 @@ import ( "path/filepath" "testing" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/rest" @@ -44,7 +45,7 @@ func newClient(t *testing.T) client.Client { return cl } -func newClientAndReconciler(t *testing.T) (client.Client, *controllers.ClusterExtensionReconciler) { +func newClientAndClusterExtensionReconciler(t *testing.T) (client.Client, *controllers.ClusterExtensionReconciler) { resolver, err := solver.New() require.NoError(t, err) @@ -59,6 +60,21 @@ func newClientAndReconciler(t *testing.T) (client.Client, *controllers.ClusterEx return cl, reconciler } +func newClientAndExtensionReconciler(t *testing.T) (client.Client, *controllers.ExtensionReconciler) { + resolver, err := solver.New() + require.NoError(t, err) + + cl := newClient(t) + fakeCatalogClient := testutil.NewFakeCatalogClient(testBundleList) + reconciler := &controllers.ExtensionReconciler{ + Client: cl, + BundleProvider: &fakeCatalogClient, + Scheme: sch, + Resolver: resolver, + } + return cl, reconciler +} + var ( sch *runtime.Scheme cfg *rest.Config @@ -82,6 +98,7 @@ func TestMain(m *testing.M) { sch = runtime.NewScheme() utilruntime.Must(ocv1alpha1.AddToScheme(sch)) utilruntime.Must(rukpakv1alpha2.AddToScheme(sch)) + utilruntime.Must(corev1.AddToScheme(sch)) code := m.Run() utilruntime.Must(testEnv.Stop()) diff --git a/internal/controllers/validators/validators.go b/internal/controllers/validators/validators.go index 4a64cd7f7..a340bf5bf 100644 --- a/internal/controllers/validators/validators.go +++ b/internal/controllers/validators/validators.go @@ -5,37 +5,52 @@ import ( mmsemver "github.com/Masterminds/semver/v3" - ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" ) -type clusterExtensionCRValidatorFunc func(clusterExtension *ocv1alpha1.ClusterExtension) error +type extensionCRValidatorFunc func(e internal.ExtensionInterface) error -// validateSemver validates that the clusterExtension's version is a valid SemVer. +// validatePackageSemver validates that the clusterExtension's version is a valid SemVer. // this validation should already be happening at the CRD level. But, it depends // on a regex that could possibly fail to validate a valid SemVer. This is added as an // extra measure to ensure a valid spec before the CR is processed for resolution -func validateSemver(clusterExtension *ocv1alpha1.ClusterExtension) error { - if clusterExtension.Spec.Version == "" { +func validatePackageSemver(e internal.ExtensionInterface) error { + pkg := e.GetPackageSpec() + if pkg == nil { return nil } - if _, err := mmsemver.NewConstraint(clusterExtension.Spec.Version); err != nil { - return fmt.Errorf("invalid .spec.version: %w", err) + if pkg.Version == "" { + return nil + } + if _, err := mmsemver.NewConstraint(pkg.Version); err != nil { + return fmt.Errorf("invalid package version spec: %w", err) + } + return nil +} + +// validatePackageOrDirect validates that one or the other exists +// TODO: For now, this just makes sure there's a Package, as Direct has not been defined +func validatePackageOrDirect(e internal.ExtensionInterface) error { + pkg := e.GetPackageSpec() + if pkg == nil { + return fmt.Errorf("package not found") } return nil } -// ValidateClusterExtensionSpec validates the clusterExtension spec, e.g. ensuring that .spec.version, if provided, is a valid SemVer -func ValidateClusterExtensionSpec(clusterExtension *ocv1alpha1.ClusterExtension) error { - validators := []clusterExtensionCRValidatorFunc{ - validateSemver, +// ValidateSpec validates the (cluster)Extension spec, e.g. ensuring that .spec.source.package.version, if provided, is a valid SemVer +func ValidateSpec(e internal.ExtensionInterface) error { + validators := []extensionCRValidatorFunc{ + validatePackageSemver, + validatePackageOrDirect, } - // TODO: currently we only have a single validator, but more will likely be added in the future + // TODO: currently we only have a two validators, but more will likely be added in the future // we need to make a decision on whether we want to run all validators or stop at the first error. If the the former, // we should consider how to present this to the user in a way that is easy to understand and fix. // this issue is tracked here: https://github.com/operator-framework/operator-controller/issues/167 for _, validator := range validators { - if err := validator(clusterExtension); err != nil { + if err := validator(e); err != nil { return err } } diff --git a/internal/controllers/validators/validators_test.go b/internal/controllers/validators/validators_test.go index de1c9c6e1..06144114c 100644 --- a/internal/controllers/validators/validators_test.go +++ b/internal/controllers/validators/validators_test.go @@ -86,9 +86,9 @@ func TestValidateClusterExtensionSpecSemVer(t *testing.T) { }, } if d.result { - require.NoError(t, validators.ValidateClusterExtensionSpec(clusterExtension)) + require.NoError(t, validators.ValidateSpec(clusterExtension)) } else { - require.Error(t, validators.ValidateClusterExtensionSpec(clusterExtension)) + require.Error(t, validators.ValidateSpec(clusterExtension)) } }) } diff --git a/internal/controllers/variables.go b/internal/controllers/variables.go index d03f8bcae..6debc1717 100644 --- a/internal/controllers/variables.go +++ b/internal/controllers/variables.go @@ -20,12 +20,12 @@ import ( "github.com/operator-framework/deppy/pkg/deppy" rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" - ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" "github.com/operator-framework/operator-controller/internal/resolution/variablesources" ) -func GenerateVariables(allBundles []*catalogmetadata.Bundle, clusterExtensions []ocv1alpha1.ClusterExtension, bundleDeployments []rukpakv1alpha2.BundleDeployment) ([]deppy.Variable, error) { +func GenerateVariables(allBundles []*catalogmetadata.Bundle, clusterExtensions []internal.ExtensionInterface, bundleDeployments []rukpakv1alpha2.BundleDeployment) ([]deppy.Variable, error) { requiredPackages, err := variablesources.MakeRequiredPackageVariables(allBundles, clusterExtensions) if err != nil { return nil, err diff --git a/internal/controllers/variables_test.go b/internal/controllers/variables_test.go index fa3cc1315..f27556573 100644 --- a/internal/controllers/variables_test.go +++ b/internal/controllers/variables_test.go @@ -21,6 +21,7 @@ import ( "k8s.io/utils/pointer" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" "github.com/operator-framework/operator-controller/internal/controllers" olmvariables "github.com/operator-framework/operator-controller/internal/resolution/variables" @@ -94,7 +95,7 @@ func TestVariableSource(t *testing.T) { }, } - vars, err := controllers.GenerateVariables(allBundles, []ocv1alpha1.ClusterExtension{clusterExtension}, []rukpakv1alpha2.BundleDeployment{bd}) + vars, err := controllers.GenerateVariables(allBundles, []internal.ExtensionInterface{&clusterExtension}, []rukpakv1alpha2.BundleDeployment{bd}) require.NoError(t, err) expectedVars := []deppy.Variable{ diff --git a/internal/internal.go b/internal/internal.go new file mode 100644 index 000000000..c19e6eb0a --- /dev/null +++ b/internal/internal.go @@ -0,0 +1,49 @@ +/* +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 internal + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" +) + +type ExtensionInterface interface { + GetPackageSpec() *ocv1alpha1.ExtensionSourcePackage + GetUpgradeConstraintPolicy() ocv1alpha1.UpgradeConstraintPolicy + GetConditions() *[]metav1.Condition + GetGeneration() int64 + GetUID() types.UID + SetInstalledBundleResource(string) +} + +func ExtensionArrayToInterface(in []ocv1alpha1.Extension) []ExtensionInterface { + ei := make([]ExtensionInterface, len(in)) + for i := range in { + ei[i] = &in[i] + } + return ei +} + +func ClusterExtensionArrayToInterface(in []ocv1alpha1.ClusterExtension) []ExtensionInterface { + ei := make([]ExtensionInterface, len(in)) + for i := range in { + ei[i] = &in[i] + } + return ei +} diff --git a/internal/resolution/variablesources/installed_package.go b/internal/resolution/variablesources/installed_package.go index 339bded3b..4829ae7b8 100644 --- a/internal/resolution/variablesources/installed_package.go +++ b/internal/resolution/variablesources/installed_package.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" catalogfilter "github.com/operator-framework/operator-controller/internal/catalogmetadata/filter" catalogsort "github.com/operator-framework/operator-controller/internal/catalogmetadata/sort" @@ -23,7 +24,7 @@ import ( // has own variable. func MakeInstalledPackageVariables( allBundles []*catalogmetadata.Bundle, - clusterExtensions []ocv1alpha1.ClusterExtension, + clusterExtensions []internal.ExtensionInterface, bundleDeployments []rukpakv1alpha2.BundleDeployment, ) ([]*olmvariables.InstalledPackageVariable, error) { var successors successorsFunc = legacySemanticsSuccessors @@ -36,11 +37,15 @@ func MakeInstalledPackageVariables( result := make([]*olmvariables.InstalledPackageVariable, 0, len(clusterExtensions)) processed := sets.Set[string]{} for _, clusterExtension := range clusterExtensions { - if clusterExtension.Spec.UpgradeConstraintPolicy == ocv1alpha1.UpgradeConstraintPolicyIgnore { + if clusterExtension.GetUpgradeConstraintPolicy() == ocv1alpha1.UpgradeConstraintPolicyIgnore { + continue + } + pkg := clusterExtension.GetPackageSpec() + if pkg == nil { continue } - bundleDeployment, ok := ownerIDToBundleDeployment[clusterExtension.UID] + bundleDeployment, ok := ownerIDToBundleDeployment[clusterExtension.GetUID()] if !ok { // This can happen when an ClusterExtension is requested, // but not yet installed (e.g. no BundleDeployment created for it) @@ -61,11 +66,11 @@ func MakeInstalledPackageVariables( // find corresponding bundle for the installed content resultSet := catalogfilter.Filter(allBundles, catalogfilter.And( - catalogfilter.WithPackageName(clusterExtension.Spec.PackageName), + catalogfilter.WithPackageName(pkg.Name), catalogfilter.WithBundleImage(bundleImage), )) if len(resultSet) == 0 { - return nil, fmt.Errorf("bundle with image %q for package %q not found in available catalogs but is currently installed via BundleDeployment %q", bundleImage, clusterExtension.Spec.PackageName, bundleDeployment.Name) + return nil, fmt.Errorf("bundle with image %q for package %q not found in available catalogs but is currently installed via BundleDeployment %q", bundleImage, pkg.Name, bundleDeployment.Name) } sort.SliceStable(resultSet, func(i, j int) bool { diff --git a/internal/resolution/variablesources/installed_package_test.go b/internal/resolution/variablesources/installed_package_test.go index 8dec706ee..8ac16e3d2 100644 --- a/internal/resolution/variablesources/installed_package_test.go +++ b/internal/resolution/variablesources/installed_package_test.go @@ -16,6 +16,7 @@ import ( featuregatetesting "k8s.io/component-base/featuregate/testing" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" olmvariables "github.com/operator-framework/operator-controller/internal/resolution/variables" "github.com/operator-framework/operator-controller/internal/resolution/variablesources" @@ -256,7 +257,7 @@ func TestMakeInstalledPackageVariablesWithForceSemverUpgradeConstraintsEnabled(t installedPackages, err := variablesources.MakeInstalledPackageVariables( allBundles, - []ocv1alpha1.ClusterExtension{fakeOwnerClusterExtension}, + []internal.ExtensionInterface{&fakeOwnerClusterExtension}, bundleDeployments, ) if tt.expectedError == "" { @@ -419,7 +420,7 @@ func TestMakeInstalledPackageVariablesWithForceSemverUpgradeConstraintsDisabled( installedPackages, err := variablesources.MakeInstalledPackageVariables( allBundles, - []ocv1alpha1.ClusterExtension{fakeOwnerClusterExtension}, + []internal.ExtensionInterface{&fakeOwnerClusterExtension}, bundleDeployments, ) if tt.expectedError == "" { diff --git a/internal/resolution/variablesources/required_package.go b/internal/resolution/variablesources/required_package.go index 2f23a6062..2adb6e98f 100644 --- a/internal/resolution/variablesources/required_package.go +++ b/internal/resolution/variablesources/required_package.go @@ -6,7 +6,7 @@ import ( mmsemver "github.com/Masterminds/semver/v3" - ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" catalogfilter "github.com/operator-framework/operator-controller/internal/catalogmetadata/filter" catalogsort "github.com/operator-framework/operator-controller/internal/catalogmetadata/sort" @@ -16,13 +16,17 @@ import ( // MakeRequiredPackageVariables returns a variable which represent // explicit requirement for a package from an user. // This is when a user explicitly asks "install this" via ClusterExtension API. -func MakeRequiredPackageVariables(allBundles []*catalogmetadata.Bundle, clusterExtensions []ocv1alpha1.ClusterExtension) ([]*olmvariables.RequiredPackageVariable, error) { +func MakeRequiredPackageVariables(allBundles []*catalogmetadata.Bundle, clusterExtensions []internal.ExtensionInterface) ([]*olmvariables.RequiredPackageVariable, error) { result := make([]*olmvariables.RequiredPackageVariable, 0, len(clusterExtensions)) for _, clusterExtension := range clusterExtensions { - packageName := clusterExtension.Spec.PackageName - channelName := clusterExtension.Spec.Channel - versionRange := clusterExtension.Spec.Version + pkg := clusterExtension.GetPackageSpec() + if pkg == nil { + return nil, fmt.Errorf("no package specified") + } + packageName := pkg.Name + channelName := pkg.Channel + versionRange := pkg.Version predicates := []catalogfilter.Predicate[catalogmetadata.Bundle]{ catalogfilter.WithPackageName(packageName), diff --git a/internal/resolution/variablesources/required_package_test.go b/internal/resolution/variablesources/required_package_test.go index 164f9a411..3a3791518 100644 --- a/internal/resolution/variablesources/required_package_test.go +++ b/internal/resolution/variablesources/required_package_test.go @@ -17,6 +17,7 @@ import ( "github.com/operator-framework/operator-registry/alpha/property" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal" "github.com/operator-framework/operator-controller/internal/catalogmetadata" olmvariables "github.com/operator-framework/operator-controller/internal/resolution/variables" "github.com/operator-framework/operator-controller/internal/resolution/variablesources" @@ -233,7 +234,7 @@ func TestMakeRequiredPackageVariables(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - vars, err := variablesources.MakeRequiredPackageVariables(allBundles, tt.clusterExtensions) + vars, err := variablesources.MakeRequiredPackageVariables(allBundles, internal.ClusterExtensionArrayToInterface(tt.clusterExtensions)) if tt.expectedError == "" { assert.NoError(t, err) } else { diff --git a/test/e2e/install_test.go b/test/e2e/cluster_extension_install_test.go similarity index 66% rename from test/e2e/install_test.go rename to test/e2e/cluster_extension_install_test.go index 37502deb5..32cdcc6be 100644 --- a/test/e2e/install_test.go +++ b/test/e2e/cluster_extension_install_test.go @@ -3,45 +3,30 @@ package e2e import ( "context" "fmt" - "io" "os" - "path/filepath" - "strings" "testing" - "time" catalogd "github.com/operator-framework/catalogd/api/core/v1alpha1" rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/rand" - kubeclient "k8s.io/client-go/kubernetes" - "k8s.io/utils/env" - "sigs.k8s.io/controller-runtime/pkg/client" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" ) -const ( - artifactName = "operator-controller-e2e" -) - -var pollDuration = time.Minute -var pollInterval = time.Second +func testClusterExtensionInit(t *testing.T) (*ocv1alpha1.ClusterExtension, string, *catalogd.Catalog) { + str := rand.String(8) + clusterExtensionName := fmt.Sprintf("clusterextension-%s", str) + catalogName := "test-catalog" -func testInit(t *testing.T) (*ocv1alpha1.ClusterExtension, string, *catalogd.Catalog) { - var err error - extensionCatalog, err := createTestCatalog(context.Background(), testCatalogName, os.Getenv(testCatalogRefEnvVar)) + extensionCatalog, err := createTestCatalog(context.Background(), catalogName, os.Getenv(testCatalogRefEnvVar)) require.NoError(t, err) - clusterExtensionName := fmt.Sprintf("clusterextension-%s", rand.String(8)) clusterExtension := &ocv1alpha1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: clusterExtensionName, @@ -50,7 +35,7 @@ func testInit(t *testing.T) (*ocv1alpha1.ClusterExtension, string, *catalogd.Cat return clusterExtension, clusterExtensionName, extensionCatalog } -func testCleanup(t *testing.T, cat *catalogd.Catalog, clusterExtension *ocv1alpha1.ClusterExtension) { +func testClusterExtensionCleanup(t *testing.T, cat *catalogd.Catalog, clusterExtension *ocv1alpha1.ClusterExtension) { require.NoError(t, c.Delete(context.Background(), cat)) require.Eventually(t, func() bool { err := c.Get(context.Background(), types.NamespacedName{Name: cat.Name}, &catalogd.Catalog{}) @@ -67,8 +52,8 @@ func TestClusterExtensionInstallRegistry(t *testing.T) { t.Log("When a cluster extension is installed from a catalog") t.Log("When the extension bundle format is registry+v1") - clusterExtension, clusterExtensionName, extensionCatalog := testInit(t) - defer testCleanup(t, extensionCatalog, clusterExtension) + clusterExtension, clusterExtensionName, extensionCatalog := testClusterExtensionInit(t) + defer testClusterExtensionCleanup(t, extensionCatalog, clusterExtension) defer getArtifactsOutput(t) clusterExtension.Spec = ocv1alpha1.ClusterExtensionSpec{ @@ -123,8 +108,8 @@ func TestClusterExtensionInstallPlain(t *testing.T) { t.Log("When a cluster extension is installed from a catalog") t.Log("When the cluster extension bundle format is plain+v0") - clusterExtension, clusterExtensionName, extensionCatalog := testInit(t) - defer testCleanup(t, extensionCatalog, clusterExtension) + clusterExtension, clusterExtensionName, extensionCatalog := testClusterExtensionInit(t) + defer testClusterExtensionCleanup(t, extensionCatalog, clusterExtension) defer getArtifactsOutput(t) clusterExtension.Spec = ocv1alpha1.ClusterExtensionSpec{ @@ -179,8 +164,8 @@ func TestClusterExtensionInstallReResolvesWhenNewCatalog(t *testing.T) { t.Log("When a cluster extension is installed from a catalog") t.Log("It resolves again when a new catalog is available") - clusterExtension, _, extensionCatalog := testInit(t) - defer testCleanup(t, extensionCatalog, clusterExtension) + clusterExtension, _, extensionCatalog := testClusterExtensionInit(t) + defer testClusterExtensionCleanup(t, extensionCatalog, clusterExtension) defer getArtifactsOutput(t) pkgName := "prometheus" @@ -212,7 +197,7 @@ func TestClusterExtensionInstallReResolvesWhenNewCatalog(t *testing.T) { t.Log("By creating an ClusterExtension catalog with the desired package") var err error - extensionCatalog, err = createTestCatalog(context.Background(), testCatalogName, os.Getenv(testCatalogRefEnvVar)) + extensionCatalog, err = createTestCatalog(context.Background(), extensionCatalog.Name, os.Getenv(testCatalogRefEnvVar)) require.NoError(t, err) require.EventuallyWithT(t, func(ct *assert.CollectT) { assert.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: extensionCatalog.Name}, extensionCatalog)) @@ -240,8 +225,8 @@ func TestClusterExtensionInstallNonSuccessorVersion(t *testing.T) { t.Log("When a cluster extension is installed from a catalog") t.Log("When resolving upgrade edges") - clusterExtension, _, extensionCatalog := testInit(t) - defer testCleanup(t, extensionCatalog, clusterExtension) + clusterExtension, _, extensionCatalog := testClusterExtensionInit(t) + defer testClusterExtensionCleanup(t, extensionCatalog, clusterExtension) defer getArtifactsOutput(t) t.Log("By creating an ClusterExtension at a specified version") @@ -284,8 +269,8 @@ func TestClusterExtensionInstallNonSuccessorVersion(t *testing.T) { func TestClusterExtensionInstallSuccessorVersion(t *testing.T) { t.Log("When a cluster extension is installed from a catalog") t.Log("When resolving upgrade edges") - clusterExtension, _, extensionCatalog := testInit(t) - defer testCleanup(t, extensionCatalog, clusterExtension) + clusterExtension, _, extensionCatalog := testClusterExtensionInit(t) + defer testClusterExtensionCleanup(t, extensionCatalog, clusterExtension) defer getArtifactsOutput(t) t.Log("By creating an ClusterExtension at a specified version") @@ -323,149 +308,3 @@ func TestClusterExtensionInstallSuccessorVersion(t *testing.T) { assert.Equal(ct, "localhost/testdata/bundles/registry-v1/prometheus-operator:v1.2.0", clusterExtension.Status.ResolvedBundleResource) }, pollDuration, pollInterval) } - -// getArtifactsOutput gets all the artifacts from the test run and saves them to the artifact path. -// Currently it saves: -// - clusterextensions -// - pods logs -// - deployments -// - bundledeployments -// - catalogsources -func getArtifactsOutput(t *testing.T) { - basePath := env.GetString("ARTIFACT_PATH", "") - if basePath == "" { - return - } - - kubeClient, err := kubeclient.NewForConfig(cfg) - require.NoError(t, err) - - // sanitize the artifact name for use as a directory name - testName := strings.ReplaceAll(strings.ToLower(t.Name()), " ", "-") - // Get the test description and sanitize it for use as a directory name - artifactPath := filepath.Join(basePath, artifactName, fmt.Sprint(time.Now().UnixNano()), testName) - - // Create the full artifact path - err = os.MkdirAll(artifactPath, 0755) - require.NoError(t, err) - - // Get all namespaces - namespaces := corev1.NamespaceList{} - if err := c.List(context.Background(), &namespaces); err != nil { - fmt.Printf("Failed to list namespaces: %v", err) - } - - // get all cluster extensions save them to the artifact path. - clusterExtensions := ocv1alpha1.ClusterExtensionList{} - if err := c.List(context.Background(), &clusterExtensions, client.InNamespace("")); err != nil { - fmt.Printf("Failed to list cluster extensions: %v", err) - } - for _, clusterExtension := range clusterExtensions.Items { - // Save cluster extension to artifact path - clusterExtensionYaml, err := yaml.Marshal(clusterExtension) - if err != nil { - fmt.Printf("Failed to marshal cluster extension: %v", err) - continue - } - if err := os.WriteFile(filepath.Join(artifactPath, clusterExtension.Name+"-clusterextension.yaml"), clusterExtensionYaml, 0600); err != nil { - fmt.Printf("Failed to write cluster extension to file: %v", err) - } - } - - // get all catalogsources save them to the artifact path. - catalogsources := catalogd.CatalogList{} - if err := c.List(context.Background(), &catalogsources, client.InNamespace("")); err != nil { - fmt.Printf("Failed to list catalogsources: %v", err) - } - for _, catalogsource := range catalogsources.Items { - // Save catalogsource to artifact path - catalogsourceYaml, err := yaml.Marshal(catalogsource) - if err != nil { - fmt.Printf("Failed to marshal catalogsource: %v", err) - continue - } - if err := os.WriteFile(filepath.Join(artifactPath, catalogsource.Name+"-catalogsource.yaml"), catalogsourceYaml, 0600); err != nil { - fmt.Printf("Failed to write catalogsource to file: %v", err) - } - } - - // Get all BundleDeployments in the namespace and save them to the artifact path. - bundleDeployments := rukpakv1alpha2.BundleDeploymentList{} - if err := c.List(context.Background(), &bundleDeployments, client.InNamespace("")); err != nil { - fmt.Printf("Failed to list bundleDeployments: %v", err) - } - for _, bundleDeployment := range bundleDeployments.Items { - // Save bundleDeployment to artifact path - bundleDeploymentYaml, err := yaml.Marshal(bundleDeployment) - if err != nil { - fmt.Printf("Failed to marshal bundleDeployment: %v", err) - continue - } - if err := os.WriteFile(filepath.Join(artifactPath, bundleDeployment.Name+"-bundleDeployment.yaml"), bundleDeploymentYaml, 0600); err != nil { - fmt.Printf("Failed to write bundleDeployment to file: %v", err) - } - } - - for _, namespace := range namespaces.Items { - // let's ignore kube-* namespaces. - if strings.Contains(namespace.Name, "kube-") { - continue - } - - namespacedArtifactPath := filepath.Join(artifactPath, namespace.Name) - if err := os.Mkdir(namespacedArtifactPath, 0755); err != nil { - fmt.Printf("Failed to create namespaced artifact path: %v", err) - continue - } - - // get all deployments in the namespace and save them to the artifact path. - deployments := appsv1.DeploymentList{} - if err := c.List(context.Background(), &deployments, client.InNamespace(namespace.Name)); err != nil { - fmt.Printf("Failed to list deployments %v in namespace: %q", err, namespace.Name) - continue - } - - for _, deployment := range deployments.Items { - // Save deployment to artifact path - deploymentYaml, err := yaml.Marshal(deployment) - if err != nil { - fmt.Printf("Failed to marshal deployment: %v", err) - continue - } - if err := os.WriteFile(filepath.Join(namespacedArtifactPath, deployment.Name+"-deployment.yaml"), deploymentYaml, 0600); err != nil { - fmt.Printf("Failed to write deployment to file: %v", err) - } - } - - // Get logs from all pods in all namespaces - pods := corev1.PodList{} - if err := c.List(context.Background(), &pods, client.InNamespace(namespace.Name)); err != nil { - fmt.Printf("Failed to list pods %v in namespace: %q", err, namespace.Name) - } - for _, pod := range pods.Items { - if pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodSucceeded && pod.Status.Phase != corev1.PodFailed { - continue - } - for _, container := range pod.Spec.Containers { - logs, err := kubeClient.CoreV1().Pods(namespace.Name).GetLogs(pod.Name, &corev1.PodLogOptions{Container: container.Name}).Stream(context.Background()) - if err != nil { - fmt.Printf("Failed to get logs for pod %q in namespace %q: %v", pod.Name, namespace.Name, err) - continue - } - defer logs.Close() - - outFile, err := os.Create(filepath.Join(namespacedArtifactPath, pod.Name+"-"+container.Name+"-logs.txt")) - if err != nil { - fmt.Printf("Failed to create file for pod %q in namespace %q: %v", pod.Name, namespace.Name, err) - continue - } - defer outFile.Close() - - if _, err := io.Copy(outFile, logs); err != nil { - fmt.Printf("Failed to copy logs for pod %q in namespace %q: %v", pod.Name, namespace.Name, err) - continue - } - } - } - } -} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 3818e8922..d9e9387a8 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -2,17 +2,26 @@ package e2e import ( "context" + "fmt" + "io" "os" + "path/filepath" + "strings" "testing" + "time" catalogd "github.com/operator-framework/catalogd/api/core/v1alpha1" rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + kubeclient "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/utils/env" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -20,13 +29,16 @@ import ( ) var ( - cfg *rest.Config - c client.Client + cfg *rest.Config + c client.Client + pollDuration = time.Minute + pollInterval = time.Second ) const ( testCatalogRefEnvVar = "CATALOG_IMG" testCatalogName = "test-catalog" + artifactName = "operator-controller-e2e" ) func TestMain(m *testing.M) { @@ -69,3 +81,179 @@ func createTestCatalog(ctx context.Context, name string, imageRef string) (*cata err := c.Create(ctx, catalog) return catalog, err } + +func createTestNamespace(ctx context.Context, name string) (*corev1.Namespace, error) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + + err := c.Create(ctx, namespace) + return namespace, err +} + +// getArtifactsOutput gets all the artifacts from the test run and saves them to the artifact path. +// Currently it saves: +// - extensions +// - clusterextensions +// - pods logs +// - deployments +// - bundledeployments +// - catalogsources +func getArtifactsOutput(t *testing.T) { + basePath := env.GetString("ARTIFACT_PATH", "") + if basePath == "" { + return + } + + kubeClient, err := kubeclient.NewForConfig(cfg) + require.NoError(t, err) + + // sanitize the artifact name for use as a directory name + testName := strings.ReplaceAll(strings.ToLower(t.Name()), " ", "-") + // Get the test description and sanitize it for use as a directory name + artifactPath := filepath.Join(basePath, artifactName, fmt.Sprint(time.Now().UnixNano()), testName) + + // Create the full artifact path + err = os.MkdirAll(artifactPath, 0755) + require.NoError(t, err) + + // Get all namespaces + namespaces := corev1.NamespaceList{} + if err := c.List(context.Background(), &namespaces); err != nil { + fmt.Printf("Failed to list namespaces: %v", err) + } + + // get all cluster extensions save them to the artifact path. + clusterExtensions := ocv1alpha1.ClusterExtensionList{} + if err := c.List(context.Background(), &clusterExtensions, client.InNamespace("")); err != nil { + fmt.Printf("Failed to list cluster extensions: %v", err) + } + for _, clusterExtension := range clusterExtensions.Items { + // Save cluster extension to artifact path + clusterExtensionYaml, err := yaml.Marshal(clusterExtension) + if err != nil { + fmt.Printf("Failed to marshal cluster extension: %v", err) + continue + } + if err := os.WriteFile(filepath.Join(artifactPath, clusterExtension.Name+"-clusterextension.yaml"), clusterExtensionYaml, 0600); err != nil { + fmt.Printf("Failed to write cluster extension to file: %v", err) + } + } + + // get all catalogsources save them to the artifact path. + catalogsources := catalogd.CatalogList{} + if err := c.List(context.Background(), &catalogsources, client.InNamespace("")); err != nil { + fmt.Printf("Failed to list catalogsources: %v", err) + } + for _, catalogsource := range catalogsources.Items { + // Save catalogsource to artifact path + catalogsourceYaml, err := yaml.Marshal(catalogsource) + if err != nil { + fmt.Printf("Failed to marshal catalogsource: %v", err) + continue + } + if err := os.WriteFile(filepath.Join(artifactPath, catalogsource.Name+"-catalogsource.yaml"), catalogsourceYaml, 0600); err != nil { + fmt.Printf("Failed to write catalogsource to file: %v", err) + } + } + + // Get all BundleDeployments in the namespace and save them to the artifact path. + bundleDeployments := rukpakv1alpha2.BundleDeploymentList{} + if err := c.List(context.Background(), &bundleDeployments, client.InNamespace("")); err != nil { + fmt.Printf("Failed to list bundleDeployments: %v", err) + } + for _, bundleDeployment := range bundleDeployments.Items { + // Save bundleDeployment to artifact path + bundleDeploymentYaml, err := yaml.Marshal(bundleDeployment) + if err != nil { + fmt.Printf("Failed to marshal bundleDeployment: %v", err) + continue + } + if err := os.WriteFile(filepath.Join(artifactPath, bundleDeployment.Name+"-bundleDeployment.yaml"), bundleDeploymentYaml, 0600); err != nil { + fmt.Printf("Failed to write bundleDeployment to file: %v", err) + } + } + + for _, namespace := range namespaces.Items { + // let's ignore kube-* namespaces. + if strings.Contains(namespace.Name, "kube-") { + continue + } + + namespacedArtifactPath := filepath.Join(artifactPath, namespace.Name) + if err := os.Mkdir(namespacedArtifactPath, 0755); err != nil { + fmt.Printf("Failed to create namespaced artifact path: %v", err) + continue + } + + // get all extensions and save them to the artifact path. + extensions := ocv1alpha1.ExtensionList{} + if err := c.List(context.Background(), &extensions, client.InNamespace(namespace.Name)); err != nil { + fmt.Printf("Failed to list extensions %v in namepace: %q", err, namespace.Name) + continue + } + for _, extension := range extensions.Items { + // Save cluster extension to artifact path + extensionYaml, err := yaml.Marshal(extension) + if err != nil { + fmt.Printf("Failed to marshal extension: %v", err) + continue + } + if err := os.WriteFile(filepath.Join(namespacedArtifactPath, extension.Name+"-extension.yaml"), extensionYaml, 0600); err != nil { + fmt.Printf("Failed to write extension to file: %v", err) + } + } + + // get all deployments in the namespace and save them to the artifact path. + deployments := appsv1.DeploymentList{} + if err := c.List(context.Background(), &deployments, client.InNamespace(namespace.Name)); err != nil { + fmt.Printf("Failed to list deployments %v in namespace: %q", err, namespace.Name) + continue + } + + for _, deployment := range deployments.Items { + // Save deployment to artifact path + deploymentYaml, err := yaml.Marshal(deployment) + if err != nil { + fmt.Printf("Failed to marshal deployment: %v", err) + continue + } + if err := os.WriteFile(filepath.Join(namespacedArtifactPath, deployment.Name+"-deployment.yaml"), deploymentYaml, 0600); err != nil { + fmt.Printf("Failed to write deployment to file: %v", err) + } + } + + // Get logs from all pods in all namespaces + pods := corev1.PodList{} + if err := c.List(context.Background(), &pods, client.InNamespace(namespace.Name)); err != nil { + fmt.Printf("Failed to list pods %v in namespace: %q", err, namespace.Name) + } + for _, pod := range pods.Items { + if pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodSucceeded && pod.Status.Phase != corev1.PodFailed { + continue + } + for _, container := range pod.Spec.Containers { + logs, err := kubeClient.CoreV1().Pods(namespace.Name).GetLogs(pod.Name, &corev1.PodLogOptions{Container: container.Name}).Stream(context.Background()) + if err != nil { + fmt.Printf("Failed to get logs for pod %q in namespace %q: %v", pod.Name, namespace.Name, err) + continue + } + defer logs.Close() + + outFile, err := os.Create(filepath.Join(namespacedArtifactPath, pod.Name+"-"+container.Name+"-logs.txt")) + if err != nil { + fmt.Printf("Failed to create file for pod %q in namespace %q: %v", pod.Name, namespace.Name, err) + continue + } + defer outFile.Close() + + if _, err := io.Copy(outFile, logs); err != nil { + fmt.Printf("Failed to copy logs for pod %q in namespace %q: %v", pod.Name, namespace.Name, err) + continue + } + } + } + } +} diff --git a/test/e2e/extension_install_test.go b/test/e2e/extension_install_test.go new file mode 100644 index 000000000..a0c679d32 --- /dev/null +++ b/test/e2e/extension_install_test.go @@ -0,0 +1,342 @@ +package e2e + +import ( + "context" + "fmt" + "os" + "testing" + + catalogd "github.com/operator-framework/catalogd/api/core/v1alpha1" + rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + + ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" +) + +func testExtensionInit(t *testing.T) (*ocv1alpha1.Extension, types.NamespacedName, *catalogd.Catalog, *corev1.Namespace) { + str := rand.String(8) + extensionName := fmt.Sprintf("extension-%s", str) + extensionNamespace := fmt.Sprintf("extension-namespace-%s", str) + catalogName := "test-catalog" + + extensionCatalog, err := createTestCatalog(context.Background(), catalogName, os.Getenv(testCatalogRefEnvVar)) + require.NoError(t, err) + + namespace, err := createTestNamespace(context.Background(), extensionNamespace) + require.NoError(t, err) + + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{ + Name: extensionName, + Namespace: extensionNamespace, + }, + } + return extension, types.NamespacedName{Name: extensionName, Namespace: extensionNamespace}, extensionCatalog, namespace +} + +func testExtensionCleanup(t *testing.T, cat *catalogd.Catalog, extension *ocv1alpha1.Extension, namespace *corev1.Namespace) { + require.NoError(t, c.Delete(context.Background(), cat)) + require.Eventually(t, func() bool { + err := c.Get(context.Background(), types.NamespacedName{Name: cat.Name}, &catalogd.Catalog{}) + return errors.IsNotFound(err) + }, pollDuration, pollInterval) + require.NoError(t, c.Delete(context.Background(), extension)) + require.Eventually(t, func() bool { + err := c.Get(context.Background(), types.NamespacedName{Name: extension.Name, Namespace: extension.Namespace}, &ocv1alpha1.Extension{}) + return errors.IsNotFound(err) + }, pollDuration, pollInterval) + require.NoError(t, c.Delete(context.Background(), namespace)) +} + +func TestExtensionInstallRegistry(t *testing.T) { + t.Log("When a cluster extension is installed from a catalog") + t.Log("When the extension bundle format is registry+v1") + + extension, extensionName, extensionCatalog, namespace := testExtensionInit(t) + defer testExtensionCleanup(t, extensionCatalog, extension, namespace) + defer getArtifactsOutput(t) + + extension.Spec = ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "prometheus", + }, + }, + ServiceAccountName: "default", + } + t.Log("It resolves the specified package with correct bundle path") + t.Log("By creating the Extension resource") + require.NoError(t, c.Create(context.Background(), extension)) + + t.Log("By eventually reporting a successful resolution and bundle path") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + assert.Len(ct, extension.Status.Conditions, 6) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionTrue, cond.Status) + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "resolved to") + assert.Equal(ct, "localhost/testdata/bundles/registry-v1/prometheus-operator:v2.0.0", extension.Status.ResolvedBundleResource) + }, pollDuration, pollInterval) + + t.Log("By eventually installing the package successfully") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionTrue, cond.Status) + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "installed from") + assert.NotEmpty(ct, extension.Status.InstalledBundleResource) + + bd := rukpakv1alpha2.BundleDeployment{} + assert.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: extensionName.Name}, &bd)) + isUnpackSuccessful := apimeta.FindStatusCondition(bd.Status.Conditions, rukpakv1alpha2.TypeUnpacked) + if !assert.NotNil(ct, isUnpackSuccessful) { + return + } + assert.Equal(ct, rukpakv1alpha2.ReasonUnpackSuccessful, isUnpackSuccessful.Reason) + installed := apimeta.FindStatusCondition(bd.Status.Conditions, rukpakv1alpha2.TypeInstalled) + if !assert.NotNil(ct, installed) { + return + } + assert.Equal(ct, rukpakv1alpha2.ReasonInstallationSucceeded, installed.Reason) + }, pollDuration, pollInterval) +} + +func TestExtensionInstallPlain(t *testing.T) { + t.Log("When a cluster extension is installed from a catalog") + t.Log("When the cluster extension bundle format is plain+v0") + + extension, extensionName, extensionCatalog, namespace := testExtensionInit(t) + defer testExtensionCleanup(t, extensionCatalog, extension, namespace) + defer getArtifactsOutput(t) + + extension.Spec = ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "plain", + }, + }, + ServiceAccountName: "default", + } + t.Log("It resolves the specified package with correct bundle path") + t.Log("By creating the Extension resource") + require.NoError(t, c.Create(context.Background(), extension)) + + t.Log("By eventually reporting a successful resolution and bundle path") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + assert.Len(ct, extension.Status.Conditions, 6) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionTrue, cond.Status) + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "resolved to") + assert.NotEmpty(ct, extension.Status.ResolvedBundleResource) + }, pollDuration, pollInterval) + + t.Log("By eventually installing the package successfully") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionTrue, cond.Status) + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "installed from") + assert.NotEmpty(ct, extension.Status.InstalledBundleResource) + + bd := rukpakv1alpha2.BundleDeployment{} + assert.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: extensionName.Name}, &bd)) + isUnpackSuccessful := apimeta.FindStatusCondition(bd.Status.Conditions, rukpakv1alpha2.TypeUnpacked) + if !assert.NotNil(ct, isUnpackSuccessful) { + return + } + assert.Equal(ct, rukpakv1alpha2.ReasonUnpackSuccessful, isUnpackSuccessful.Reason) + installed := apimeta.FindStatusCondition(bd.Status.Conditions, rukpakv1alpha2.TypeInstalled) + if !assert.NotNil(ct, installed) { + return + } + assert.Equal(ct, rukpakv1alpha2.ReasonInstallationSucceeded, installed.Reason) + }, pollDuration, pollInterval) +} + +func TestExtensionInstallReResolvesWhenNewCatalog(t *testing.T) { + t.Log("When a cluster extension is installed from a catalog") + t.Log("It resolves again when a new catalog is available") + + extension, extensionName, extensionCatalog, namespace := testExtensionInit(t) + defer testExtensionCleanup(t, extensionCatalog, extension, namespace) + defer getArtifactsOutput(t) + + pkgName := "prometheus" + extension.Spec = ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }, + ServiceAccountName: "default", + } + + t.Log("By deleting the catalog first") + require.NoError(t, c.Delete(context.Background(), extensionCatalog)) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + err := c.Get(context.Background(), extensionName, &catalogd.Catalog{}) + assert.True(ct, errors.IsNotFound(err)) + }, pollDuration, pollInterval) + + t.Log("By creating the Extension resource") + require.NoError(t, c.Create(context.Background(), extension)) + + t.Log("By failing to find Extension during resolution") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionFalse, cond.Status) + assert.Equal(ct, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + assert.Equal(ct, fmt.Sprintf("no package %q found", pkgName), cond.Message) + }, pollDuration, pollInterval) + + t.Log("By creating an Extension catalog with the desired package") + var err error + extensionCatalog, err = createTestCatalog(context.Background(), extensionCatalog.Name, os.Getenv(testCatalogRefEnvVar)) + require.NoError(t, err) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: extensionCatalog.Name}, extensionCatalog)) + cond := apimeta.FindStatusCondition(extensionCatalog.Status.Conditions, catalogd.TypeUnpacked) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionTrue, cond.Status) + assert.Equal(ct, catalogd.ReasonUnpackSuccessful, cond.Reason) + }, pollDuration, pollInterval) + + t.Log("By eventually resolving the package successfully") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, metav1.ConditionTrue, cond.Status) + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + }, pollDuration, pollInterval) +} + +func TestExtensionInstallNonSuccessorVersion(t *testing.T) { + t.Log("When a cluster extension is installed from a catalog") + t.Log("When resolving upgrade edges") + + extension, extensionName, extensionCatalog, namespace := testExtensionInit(t) + defer testExtensionCleanup(t, extensionCatalog, extension, namespace) + defer getArtifactsOutput(t) + + t.Log("By creating an Extension at a specified version") + extension.Spec = ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "prometheus", + Version: "1.0.0", + }, + }, + ServiceAccountName: "default", + } + require.NoError(t, c.Create(context.Background(), extension)) + t.Log("By eventually reporting a successful resolution") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "resolved to") + assert.Equal(ct, "localhost/testdata/bundles/registry-v1/prometheus-operator:v1.0.0", extension.Status.ResolvedBundleResource) + }, pollDuration, pollInterval) + + t.Log("It does not allow to upgrade the Extension to a non-successor version") + t.Log("By updating the Extension resource to a non-successor version") + // Semver only allows upgrades within major version at the moment. + extension.Spec.Source.Package.Version = "2.0.0" + require.NoError(t, c.Update(context.Background(), extension)) + t.Log("By eventually reporting an unsatisfiable resolution") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + assert.Contains(ct, cond.Message, "constraints not satisfiable") + assert.Contains(ct, cond.Message, "installed package prometheus requires at least one of test-catalog-prometheus-prometheus-operator.1.2.0, test-catalog-prometheus-prometheus-operator.1.0.1, test-catalog-prometheus-prometheus-operator.1.0.0") + assert.Empty(ct, extension.Status.ResolvedBundleResource) + }, pollDuration, pollInterval) +} + +func TestExtensionInstallSuccessorVersion(t *testing.T) { + t.Log("When a cluster extension is installed from a catalog") + t.Log("When resolving upgrade edges") + extension, extensionName, extensionCatalog, namespace := testExtensionInit(t) + defer testExtensionCleanup(t, extensionCatalog, extension, namespace) + defer getArtifactsOutput(t) + + t.Log("By creating an Extension at a specified version") + extension.Spec = ocv1alpha1.ExtensionSpec{ + Source: ocv1alpha1.ExtensionSource{ + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: "prometheus", + Version: "1.0.0", + }, + }, + ServiceAccountName: "default", + } + require.NoError(t, c.Create(context.Background(), extension)) + t.Log("By eventually reporting a successful resolution") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "resolved to") + assert.Equal(ct, "localhost/testdata/bundles/registry-v1/prometheus-operator:v1.0.0", extension.Status.ResolvedBundleResource) + }, pollDuration, pollInterval) + + t.Log("It does allow to upgrade the Extension to any of the successor versions within non-zero major version") + t.Log("By updating the Extension resource by skipping versions") + // Test catalog has versions between the initial version and new version + extension.Spec.Source.Package.Version = "1.2.0" + require.NoError(t, c.Update(context.Background(), extension)) + t.Log("By eventually reporting a successful resolution and bundle path") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + assert.NoError(ct, c.Get(context.Background(), extensionName, extension)) + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + if !assert.NotNil(ct, cond) { + return + } + assert.Equal(ct, ocv1alpha1.ReasonSuccess, cond.Reason) + assert.Contains(ct, cond.Message, "resolved to") + assert.Equal(ct, "localhost/testdata/bundles/registry-v1/prometheus-operator:v1.2.0", extension.Status.ResolvedBundleResource) + }, pollDuration, pollInterval) +}