diff --git a/operator/v1alpha1/tests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml b/operator/v1alpha1/tests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml index 4d962612201..8e438eef9af 100644 --- a/operator/v1alpha1/tests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml +++ b/operator/v1alpha1/tests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml @@ -1406,3 +1406,443 @@ tests: ref: quay.io/openshift/cluster-api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa profile: default expectedStatusError: "observedRevisionGeneration may not be unset once set" + + - name: Should accept image with single path segment + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: quay.io/openshift@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: quay.io/openshift@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + + - name: Should accept image with deep mirror path (5+ segments) + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: registry.example.com/team/project/product/component/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: registry.example.com/team/project/product/component/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + + - name: Should accept image with registry port and deep path + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: registry.example.com:5000/org/team/project/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: registry.example.com:5000/org/team/project/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + + - name: Should reject image with invalid digest format (too short) + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: quay.io/openshift/cluster-api@sha256:invalid + profile: default + expectedStatusError: "the OCI Image reference must end with a valid '@sha256:' suffix" + + - name: Should reject image with uppercase in digest + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: quay.io/openshift/cluster-api@sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + profile: default + expectedStatusError: "the OCI Image reference must end with a valid '@sha256:' suffix" + + - name: Should reject image without path segments + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: quay.io@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expectedStatusError: "the OCI Image name should follow the host[:port][/namespace[/namespace...]]/name format, where host is a domain name, IPv4, or IPv6 address in brackets" + + - name: Should reject image with uppercase in path component + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: quay.io/openshift/Cluster-API@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expectedStatusError: "the OCI Image name should follow the host[:port][/namespace[/namespace...]]/name format, where host is a domain name, IPv4, or IPv6 address in brackets" + + - name: Should accept IPv4 address with port + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: 192.168.1.1:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: 192.168.1.1:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + + - name: Should accept IPv4 address without port + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: 10.0.0.1/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: 10.0.0.1/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + + - name: Should accept IPv6 address with port + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: "[fd00::1]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: "[fd00::1]:5000/org/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + profile: default + + - name: Should accept IPv6 address without port + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: "[2001:db8::1]/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: "[2001:db8::1]/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + profile: default + + - name: Should accept localhost + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: localhost/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ClusterAPI + metadata: + name: cluster + spec: {} + status: + desiredRevision: rev-1 + revisions: + - name: rev-1 + revision: 1 + contentID: content-1 + components: + - type: Image + image: + ref: localhost/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + profile: default diff --git a/operator/v1alpha1/types_clusterapi.go b/operator/v1alpha1/types_clusterapi.go index 719893f8a53..291cedc1455 100644 --- a/operator/v1alpha1/types_clusterapi.go +++ b/operator/v1alpha1/types_clusterapi.go @@ -244,13 +244,24 @@ type ClusterAPIInstallerComponentSource struct { Image ClusterAPIInstallerComponentImage `json:"image,omitzero"` } -// ImageDigestFormat is a type that conforms to the format host[:port][/namespace]/name@sha256:. +// The image name validation regex is derived from the OCI distribution reference: +// https://github.com/distribution/reference/blob/main/regexp.go +// It matches: domainAndPort "/" remoteName +// +// domainAndPort = (domainName | "[" ipv6 "]") [ ":" port ] +// domainName = label ("." label)* +// label = alphanumeric, may contain hyphens but must start/end with [a-zA-Z0-9] +// remoteName = pathComponent ("/" pathComponent)* +// pathComponent = [a-z0-9]+ separated by ".", "_", "__", or "-"+ + +// ImageDigestFormat is a type that conforms to the format host[:port][/namespace[/namespace...]]/name@sha256:. +// The host may be a dotted domain name (including IPv4 addresses like 192.168.1.1), or an IPv6 address in brackets (e.g. [fd00::1]). // The digest must be 64 characters long, and consist only of lowercase hexadecimal characters, a-f and 0-9. // The length of the field must be between 1 to 447 characters. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=447 // +kubebuilder:validation:XValidation:rule=`(self.split('@').size() == 2 && self.split('@')[1].matches('^sha256:[a-f0-9]{64}$'))`,message="the OCI Image reference must end with a valid '@sha256:' suffix, where '' is 64 characters long" -// +kubebuilder:validation:XValidation:rule=`(self.split('@')[0].matches('^([a-zA-Z0-9-]+\\.)+[a-zA-Z0-9-]+(:[0-9]{2,5})?/([a-zA-Z0-9-_]{0,61}/)?[a-zA-Z0-9-_.]*?$'))`,message="the OCI Image name should follow the host[:port][/namespace]/name format, resembling a valid URL without the scheme" +// +kubebuilder:validation:XValidation:rule=`(self.split('@')[0].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]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$'))`,message="the OCI Image name should follow the host[:port][/namespace[/namespace...]]/name format, where host is a domain name, IPv4, or IPv6 address in brackets" type ImageDigestFormat string // ClusterAPIInstallerComponentImage defines an image source for a component. diff --git a/operator/v1alpha1/zz_generated.crd-manifests/0000_30_cluster-api_01_clusterapis.crd.yaml b/operator/v1alpha1/zz_generated.crd-manifests/0000_30_cluster-api_01_clusterapis.crd.yaml index 67d384d3eb8..501ad52418c 100644 --- a/operator/v1alpha1/zz_generated.crd-manifests/0000_30_cluster-api_01_clusterapis.crd.yaml +++ b/operator/v1alpha1/zz_generated.crd-manifests/0000_30_cluster-api_01_clusterapis.crd.yaml @@ -172,9 +172,10 @@ spec: valid '@sha256:' suffix, where '' is 64 characters long rule: (self.split('@').size() == 2 && self.split('@')[1].matches('^sha256:[a-f0-9]{64}$')) - - message: the OCI Image name should follow the host[:port][/namespace]/name - format, resembling a valid URL without the scheme - rule: (self.split('@')[0].matches('^([a-zA-Z0-9-]+\\.)+[a-zA-Z0-9-]+(:[0-9]{2,5})?/([a-zA-Z0-9-_]{0,61}/)?[a-zA-Z0-9-_.]*?$')) + - message: the OCI Image name should follow the host[:port][/namespace[/namespace...]]/name + format, where host is a domain name, IPv4, or + IPv6 address in brackets + rule: (self.split('@')[0].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]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$')) required: - profile - ref diff --git a/operator/v1alpha1/zz_generated.featuregated-crd-manifests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml b/operator/v1alpha1/zz_generated.featuregated-crd-manifests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml index 5a43bfadd13..4ce5c053d90 100644 --- a/operator/v1alpha1/zz_generated.featuregated-crd-manifests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml +++ b/operator/v1alpha1/zz_generated.featuregated-crd-manifests/clusterapis.operator.openshift.io/ClusterAPIMachineManagement.yaml @@ -173,9 +173,10 @@ spec: valid '@sha256:' suffix, where '' is 64 characters long rule: (self.split('@').size() == 2 && self.split('@')[1].matches('^sha256:[a-f0-9]{64}$')) - - message: the OCI Image name should follow the host[:port][/namespace]/name - format, resembling a valid URL without the scheme - rule: (self.split('@')[0].matches('^([a-zA-Z0-9-]+\\.)+[a-zA-Z0-9-]+(:[0-9]{2,5})?/([a-zA-Z0-9-_]{0,61}/)?[a-zA-Z0-9-_.]*?$')) + - message: the OCI Image name should follow the host[:port][/namespace[/namespace...]]/name + format, where host is a domain name, IPv4, or + IPv6 address in brackets + rule: (self.split('@')[0].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]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$')) required: - profile - ref diff --git a/payload-manifests/crds/0000_30_cluster-api_01_clusterapis.crd.yaml b/payload-manifests/crds/0000_30_cluster-api_01_clusterapis.crd.yaml index 67d384d3eb8..501ad52418c 100644 --- a/payload-manifests/crds/0000_30_cluster-api_01_clusterapis.crd.yaml +++ b/payload-manifests/crds/0000_30_cluster-api_01_clusterapis.crd.yaml @@ -172,9 +172,10 @@ spec: valid '@sha256:' suffix, where '' is 64 characters long rule: (self.split('@').size() == 2 && self.split('@')[1].matches('^sha256:[a-f0-9]{64}$')) - - message: the OCI Image name should follow the host[:port][/namespace]/name - format, resembling a valid URL without the scheme - rule: (self.split('@')[0].matches('^([a-zA-Z0-9-]+\\.)+[a-zA-Z0-9-]+(:[0-9]{2,5})?/([a-zA-Z0-9-_]{0,61}/)?[a-zA-Z0-9-_.]*?$')) + - message: the OCI Image name should follow the host[:port][/namespace[/namespace...]]/name + format, where host is a domain name, IPv4, or + IPv6 address in brackets + rule: (self.split('@')[0].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]))*|\\[(?:[a-fA-F0-9:]+)\\])(?::[0-9]+)?/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$')) required: - profile - ref