From a313edb69ef88a11585acefa206fdcb548e5a567 Mon Sep 17 00:00:00 2001 From: grokspawn Date: Fri, 4 Sep 2026 11:03:13 -0500 Subject: [PATCH 1/2] implementation of a source-sniffing direct bundle installer Signed-off-by: grokspawn --- api/v1/clusterextension_types.go | 37 +++++- api/v1/zz_generated.deepcopy.go | 20 +++ .../api/v1/clusterextensionspec.go | 1 - applyconfigurations/api/v1/ociimagesource.go | 41 ++++++ applyconfigurations/api/v1/sourceconfig.go | 18 ++- applyconfigurations/internal/internal.go | 9 ++ applyconfigurations/utils.go | 4 +- cmd/operator-controller/main.go | 13 +- docs/api-reference/olmv1-api-reference.md | 21 +++- ...peratorframework.io_clusterextensions.yaml | 50 +++++++- ...peratorframework.io_clusterextensions.yaml | 50 +++++++- .../clusterextension_admission_test.go | 48 +++++++ .../clusterextension_reconcile_steps.go | 41 ++++++ .../controllers/direct_bundle_test.go | 35 ++++++ .../operator-controller/resolve/ociimage.go | 118 ++++++++++++++++++ .../resolve/ociimage_test.go | 70 +++++++++++ .../operator-controller/resolve/resolver.go | 18 +++ manifests/experimental-e2e.yaml | 50 +++++++- manifests/experimental.yaml | 50 +++++++- manifests/standard-e2e.yaml | 50 +++++++- manifests/standard.yaml | 50 +++++++- 21 files changed, 771 insertions(+), 23 deletions(-) create mode 100644 applyconfigurations/api/v1/ociimagesource.go create mode 100644 internal/operator-controller/controllers/direct_bundle_test.go create mode 100644 internal/operator-controller/resolve/ociimage.go create mode 100644 internal/operator-controller/resolve/ociimage_test.go diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index 6f7912ae9b..80b5560f30 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -79,7 +79,6 @@ type ClusterExtensionSpec struct { // source is required and selects the installation source of content for this ClusterExtension. // Set the sourceType field to perform the selection. // - // Catalog is currently the only implemented sourceType. // Setting sourceType to "Catalog" requires the catalog field to also be defined. // // Below is a minimal example of a source definition (in yaml): @@ -122,23 +121,30 @@ type ClusterExtensionSpec struct { ProgressDeadlineMinutes int32 `json:"progressDeadlineMinutes,omitempty"` } -const SourceTypeCatalog = "Catalog" +const ( + SourceTypeCatalog = "Catalog" + SourceTypeOCIImage = "OCIImage" +) // SourceConfig is a discriminated union which selects the installation source. // // +union // +kubebuilder:validation:XValidation:rule="has(self.sourceType) && self.sourceType == 'Catalog' ? has(self.catalog) : !has(self.catalog)",message="catalog is required when sourceType is Catalog, and forbidden otherwise" +// +kubebuilder:validation:XValidation:rule="has(self.sourceType) && self.sourceType == 'OCIImage' ? has(self.ociImage) : !has(self.ociImage)",message="ociImage is required when sourceType is OCIImage, and forbidden otherwise" type SourceConfig struct { // sourceType is required and specifies the type of install source. // - // The only allowed value is "Catalog". + // The allowed values are "Catalog" and "OCIImage". + // + // When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + // dependency resolution and are only supported by the Boxcutter runtime. // // When set to "Catalog", information for determining the appropriate bundle of content to install // is fetched from ClusterCatalog resources on the cluster. // When using the Catalog sourceType, the catalog field must also be set. // // +unionDiscriminator - // +kubebuilder:validation:Enum:="Catalog" + // +kubebuilder:validation:Enum:="Catalog";"OCIImage" // +required SourceType string `json:"sourceType"` @@ -147,6 +153,29 @@ type SourceConfig struct { // // +optional Catalog *CatalogFilter `json:"catalog,omitempty"` + + // ociImage configures a bundle image to install directly. + // They do not provide catalog dependency resolution or upgrade safety. + // + // +optional + OCIImage *OCIImageSource `json:"ociImage,omitempty"` +} + +// OCIImageSource identifies a bundle image to install directly from an OCI registry. +type OCIImageSource struct { + // ref is a Docker-style image reference with a tag or digest. + // + // +required + // +kubebuilder:validation:MaxLength:=1000 + // +kubebuilder:validation:XValidation:rule="self.matches(\"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\\\b\")",message="must start with a valid domain" + // +kubebuilder:validation:XValidation:rule="self.find(\"(\\\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)\") != \"\"",message="a valid image name is required" + // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" || self.find(\":.*$\") != \"\"",message="must end with a digest or a tag" + // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") == \"\" ? (self.find(\":.*$\") != \"\" ? self.find(\":.*$\").substring(1).size() <= 127 : true) : true",message="tag is invalid" + // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") == \"\" ? (self.find(\":.*$\") != \"\" ? self.find(\":.*$\").matches(\":[\\\\w][\\\\w.-]*$\") : true) : true",message="tag is invalid" + // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" ? self.find(\"(@.*:)\").matches(\"(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])\") : true",message="digest algorithm is not valid" + // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" ? self.find(\":.*$\").substring(1).size() >= 32 : true",message="digest is not valid" + // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" ? self.find(\":.*$\").matches(\":[0-9A-Fa-f]*$\") : true",message="digest is not valid" + Ref string `json:"ref"` } // ClusterExtensionInstallConfig is a union which selects the clusterExtension installation config. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 6836216378..80967b6aba 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -642,6 +642,21 @@ func (in *ImageSource) DeepCopy() *ImageSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OCIImageSource) DeepCopyInto(out *OCIImageSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIImageSource. +func (in *OCIImageSource) DeepCopy() *OCIImageSource { + if in == nil { + return nil + } + out := new(OCIImageSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectSelector) DeepCopyInto(out *ObjectSelector) { *out = *in @@ -810,6 +825,11 @@ func (in *SourceConfig) DeepCopyInto(out *SourceConfig) { *out = new(CatalogFilter) (*in).DeepCopyInto(*out) } + if in.OCIImage != nil { + in, out := &in.OCIImage, &out.OCIImage + *out = new(OCIImageSource) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceConfig. diff --git a/applyconfigurations/api/v1/clusterextensionspec.go b/applyconfigurations/api/v1/clusterextensionspec.go index 47d810a74a..cf0c910a4c 100644 --- a/applyconfigurations/api/v1/clusterextensionspec.go +++ b/applyconfigurations/api/v1/clusterextensionspec.go @@ -43,7 +43,6 @@ type ClusterExtensionSpecApplyConfiguration struct { // source is required and selects the installation source of content for this ClusterExtension. // Set the sourceType field to perform the selection. // - // Catalog is currently the only implemented sourceType. // Setting sourceType to "Catalog" requires the catalog field to also be defined. // // Below is a minimal example of a source definition (in yaml): diff --git a/applyconfigurations/api/v1/ociimagesource.go b/applyconfigurations/api/v1/ociimagesource.go new file mode 100644 index 0000000000..11ee5f265d --- /dev/null +++ b/applyconfigurations/api/v1/ociimagesource.go @@ -0,0 +1,41 @@ +/* +Copyright 2022. + +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. +*/ +// Code generated by controller-gen-v0.21. DO NOT EDIT. + +package v1 + +// OCIImageSourceApplyConfiguration represents a declarative configuration of the OCIImageSource type for use +// with apply. +// +// OCIImageSource identifies a bundle image to install directly from an OCI registry. +type OCIImageSourceApplyConfiguration struct { + // ref is a Docker-style image reference with a tag or digest. + Ref *string `json:"ref,omitempty"` +} + +// OCIImageSourceApplyConfiguration constructs a declarative configuration of the OCIImageSource type for use with +// apply. +func OCIImageSource() *OCIImageSourceApplyConfiguration { + return &OCIImageSourceApplyConfiguration{} +} + +// WithRef sets the Ref field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Ref field is set to the value of the last call. +func (b *OCIImageSourceApplyConfiguration) WithRef(value string) *OCIImageSourceApplyConfiguration { + b.Ref = &value + return b +} diff --git a/applyconfigurations/api/v1/sourceconfig.go b/applyconfigurations/api/v1/sourceconfig.go index 13221594a1..4b39793b5f 100644 --- a/applyconfigurations/api/v1/sourceconfig.go +++ b/applyconfigurations/api/v1/sourceconfig.go @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by controller-gen-v0.20. DO NOT EDIT. +// Code generated by controller-gen-v0.21. DO NOT EDIT. package v1 @@ -24,7 +24,10 @@ package v1 type SourceConfigApplyConfiguration struct { // sourceType is required and specifies the type of install source. // - // The only allowed value is "Catalog". + // The allowed values are "Catalog" and "OCIImage". + // + // When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + // dependency resolution and are only supported by the Boxcutter runtime. // // When set to "Catalog", information for determining the appropriate bundle of content to install // is fetched from ClusterCatalog resources on the cluster. @@ -33,6 +36,9 @@ type SourceConfigApplyConfiguration struct { // catalog configures how information is sourced from a catalog. // It is required when sourceType is "Catalog", and forbidden otherwise. Catalog *CatalogFilterApplyConfiguration `json:"catalog,omitempty"` + // ociImage configures a bundle image to install directly. + // They do not provide catalog dependency resolution or upgrade safety. + OCIImage *OCIImageSourceApplyConfiguration `json:"ociImage,omitempty"` } // SourceConfigApplyConfiguration constructs a declarative configuration of the SourceConfig type for use with @@ -56,3 +62,11 @@ func (b *SourceConfigApplyConfiguration) WithCatalog(value *CatalogFilterApplyCo b.Catalog = value return b } + +// WithOCIImage sets the OCIImage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OCIImage field is set to the value of the last call. +func (b *SourceConfigApplyConfiguration) WithOCIImage(value *OCIImageSourceApplyConfiguration) *SourceConfigApplyConfiguration { + b.OCIImage = value + return b +} diff --git a/applyconfigurations/internal/internal.go b/applyconfigurations/internal/internal.go index dde5aaf513..d7bac0104e 100644 --- a/applyconfigurations/internal/internal.go +++ b/applyconfigurations/internal/internal.go @@ -381,6 +381,12 @@ var schemaYAML = typed.YAMLObject(`types: - name: ref type: scalar: string +- name: com.github.operator-framework.operator-controller.api.v1.OCIImageSource + map: + fields: + - name: ref + type: + scalar: string - name: com.github.operator-framework.operator-controller.api.v1.ObjectSelector map: fields: @@ -477,6 +483,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: catalog type: namedType: com.github.operator-framework.operator-controller.api.v1.CatalogFilter + - name: ociImage + type: + namedType: com.github.operator-framework.operator-controller.api.v1.OCIImageSource - name: sourceType type: scalar: string diff --git a/applyconfigurations/utils.go b/applyconfigurations/utils.go index 6a467f96a6..6b09afb643 100644 --- a/applyconfigurations/utils.go +++ b/applyconfigurations/utils.go @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -// Code generated by controller-gen-v0.20. DO NOT EDIT. +// Code generated by controller-gen-v0.21. DO NOT EDIT. package applyconfigurations @@ -85,6 +85,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1.ObjectSourceRefApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("ObservedPhase"): return &apiv1.ObservedPhaseApplyConfiguration{} + case v1.SchemeGroupVersion.WithKind("OCIImageSource"): + return &apiv1.OCIImageSourceApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("PreflightConfig"): return &apiv1.PreflightConfigApplyConfiguration{} case v1.SchemeGroupVersion.WithKind("ProgressionProbe"): diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 2fcea83ef0..5be07f7351 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -434,7 +434,7 @@ func run() error { return catalogclient.BuildHTTPClient(cpwCatalogd) }) - resolver := &resolve.CatalogResolver{ + catalogResolver := &resolve.CatalogResolver{ WalkCatalogsFunc: resolve.CatalogWalker( func(ctx context.Context, option ...client.ListOption) ([]ocv1.ClusterCatalog, error) { var catalogs ocv1.ClusterCatalogList @@ -449,6 +449,15 @@ func run() error { resolve.NoDependencyValidation, }, } + resolver := resolve.MultiResolver{ + ocv1.SourceTypeCatalog: catalogResolver, + } + if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + resolver.RegisterType(ocv1.SourceTypeOCIImage, &resolve.OCIImageResolver{ + Puller: imagePuller, + Cache: imageCache, + }) + } aeClient, err := apiextensionsv1client.NewForConfig(mgr.GetConfig()) if err != nil { @@ -654,6 +663,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl controllers.HandleFinalizers(c.finalizers), controllers.ValidateClusterExtension( controllers.ServiceAccountDeprecationWarning(), + controllers.DirectBundleRequiresBoxcutter(), ), controllers.MigrateStorage(storageMigrator), controllers.RetrieveRevisionStates(revisionStatesGetter), @@ -742,6 +752,7 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster controllers.HandleFinalizers(c.finalizers), controllers.ValidateClusterExtension( controllers.ServiceAccountDeprecationWarning(), + controllers.DirectBundleRequiresBoxcutter(), ), controllers.RetrieveRevisionStates(revisionStatesGetter), controllers.ResolveBundle(c.resolver, c.mgr.GetClient()), diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 1d686238ca..4d888840fa 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -360,7 +360,7 @@ _Appears in:_ | --- | --- | --- | --- | | `namespace` _string_ | namespace specifies a Kubernetes namespace.
It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
Some extensions may contain namespace-scoped resources to be applied in other namespaces.
This namespace must exist.
The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxLength: 63
Required: \{\}
| | `serviceAccount` _[ServiceAccountReference](#serviceaccountreference)_ | serviceAccount is a deprecated field and is completely ignored.
OLMv1 is a single-tenant system where users with ClusterExtension write access are
effectively delegated cluster-admin trust. The operator-controller runs with
cluster-admin privileges and uses its own service account for all cluster interactions.
Deprecated: serviceAccount is no longer used and will be removed in a future release. | | MinProperties: 1
Optional: \{\}
| -| `source` _[SourceConfig](#sourceconfig)_ | source is required and selects the installation source of content for this ClusterExtension.
Set the sourceType field to perform the selection.
Catalog is currently the only implemented sourceType.
Setting sourceType to "Catalog" requires the catalog field to also be defined.
Below is a minimal example of a source definition (in yaml):
source:
sourceType: Catalog
catalog:
packageName: example-package | | Required: \{\}
| +| `source` _[SourceConfig](#sourceconfig)_ | source is required and selects the installation source of content for this ClusterExtension.
Set the sourceType field to perform the selection.
Setting sourceType to "Catalog" requires the catalog field to also be defined.
Below is a minimal example of a source definition (in yaml):
source:
sourceType: Catalog
catalog:
packageName: example-package | | Required: \{\}
| | `install` _[ClusterExtensionInstallConfig](#clusterextensioninstallconfig)_ | install is optional and configures installation options for the ClusterExtension,
such as the pre-flight check configuration. | | Optional: \{\}
| | `config` _[ClusterExtensionConfig](#clusterextensionconfig)_ | config is optional and specifies bundle-specific configuration.
Configuration is bundle-specific and a bundle may provide a configuration schema.
When not specified, the default configuration of the resolved bundle is used.
config is validated against a configuration schema provided by the resolved bundle. If the bundle does not provide
a configuration schema the bundle is deemed to not be configurable. More information on how
to configure bundles can be found in the OLM documentation associated with your current OLM version.
| | Optional: \{\}
| | `progressDeadlineMinutes` _integer_ | progressDeadlineMinutes is an optional field that defines the maximum period
of time in minutes after which an installation should be considered failed and
require manual intervention. This functionality is disabled when no value
is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours).
| | Maximum: 720
Minimum: 10
Optional: \{\}
| @@ -457,6 +457,22 @@ _Appears in:_ | `pollIntervalMinutes` _integer_ | pollIntervalMinutes is an optional field that sets the interval, in minutes, at which the image source is polled for new content.
You cannot specify pollIntervalMinutes when ref is a digest-based reference.
When omitted, the image is not polled for new content. | | Minimum: 1
Optional: \{\}
| +#### OCIImageSource + + + +OCIImageSource identifies a bundle image to install directly from an OCI registry. + + + +_Appears in:_ +- [SourceConfig](#sourceconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `ref` _string_ | ref is a Docker-style image reference with a tag or digest. | | MaxLength: 1000
Required: \{\}
| + + #### ObjectSelector @@ -613,8 +629,9 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `sourceType` _string_ | sourceType is required and specifies the type of install source.
The only allowed value is "Catalog".
When set to "Catalog", information for determining the appropriate bundle of content to install
is fetched from ClusterCatalog resources on the cluster.
When using the Catalog sourceType, the catalog field must also be set. | | Enum: [Catalog]
Required: \{\}
| +| `sourceType` _string_ | sourceType is required and specifies the type of install source.
The allowed values are "Catalog" and "OCIImage".
When set to "OCIImage", the bundle image is used directly. Direct sources do not perform
dependency resolution and are only supported by the Boxcutter runtime.
When set to "Catalog", information for determining the appropriate bundle of content to install
is fetched from ClusterCatalog resources on the cluster.
When using the Catalog sourceType, the catalog field must also be set. | | Enum: [Catalog OCIImage]
Required: \{\}
| | `catalog` _[CatalogFilter](#catalogfilter)_ | catalog configures how information is sourced from a catalog.
It is required when sourceType is "Catalog", and forbidden otherwise. | | Optional: \{\}
| +| `ociImage` _[OCIImageSource](#ociimagesource)_ | ociImage configures a bundle image to install directly.
They do not provide catalog dependency resolution or upgrade safety. | | Optional: \{\}
| #### SourceType diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml index 3082a69946..f235618dcc 100644 --- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml +++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml @@ -223,7 +223,6 @@ spec: source is required and selects the installation source of content for this ClusterExtension. Set the sourceType field to perform the selection. - Catalog is currently the only implemented sourceType. Setting sourceType to "Catalog" requires the catalog field to also be defined. Below is a minimal example of a source definition (in yaml): @@ -472,17 +471,60 @@ spec: required: - packageName type: object + ociImage: + description: |- + ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + properties: + ref: + description: ref is a Docker-style image reference with a + tag or digest. + maxLength: 1000 + type: string + x-kubernetes-validations: + - message: must start with a valid domain + rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") + - message: a valid image name is required + rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") + != "" + - message: must end with a digest or a tag + rule: self.find("(@.*:)") != "" || self.find(":.*$") != + "" + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").substring(1).size() <= 127 : true) + : true' + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) + : true' + - message: digest algorithm is not valid + rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") + : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() + >= 32 : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") + : true' + required: + - ref + type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The only allowed value is "Catalog". + The allowed values are "Catalog" and "OCIImage". + + When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + dependency resolution and are only supported by the Boxcutter runtime. When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog + - OCIImage type: string required: - sourceType @@ -492,6 +534,10 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' + - message: ociImage is required when sourceType is OCIImage, and forbidden + otherwise + rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? + has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml index 954dea621e..d7cb6ca823 100644 --- a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml +++ b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml @@ -175,7 +175,6 @@ spec: source is required and selects the installation source of content for this ClusterExtension. Set the sourceType field to perform the selection. - Catalog is currently the only implemented sourceType. Setting sourceType to "Catalog" requires the catalog field to also be defined. Below is a minimal example of a source definition (in yaml): @@ -424,17 +423,60 @@ spec: required: - packageName type: object + ociImage: + description: |- + ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + properties: + ref: + description: ref is a Docker-style image reference with a + tag or digest. + maxLength: 1000 + type: string + x-kubernetes-validations: + - message: must start with a valid domain + rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") + - message: a valid image name is required + rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") + != "" + - message: must end with a digest or a tag + rule: self.find("(@.*:)") != "" || self.find(":.*$") != + "" + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").substring(1).size() <= 127 : true) + : true' + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) + : true' + - message: digest algorithm is not valid + rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") + : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() + >= 32 : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") + : true' + required: + - ref + type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The only allowed value is "Catalog". + The allowed values are "Catalog" and "OCIImage". + + When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + dependency resolution and are only supported by the Boxcutter runtime. When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog + - OCIImage type: string required: - sourceType @@ -444,6 +486,10 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' + - message: ociImage is required when sourceType is OCIImage, and forbidden + otherwise + rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? + has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/internal/operator-controller/controllers/clusterextension_admission_test.go b/internal/operator-controller/controllers/clusterextension_admission_test.go index 14cfea8fc9..801e5b7b4e 100644 --- a/internal/operator-controller/controllers/clusterextension_admission_test.go +++ b/internal/operator-controller/controllers/clusterextension_admission_test.go @@ -74,6 +74,54 @@ func TestClusterExtensionSourceConfig(t *testing.T) { } } +func TestClusterExtensionOCIImageSourceConfig(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + source ocv1.SourceConfig + wantError bool + }{ + { + name: "valid tagged image", + source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: &ocv1.OCIImageSource{Ref: "quay.io/example/operator:latest"}, + }, + }, + { + name: "missing image payload", + source: ocv1.SourceConfig{SourceType: ocv1.SourceTypeOCIImage}, + wantError: true, + }, + { + name: "catalog payload with image source", + source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: &ocv1.OCIImageSource{Ref: "quay.io/example/operator:latest"}, + Catalog: &ocv1.CatalogFilter{PackageName: "example"}, + }, + wantError: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cl := newClient(t) + err := cl.Create(context.Background(), buildClusterExtension(ocv1.ClusterExtensionSpec{ + Source: tc.source, + Namespace: "default", + })) + if tc.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + func TestClusterExtensionAdmissionPackageName(t *testing.T) { tooLongError := "spec.source.catalog.packageName: Too long: may not be more than 253" regexMismatchError := "packageName must be a valid DNS1123 subdomain" diff --git a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go index b07a5072f4..71a3bdace5 100644 --- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go +++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go @@ -30,6 +30,7 @@ import ( 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/features" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/resolve" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" @@ -108,6 +109,18 @@ func ServiceAccountDeprecationWarning() ClusterExtensionValidator { } } +// DirectBundleRequiresBoxcutter rejects direct OCI image sources when the +// Boxcutter runtime is unavailable. The Helm runtime has no direct-source +// implementation and must never silently interpret the source as a catalog. +func DirectBundleRequiresBoxcutter() ClusterExtensionValidator { + return func(_ context.Context, ext *ocv1.ClusterExtension) error { + if ext.Spec.Source.SourceType == ocv1.SourceTypeOCIImage && !features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + return fmt.Errorf("sourceType %q requires the %s feature gate", ocv1.SourceTypeOCIImage, features.BoxcutterRuntime) + } + return nil + } +} + func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc { return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) { l := log.FromContext(ctx) @@ -146,6 +159,27 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc { return nil, nil } + // Direct OCIImage sources have no catalog metadata, so resolve them + // without running catalog fallback or deprecation handling. + if ext.Spec.Source.SourceType == ocv1.SourceTypeOCIImage { + l.V(1).Info("resolving direct OCI image bundle") + resolvedBundle, resolvedBundleVersion, _, err := r.Resolve(ctx, ext, nil) + if err != nil { + setStatusProgressing(ext, err) + setInstalledStatusFromRevisionStates(ext, state.revisionStates) + return nil, err + } + state.hasCatalogData = false + state.resolvedDeprecation = nil + SetDeprecationStatus(ext, installedBundleName(state.revisionStates), nil, false) + state.resolvedRevisionMetadata = &RevisionMetadata{ + Package: resolvedBundle.Package, + Image: resolvedBundle.Image, + BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion), + } + return nil, nil + } + // Resolve a new bundle from the catalog l.V(1).Info("resolving bundle") var bm *ocv1.BundleMetadata @@ -198,6 +232,13 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc { } } +func installedBundleName(states *RevisionStates) string { + if states != nil && states.Installed != nil { + return states.Installed.Name + } + return "" +} + // handleResolutionError handles the case when bundle resolution fails. // // Decision logic (evaluated in order): diff --git a/internal/operator-controller/controllers/direct_bundle_test.go b/internal/operator-controller/controllers/direct_bundle_test.go new file mode 100644 index 0000000000..080d406e1b --- /dev/null +++ b/internal/operator-controller/controllers/direct_bundle_test.go @@ -0,0 +1,35 @@ +package controllers_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" +) + +func TestDirectBundleRequiresBoxcutter(t *testing.T) { + previous := features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) + t.Cleanup(func() { + _ = features.OperatorControllerFeatureGate.Set(string(features.BoxcutterRuntime) + "=" + boolString(previous)) + }) + + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{SourceType: ocv1.SourceTypeOCIImage}}} + validator := controllers.DirectBundleRequiresBoxcutter() + + require.NoError(t, features.OperatorControllerFeatureGate.Set(string(features.BoxcutterRuntime)+"=false")) + require.Error(t, validator(context.Background(), ext)) + + require.NoError(t, features.OperatorControllerFeatureGate.Set(string(features.BoxcutterRuntime)+"=true")) + require.NoError(t, validator(context.Background(), ext)) +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/internal/operator-controller/resolve/ociimage.go b/internal/operator-controller/resolve/ociimage.go new file mode 100644 index 0000000000..ee713d4388 --- /dev/null +++ b/internal/operator-controller/resolve/ociimage.go @@ -0,0 +1,118 @@ +package resolve + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" + bundlesource "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" + imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" +) + +// OCIImageResolver resolves a bundle directly from an OCI image. The image is +// unpacked through the shared image cache before its content is inspected. +type OCIImageResolver struct { + Puller imageutil.Puller + Cache imageutil.Cache + Detectors []BundleContentDetector +} + +// BundleContentDetector identifies and loads a supported bundle format from +// already-unpacked image content. +type BundleContentDetector interface { + Detect(fs.FS, string) (*declcfg.Bundle, error) +} + +// RegistryV1ContentDetector loads registry+v1 bundles from their filesystem layout. +type RegistryV1ContentDetector struct{} + +func (RegistryV1ContentDetector) Detect(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { + return bundleFromFS(bundleFS, image) +} + +// Resolve loads a registry+v1 bundle from the direct OCIImage source. Direct +// sources intentionally do not consult catalogs or perform dependency resolution. +func (r *OCIImageResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) { + if ext.Spec.Source.OCIImage == nil { + return nil, nil, nil, reconcile.TerminalError(fmt.Errorf("OCIImage source is missing ociImage.ref")) + } + if r.Puller == nil || r.Cache == nil { + return nil, nil, nil, fmt.Errorf("direct OCIImage resolver is not configured") + } + + imageFS, canonicalRef, _, err := r.Puller.Pull(ctx, ext.Name, ext.Spec.Source.OCIImage.Ref, r.Cache) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to pull direct bundle image: %w", err) + } + if canonicalRef == nil { + return nil, nil, nil, fmt.Errorf("direct bundle image pull returned no canonical reference") + } + + bundle, err := r.detect(imageFS, canonicalRef.String()) + if err != nil { + return nil, nil, nil, reconcile.TerminalError(fmt.Errorf("invalid direct bundle image: %w", err)) + } + versionRelease, err := bundleutil.GetVersionAndRelease(*bundle) + if err != nil { + return nil, nil, nil, reconcile.TerminalError(err) + } + return bundle, versionRelease, nil, nil +} + +func (r *OCIImageResolver) detect(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { + detectors := r.Detectors + if len(detectors) == 0 { + detectors = []BundleContentDetector{RegistryV1ContentDetector{}} + } + var errs []error + for _, detector := range detectors { + bundle, err := detector.Detect(bundleFS, image) + if err == nil { + return bundle, nil + } + errs = append(errs, err) + } + return nil, errors.Join(errs...) +} + +func bundleFromFS(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { + registryBundle, err := bundlesource.FromFS(bundleFS).GetBundle() + if err != nil { + return nil, err + } + + bundle := &declcfg.Bundle{ + Name: registryBundle.CSV.Name, + Package: registryBundle.PackageName, + Image: image, + } + propertiesJSON := registryBundle.CSV.Annotations[bundlesource.PropertyOLMProperties] + if propertiesJSON == "" { + return nil, fmt.Errorf("bundle %q has no %q package property", bundle.Name, bundlesource.PropertyOLMProperties) + } + if err := json.Unmarshal([]byte(propertiesJSON), &bundle.Properties); err != nil { + return nil, fmt.Errorf("failed to parse bundle properties: %w", err) + } + if !hasPackageProperty(bundle.Properties) { + return nil, fmt.Errorf("bundle %q has no package property", bundle.Name) + } + return bundle, nil +} + +func hasPackageProperty(properties []property.Property) bool { + for _, p := range properties { + if p.Type == property.TypePackage { + return true + } + } + return false +} diff --git a/internal/operator-controller/resolve/ociimage_test.go b/internal/operator-controller/resolve/ociimage_test.go new file mode 100644 index 0000000000..1e88294c26 --- /dev/null +++ b/internal/operator-controller/resolve/ociimage_test.go @@ -0,0 +1,70 @@ +package resolve + +import ( + "context" + "io/fs" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.podman.io/image/v5/docker/reference" + "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/rukpak/bundle/source" + imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" + csvbuilder "github.com/operator-framework/operator-controller/internal/testing/bundle/csv" + bundlefs "github.com/operator-framework/operator-controller/internal/testing/bundle/fs" +) + +func TestOCIImageResolverResolve(t *testing.T) { + ref := "quay.io/example/operator@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + bundleFS := bundlefs.Builder(). + WithPackageName("example-operator"). + WithCSV(csvbuilder.Builder().WithName("example-operator.v1.2.3").WithAnnotations(map[string]string{ + source.PropertyOLMProperties: `[{"type":"olm.package","value":{"packageName":"example-operator","version":"1.2.3"}}]`, + }).Build()). + Build() + + resolver := &OCIImageResolver{Puller: fakePuller{fs: bundleFS, ref: ref}, Cache: fakeCache{}} + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: &ocv1.OCIImageSource{Ref: ref}, + }}} + + bundle, version, deprecation, err := resolver.Resolve(context.Background(), ext, nil) + require.NoError(t, err) + require.Equal(t, "example-operator.v1.2.3", bundle.Name) + require.Equal(t, "example-operator", bundle.Package) + require.Equal(t, ref, bundle.Image) + require.Equal(t, "1.2.3", version.Version.String()) + require.Nil(t, deprecation) +} + +func TestOCIImageResolverRejectsInvalidBundle(t *testing.T) { + ref := "quay.io/example/operator@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + resolver := &OCIImageResolver{Puller: fakePuller{fs: bundlefs.Builder().Build(), ref: ref}, Cache: fakeCache{}} + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: &ocv1.OCIImageSource{Ref: ref}, + }}} + + _, _, _, err := resolver.Resolve(context.Background(), ext, nil) + require.Error(t, err) + require.ErrorIs(t, err, reconcile.TerminalError(nil)) +} + +type fakePuller struct { + fs fs.FS + ref string +} + +func (p fakePuller) Pull(context.Context, string, string, imageutil.Cache) (fs.FS, reference.Canonical, time.Time, error) { + canonical, err := reference.ParseNormalizedNamed(p.ref) + if err != nil { + return nil, nil, time.Time{}, err + } + return p.fs, canonical.(reference.Canonical), time.Time{}, nil +} + +type fakeCache struct{ imageutil.Cache } diff --git a/internal/operator-controller/resolve/resolver.go b/internal/operator-controller/resolve/resolver.go index ef7543b5c8..7ec8d69edb 100644 --- a/internal/operator-controller/resolve/resolver.go +++ b/internal/operator-controller/resolve/resolver.go @@ -2,6 +2,7 @@ package resolve import ( "context" + "fmt" "github.com/operator-framework/operator-registry/alpha/declcfg" @@ -17,3 +18,20 @@ type Func func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle func (f Func) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) { return f(ctx, ext, installedBundle) } + +// MultiResolver dispatches bundle resolution by ClusterExtension source type. +type MultiResolver map[string]Resolver + +// RegisterType associates a source type with its resolver. +func (m MultiResolver) RegisterType(sourceType string, resolver Resolver) { + m[sourceType] = resolver +} + +// Resolve dispatches to the resolver selected by the ClusterExtension source type. +func (m MultiResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) { + resolver, ok := m[ext.Spec.Source.SourceType] + if !ok { + return nil, nil, nil, fmt.Errorf("no resolver for source type %q", ext.Spec.Source.SourceType) + } + return resolver.Resolve(ctx, ext, installedBundle) +} diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 6d9346b4ae..331bddf043 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -837,7 +837,6 @@ spec: source is required and selects the installation source of content for this ClusterExtension. Set the sourceType field to perform the selection. - Catalog is currently the only implemented sourceType. Setting sourceType to "Catalog" requires the catalog field to also be defined. Below is a minimal example of a source definition (in yaml): @@ -1086,17 +1085,60 @@ spec: required: - packageName type: object + ociImage: + description: |- + ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + properties: + ref: + description: ref is a Docker-style image reference with a + tag or digest. + maxLength: 1000 + type: string + x-kubernetes-validations: + - message: must start with a valid domain + rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") + - message: a valid image name is required + rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") + != "" + - message: must end with a digest or a tag + rule: self.find("(@.*:)") != "" || self.find(":.*$") != + "" + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").substring(1).size() <= 127 : true) + : true' + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) + : true' + - message: digest algorithm is not valid + rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") + : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() + >= 32 : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") + : true' + required: + - ref + type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The only allowed value is "Catalog". + The allowed values are "Catalog" and "OCIImage". + + When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + dependency resolution and are only supported by the Boxcutter runtime. When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog + - OCIImage type: string required: - sourceType @@ -1106,6 +1148,10 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' + - message: ociImage is required when sourceType is OCIImage, and forbidden + otherwise + rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? + has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index f8c3add53b..4388094367 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -798,7 +798,6 @@ spec: source is required and selects the installation source of content for this ClusterExtension. Set the sourceType field to perform the selection. - Catalog is currently the only implemented sourceType. Setting sourceType to "Catalog" requires the catalog field to also be defined. Below is a minimal example of a source definition (in yaml): @@ -1047,17 +1046,60 @@ spec: required: - packageName type: object + ociImage: + description: |- + ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + properties: + ref: + description: ref is a Docker-style image reference with a + tag or digest. + maxLength: 1000 + type: string + x-kubernetes-validations: + - message: must start with a valid domain + rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") + - message: a valid image name is required + rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") + != "" + - message: must end with a digest or a tag + rule: self.find("(@.*:)") != "" || self.find(":.*$") != + "" + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").substring(1).size() <= 127 : true) + : true' + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) + : true' + - message: digest algorithm is not valid + rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") + : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() + >= 32 : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") + : true' + required: + - ref + type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The only allowed value is "Catalog". + The allowed values are "Catalog" and "OCIImage". + + When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + dependency resolution and are only supported by the Boxcutter runtime. When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog + - OCIImage type: string required: - sourceType @@ -1067,6 +1109,10 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' + - message: ociImage is required when sourceType is OCIImage, and forbidden + otherwise + rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? + has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index 28dca6563d..a0c8344d20 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -789,7 +789,6 @@ spec: source is required and selects the installation source of content for this ClusterExtension. Set the sourceType field to perform the selection. - Catalog is currently the only implemented sourceType. Setting sourceType to "Catalog" requires the catalog field to also be defined. Below is a minimal example of a source definition (in yaml): @@ -1038,17 +1037,60 @@ spec: required: - packageName type: object + ociImage: + description: |- + ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + properties: + ref: + description: ref is a Docker-style image reference with a + tag or digest. + maxLength: 1000 + type: string + x-kubernetes-validations: + - message: must start with a valid domain + rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") + - message: a valid image name is required + rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") + != "" + - message: must end with a digest or a tag + rule: self.find("(@.*:)") != "" || self.find(":.*$") != + "" + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").substring(1).size() <= 127 : true) + : true' + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) + : true' + - message: digest algorithm is not valid + rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") + : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() + >= 32 : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") + : true' + required: + - ref + type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The only allowed value is "Catalog". + The allowed values are "Catalog" and "OCIImage". + + When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + dependency resolution and are only supported by the Boxcutter runtime. When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog + - OCIImage type: string required: - sourceType @@ -1058,6 +1100,10 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' + - message: ociImage is required when sourceType is OCIImage, and forbidden + otherwise + rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? + has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/manifests/standard.yaml b/manifests/standard.yaml index 71c7677772..035322245e 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -750,7 +750,6 @@ spec: source is required and selects the installation source of content for this ClusterExtension. Set the sourceType field to perform the selection. - Catalog is currently the only implemented sourceType. Setting sourceType to "Catalog" requires the catalog field to also be defined. Below is a minimal example of a source definition (in yaml): @@ -999,17 +998,60 @@ spec: required: - packageName type: object + ociImage: + description: |- + ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + properties: + ref: + description: ref is a Docker-style image reference with a + tag or digest. + maxLength: 1000 + type: string + x-kubernetes-validations: + - message: must start with a valid domain + rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") + - message: a valid image name is required + rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") + != "" + - message: must end with a digest or a tag + rule: self.find("(@.*:)") != "" || self.find(":.*$") != + "" + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").substring(1).size() <= 127 : true) + : true' + - message: tag is invalid + rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != + "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) + : true' + - message: digest algorithm is not valid + rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") + : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() + >= 32 : true' + - message: digest is not valid + rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") + : true' + required: + - ref + type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The only allowed value is "Catalog". + The allowed values are "Catalog" and "OCIImage". + + When set to "OCIImage", the bundle image is used directly. Direct sources do not perform + dependency resolution and are only supported by the Boxcutter runtime. When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog + - OCIImage type: string required: - sourceType @@ -1019,6 +1061,10 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' + - message: ociImage is required when sourceType is OCIImage, and forbidden + otherwise + rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? + has(self.ociImage) : !has(self.ociImage)' required: - namespace - source From 0798cc0522e893403700227eb23b9ce7e17a18bd Mon Sep 17 00:00:00 2001 From: grokspawn Date: Tue, 8 Sep 2026 14:08:32 -0500 Subject: [PATCH 2/2] review updates Signed-off-by: grokspawn --- api/v1/clusterextension_types.go | 34 ++++++---- api/v1/zz_generated.deepcopy.go | 6 +- applyconfigurations/api/v1/sourceconfig.go | 17 +++++ cmd/operator-controller/main.go | 2 + docs/api-reference/olmv1-api-reference.md | 11 +-- ...peratorframework.io_clusterextensions.yaml | 35 ++-------- ...peratorframework.io_clusterextensions.yaml | 49 +------------- .../clusterextension_admission_test.go | 22 ++++-- .../clusterextension_reconcile_steps.go | 58 ++++++++-------- .../controllers/direct_bundle_test.go | 23 ++++++- .../operator-controller/resolve/ociimage.go | 67 ++++++++----------- .../resolve/ociimage_test.go | 23 ++++++- .../operator-controller/resolve/resolver.go | 17 +++++ manifests/experimental-e2e.yaml | 35 ++-------- manifests/experimental.yaml | 35 ++-------- manifests/standard-e2e.yaml | 49 +------------- manifests/standard.yaml | 49 +------------- 17 files changed, 196 insertions(+), 336 deletions(-) diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index 80b5560f30..872b6572c6 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -130,10 +130,19 @@ const ( // // +union // +kubebuilder:validation:XValidation:rule="has(self.sourceType) && self.sourceType == 'Catalog' ? has(self.catalog) : !has(self.catalog)",message="catalog is required when sourceType is Catalog, and forbidden otherwise" -// +kubebuilder:validation:XValidation:rule="has(self.sourceType) && self.sourceType == 'OCIImage' ? has(self.ociImage) : !has(self.ociImage)",message="ociImage is required when sourceType is OCIImage, and forbidden otherwise" +// type SourceConfig struct { // sourceType is required and specifies the type of install source. // + // + // The allowed value is "Catalog". + // + // When set to "Catalog", information for determining the appropriate bundle of content to install + // is fetched from ClusterCatalog resources on the cluster. + // When using the Catalog sourceType, the catalog field must also be set. + // + // + // // The allowed values are "Catalog" and "OCIImage". // // When set to "OCIImage", the bundle image is used directly. Direct sources do not perform @@ -142,9 +151,11 @@ type SourceConfig struct { // When set to "Catalog", information for determining the appropriate bundle of content to install // is fetched from ClusterCatalog resources on the cluster. // When using the Catalog sourceType, the catalog field must also be set. + // // // +unionDiscriminator - // +kubebuilder:validation:Enum:="Catalog";"OCIImage" + // +kubebuilder:validation:Enum:="Catalog" + // // +required SourceType string `json:"sourceType"` @@ -155,27 +166,24 @@ type SourceConfig struct { Catalog *CatalogFilter `json:"catalog,omitempty"` // ociImage configures a bundle image to install directly. + // // They do not provide catalog dependency resolution or upgrade safety. - // + // + // // +optional - OCIImage *OCIImageSource `json:"ociImage,omitempty"` + OCIImage OCIImageSource `json:"ociImage,omitzero"` } // OCIImageSource identifies a bundle image to install directly from an OCI registry. +// +kubebuilder:validation:MinProperties:=1 type OCIImageSource struct { // ref is a Docker-style image reference with a tag or digest. // // +required // +kubebuilder:validation:MaxLength:=1000 - // +kubebuilder:validation:XValidation:rule="self.matches(\"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\\\b\")",message="must start with a valid domain" - // +kubebuilder:validation:XValidation:rule="self.find(\"(\\\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)\") != \"\"",message="a valid image name is required" - // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" || self.find(\":.*$\") != \"\"",message="must end with a digest or a tag" - // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") == \"\" ? (self.find(\":.*$\") != \"\" ? self.find(\":.*$\").substring(1).size() <= 127 : true) : true",message="tag is invalid" - // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") == \"\" ? (self.find(\":.*$\") != \"\" ? self.find(\":.*$\").matches(\":[\\\\w][\\\\w.-]*$\") : true) : true",message="tag is invalid" - // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" ? self.find(\"(@.*:)\").matches(\"(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])\") : true",message="digest algorithm is not valid" - // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" ? self.find(\":.*$\").substring(1).size() >= 32 : true",message="digest is not valid" - // +kubebuilder:validation:XValidation:rule="self.find(\"(@.*:)\") != \"\" ? self.find(\":.*$\").matches(\":[0-9A-Fa-f]*$\") : true",message="digest is not valid" - Ref string `json:"ref"` + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:XValidation:rule="self.matches(\"^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:[0-9]+)?/[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,126}|@[A-Za-z][A-Za-z0-9+._-]*:[0-9A-Fa-f]{32,})$\")",message="must be a complete image reference with a valid repository and tag or digest" + Ref string `json:"ref,omitempty"` } // ClusterExtensionInstallConfig is a union which selects the clusterExtension installation config. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 80967b6aba..53310813b1 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -825,11 +825,7 @@ func (in *SourceConfig) DeepCopyInto(out *SourceConfig) { *out = new(CatalogFilter) (*in).DeepCopyInto(*out) } - if in.OCIImage != nil { - in, out := &in.OCIImage, &out.OCIImage - *out = new(OCIImageSource) - **out = **in - } + out.OCIImage = in.OCIImage } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceConfig. diff --git a/applyconfigurations/api/v1/sourceconfig.go b/applyconfigurations/api/v1/sourceconfig.go index 4b39793b5f..60db67f62d 100644 --- a/applyconfigurations/api/v1/sourceconfig.go +++ b/applyconfigurations/api/v1/sourceconfig.go @@ -21,9 +21,20 @@ package v1 // with apply. // // SourceConfig is a discriminated union which selects the installation source. +// +// type SourceConfigApplyConfiguration struct { // sourceType is required and specifies the type of install source. // + // + // The allowed value is "Catalog". + // + // When set to "Catalog", information for determining the appropriate bundle of content to install + // is fetched from ClusterCatalog resources on the cluster. + // When using the Catalog sourceType, the catalog field must also be set. + // + // + // // The allowed values are "Catalog" and "OCIImage". // // When set to "OCIImage", the bundle image is used directly. Direct sources do not perform @@ -32,12 +43,18 @@ type SourceConfigApplyConfiguration struct { // When set to "Catalog", information for determining the appropriate bundle of content to install // is fetched from ClusterCatalog resources on the cluster. // When using the Catalog sourceType, the catalog field must also be set. + // + // + // SourceType *string `json:"sourceType,omitempty"` // catalog configures how information is sourced from a catalog. // It is required when sourceType is "Catalog", and forbidden otherwise. Catalog *CatalogFilterApplyConfiguration `json:"catalog,omitempty"` // ociImage configures a bundle image to install directly. + // // They do not provide catalog dependency resolution or upgrade safety. + // + // OCIImage *OCIImageSourceApplyConfiguration `json:"ociImage,omitempty"` } diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 5be07f7351..eb8493cbfd 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -664,6 +664,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl controllers.ValidateClusterExtension( controllers.ServiceAccountDeprecationWarning(), controllers.DirectBundleRequiresBoxcutter(), + controllers.ValidateDirectBundleSource(), ), controllers.MigrateStorage(storageMigrator), controllers.RetrieveRevisionStates(revisionStatesGetter), @@ -753,6 +754,7 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster controllers.ValidateClusterExtension( controllers.ServiceAccountDeprecationWarning(), controllers.DirectBundleRequiresBoxcutter(), + controllers.ValidateDirectBundleSource(), ), controllers.RetrieveRevisionStates(revisionStatesGetter), controllers.ResolveBundle(c.resolver, c.mgr.GetClient()), diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 4d888840fa..ff676965fb 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -463,14 +463,15 @@ _Appears in:_ OCIImageSource identifies a bundle image to install directly from an OCI registry. - +_Validation:_ +- MinProperties: 1 _Appears in:_ - [SourceConfig](#sourceconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `ref` _string_ | ref is a Docker-style image reference with a tag or digest. | | MaxLength: 1000
Required: \{\}
| +| `ref` _string_ | ref is a Docker-style image reference with a tag or digest. | | MaxLength: 1000
MinLength: 1
Required: \{\}
| #### ObjectSelector @@ -622,6 +623,8 @@ _Appears in:_ SourceConfig is a discriminated union which selects the installation source. + + _Appears in:_ @@ -629,9 +632,9 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `sourceType` _string_ | sourceType is required and specifies the type of install source.
The allowed values are "Catalog" and "OCIImage".
When set to "OCIImage", the bundle image is used directly. Direct sources do not perform
dependency resolution and are only supported by the Boxcutter runtime.
When set to "Catalog", information for determining the appropriate bundle of content to install
is fetched from ClusterCatalog resources on the cluster.
When using the Catalog sourceType, the catalog field must also be set. | | Enum: [Catalog OCIImage]
Required: \{\}
| +| `sourceType` _string_ | sourceType is required and specifies the type of install source.

The allowed value is "Catalog".
When set to "Catalog", information for determining the appropriate bundle of content to install
is fetched from ClusterCatalog resources on the cluster.
When using the Catalog sourceType, the catalog field must also be set.


The allowed values are "Catalog" and "OCIImage".
When set to "OCIImage", the bundle image is used directly. Direct sources do not perform
dependency resolution and are only supported by the Boxcutter runtime.
When set to "Catalog", information for determining the appropriate bundle of content to install
is fetched from ClusterCatalog resources on the cluster.
When using the Catalog sourceType, the catalog field must also be set.

| | Enum: [Catalog]
Required: \{\}
| | `catalog` _[CatalogFilter](#catalogfilter)_ | catalog configures how information is sourced from a catalog.
It is required when sourceType is "Catalog", and forbidden otherwise. | | Optional: \{\}
| -| `ociImage` _[OCIImageSource](#ociimagesource)_ | ociImage configures a bundle image to install directly.
They do not provide catalog dependency resolution or upgrade safety. | | Optional: \{\}
| +| `ociImage` _[OCIImageSource](#ociimagesource)_ | ociImage configures a bundle image to install directly.

They do not provide catalog dependency resolution or upgrade safety.

| | MinProperties: 1
Optional: \{\}
| #### SourceType diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml index f235618dcc..4c55364fe9 100644 --- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml +++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml @@ -474,39 +474,20 @@ spec: ociImage: description: |- ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + minProperties: 1 properties: ref: description: ref is a Docker-style image reference with a tag or digest. maxLength: 1000 + minLength: 1 type: string x-kubernetes-validations: - - message: must start with a valid domain - rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") - - message: a valid image name is required - rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") - != "" - - message: must end with a digest or a tag - rule: self.find("(@.*:)") != "" || self.find(":.*$") != - "" - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").substring(1).size() <= 127 : true) - : true' - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) - : true' - - message: digest algorithm is not valid - rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") - : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() - >= 32 : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") - : true' + - message: must be a complete image reference with a valid + repository and tag or digest + rule: self.matches("^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:[0-9]+)?/[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,126}|@[A-Za-z][A-Za-z0-9+._-]*:[0-9A-Fa-f]{32,})$") required: - ref type: object @@ -534,10 +515,6 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' - - message: ociImage is required when sourceType is OCIImage, and forbidden - otherwise - rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? - has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml index d7cb6ca823..b1da1bd718 100644 --- a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml +++ b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml @@ -423,60 +423,17 @@ spec: required: - packageName type: object - ociImage: - description: |- - ociImage configures a bundle image to install directly. - They do not provide catalog dependency resolution or upgrade safety. - properties: - ref: - description: ref is a Docker-style image reference with a - tag or digest. - maxLength: 1000 - type: string - x-kubernetes-validations: - - message: must start with a valid domain - rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") - - message: a valid image name is required - rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") - != "" - - message: must end with a digest or a tag - rule: self.find("(@.*:)") != "" || self.find(":.*$") != - "" - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").substring(1).size() <= 127 : true) - : true' - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) - : true' - - message: digest algorithm is not valid - rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") - : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() - >= 32 : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") - : true' - required: - - ref - type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The allowed values are "Catalog" and "OCIImage". - - When set to "OCIImage", the bundle image is used directly. Direct sources do not perform - dependency resolution and are only supported by the Boxcutter runtime. + The allowed value is "Catalog". When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog - - OCIImage type: string required: - sourceType @@ -486,10 +443,6 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' - - message: ociImage is required when sourceType is OCIImage, and forbidden - otherwise - rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? - has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/internal/operator-controller/controllers/clusterextension_admission_test.go b/internal/operator-controller/controllers/clusterextension_admission_test.go index 801e5b7b4e..4fda708635 100644 --- a/internal/operator-controller/controllers/clusterextension_admission_test.go +++ b/internal/operator-controller/controllers/clusterextension_admission_test.go @@ -85,20 +85,28 @@ func TestClusterExtensionOCIImageSourceConfig(t *testing.T) { name: "valid tagged image", source: ocv1.SourceConfig{ SourceType: ocv1.SourceTypeOCIImage, - OCIImage: &ocv1.OCIImageSource{Ref: "quay.io/example/operator:latest"}, + OCIImage: ocv1.OCIImageSource{Ref: "quay.io/example/operator:latest"}, }, }, { - name: "missing image payload", - source: ocv1.SourceConfig{SourceType: ocv1.SourceTypeOCIImage}, - wantError: true, + name: "valid tagged image with registry port", + source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: ocv1.OCIImageSource{Ref: "quay.io:5000/example/operator:latest"}, + }, + }, + { + name: "valid digested image with registry port", + source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: ocv1.OCIImageSource{Ref: "quay.io:5000/example/operator@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + }, }, { - name: "catalog payload with image source", + name: "uppercase repository segment", source: ocv1.SourceConfig{ SourceType: ocv1.SourceTypeOCIImage, - OCIImage: &ocv1.OCIImageSource{Ref: "quay.io/example/operator:latest"}, - Catalog: &ocv1.CatalogFilter{PackageName: "example"}, + OCIImage: ocv1.OCIImageSource{Ref: "quay.io/example/Operator:latest"}, }, wantError: true, }, diff --git a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go index 71a3bdace5..e3e98c5f26 100644 --- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go +++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go @@ -121,6 +121,23 @@ func DirectBundleRequiresBoxcutter() ClusterExtensionValidator { } } +// ValidateDirectBundleSource validates the direct source fields that cannot be +// expressed in the standard CRD because OCIImage is experimental-only. +func ValidateDirectBundleSource() ClusterExtensionValidator { + return func(_ context.Context, ext *ocv1.ClusterExtension) error { + if ext.Spec.Source.SourceType != ocv1.SourceTypeOCIImage { + return nil + } + if ext.Spec.Source.OCIImage.Ref == "" { + return fmt.Errorf("sourceType %q requires ociImage.ref", ocv1.SourceTypeOCIImage) + } + if ext.Spec.Source.Catalog != nil { + return fmt.Errorf("sourceType %q forbids source.catalog", ocv1.SourceTypeOCIImage) + } + return nil + } +} + func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc { return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) { l := log.FromContext(ctx) @@ -150,37 +167,11 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc { // If already rolling out, use existing revision and set deprecation to Unknown (no catalog check) if len(state.revisionStates.RollingOut) > 0 { - installedBundleName := "" - if state.revisionStates.Installed != nil { - installedBundleName = state.revisionStates.Installed.Name - } - SetDeprecationStatus(ext, installedBundleName, nil, false) - state.resolvedRevisionMetadata = state.revisionStates.RollingOut[0] - return nil, nil - } - - // Direct OCIImage sources have no catalog metadata, so resolve them - // without running catalog fallback or deprecation handling. - if ext.Spec.Source.SourceType == ocv1.SourceTypeOCIImage { - l.V(1).Info("resolving direct OCI image bundle") - resolvedBundle, resolvedBundleVersion, _, err := r.Resolve(ctx, ext, nil) - if err != nil { - setStatusProgressing(ext, err) - setInstalledStatusFromRevisionStates(ext, state.revisionStates) - return nil, err - } - state.hasCatalogData = false - state.resolvedDeprecation = nil SetDeprecationStatus(ext, installedBundleName(state.revisionStates), nil, false) - state.resolvedRevisionMetadata = &RevisionMetadata{ - Package: resolvedBundle.Package, - Image: resolvedBundle.Image, - BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion), - } + state.resolvedRevisionMetadata = state.revisionStates.RollingOut[0] return nil, nil } - // Resolve a new bundle from the catalog l.V(1).Info("resolving bundle") var bm *ocv1.BundleMetadata if state.revisionStates.Installed != nil { @@ -190,10 +181,7 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc { // Get the installed bundle name for deprecation status. // BundleDeprecated should reflect what's currently running, not what we're trying to install. - installedBundleName := "" - if state.revisionStates.Installed != nil { - installedBundleName = state.revisionStates.Installed.Name - } + installedBundleName := installedBundleName(state.revisionStates) // Set deprecation status based on resolution results: // - If resolution succeeds: hasCatalogData=true, deprecation shows catalog data (nil=not deprecated) @@ -209,11 +197,19 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc { // the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from // all catalogs? This needs a follow-up discussion and PR. hasCatalogData := err == nil || resolvedDeprecation != nil + if behavior, ok := r.(resolve.ResolverBehavior); ok { + hasCatalogData = behavior.HasCatalogData(ext) + } state.resolvedDeprecation = resolvedDeprecation state.hasCatalogData = hasCatalogData SetDeprecationStatus(ext, installedBundleName, resolvedDeprecation, hasCatalogData) if err != nil { + if behavior, ok := r.(resolve.ResolverBehavior); ok && !behavior.ShouldFallbackOnError(ext) { + setStatusProgressing(ext, err) + setInstalledStatusFromRevisionStates(ext, state.revisionStates) + return nil, err + } return handleResolutionError(ctx, c, state, ext, err) } diff --git a/internal/operator-controller/controllers/direct_bundle_test.go b/internal/operator-controller/controllers/direct_bundle_test.go index 080d406e1b..2f56149f44 100644 --- a/internal/operator-controller/controllers/direct_bundle_test.go +++ b/internal/operator-controller/controllers/direct_bundle_test.go @@ -1,4 +1,4 @@ -package controllers_test +package controllers import ( "context" @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/features" ) @@ -18,7 +17,7 @@ func TestDirectBundleRequiresBoxcutter(t *testing.T) { }) ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{SourceType: ocv1.SourceTypeOCIImage}}} - validator := controllers.DirectBundleRequiresBoxcutter() + validator := DirectBundleRequiresBoxcutter() require.NoError(t, features.OperatorControllerFeatureGate.Set(string(features.BoxcutterRuntime)+"=false")) require.Error(t, validator(context.Background(), ext)) @@ -27,6 +26,24 @@ func TestDirectBundleRequiresBoxcutter(t *testing.T) { require.NoError(t, validator(context.Background(), ext)) } +func TestValidateDirectBundleSource(t *testing.T) { + validator := ValidateDirectBundleSource() + + t.Run("requires reference", func(t *testing.T) { + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{SourceType: ocv1.SourceTypeOCIImage}}} + require.Error(t, validator(context.Background(), ext)) + }) + + t.Run("rejects catalog payload", func(t *testing.T) { + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: ocv1.OCIImageSource{Ref: "quay.io/example/operator:latest"}, + Catalog: &ocv1.CatalogFilter{PackageName: "example"}, + }}} + require.Error(t, validator(context.Background(), ext)) + }) +} + func boolString(value bool) string { if value { return "true" diff --git a/internal/operator-controller/resolve/ociimage.go b/internal/operator-controller/resolve/ociimage.go index ee713d4388..015e2d962f 100644 --- a/internal/operator-controller/resolve/ociimage.go +++ b/internal/operator-controller/resolve/ociimage.go @@ -3,7 +3,6 @@ package resolve import ( "context" "encoding/json" - "errors" "fmt" "io/fs" @@ -21,28 +20,14 @@ import ( // OCIImageResolver resolves a bundle directly from an OCI image. The image is // unpacked through the shared image cache before its content is inspected. type OCIImageResolver struct { - Puller imageutil.Puller - Cache imageutil.Cache - Detectors []BundleContentDetector -} - -// BundleContentDetector identifies and loads a supported bundle format from -// already-unpacked image content. -type BundleContentDetector interface { - Detect(fs.FS, string) (*declcfg.Bundle, error) -} - -// RegistryV1ContentDetector loads registry+v1 bundles from their filesystem layout. -type RegistryV1ContentDetector struct{} - -func (RegistryV1ContentDetector) Detect(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { - return bundleFromFS(bundleFS, image) + Puller imageutil.Puller + Cache imageutil.Cache } // Resolve loads a registry+v1 bundle from the direct OCIImage source. Direct // sources intentionally do not consult catalogs or perform dependency resolution. func (r *OCIImageResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) { - if ext.Spec.Source.OCIImage == nil { + if ext.Spec.Source.OCIImage.Ref == "" { return nil, nil, nil, reconcile.TerminalError(fmt.Errorf("OCIImage source is missing ociImage.ref")) } if r.Puller == nil || r.Cache == nil { @@ -57,7 +42,7 @@ func (r *OCIImageResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtensi return nil, nil, nil, fmt.Errorf("direct bundle image pull returned no canonical reference") } - bundle, err := r.detect(imageFS, canonicalRef.String()) + bundle, err := bundleFromFS(imageFS, canonicalRef.String()) if err != nil { return nil, nil, nil, reconcile.TerminalError(fmt.Errorf("invalid direct bundle image: %w", err)) } @@ -68,22 +53,6 @@ func (r *OCIImageResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtensi return bundle, versionRelease, nil, nil } -func (r *OCIImageResolver) detect(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { - detectors := r.Detectors - if len(detectors) == 0 { - detectors = []BundleContentDetector{RegistryV1ContentDetector{}} - } - var errs []error - for _, detector := range detectors { - bundle, err := detector.Detect(bundleFS, image) - if err == nil { - return bundle, nil - } - errs = append(errs, err) - } - return nil, errors.Join(errs...) -} - func bundleFromFS(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { registryBundle, err := bundlesource.FromFS(bundleFS).GetBundle() if err != nil { @@ -102,17 +71,35 @@ func bundleFromFS(bundleFS fs.FS, image string) (*declcfg.Bundle, error) { if err := json.Unmarshal([]byte(propertiesJSON), &bundle.Properties); err != nil { return nil, fmt.Errorf("failed to parse bundle properties: %w", err) } - if !hasPackageProperty(bundle.Properties) { - return nil, fmt.Errorf("bundle %q has no package property", bundle.Name) + if err := validatePackageProperty(bundle.Properties, registryBundle.PackageName); err != nil { + return nil, err } return bundle, nil } -func hasPackageProperty(properties []property.Property) bool { +func validatePackageProperty(properties []property.Property, expectedPackageName string) error { + var packageProperties []property.Property for _, p := range properties { if p.Type == property.TypePackage { - return true + packageProperties = append(packageProperties, p) } } - return false + if len(packageProperties) != 1 { + return fmt.Errorf("expected exactly one %q package property, found %d", property.TypePackage, len(packageProperties)) + } + + var packageData struct { + PackageName string `json:"packageName"` + Version string `json:"version"` + } + if err := json.Unmarshal(packageProperties[0].Value, &packageData); err != nil { + return fmt.Errorf("failed to parse %q package property: %w", property.TypePackage, err) + } + if packageData.PackageName == "" || packageData.PackageName != expectedPackageName { + return fmt.Errorf("package property name %q does not match bundle package name %q", packageData.PackageName, expectedPackageName) + } + if packageData.Version == "" { + return fmt.Errorf("package property for %q has no version", expectedPackageName) + } + return nil } diff --git a/internal/operator-controller/resolve/ociimage_test.go b/internal/operator-controller/resolve/ociimage_test.go index 1e88294c26..1f2770bb90 100644 --- a/internal/operator-controller/resolve/ociimage_test.go +++ b/internal/operator-controller/resolve/ociimage_test.go @@ -29,7 +29,7 @@ func TestOCIImageResolverResolve(t *testing.T) { resolver := &OCIImageResolver{Puller: fakePuller{fs: bundleFS, ref: ref}, Cache: fakeCache{}} ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{ SourceType: ocv1.SourceTypeOCIImage, - OCIImage: &ocv1.OCIImageSource{Ref: ref}, + OCIImage: ocv1.OCIImageSource{Ref: ref}, }}} bundle, version, deprecation, err := resolver.Resolve(context.Background(), ext, nil) @@ -46,7 +46,7 @@ func TestOCIImageResolverRejectsInvalidBundle(t *testing.T) { resolver := &OCIImageResolver{Puller: fakePuller{fs: bundlefs.Builder().Build(), ref: ref}, Cache: fakeCache{}} ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{ SourceType: ocv1.SourceTypeOCIImage, - OCIImage: &ocv1.OCIImageSource{Ref: ref}, + OCIImage: ocv1.OCIImageSource{Ref: ref}, }}} _, _, _, err := resolver.Resolve(context.Background(), ext, nil) @@ -54,6 +54,25 @@ func TestOCIImageResolverRejectsInvalidBundle(t *testing.T) { require.ErrorIs(t, err, reconcile.TerminalError(nil)) } +func TestOCIImageResolverRejectsMismatchedPackageProperty(t *testing.T) { + ref := "quay.io/example/operator@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + bundleFS := bundlefs.Builder(). + WithPackageName("example-operator"). + WithCSV(csvbuilder.Builder().WithName("example-operator.v1.2.3").WithAnnotations(map[string]string{ + source.PropertyOLMProperties: `[{"type":"olm.package","value":{"packageName":"other-operator","version":"1.2.3"}}]`, + }).Build()). + Build() + resolver := &OCIImageResolver{Puller: fakePuller{fs: bundleFS, ref: ref}, Cache: fakeCache{}} + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Source: ocv1.SourceConfig{ + SourceType: ocv1.SourceTypeOCIImage, + OCIImage: ocv1.OCIImageSource{Ref: ref}, + }}} + + _, _, _, err := resolver.Resolve(context.Background(), ext, nil) + require.ErrorIs(t, err, reconcile.TerminalError(nil)) + require.ErrorContains(t, err, "does not match bundle package name") +} + type fakePuller struct { fs fs.FS ref string diff --git a/internal/operator-controller/resolve/resolver.go b/internal/operator-controller/resolve/resolver.go index 7ec8d69edb..aac42615f8 100644 --- a/internal/operator-controller/resolve/resolver.go +++ b/internal/operator-controller/resolve/resolver.go @@ -13,6 +13,13 @@ type Resolver interface { Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) } +// ResolverBehavior describes source-specific reconciliation behavior that +// cannot be inferred from a resolved bundle alone. +type ResolverBehavior interface { + HasCatalogData(*ocv1.ClusterExtension) bool + ShouldFallbackOnError(*ocv1.ClusterExtension) bool +} + type Func func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) func (f Func) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *declcfg.VersionRelease, *declcfg.Deprecation, error) { @@ -35,3 +42,13 @@ func (m MultiResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, } return resolver.Resolve(ctx, ext, installedBundle) } + +// HasCatalogData reports whether the selected source can provide catalog metadata. +func (m MultiResolver) HasCatalogData(ext *ocv1.ClusterExtension) bool { + return ext.Spec.Source.SourceType == ocv1.SourceTypeCatalog +} + +// ShouldFallbackOnError reports whether catalog fallback behavior is valid for the source. +func (m MultiResolver) ShouldFallbackOnError(ext *ocv1.ClusterExtension) bool { + return ext.Spec.Source.SourceType == ocv1.SourceTypeCatalog +} diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 331bddf043..6f7dc0bfd6 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1088,39 +1088,20 @@ spec: ociImage: description: |- ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + minProperties: 1 properties: ref: description: ref is a Docker-style image reference with a tag or digest. maxLength: 1000 + minLength: 1 type: string x-kubernetes-validations: - - message: must start with a valid domain - rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") - - message: a valid image name is required - rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") - != "" - - message: must end with a digest or a tag - rule: self.find("(@.*:)") != "" || self.find(":.*$") != - "" - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").substring(1).size() <= 127 : true) - : true' - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) - : true' - - message: digest algorithm is not valid - rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") - : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() - >= 32 : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") - : true' + - message: must be a complete image reference with a valid + repository and tag or digest + rule: self.matches("^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:[0-9]+)?/[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,126}|@[A-Za-z][A-Za-z0-9+._-]*:[0-9A-Fa-f]{32,})$") required: - ref type: object @@ -1148,10 +1129,6 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' - - message: ociImage is required when sourceType is OCIImage, and forbidden - otherwise - rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? - has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 4388094367..2d4d858a5d 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1049,39 +1049,20 @@ spec: ociImage: description: |- ociImage configures a bundle image to install directly. + They do not provide catalog dependency resolution or upgrade safety. + minProperties: 1 properties: ref: description: ref is a Docker-style image reference with a tag or digest. maxLength: 1000 + minLength: 1 type: string x-kubernetes-validations: - - message: must start with a valid domain - rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") - - message: a valid image name is required - rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") - != "" - - message: must end with a digest or a tag - rule: self.find("(@.*:)") != "" || self.find(":.*$") != - "" - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").substring(1).size() <= 127 : true) - : true' - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) - : true' - - message: digest algorithm is not valid - rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") - : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() - >= 32 : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") - : true' + - message: must be a complete image reference with a valid + repository and tag or digest + rule: self.matches("^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:[0-9]+)?/[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,126}|@[A-Za-z][A-Za-z0-9+._-]*:[0-9A-Fa-f]{32,})$") required: - ref type: object @@ -1109,10 +1090,6 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' - - message: ociImage is required when sourceType is OCIImage, and forbidden - otherwise - rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? - has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index a0c8344d20..49e4f72300 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -1037,60 +1037,17 @@ spec: required: - packageName type: object - ociImage: - description: |- - ociImage configures a bundle image to install directly. - They do not provide catalog dependency resolution or upgrade safety. - properties: - ref: - description: ref is a Docker-style image reference with a - tag or digest. - maxLength: 1000 - type: string - x-kubernetes-validations: - - message: must start with a valid domain - rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") - - message: a valid image name is required - rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") - != "" - - message: must end with a digest or a tag - rule: self.find("(@.*:)") != "" || self.find(":.*$") != - "" - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").substring(1).size() <= 127 : true) - : true' - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) - : true' - - message: digest algorithm is not valid - rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") - : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() - >= 32 : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") - : true' - required: - - ref - type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The allowed values are "Catalog" and "OCIImage". - - When set to "OCIImage", the bundle image is used directly. Direct sources do not perform - dependency resolution and are only supported by the Boxcutter runtime. + The allowed value is "Catalog". When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog - - OCIImage type: string required: - sourceType @@ -1100,10 +1057,6 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' - - message: ociImage is required when sourceType is OCIImage, and forbidden - otherwise - rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? - has(self.ociImage) : !has(self.ociImage)' required: - namespace - source diff --git a/manifests/standard.yaml b/manifests/standard.yaml index 035322245e..a2576644e6 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -998,60 +998,17 @@ spec: required: - packageName type: object - ociImage: - description: |- - ociImage configures a bundle image to install directly. - They do not provide catalog dependency resolution or upgrade safety. - properties: - ref: - description: ref is a Docker-style image reference with a - tag or digest. - maxLength: 1000 - type: string - x-kubernetes-validations: - - message: must start with a valid domain - rule: self.matches("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b") - - message: a valid image name is required - rule: self.find("(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)") - != "" - - message: must end with a digest or a tag - rule: self.find("(@.*:)") != "" || self.find(":.*$") != - "" - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").substring(1).size() <= 127 : true) - : true' - - message: tag is invalid - rule: 'self.find("(@.*:)") == "" ? (self.find(":.*$") != - "" ? self.find(":.*$").matches(":[\\w][\\w.-]*$") : true) - : true' - - message: digest algorithm is not valid - rule: 'self.find("(@.*:)") != "" ? self.find("(@.*:)").matches("(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])") - : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").substring(1).size() - >= 32 : true' - - message: digest is not valid - rule: 'self.find("(@.*:)") != "" ? self.find(":.*$").matches(":[0-9A-Fa-f]*$") - : true' - required: - - ref - type: object sourceType: description: |- sourceType is required and specifies the type of install source. - The allowed values are "Catalog" and "OCIImage". - - When set to "OCIImage", the bundle image is used directly. Direct sources do not perform - dependency resolution and are only supported by the Boxcutter runtime. + The allowed value is "Catalog". When set to "Catalog", information for determining the appropriate bundle of content to install is fetched from ClusterCatalog resources on the cluster. When using the Catalog sourceType, the catalog field must also be set. enum: - Catalog - - OCIImage type: string required: - sourceType @@ -1061,10 +1018,6 @@ spec: otherwise rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ? has(self.catalog) : !has(self.catalog)' - - message: ociImage is required when sourceType is OCIImage, and forbidden - otherwise - rule: 'has(self.sourceType) && self.sourceType == ''OCIImage'' ? - has(self.ociImage) : !has(self.ociImage)' required: - namespace - source