From 2c5e338c991ef2f23c0b6e452f7a541a4887e7a3 Mon Sep 17 00:00:00 2001 From: Anik Bhattacharjee Date: Thu, 26 Jun 2025 08:31:17 -0400 Subject: [PATCH] Introduce ClusterExtensionRevision API --- Makefile | 1 + api/v1/clusterextensionrevision_types.go | 125 +++++ api/v1/clusterextensionrevision_types_test.go | 91 +++ api/v1/zz_generated.deepcopy.go | 117 +++- cmd/operator-controller/main.go | 10 + ...ramework.io_clusterextensionrevisions.yaml | 211 +++++++ .../crd/kustomization.yaml | 1 + .../base/operator-controller/rbac/role.yaml | 20 + .../clusterextension-revision-flow.md | 511 +++++++++++++++++ .../clusterextension_controller.go | 48 ++ .../clusterextensionrevision_controller.go | 528 ++++++++++++++++++ ...lusterextensionrevision_controller_test.go | 332 +++++++++++ manifests/standard.yaml | 231 ++++++++ 13 files changed, 2224 insertions(+), 2 deletions(-) create mode 100644 api/v1/clusterextensionrevision_types.go create mode 100644 api/v1/clusterextensionrevision_types_test.go create mode 100644 config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensionrevisions.yaml create mode 100644 docs/api-reference/clusterextension-revision-flow.md create mode 100644 internal/operator-controller/controllers/clusterextensionrevision_controller.go create mode 100644 internal/operator-controller/controllers/clusterextensionrevision_controller_test.go diff --git a/Makefile b/Makefile index 76c7801cd..a6b535dd4 100644 --- a/Makefile +++ b/Makefile @@ -150,6 +150,7 @@ manifests: $(CONTROLLER_GEN) $(KUSTOMIZE) #EXHELP Generate WebhookConfiguration, mkdir $(CRD_WORKING_DIR) $(CONTROLLER_GEN) --load-build-tags=$(GO_BUILD_TAGS) crd paths="./api/v1/..." output:crd:artifacts:config=$(CRD_WORKING_DIR) mv $(CRD_WORKING_DIR)/olm.operatorframework.io_clusterextensions.yaml $(KUSTOMIZE_OPCON_CRDS_DIR) + mv $(CRD_WORKING_DIR)/olm.operatorframework.io_clusterextensionrevisions.yaml $(KUSTOMIZE_OPCON_CRDS_DIR) mv $(CRD_WORKING_DIR)/olm.operatorframework.io_clustercatalogs.yaml $(KUSTOMIZE_CATD_CRDS_DIR) rmdir $(CRD_WORKING_DIR) # Generate the remaining operator-controller manifests diff --git a/api/v1/clusterextensionrevision_types.go b/api/v1/clusterextensionrevision_types.go new file mode 100644 index 000000000..f17553d1e --- /dev/null +++ b/api/v1/clusterextensionrevision_types.go @@ -0,0 +1,125 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ClusterExtensionRevisionSpec defines the desired state of ClusterExtensionRevision +type ClusterExtensionRevisionSpec struct { + // clusterExtensionRef is a required reference to the ClusterExtension + // that this revision represents an available upgrade for. + // + // +kubebuilder:validation:Required + ClusterExtensionRef ClusterExtensionReference `json:"clusterExtensionRef"` + + // version is a required field that specifies the exact version of the bundle + // that represents this available upgrade. + // + // version follows the semantic versioning standard as defined in https://semver.org/. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self.matches(\"^([0-9]+)(\\\\.[0-9]+)?(\\\\.[0-9]+)?(-([-0-9A-Za-z]+(\\\\.[-0-9A-Za-z]+)*))?(\\\\+([-0-9A-Za-z]+(-\\\\.[-0-9A-Za-z]+)*))?\")",message="version must be well-formed semver" + Version string `json:"version"` + // bundleMetadata contains the complete metadata for the bundle that represents + // this available upgrade. + // + // +kubebuilder:validation:Required + BundleMetadata BundleMetadata `json:"bundleMetadata"` + + // availableSince indicates when this upgrade revision was first detected + // as being available. This helps track how long an upgrade has been pending. + // + // +kubebuilder:validation:Required + AvailableSince metav1.Time `json:"availableSince"` + + // approved indicates whether this upgrade revision has been approved for execution. + // When set to true, the controller will automatically update the corresponding + // ClusterExtension to trigger the upgrade to this version. + // + // +optional + Approved bool `json:"approved,omitempty"` + + // approvedAt indicates when this upgrade revision was approved for execution. + // This field is set automatically when the approved field changes from false to true. + // + // +optional + ApprovedAt *metav1.Time `json:"approvedAt,omitempty"` +} + +// ClusterExtensionReference identifies a ClusterExtension +type ClusterExtensionReference struct { + // name is the name of the ClusterExtension + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength:=253 + // +kubebuilder:validation:XValidation:rule="self.matches(\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$\")",message="name must be a valid DNS1123 subdomain" + Name string `json:"name"` +} + +// ClusterExtensionRevisionStatus defines the observed state of ClusterExtensionRevision +type ClusterExtensionRevisionStatus struct { + // conditions represent the latest available observations of the ClusterExtensionRevision's current state. + // + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Extension",type=string,JSONPath=".spec.clusterExtensionRef.name" +// +kubebuilder:printcolumn:name="Version",type=string,JSONPath=".spec.version" +// +kubebuilder:printcolumn:name="Approved",type=boolean,JSONPath=".spec.approved" +// +kubebuilder:printcolumn:name="Approved At",type=date,JSONPath=".spec.approvedAt" +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" + +// ClusterExtensionRevision represents an available upgrade for a ClusterExtension. +// It is created automatically by the operator-controller when new versions become +// available in catalogs that represent valid upgrade paths for installed ClusterExtensions. +type ClusterExtensionRevision struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // spec defines the available upgrade revision details. + // + // +kubebuilder:validation:Required + Spec ClusterExtensionRevisionSpec `json:"spec"` + + // status represents the current status of this ClusterExtensionRevision. + // + // +optional + Status ClusterExtensionRevisionStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ClusterExtensionRevisionList contains a list of ClusterExtensionRevision +type ClusterExtensionRevisionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ClusterExtensionRevision `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ClusterExtensionRevision{}, &ClusterExtensionRevisionList{}) +} diff --git a/api/v1/clusterextensionrevision_types_test.go b/api/v1/clusterextensionrevision_types_test.go new file mode 100644 index 000000000..fc585e4d2 --- /dev/null +++ b/api/v1/clusterextensionrevision_types_test.go @@ -0,0 +1,91 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestClusterExtensionRevisionTypes(t *testing.T) { + // Test that we can create a ClusterExtensionRevision with all required fields + revision := &ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension-1.2.0", + }, + Spec: ClusterExtensionRevisionSpec{ + ClusterExtensionRef: ClusterExtensionReference{ + Name: "test-extension", + }, + Version: "1.2.0", + BundleMetadata: BundleMetadata{ + Name: "test-operator.v1.2.0", + Version: "1.2.0", + }, + AvailableSince: metav1.Now(), + Approved: false, + }, + Status: ClusterExtensionRevisionStatus{ + Conditions: []metav1.Condition{ + { + Type: "Available", + Status: metav1.ConditionTrue, + Reason: "UpgradeDetected", + }, + }, + }, + } + + // Verify the spec fields are accessible + if revision.Spec.ClusterExtensionRef.Name != "test-extension" { + t.Errorf("expected ClusterExtensionRef.Name to be 'test-extension', got %q", revision.Spec.ClusterExtensionRef.Name) + } + + if revision.Spec.Version != "1.2.0" { + t.Errorf("expected Version to be '1.2.0', got %q", revision.Spec.Version) + } + +} + +func TestClusterExtensionRevisionList(t *testing.T) { + // Test that we can create a ClusterExtensionRevisionList + revisionList := &ClusterExtensionRevisionList{ + Items: []ClusterExtensionRevision{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension-1.2.0", + }, + Spec: ClusterExtensionRevisionSpec{ + ClusterExtensionRef: ClusterExtensionReference{ + Name: "test-extension", + }, + Version: "1.2.0", + BundleMetadata: BundleMetadata{ + Name: "test-operator.v1.2.0", + Version: "1.2.0", + }, + AvailableSince: metav1.Now(), + }, + }, + }, + } + + if len(revisionList.Items) != 1 { + t.Errorf("expected 1 item in list, got %d", len(revisionList.Items)) + } +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 37694f61f..07fad7caf 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -65,8 +65,7 @@ func (in *CatalogFilter) DeepCopyInto(out *CatalogFilter) { } if in.Selector != nil { in, out := &in.Selector, &out.Selector - *out = new(metav1.LabelSelector) - (*in).DeepCopyInto(*out) + *out = (*in).DeepCopy() } } @@ -321,6 +320,120 @@ func (in *ClusterExtensionList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionReference) DeepCopyInto(out *ClusterExtensionReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionReference. +func (in *ClusterExtensionReference) DeepCopy() *ClusterExtensionReference { + if in == nil { + return nil + } + out := new(ClusterExtensionReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevision) DeepCopyInto(out *ClusterExtensionRevision) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevision. +func (in *ClusterExtensionRevision) DeepCopy() *ClusterExtensionRevision { + if in == nil { + return nil + } + out := new(ClusterExtensionRevision) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterExtensionRevision) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionList) DeepCopyInto(out *ClusterExtensionRevisionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterExtensionRevision, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionList. +func (in *ClusterExtensionRevisionList) DeepCopy() *ClusterExtensionRevisionList { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterExtensionRevisionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionSpec) DeepCopyInto(out *ClusterExtensionRevisionSpec) { + *out = *in + out.ClusterExtensionRef = in.ClusterExtensionRef + out.BundleMetadata = in.BundleMetadata + in.AvailableSince.DeepCopyInto(&out.AvailableSince) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionSpec. +func (in *ClusterExtensionRevisionSpec) DeepCopy() *ClusterExtensionRevisionSpec { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionRevisionStatus) DeepCopyInto(out *ClusterExtensionRevisionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionStatus. +func (in *ClusterExtensionRevisionStatus) DeepCopy() *ClusterExtensionRevisionStatus { + if in == nil { + return nil + } + out := new(ClusterExtensionRevisionStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterExtensionSpec) DeepCopyInto(out *ClusterExtensionSpec) { *out = *in diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index d426793d4..30c48aa4c 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -476,6 +476,16 @@ func run() error { return err } + if err = (&controllers.ClusterExtensionRevisionReconciler{ + Client: cl, + Scheme: mgr.GetScheme(), + Resolver: resolver, + InstalledBundleGetter: &controllers.DefaultInstalledBundleGetter{ActionClientGetter: acg}, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ClusterExtensionRevision") + return err + } + if err = (&controllers.ClusterCatalogReconciler{ Client: cl, CatalogCache: catalogClientBackend, diff --git a/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensionrevisions.yaml b/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensionrevisions.yaml new file mode 100644 index 000000000..34016d424 --- /dev/null +++ b/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensionrevisions.yaml @@ -0,0 +1,211 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: clusterextensionrevisions.olm.operatorframework.io +spec: + group: olm.operatorframework.io + names: + kind: ClusterExtensionRevision + listKind: ClusterExtensionRevisionList + plural: clusterextensionrevisions + singular: clusterextensionrevision + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterExtensionRef.name + name: Extension + type: string + - jsonPath: .spec.version + name: Version + type: string + - jsonPath: .spec.approved + name: Approved + type: boolean + - jsonPath: .spec.approvedAt + name: Approved At + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + ClusterExtensionRevision represents an available upgrade for a ClusterExtension. + It is created automatically by the operator-controller when new versions become + available in catalogs that represent valid upgrade paths for installed ClusterExtensions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the available upgrade revision details. + properties: + approved: + description: |- + approved indicates whether this upgrade revision has been approved for execution. + When set to true, the controller will automatically update the corresponding + ClusterExtension to trigger the upgrade to this version. + type: boolean + approvedAt: + description: |- + approvedAt indicates when this upgrade revision was approved for execution. + This field is set automatically when the approved field changes from false to true. + format: date-time + type: string + availableSince: + description: |- + availableSince indicates when this upgrade revision was first detected + as being available. This helps track how long an upgrade has been pending. + format: date-time + type: string + bundleMetadata: + description: |- + bundleMetadata contains the complete metadata for the bundle that represents + this available upgrade. + properties: + name: + description: |- + name is required and follows the DNS subdomain standard + as defined in [RFC 1123]. It must contain only lowercase alphanumeric characters, + hyphens (-) or periods (.), start and end with an alphanumeric character, + and be no longer than 253 characters. + type: string + x-kubernetes-validations: + - message: packageName must be a valid DNS1123 subdomain. It must + contain only lowercase alphanumeric characters, hyphens (-) + or periods (.), start and end with an alphanumeric character, + and be no longer than 253 characters + rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$") + version: + description: |- + version is a required field and is a reference to the version that this bundle represents + version follows the semantic versioning standard as defined in https://semver.org/. + type: string + x-kubernetes-validations: + - message: version must be well-formed semver + rule: self.matches("^([0-9]+)(\\.[0-9]+)?(\\.[0-9]+)?(-([-0-9A-Za-z]+(\\.[-0-9A-Za-z]+)*))?(\\+([-0-9A-Za-z]+(-\\.[-0-9A-Za-z]+)*))?") + required: + - name + - version + type: object + clusterExtensionRef: + description: |- + clusterExtensionRef is a required reference to the ClusterExtension + that this revision represents an available upgrade for. + properties: + name: + description: name is the name of the ClusterExtension + maxLength: 253 + type: string + x-kubernetes-validations: + - message: name must be a valid DNS1123 subdomain + rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$") + required: + - name + type: object + version: + description: |- + version is a required field that specifies the exact version of the bundle + that represents this available upgrade. + + version follows the semantic versioning standard as defined in https://semver.org/. + type: string + x-kubernetes-validations: + - message: version must be well-formed semver + rule: self.matches("^([0-9]+)(\\.[0-9]+)?(\\.[0-9]+)?(-([-0-9A-Za-z]+(\\.[-0-9A-Za-z]+)*))?(\\+([-0-9A-Za-z]+(-\\.[-0-9A-Za-z]+)*))?") + required: + - availableSince + - bundleMetadata + - clusterExtensionRef + - version + type: object + status: + description: status represents the current status of this ClusterExtensionRevision. + properties: + conditions: + description: conditions represent the latest available observations + of the ClusterExtensionRevision's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/base/operator-controller/crd/kustomization.yaml b/config/base/operator-controller/crd/kustomization.yaml index ec864639d..f826fdf49 100644 --- a/config/base/operator-controller/crd/kustomization.yaml +++ b/config/base/operator-controller/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_clusterextensionrevisions.yaml # the following config is for teaching kustomize how to do kustomization for CRDs. configurations: diff --git a/config/base/operator-controller/rbac/role.yaml b/config/base/operator-controller/rbac/role.yaml index d18eb4c6c..9c46fa29a 100644 --- a/config/base/operator-controller/rbac/role.yaml +++ b/config/base/operator-controller/rbac/role.yaml @@ -24,6 +24,26 @@ rules: - get - list - watch +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensionrevisions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensionrevisions/status + verbs: + - get + - patch + - update - apiGroups: - olm.operatorframework.io resources: diff --git a/docs/api-reference/clusterextension-revision-flow.md b/docs/api-reference/clusterextension-revision-flow.md new file mode 100644 index 000000000..2dd3eb6c4 --- /dev/null +++ b/docs/api-reference/clusterextension-revision-flow.md @@ -0,0 +1,511 @@ +# ClusterExtension to ClusterExtensionRevision Flow + +This document describes the interaction flow between `ClusterExtension` and `ClusterExtensionRevision` resources in OLM v1, explaining how upgrade notifications and approvals work. + +## Overview + +The `ClusterExtensionRevision` feature provides a mechanism for: +- **Detecting available upgrades** for installed ClusterExtensions +- **Notifying users** when upgrades become available +- **Controlling upgrade timing and policy** through an approval workflow +- **Preventing automatic upgrades** by requiring explicit approval for version changes +- **Preserving user's version constraints** without overwriting them during upgrades +- **Respecting version constraints** for upgrade detection + +## Architecture + +### Key Components + +1. **ClusterExtension**: Represents an installed operator/extension with upgrade approval logic +2. **ClusterExtensionRevision**: Represents an available upgrade for a ClusterExtension +3. **ClusterExtensionRevision Controller**: Monitors catalogs and creates/manages revision resources +4. **ClusterExtension Controller**: Enhanced to check for approved revisions before performing upgrades +5. **Catalog Resolver**: Determines available upgrades using existing resolution logic + +### Design Constraints + +- **One-to-One Relationship**: Each ClusterExtension can have at most one ClusterExtensionRevision +- **Latest Upgrade Only**: Only the latest available upgrade is tracked as a revision +- **Approval-Based**: Upgrades only occur when explicitly approved by users +- **Initial Install Exception**: Initial installations proceed without approval (no existing version) +- **Version Constraint Preservation**: Original version constraints are never overwritten +- **Version Constraint Aware**: Respects version constraints for upgrade detection + +### Version Constraint Handling + +The controller handles three scenarios for version constraints: + +1. **No Version Constraint**: Finds any available upgrade +2. **Pinned Version** (exact version): No ClusterExtensionRevision created (no upgrades allowed) +3. **Version Range**: Finds upgrades within the specified range + +## Flow Diagram + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ ClusterCatalog │ │ ClusterExtension │ │ClusterExtensionRev. │ +│ │ │ │ │ │ +│ [Catalog Data] │ │ [Installed] │ │ [Upgrade Avail.] │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ + │ │ │ + │ 1. Catalog Update │ │ + ├────────────────────────┼─────────────────────────┤ + │ │ │ + │ 2. Controller Detects Change │ + │ ┌─────────────▼─────────────┐ │ + │ │ Revision Controller │ │ + │ │ - Monitors catalogs │ │ + │ │ - Checks for upgrades │ │ + │ │ - Handles version ranges │ │ + │ │ - Manages revisions │ │ + │ └─────────────┬─────────────┘ │ + │ │ │ + │ 3. Version Constraint Analysis │ + │ ┌─────────────▼─────────────┐ │ + │ │ Check Version Constraint │ │ + │ │ - Pinned? Skip revision │ │ + │ │ - Range? Find in range │ │ + │ │ - None? Find any upgrade │ │ + │ └─────────────┬─────────────┘ │ + │ │ │ + │ 4. Find Available Upgrade │ + │ ┌─────────────▼─────────────┐ │ + │ │ Catalog Resolver │ │ + │ │ - Query available vers. │ │ + │ │ - Respect constraints │ │ + │ │ - Compare with installed │ │ + │ │ - Return upgrade info │ │ + │ └─────────────┬─────────────┘ │ + │ │ │ + │ 5. Create/Update Revision │ + │ ├─────────────────────────▶ + │ │ 6. User/Policy controller Reviews and Approves + │ │ │ + │ 7. User/Policy controller Approves Revision (approved=true)│ + │ │ │ + │ 8. ClusterExtension Controller Detects │ + │ Upgrade and Checks for Approval │ + │ │ │ + │ 9. Upgrade Executed (if approved) │ + │ │ │ +``` + +## Detailed Flow + +### 1. Catalog Change Detection + +**Trigger**: ClusterCatalog image reference changes (new catalog content available) + +**Process**: +- ClusterCatalog controller polls for catalog updates (if polling enabled) +- When new catalog content is detected, ClusterExtensionRevision controller is notified +- Controller reconciles all ClusterExtensions to check for available upgrades + +### 2. Upgrade Detection with Version Constraints + +**Logic for version constraint handling**: + +```go +// upgrade detection logic +func findAvailableUpgrade(ext *ClusterExtension, installedBundle *BundleMetadata) (*AvailableUpgrade, error) { + // If no version constraint is specified, find any available upgrade + if ext.Spec.Source.Catalog == nil || ext.Spec.Source.Catalog.Version == "" { + return findAnyAvailableUpgrade(ctx, ext, installedBundle) + } + + versionConstraint := ext.Spec.Source.Catalog.Version + + // Check if this is a pinned version (exact version match) + if isPinnedVersion(versionConstraint, installedBundle.Version) { + // No upgrades for pinned versions - no revision created + return nil, nil + } + + // For version ranges, find upgrades within the range + return findUpgradeInVersionRange(ctx, ext, installedBundle) +} + +// Check if version is pinned (exact match without operators) +func isPinnedVersion(versionConstraint, installedVersion string) bool { + // Consider it pinned if: + // 1. No operators like >=, <=, >, <, ~, ^, || + // 2. Exact match with installed version + hasOperators := strings.ContainsAny(versionConstraint, "><=~^|") + if hasOperators { + return false + } + return strings.TrimSpace(versionConstraint) == strings.TrimSpace(installedVersion) +} +``` + +### Version Constraint Scenarios + +#### Scenario 1: No Version Constraint +```yaml +apiVersion: olm.operatorframework.io/v1 +kind: ClusterExtension +spec: + source: + catalog: + packageName: my-operator + # No version constraint +``` +**Behavior**: Finds any available upgrade, removing version constraints during resolution. + +#### Scenario 2: Pinned Version +```yaml +apiVersion: olm.operatorframework.io/v1 +kind: ClusterExtension +spec: + source: + catalog: + packageName: my-operator + version: "1.2.3" # Exact version +``` +**Behavior**: No ClusterExtensionRevision created - version is pinned. + +#### Scenario 3: Version Range +```yaml +apiVersion: olm.operatorframework.io/v1 +kind: ClusterExtension +spec: + source: + catalog: + packageName: my-operator + version: ">=1.2.0, <2.0.0" # Version range +``` +**Behavior**: Finds upgrades within the specified range, respecting the constraint. + +### 3. ClusterExtensionRevision Management + +**Creation**: When an upgrade is available and no revision exists +```yaml +apiVersion: olm.operatorframework.io/v1 +kind: ClusterExtensionRevision +metadata: + name: my-extension-1.2.0 + ownerReferences: + - apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + name: my-extension +spec: + clusterExtensionRef: + name: my-extension + version: "1.2.0" + bundleMetadata: + name: my-extension + version: "1.2.0" + availableSince: "2024-01-15T10:30:00Z" + approved: false # Defaults to false +``` + +**Update**: When a newer upgrade becomes available +- The existing revision is updated to reflect the latest available upgrade +- `availableSince` timestamp is updated +- `approved` field is reset to `false` + +**Cleanup**: When no upgrades are available +- Obsolete revisions are deleted +- This happens when: + - The installed version is already the latest + - Version is pinned (exact match) + - No upgrades exist within the version range + +**No Creation**: When version is pinned +- No ClusterExtensionRevision is created for pinned versions +- Pinned versions are detected by exact version match without operators + +### 4. Approval Workflow + +**User Action**: Set `approved: true` on the ClusterExtensionRevision + +```yaml +# User approves the upgrade +apiVersion: olm.operatorframework.io/v1 +kind: ClusterExtensionRevision +metadata: + name: my-extension-1.2.0 +spec: + # ... other fields + approved: true # User sets this to approve +``` + +**Controller Response**: +- Watches for `approved` field changes from `false` to `true` +- Sets approval timestamp on the revision +- ClusterExtension controller detects approved revisions during reconciliation + +### 5. Upgrade Execution + +**Enhanced ClusterExtension Controller Process**: +```go +func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ClusterExtension) error { + // ... existing resolution logic ... + + // NEW: Check if this is an upgrade that requires approval + if installedBundle != nil && installedBundle.Version != resolvedBundleVersion.String() { + // This is an upgrade - check for approved revision + if approved, err := r.isUpgradeApproved(ctx, ext, resolvedBundleVersion.String()); err != nil { + return err + } else if !approved { + // No approved revision found, don't upgrade + log.Info("upgrade available but not approved, waiting for approval") + return nil // Don't proceed with upgrade + } + // Upgrade is approved, continue with installation + } + + // ... continue with normal installation/upgrade process ... +} + +func (r *ClusterExtensionReconciler) isUpgradeApproved(ctx context.Context, ext *ClusterExtension, targetVersion string) (bool, error) { + // List all revisions for this ClusterExtension + var revisions ClusterExtensionRevisionList + if err := r.List(ctx, &revisions); err != nil { + return false, err + } + + // Check for approved revision with target version + for _, revision := range revisions.Items { + if revision.Spec.ClusterExtensionRef.Name == ext.Name && + revision.Spec.Version == targetVersion && + revision.Spec.Approved { + return true, nil + } + } + return false, nil +} +``` + +**ClusterExtensionRevision Controller Process**: +```go +func (r *ClusterExtensionRevisionReconciler) upgradeClusterExtension(ctx context.Context, ext *ClusterExtension, revision *ClusterExtensionRevision) error { + // Check if upgrade is already completed + if ext.Status.Install != nil && ext.Status.Install.Bundle.Version == revision.Spec.Version { + // Upgrade completed, clean up the revision + return r.Delete(ctx, revision) + } + + // The approved revision exists - ClusterExtension controller will handle the upgrade + return nil +} +``` + +**Result**: +- **Initial installations**: Proceed normally without approval checks +- **Upgrades**: Only proceed if there's an approved ClusterExtensionRevision +- **Version constraints**: Original user constraints are preserved (never overwritten) +- **Cleanup**: ClusterExtensionRevision is deleted after successful upgrade + +## State Transitions + +### ClusterExtensionRevision Lifecycle + +``` +[Extension Installed] + │ + │ Check Version Constraint + ├─────────────────────────┬─────────────────────────┐ + │ │ │ + │ Pinned Version │ Version Range │ No Constraint + ▼ ▼ ▼ +[No Revision Created] [Check Range for Upgrades] [Find Any Upgrade] + │ │ + │ Upgrade Available │ Upgrade Available + ▼ ▼ + [Revision Created: approved=false] + │ │ + │ User Approval │ + ▼ ▼ + [Revision Approved: approved=true] + │ │ + │ ClusterExtension Reconcile │ + ▼ ▼ + [Approval Check: isUpgradeApproved()] + │ │ + │ Upgrade Executed │ + ▼ ▼ + [Extension Status Updated] + │ │ + │ Revision Cleanup │ + ▼ ▼ + [Revision Deleted - Upgrade Complete] +``` + +### ClusterExtension Integration + +The ClusterExtensionRevision controller integrates with ClusterExtension lifecycle: + +- **Installation**: No revisions created until extension is successfully installed +- **Initial Install**: ClusterExtension controller proceeds without approval checks +- **Pinned Version**: No revisions created for exact version matches +- **Version Range**: Revisions created only for upgrades within the range +- **Upgrade Available**: Revision created with `approved=false` +- **User Approval**: User sets `approved=true` +- **Upgrade Check**: ClusterExtension controller checks for approved revisions during reconcile +- **Upgrade Execution**: Only proceeds if approved revision exists for target version +- **Version Preservation**: Original version constraints are never modified +- **Cleanup**: Revision deleted after successful upgrade completion + +## Controller Behavior + +### Reconciliation Triggers + +The ClusterExtensionRevision controller reconciles when: + +1. **ClusterCatalog changes**: New catalog content may contain upgrades +2. **ClusterExtension changes**: Installation status or spec changes +3. **ClusterExtensionRevision changes**: Approval status changes + +The ClusterExtension controller reconciles when: + +1. **ClusterExtension changes**: Spec or metadata changes +2. **ClusterCatalog changes**: New catalog content may affect resolution +3. **Normal reconcile loop**: Periodic reconciliation (with approval checks for upgrades) + +### Enhanced Controller Logic + +```go +func (r *ClusterExtensionRevisionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // Get the ClusterExtension + ext := &ocv1.ClusterExtension{} + if err := r.Get(ctx, req.NamespacedName, ext); err != nil { + if apierrors.IsNotFound(err) { + // Extension deleted - cleanup revisions + return r.cleanupRevisionsForDeletedExtension(ctx, req.Name) + } + return ctrl.Result{}, err + } + + // Handle approved revisions (upgrade flow) + if err := r.handleApprovedRevision(ctx, ext); err != nil { + return ctrl.Result{}, err + } + + // Check for available upgrades and manage revisions + // This now includes version constraint handling + return ctrl.Result{RequeueAfter: 30 * time.Minute}, r.reconcileExtensionRevisions(ctx, ext) +} +``` + +**Enhanced ClusterExtension Controller Logic**: +```go +func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ClusterExtension) (ctrl.Result, error) { + // ... existing setup and installed bundle detection ... + + // Run resolution to find available bundles + resolvedBundle, resolvedVersion, _, err := r.Resolver.Resolve(ctx, ext, installedBundle) + if err != nil { + return ctrl.Result{}, err + } + + // NEW: Check if this is an upgrade that requires approval + if installedBundle != nil && installedBundle.Version != resolvedVersion.String() { + // This is an upgrade - check for approved revision + if approved, err := r.isUpgradeApproved(ctx, ext, resolvedVersion.String()); err != nil { + return ctrl.Result{}, err + } else if !approved { + // No approved revision found, don't upgrade - wait and requeue + log.Info("upgrade available but not approved, waiting for approval") + setInstalledStatusFromBundle(ext, installedBundle) + setStatusProgressing(ext, nil) // No error, just waiting + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + // Upgrade is approved, continue with installation + log.Info("upgrade approved, proceeding with installation") + } + + // Continue with normal installation/upgrade process + // ... existing installation logic unchanged ... +} +``` + +## User Experience + +### Understanding Version Constraints + +Users should understand how version constraints affect upgrade detection: + +**Pinned Versions**: No upgrades will be detected +```yaml +# No ClusterExtensionRevision will be created +spec: + source: + catalog: + version: "1.2.3" +``` + +**Version Ranges**: Upgrades detected within range +```yaml +# ClusterExtensionRevision created for upgrades in range +spec: + source: + catalog: + version: ">=1.2.0, <2.0.0" +``` + +**No Constraints**: Any upgrade detected +```yaml +# ClusterExtensionRevision created for any newer version +spec: + source: + catalog: + packageName: my-operator + # No version constraint +``` + +### Discovering Available Upgrades + +Users can discover available upgrades by listing ClusterExtensionRevisions: + +```bash +# List all available upgrades +kubectl get clusterextensionrevisions + +# Check specific extension +kubectl get clusterextensionrevisions -l clusterextension=my-extension +``` + +### Approving Upgrades + +Users approve upgrades by patching the revision: + +```bash +# Approve an upgrade +kubectl patch clusterextensionrevision my-extension-1.2.0 \ + --type='merge' -p='{"spec":{"approved":true}}' +``` + +### Monitoring Upgrade Status + +Users can monitor the upgrade process through: + +1. **ClusterExtensionRevision status**: Track approval and upgrade initiation +2. **ClusterExtension status**: Monitor actual upgrade progress +3. **Events**: Kubernetes events provide detailed upgrade information + +## Benefits + +1. **Proactive Notifications**: Users are notified when upgrades become available +2. **Controlled Upgrades**: Users decide when to apply upgrades through explicit approval +3. **Automatic Upgrade Prevention**: No accidental upgrades - all version changes require approval +4. **Version Constraint Preservation**: Original user constraints are never overwritten +5. **Initial Install Flow**: New installations proceed normally without approval workflow +6. **Version Constraint Awareness**: Respects existing version constraints for upgrade detection +7. **Pinned Version Support**: No unwanted upgrade notifications for pinned versions +8. **Range-Based Upgrades**: Finds upgrades within specified version ranges +9. **Clean Separation**: ClusterExtensionRevision manages detection, ClusterExtension manages execution +10. **Integration**: Leverages existing ClusterExtension upgrade mechanisms +11. **Simplicity**: One revision per extension, direct approval checks + +## Limitations + +1. **Latest Only**: Only tracks the latest available upgrade within constraints +2. **Single Approval**: No support for bulk approval of multiple extensions +3. **No Rollback**: Revisions don't support rollback to previous versions +4. **Manual Process**: Approval is manual; no automatic upgrade policies yet +5. **Pinned Versions**: No upgrade path for pinned versions +6. **Initial Install Bypass**: Initial installations skip the approval workflow (by design) +7. **Requeue Frequency**: Upgrade checks happen every 5 seconds when waiting for approval + +## Future Work diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index 7d268df05..94763cc78 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -263,6 +263,30 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl resolvedBundleMetadata := bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion) + // Check if this is an upgrade that should be handled by ClusterExtensionRevision controller + if installedBundle != nil && installedBundle.Version != resolvedBundleVersion.String() { + // This is an upgrade (there's an installed bundle and the resolved version is different) + // Check if there's an approved ClusterExtensionRevision for this upgrade + if approved, err := r.isUpgradeApproved(ctx, ext, resolvedBundleVersion.String()); err != nil { + l.Error(err, "failed to check for approved revision") + setStatusProgressing(ext, fmt.Errorf("failed to check for approved revision: %w", err)) + setInstalledStatusFromBundle(ext, installedBundle) + return ctrl.Result{}, err + } else if !approved { + // No approved revision found, don't upgrade + l.Info("upgrade available but not approved, waiting for ClusterExtensionRevision approval", + "installedVersion", installedBundle.Version, + "availableVersion", resolvedBundleVersion.String()) + setInstalledStatusFromBundle(ext, installedBundle) + setStatusProgressing(ext, nil) // No error, just waiting for approval + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Check again later + } + // If we reach here, the upgrade is approved, continue with installation + l.Info("upgrade approved, proceeding with installation", + "installedVersion", installedBundle.Version, + "targetVersion", resolvedBundleVersion.String()) + } + l.Info("unpacking resolved bundle") imageFS, _, _, err := r.ImagePuller.Pull(ctx, ext.GetName(), resolvedBundle.Image, r.ImageCache) if err != nil { @@ -334,6 +358,30 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl return ctrl.Result{}, nil } +// isUpgradeApproved checks if there's an approved ClusterExtensionRevision for the given version upgrade +func (r *ClusterExtensionReconciler) isUpgradeApproved(ctx context.Context, ext *ocv1.ClusterExtension, targetVersion string) (bool, error) { + logger := log.FromContext(ctx) + + // List all ClusterExtensionRevisions for this ClusterExtension + var revisions ocv1.ClusterExtensionRevisionList + if err := r.List(ctx, &revisions); err != nil { + return false, fmt.Errorf("failed to list ClusterExtensionRevisions: %w", err) + } + + // Filter and look for an approved revision with the target version for this ClusterExtension + for _, revision := range revisions.Items { + if revision.Spec.ClusterExtensionRef.Name == ext.Name && + revision.Spec.Version == targetVersion && + revision.Spec.Approved { + logger.V(4).Info("found approved revision for upgrade", "revision", revision.Name, "version", targetVersion) + return true, nil + } + } + + logger.V(4).Info("no approved revision found for upgrade", "targetVersion", targetVersion) + return false, nil +} + // SetDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension // based on the provided bundle func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) { diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller.go b/internal/operator-controller/controllers/clusterextensionrevision_controller.go new file mode 100644 index 000000000..4d175af35 --- /dev/null +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller.go @@ -0,0 +1,528 @@ +/* +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 controllers + +import ( + "context" + "fmt" + "strings" + "time" + + bsemver "github.com/blang/semver/v4" + "github.com/operator-framework/operator-registry/alpha/declcfg" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + crhandler "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" + "github.com/operator-framework/operator-controller/internal/operator-controller/resolve" +) + +// ClusterExtensionRevisionReconciler reconciles ClusterExtension and ClusterCatalog objects +// to detect available upgrades and create ClusterExtensionRevision resources +type ClusterExtensionRevisionReconciler struct { + client.Client + Scheme *runtime.Scheme + Resolver resolve.Resolver + InstalledBundleGetter InstalledBundleGetter +} + +// +kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensions,verbs=get;list;watch +// +kubebuilder:rbac:groups=olm.operatorframework.io,resources=clustercatalogs,verbs=get;list;watch + +// Reconcile detects available upgrades for ClusterExtensions and manages ClusterExtensionRevision resources +func (r *ClusterExtensionRevisionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithName("clusterextensionrevision-controller") + ctx = log.IntoContext(ctx, logger) + + logger.Info("reconcile starting") + defer logger.Info("reconcile ending") + + // Get the specific ClusterExtension to reconcile + var ext ocv1.ClusterExtension + if err := r.Get(ctx, req.NamespacedName, &ext); err != nil { + if apierrors.IsNotFound(err) { + // ClusterExtension was deleted, clean up any associated revisions + logger.Info("ClusterExtension not found, cleaning up revisions") + return r.cleanupRevisionsForDeletedExtension(ctx, req.Name) + } + logger.Error(err, "failed to get ClusterExtension") + return ctrl.Result{}, err + } + + // Reconcile revisions for this specific ClusterExtension + if err := r.reconcileExtensionRevisions(ctx, &ext); err != nil { + logger.Error(err, "failed to reconcile revisions for ClusterExtension") + return ctrl.Result{}, err + } + + // Check for approved revisions and handle upgrades + if err := r.handleApprovedRevision(ctx, &ext); err != nil { + logger.Error(err, "failed to handle approved revisions for ClusterExtension") + return ctrl.Result{}, err + } + + // Requeue after a reasonable interval to periodically check for upgrades for this extension + return ctrl.Result{RequeueAfter: 30 * time.Minute}, nil +} + +// reconcileExtensionRevisions detects and manages available upgrade revisions for a single ClusterExtension +func (r *ClusterExtensionRevisionReconciler) reconcileExtensionRevisions(ctx context.Context, ext *ocv1.ClusterExtension) error { + logger := log.FromContext(ctx).WithValues("clusterextension", ext.Name) + + // Skip if the extension is not installed yet + if ext.Status.Install == nil { + logger.V(4).Info("skipping ClusterExtension that is not yet installed") + return nil + } + + // Get the currently installed bundle + installedBundle, err := r.InstalledBundleGetter.GetInstalledBundle(ctx, ext) + if err != nil { + return fmt.Errorf("failed to get installed bundle: %w", err) + } + if installedBundle == nil { + logger.V(4).Info("no installed bundle found, skipping") + return nil + } + + // Find available upgrade using the resolver + availableUpgrade, err := r.findAvailableUpgrade(ctx, ext, &installedBundle.BundleMetadata) + if err != nil { + return fmt.Errorf("failed to find available upgrades: %w", err) + } + + if availableUpgrade == nil { + // No upgrade available, nothing to do + logger.V(4).Info("no upgrade available") + return nil + } + + // Clean old revision + if err := r.cleanupObsoleteRevision(ctx, ext, availableUpgrade); err != nil { + logger.Error(err, "failed to cleanup obsolete revisions") + } + + if err := r.ensureRevision(ctx, ext, *availableUpgrade); err != nil { + logger.Error(err, "failed to ensure revision", "version", availableUpgrade.Version) + } + + return nil +} + +// AvailableUpgrade represents an upgrade that's available for a ClusterExtension +type AvailableUpgrade struct { + Bundle *declcfg.Bundle + Version *bsemver.Version +} + +// findAvailableUpgrade uses the existing resolver logic to find available upgrades +func (r *ClusterExtensionRevisionReconciler) findAvailableUpgrade(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*AvailableUpgrade, error) { + logger := log.FromContext(ctx) + + // If no version constraint is specified, find any available upgrade + if ext.Spec.Source.Catalog == nil || ext.Spec.Source.Catalog.Version == "" { + return r.findAnyAvailableUpgrade(ctx, ext, installedBundle) + } + + versionConstraint := ext.Spec.Source.Catalog.Version + + // Check if this is a pinned version (exact version match) + if isPinnedVersion(versionConstraint, installedBundle.Version) { + logger.V(4).Info("version is pinned, no upgrades will be available", "version", versionConstraint, "installed", installedBundle.Version) + return nil, nil // No upgrades for pinned versions + } + + // For version ranges, find upgrades within the range + return r.findUpgradeInVersionRange(ctx, ext, installedBundle) +} + +// isPinnedVersion checks if the version constraint represents a pinned version +// A pinned version is an exact version match without operators (e.g., "1.2.3" but not ">=1.2.3") +func isPinnedVersion(versionConstraint, installedVersion string) bool { + // Check if the constraint only allows exactly one version + // This is a heuristic - we consider it pinned if: + // 1. The constraint string doesn't contain operators like >=, <=, >, <, ~, ^, || + // 2. The constraint matches exactly the installed version + hasOperators := strings.ContainsAny(versionConstraint, "><=~^|") + if hasOperators { + return false + } + + // For a simple version string without operators, we consider it pinned + // if it exactly matches the installed version + return strings.TrimSpace(versionConstraint) == strings.TrimSpace(installedVersion) +} + +// findAnyAvailableUpgrade finds any available upgrade without version constraints +func (r *ClusterExtensionRevisionReconciler) findAnyAvailableUpgrade(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*AvailableUpgrade, error) { + logger := log.FromContext(ctx) + + // Create a modified ClusterExtension spec that removes version constraints + // to find all available bundles, not just those matching the current version spec + extForResolution := ext.DeepCopy() + if extForResolution.Spec.Source.Catalog != nil { + // Remove version constraint to find all available versions + extForResolution.Spec.Source.Catalog.Version = "" + } + + // Use the resolver to find all available bundles + resolvedBundle, resolvedVersion, _, err := r.Resolver.Resolve(ctx, extForResolution, installedBundle) + if err != nil { + logger.V(4).Info("no bundles resolved", "error", err) + return nil, nil // No error, just no upgrades available + } + + // Check if the resolved version is actually an upgrade + installedVersion, err := bsemver.ParseTolerant(installedBundle.Version) + if err != nil { + return nil, fmt.Errorf("failed to parse installed version %q: %w", installedBundle.Version, err) + } + + if !resolvedVersion.GT(installedVersion) { + logger.V(4).Info("resolved version is not an upgrade", "resolved", resolvedVersion.String(), "installed", installedVersion.String()) + return nil, nil // No upgrade available + } + + return &AvailableUpgrade{ + Bundle: resolvedBundle, + Version: resolvedVersion, + }, nil +} + +// findUpgradeInVersionRange finds upgrades within the specified version range +// The version range is taken from ext.Spec.Source.Catalog.Version +func (r *ClusterExtensionRevisionReconciler) findUpgradeInVersionRange(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*AvailableUpgrade, error) { + logger := log.FromContext(ctx) + + versionConstraint := ext.Spec.Source.Catalog.Version + + // Use the resolver to find bundles within the version range + // The resolver will respect the version constraint in ext.Spec.Source.Catalog.Version + resolvedBundle, resolvedVersion, _, err := r.Resolver.Resolve(ctx, ext, installedBundle) + if err != nil { + logger.V(4).Info("no bundles resolved within version range", "versionRange", versionConstraint, "error", err) + return nil, nil // No error, just no upgrades available + } + + // Check if the resolved version is actually an upgrade + installedVersion, err := bsemver.ParseTolerant(installedBundle.Version) + if err != nil { + return nil, fmt.Errorf("failed to parse installed version %q: %w", installedBundle.Version, err) + } + + if !resolvedVersion.GT(installedVersion) { + logger.V(4).Info("resolved version within range is not an upgrade", "resolved", resolvedVersion.String(), "installed", installedVersion.String(), "versionRange", versionConstraint) + return nil, nil // No upgrade available within range + } + + logger.V(4).Info("found upgrade within version range", "resolved", resolvedVersion.String(), "installed", installedVersion.String(), "versionRange", versionConstraint) + + return &AvailableUpgrade{ + Bundle: resolvedBundle, + Version: resolvedVersion, + }, nil +} + +// ensureRevision creates or updates a ClusterExtensionRevision for an available upgrade +func (r *ClusterExtensionRevisionReconciler) ensureRevision(ctx context.Context, ext *ocv1.ClusterExtension, upgrade AvailableUpgrade) error { + logger := log.FromContext(ctx) + + // Generate a name for the revision + revisionName := fmt.Sprintf("%s-%s", ext.Name, upgrade.Version.String()) + + // Check if revision already exists + var existingRevision ocv1.ClusterExtensionRevision + err := r.Get(ctx, types.NamespacedName{Name: revisionName}, &existingRevision) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get existing revision: %w", err) + } + + now := metav1.Now() + bundleMetadata := bundleutil.MetadataFor(upgrade.Bundle.Name, *upgrade.Version) + + if apierrors.IsNotFound(err) { + // Create new revision + revision := &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: revisionName, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: ext.APIVersion, + Kind: ext.Kind, + Name: ext.Name, + UID: ext.UID, + }, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + ClusterExtensionRef: ocv1.ClusterExtensionReference{ + Name: ext.Name, + }, + Version: upgrade.Version.String(), + BundleMetadata: bundleMetadata, + AvailableSince: now, + }, + } + + if err := r.Create(ctx, revision); err != nil { + return fmt.Errorf("failed to create revision: %w", err) + } + + logger.Info("created new ClusterExtensionRevision", "revision", revisionName, "version", upgrade.Version.String()) + } else { + // Update existing revision if needed + // For now, we mainly need to ensure the spec is current + // The AvailableSince timestamp should remain unchanged + logger.V(4).Info("revision already exists", "revision", revisionName) + } + + return nil +} + +// cleanupObsoleteRevisions removes ClusterExtensionRevision resources that are no longer valid +func (r *ClusterExtensionRevisionReconciler) cleanupObsoleteRevision(ctx context.Context, ext *ocv1.ClusterExtension, currentUpgrade *AvailableUpgrade) error { + logger := log.FromContext(ctx) + + // List all existing revisions for this ClusterExtension + var revisions ocv1.ClusterExtensionRevisionList + if err := r.List(ctx, &revisions, client.MatchingFields{"spec.clusterExtensionRef.name": ext.Name}); err != nil { + return fmt.Errorf("failed to list existing revisions: %w", err) + } + + // Delete revisions that are no longer available + for _, revision := range revisions.Items { + if revision.Spec.Version != currentUpgrade.Version.String() { + logger.Info("deleting obsolete revision", "revision", revision.Name, "version", revision.Spec.Version) + if err := r.Delete(ctx, &revision); err != nil { + logger.Error(err, "failed to delete obsolete revision", "revision", revision.Name) + } + } + } + + return nil +} + +// cleanupRevisionsForDeletedExtension removes all ClusterExtensionRevision resources for a deleted ClusterExtension +func (r *ClusterExtensionRevisionReconciler) cleanupRevisionsForDeletedExtension(ctx context.Context, extensionName string) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // List all revisions for this ClusterExtension + var revisions ocv1.ClusterExtensionRevisionList + if err := r.List(ctx, &revisions, client.MatchingFields{"spec.clusterExtensionRef.name": extensionName}); err != nil { + logger.Error(err, "failed to list revisions for deleted ClusterExtension", "extensionName", extensionName) + return ctrl.Result{}, err + } + + // Delete all revisions for the deleted extension + for _, revision := range revisions.Items { + logger.Info("deleting revision for deleted ClusterExtension", "revision", revision.Name, "extensionName", extensionName) + if err := r.Delete(ctx, &revision); err != nil { + logger.Error(err, "failed to delete revision for deleted ClusterExtension", "revision", revision.Name) + } + } + + return ctrl.Result{}, nil +} + +// handleApprovedRevision handles the approved ClusterExtensionRevision resource and upgrades the corresponding ClusterExtension +func (r *ClusterExtensionRevisionReconciler) handleApprovedRevision(ctx context.Context, ext *ocv1.ClusterExtension) error { + logger := log.FromContext(ctx).WithValues("clusterextension", ext.Name) + + // List all revisions for this ClusterExtension (should be at most one) + var revisions ocv1.ClusterExtensionRevisionList + if err := r.List(ctx, &revisions, client.MatchingFields{"spec.clusterExtensionRef.name": ext.Name}); err != nil { + return fmt.Errorf("failed to list revisions: %w", err) + } + + // Find the approved revision (there should be at most one) + var approvedRevision *ocv1.ClusterExtensionRevision + for _, revision := range revisions.Items { + if revision.Spec.Approved { + if approvedRevision != nil { + // This shouldn't happen given our design constraint, but let's be defensive + logger.Error(nil, "multiple approved revisions found, this should not happen", + "existing", approvedRevision.Name, "duplicate", revision.Name) + continue + } + approvedRevision = &revision + } + } + + // If no approved revision found, nothing to do + if approvedRevision == nil { + return nil + } + + // Set approvedAt timestamp if not already set + if approvedRevision.Spec.ApprovedAt == nil { + now := metav1.Now() + approvedRevision.Spec.ApprovedAt = &now + if err := r.Update(ctx, approvedRevision); err != nil { + return fmt.Errorf("failed to update revision with approval timestamp: %w", err) + } + logger.Info("set approval timestamp for revision", "revision", approvedRevision.Name, "approvedAt", now) + } + + logger.Info("handling approved revision", "revision", approvedRevision.Name, "version", approvedRevision.Spec.Version) + + // Upgrade the ClusterExtension + if err := r.upgradeClusterExtension(ctx, ext, approvedRevision); err != nil { + return fmt.Errorf("failed to upgrade ClusterExtension: %w", err) + } + + return nil +} + +// upgradeClusterExtension handles the approved revision lifecycle +func (r *ClusterExtensionRevisionReconciler) upgradeClusterExtension(ctx context.Context, ext *ocv1.ClusterExtension, revision *ocv1.ClusterExtensionRevision) error { + logger := log.FromContext(ctx).WithValues("clusterextension", ext.Name, "revision", revision.Name) + + // Check if the upgrade has already been completed + if ext.Status.Install != nil && ext.Status.Install.Bundle.Version == revision.Spec.Version { + // Upgrade completed, clean up the revision + logger.Info("upgrade completed, cleaning up revision", "version", revision.Spec.Version) + + // Delete the completed revision + if err := r.Delete(ctx, revision); err != nil { + return fmt.Errorf("failed to delete completed revision: %w", err) + } + + return nil + } + + // The approved revision exists and upgrade hasn't completed yet + // The ClusterExtension controller will detect this approved revision during its reconcile + logger.Info("approved revision ready for upgrade", "version", revision.Spec.Version) + return nil +} + +// SetupWithManager sets up the controller with the Manager +func (r *ClusterExtensionRevisionReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Add index for efficient lookups of revisions by ClusterExtension name + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &ocv1.ClusterExtensionRevision{}, + "spec.clusterExtensionRef.name", + func(rawObj client.Object) []string { + revision := rawObj.(*ocv1.ClusterExtensionRevision) + return []string{revision.Spec.ClusterExtensionRef.Name} + }, + ); err != nil { + return err + } + + _, err := ctrl.NewControllerManagedBy(mgr). + For(&ocv1.ClusterExtension{}). + Owns(&ocv1.ClusterExtensionRevision{}). + Named("clusterextensionrevision-controller"). + Watches(&ocv1.ClusterCatalog{}, + crhandler.EnqueueRequestsFromMapFunc(r.catalogToExtensionRequests), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(ue event.UpdateEvent) bool { + // Only trigger when catalog content changes (similar to ClusterExtension controller) + oldCatalog, isOldCatalog := ue.ObjectOld.(*ocv1.ClusterCatalog) + newCatalog, isNewCatalog := ue.ObjectNew.(*ocv1.ClusterCatalog) + + if !isOldCatalog || !isNewCatalog { + return true + } + + if oldCatalog.Status.ResolvedSource != nil && newCatalog.Status.ResolvedSource != nil { + if oldCatalog.Status.ResolvedSource.Image != nil && newCatalog.Status.ResolvedSource.Image != nil { + return oldCatalog.Status.ResolvedSource.Image.Ref != newCatalog.Status.ResolvedSource.Image.Ref + } + } + return true + }, + })). + Watches(&ocv1.ClusterExtensionRevision{}, + crhandler.EnqueueRequestsFromMapFunc(r.revisionToExtensionRequests), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(ue event.UpdateEvent) bool { + // Only trigger when the approved field changes from false to true + oldRevision, isOldRevision := ue.ObjectOld.(*ocv1.ClusterExtensionRevision) + newRevision, isNewRevision := ue.ObjectNew.(*ocv1.ClusterExtensionRevision) + + if !isOldRevision || !isNewRevision { + return false + } + + // Trigger reconcile when approved changes from false to true + return !oldRevision.Spec.Approved && newRevision.Spec.Approved + }, + })). + Build(r) + return err +} + +// catalogToExtensionRequests generates reconcile requests for all ClusterExtensions when a catalog changes +func (r *ClusterExtensionRevisionReconciler) catalogToExtensionRequests(ctx context.Context, obj client.Object) []reconcile.Request { + logger := log.FromContext(ctx) + + // List all ClusterExtensions and create reconcile requests for each one + // This follows the same pattern as the existing ClusterExtension controller + clusterExtensions := metav1.PartialObjectMetadataList{} + clusterExtensions.SetGroupVersionKind(ocv1.GroupVersion.WithKind("ClusterExtensionList")) + err := r.List(ctx, &clusterExtensions) + if err != nil { + logger.Error(err, "unable to enqueue cluster extensions for catalog reconcile") + return nil + } + + var requests []reconcile.Request + for _, ext := range clusterExtensions.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: ext.GetNamespace(), + Name: ext.GetName(), + }, + }) + } + + logger.V(4).Info("enqueuing reconcile requests for catalog change", "numExtensions", len(requests)) + return requests +} + +// revisionToExtensionRequests generates reconcile requests for ClusterExtensions when a revision is approved +func (r *ClusterExtensionRevisionReconciler) revisionToExtensionRequests(ctx context.Context, obj client.Object) []reconcile.Request { + revision, ok := obj.(*ocv1.ClusterExtensionRevision) + if !ok { + return nil + } + + // Return a reconcile request for the ClusterExtension referenced by this revision + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: revision.Spec.ClusterExtensionRef.Name, + }, + }, + } +} diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go b/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go new file mode 100644 index 000000000..cb412cd95 --- /dev/null +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go @@ -0,0 +1,332 @@ +/* +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 controllers + +import ( + "context" + "testing" + + bsemver "github.com/blang/semver/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// mockResolver is a test implementation of the Resolver interface +type mockResolver struct { + resolveFunc func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) +} + +func (m *mockResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { + return m.resolveFunc(ctx, ext, installedBundle) +} + +// mockInstalledBundleGetter is a test implementation of the InstalledBundleGetter interface +type mockInstalledBundleGetter struct { + getInstalledBundleFunc func(ctx context.Context, ext *ocv1.ClusterExtension) (*InstalledBundle, error) +} + +func (m *mockInstalledBundleGetter) GetInstalledBundle(ctx context.Context, ext *ocv1.ClusterExtension) (*InstalledBundle, error) { + return m.getInstalledBundleFunc(ctx, ext) +} + +func TestIsPinnedVersion(t *testing.T) { + testCases := []struct { + name string + versionConstraint string + installedVersion string + expected bool + }{ + { + name: "exact version match - pinned", + versionConstraint: "1.2.3", + installedVersion: "1.2.3", + expected: true, + }, + { + name: "exact version mismatch - not pinned", + versionConstraint: "1.2.3", + installedVersion: "1.2.4", + expected: false, + }, + { + name: "range constraint - not pinned", + versionConstraint: ">=1.2.3", + installedVersion: "1.2.3", + expected: false, + }, + { + name: "range constraint with upper bound - not pinned", + versionConstraint: ">=1.2.3, <2.0.0", + installedVersion: "1.2.3", + expected: false, + }, + { + name: "tilde constraint - not pinned", + versionConstraint: "~1.2.3", + installedVersion: "1.2.3", + expected: false, + }, + { + name: "caret constraint - not pinned", + versionConstraint: "^1.2.3", + installedVersion: "1.2.3", + expected: false, + }, + { + name: "OR constraint - not pinned", + versionConstraint: "1.2.3 || 1.2.4", + installedVersion: "1.2.3", + expected: false, + }, + { + name: "whitespace handling - pinned", + versionConstraint: " 1.2.3 ", + installedVersion: " 1.2.3 ", + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := isPinnedVersion(tc.versionConstraint, tc.installedVersion) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestFindAvailableUpgrade_PinnedVersion(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(scheme)) + + // Create a ClusterExtension with a pinned version + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{ + PackageName: "test-package", + Version: "1.2.3", // Pinned version + }, + }, + }, + } + + installedBundle := &ocv1.BundleMetadata{ + Name: "test-package.v1.2.3", + Version: "1.2.3", + } + + // Mock resolver - shouldn't be called for pinned versions + mockRes := &mockResolver{ + resolveFunc: func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { + t.Error("Resolver should not be called for pinned versions") + return nil, nil, nil, nil + }, + } + + mockBundleGetter := &mockInstalledBundleGetter{} + + reconciler := &ClusterExtensionRevisionReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Scheme: scheme, + Resolver: mockRes, + InstalledBundleGetter: mockBundleGetter, + } + + ctx := context.Background() + upgrade, err := reconciler.findAvailableUpgrade(ctx, ext, installedBundle) + + require.NoError(t, err) + assert.Nil(t, upgrade, "No upgrade should be available for pinned versions") +} + +func TestFindAvailableUpgrade_VersionRange(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(scheme)) + + // Create a ClusterExtension with a version range + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{ + PackageName: "test-package", + Version: ">=1.2.0, <2.0.0", // Version range + }, + }, + }, + } + + installedBundle := &ocv1.BundleMetadata{ + Name: "test-package.v1.2.3", + Version: "1.2.3", + } + + // Mock resolver to return an upgrade within the range + expectedBundle := &declcfg.Bundle{ + Name: "test-package.v1.3.0", + } + expectedVersion := bsemver.MustParse("1.3.0") + + mockRes := &mockResolver{ + resolveFunc: func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { + // Verify that the original extension (with version constraint) is passed + assert.Equal(t, ">=1.2.0, <2.0.0", ext.Spec.Source.Catalog.Version) + return expectedBundle, &expectedVersion, nil, nil + }, + } + + mockBundleGetter := &mockInstalledBundleGetter{} + + reconciler := &ClusterExtensionRevisionReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Scheme: scheme, + Resolver: mockRes, + InstalledBundleGetter: mockBundleGetter, + } + + ctx := context.Background() + upgrade, err := reconciler.findAvailableUpgrade(ctx, ext, installedBundle) + + require.NoError(t, err) + require.NotNil(t, upgrade) + assert.Equal(t, expectedBundle, upgrade.Bundle) + assert.Equal(t, &expectedVersion, upgrade.Version) +} + +func TestFindAvailableUpgrade_NoVersionConstraint(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(scheme)) + + // Create a ClusterExtension without version constraint + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{ + PackageName: "test-package", + // No version constraint + }, + }, + }, + } + + installedBundle := &ocv1.BundleMetadata{ + Name: "test-package.v1.2.3", + Version: "1.2.3", + } + + // Mock resolver to return an upgrade + expectedBundle := &declcfg.Bundle{ + Name: "test-package.v2.0.0", + } + expectedVersion := bsemver.MustParse("2.0.0") + + mockRes := &mockResolver{ + resolveFunc: func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { + // Verify that version constraint is removed + assert.Empty(t, ext.Spec.Source.Catalog.Version) + return expectedBundle, &expectedVersion, nil, nil + }, + } + + mockBundleGetter := &mockInstalledBundleGetter{} + + reconciler := &ClusterExtensionRevisionReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Scheme: scheme, + Resolver: mockRes, + InstalledBundleGetter: mockBundleGetter, + } + + ctx := context.Background() + upgrade, err := reconciler.findAvailableUpgrade(ctx, ext, installedBundle) + + require.NoError(t, err) + require.NotNil(t, upgrade) + assert.Equal(t, expectedBundle, upgrade.Bundle) + assert.Equal(t, &expectedVersion, upgrade.Version) +} + +func TestFindAvailableUpgrade_NoUpgradeAvailable(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(scheme)) + + // Create a ClusterExtension with version range + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{ + PackageName: "test-package", + Version: ">=1.2.0, <2.0.0", + }, + }, + }, + } + + installedBundle := &ocv1.BundleMetadata{ + Name: "test-package.v1.5.0", + Version: "1.5.0", + } + + // Mock resolver to return the same version (no upgrade) + currentBundle := &declcfg.Bundle{ + Name: "test-package.v1.5.0", + } + currentVersion := bsemver.MustParse("1.5.0") + + mockRes := &mockResolver{ + resolveFunc: func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { + return currentBundle, ¤tVersion, nil, nil + }, + } + + mockBundleGetter := &mockInstalledBundleGetter{} + + reconciler := &ClusterExtensionRevisionReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Scheme: scheme, + Resolver: mockRes, + InstalledBundleGetter: mockBundleGetter, + } + + ctx := context.Background() + upgrade, err := reconciler.findAvailableUpgrade(ctx, ext, installedBundle) + + require.NoError(t, err) + assert.Nil(t, upgrade, "No upgrade should be available when resolved version is not newer") +} diff --git a/manifests/standard.yaml b/manifests/standard.yaml index da08382a7..c8116e7c8 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -450,6 +450,217 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: clusterextensionrevisions.olm.operatorframework.io +spec: + group: olm.operatorframework.io + names: + kind: ClusterExtensionRevision + listKind: ClusterExtensionRevisionList + plural: clusterextensionrevisions + singular: clusterextensionrevision + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterExtensionRef.name + name: Extension + type: string + - jsonPath: .spec.version + name: Version + type: string + - jsonPath: .spec.approved + name: Approved + type: boolean + - jsonPath: .spec.approvedAt + name: Approved At + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + ClusterExtensionRevision represents an available upgrade for a ClusterExtension. + It is created automatically by the operator-controller when new versions become + available in catalogs that represent valid upgrade paths for installed ClusterExtensions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the available upgrade revision details. + properties: + approved: + description: |- + approved indicates whether this upgrade revision has been approved for execution. + When set to true, the controller will automatically update the corresponding + ClusterExtension to trigger the upgrade to this version. + type: boolean + approvedAt: + description: |- + approvedAt indicates when this upgrade revision was approved for execution. + This field is set automatically when the approved field changes from false to true. + format: date-time + type: string + availableSince: + description: |- + availableSince indicates when this upgrade revision was first detected + as being available. This helps track how long an upgrade has been pending. + format: date-time + type: string + bundleMetadata: + description: |- + bundleMetadata contains the complete metadata for the bundle that represents + this available upgrade. + properties: + name: + description: |- + name is required and follows the DNS subdomain standard + as defined in [RFC 1123]. It must contain only lowercase alphanumeric characters, + hyphens (-) or periods (.), start and end with an alphanumeric character, + and be no longer than 253 characters. + type: string + x-kubernetes-validations: + - message: packageName must be a valid DNS1123 subdomain. It must + contain only lowercase alphanumeric characters, hyphens (-) + or periods (.), start and end with an alphanumeric character, + and be no longer than 253 characters + rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$") + version: + description: |- + version is a required field and is a reference to the version that this bundle represents + version follows the semantic versioning standard as defined in https://semver.org/. + type: string + x-kubernetes-validations: + - message: version must be well-formed semver + rule: self.matches("^([0-9]+)(\\.[0-9]+)?(\\.[0-9]+)?(-([-0-9A-Za-z]+(\\.[-0-9A-Za-z]+)*))?(\\+([-0-9A-Za-z]+(-\\.[-0-9A-Za-z]+)*))?") + required: + - name + - version + type: object + clusterExtensionRef: + description: |- + clusterExtensionRef is a required reference to the ClusterExtension + that this revision represents an available upgrade for. + properties: + name: + description: name is the name of the ClusterExtension + maxLength: 253 + type: string + x-kubernetes-validations: + - message: name must be a valid DNS1123 subdomain + rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$") + required: + - name + type: object + version: + description: |- + version is a required field that specifies the exact version of the bundle + that represents this available upgrade. + + version follows the semantic versioning standard as defined in https://semver.org/. + type: string + x-kubernetes-validations: + - message: version must be well-formed semver + rule: self.matches("^([0-9]+)(\\.[0-9]+)?(\\.[0-9]+)?(-([-0-9A-Za-z]+(\\.[-0-9A-Za-z]+)*))?(\\+([-0-9A-Za-z]+(-\\.[-0-9A-Za-z]+)*))?") + required: + - availableSince + - bundleMetadata + - clusterExtensionRef + - version + type: object + status: + description: status represents the current status of this ClusterExtensionRevision. + properties: + conditions: + description: conditions represent the latest available observations + of the ClusterExtensionRevision's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.17.3 @@ -1298,6 +1509,26 @@ rules: - get - list - watch +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensionrevisions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - olm.operatorframework.io + resources: + - clusterextensionrevisions/status + verbs: + - get + - patch + - update - apiGroups: - olm.operatorframework.io resources: