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