diff --git a/api/v1/ocirepository_types.go b/api/v1/ocirepository_types.go index b24c259b4..5f0d64688 100644 --- a/api/v1/ocirepository_types.go +++ b/api/v1/ocirepository_types.go @@ -58,8 +58,8 @@ type OCIRepositorySpec struct { // URL is a reference to an OCI artifact repository hosted // on a remote container registry. // +kubebuilder:validation:Pattern="^oci://.*$" - // +required - URL string `json:"url"` + // +optional + URL string `json:"url,omitempty"` // The OCI reference to pull and monitor for changes, // defaults to the latest tag. @@ -74,7 +74,6 @@ type OCIRepositorySpec struct { // The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. // When not specified, defaults to 'generic'. // +kubebuilder:validation:Enum=generic;aws;azure;gcp - // +kubebuilder:default:=generic // +optional Provider string `json:"provider,omitempty"` @@ -121,11 +120,10 @@ type OCIRepositorySpec struct { // efficient use of resources. // +kubebuilder:validation:Type=string // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$" - // +required - Interval metav1.Duration `json:"interval"` + // +optional + Interval *metav1.Duration `json:"interval,omitempty"` // The timeout for remote OCI Repository operations like pulling, defaults to 60s. - // +kubebuilder:default="60s" // +kubebuilder:validation:Type=string // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m))+$" // +optional @@ -236,6 +234,9 @@ func (in *OCIRepository) SetConditions(conditions []metav1.Condition) { // GetRequeueAfter returns the duration after which the OCIRepository must be // reconciled again. func (in OCIRepository) GetRequeueAfter() time.Duration { + if in.Spec.Interval == nil { + return 0 + } return in.Spec.Interval.Duration } @@ -263,6 +264,14 @@ func (in *OCIRepository) GetLayerOperation() string { return in.Spec.LayerSelector.Operation } +// GetTimeout applies the 60s default via code to avoid using the CRD schema default. +func (in *OCIRepository) GetTimeout() time.Duration { + if in.Spec.Timeout == nil { + return 60 * time.Second + } + return in.Spec.Timeout.Duration +} + // +genclient // +kubebuilder:storageversion // +kubebuilder:object:root=true @@ -273,6 +282,8 @@ func (in *OCIRepository) GetLayerOperation() string { // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // +kubebuilder:metadata:annotations="kustomize.toolkit.fluxcd.io/substitute=disabled" +// +kubebuilder:validation:XValidation:rule="has(self.spec) && has(self.spec.url)",message="spec.url is required" +// +kubebuilder:validation:XValidation:rule="has(self.spec) && has(self.spec.interval)",message="spec.interval is required" // OCIRepository is the Schema for the ocirepositories API type OCIRepository struct { diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index c8ff55417..3c9ea2a60 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -921,7 +921,11 @@ func (in *OCIRepositorySpec) DeepCopyInto(out *OCIRepositorySpec) { *out = new(meta.LocalObjectReference) **out = **in } - out.Interval = in.Interval + if in.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(metav1.Duration) + **out = **in + } if in.Timeout != nil { in, out := &in.Timeout, &out.Timeout *out = new(metav1.Duration) diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 10be7301e..1bc9d6296 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -24,7 +24,7 @@ import ( "github.com/fluxcd/pkg/apis/acl" "github.com/fluxcd/pkg/apis/meta" "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml index d1ab384e5..26b04ca47 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml @@ -120,7 +120,6 @@ spec: type: string type: object provider: - default: generic description: |- The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. When not specified, defaults to 'generic'. @@ -187,7 +186,6 @@ spec: of this source. type: boolean timeout: - default: 60s description: The timeout for remote OCI Repository operations like pulling, defaults to 60s. pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ @@ -267,9 +265,6 @@ spec: required: - provider type: object - required: - - interval - - url type: object status: default: @@ -423,6 +418,11 @@ spec: type: string type: object type: object + x-kubernetes-validations: + - message: spec.url is required + rule: has(self.spec) && has(self.spec.url) + - message: spec.interval is required + rule: has(self.spec) && has(self.spec.interval) served: true storage: true subresources: diff --git a/docs/api/v1/source.md b/docs/api/v1/source.md index f7ecd4e34..19ff33971 100644 --- a/docs/api/v1/source.md +++ b/docs/api/v1/source.md @@ -1108,6 +1108,7 @@ string
URL is a reference to an OCI artifact repository hosted on a remote container registry.
Interval at which the OCIRepository URL is checked for updates. This interval is approximate and may be subject to jitter to ensure efficient use of resources.
@@ -3350,6 +3352,7 @@ stringURL is a reference to an OCI artifact repository hosted on a remote container registry.
Interval at which the OCIRepository URL is checked for updates. This interval is approximate and may be subject to jitter to ensure efficient use of resources.
diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index 96c57958a..3ae2d40ed 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -328,7 +328,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch obj *sourcev1.OCIRepository, metadata *meta.Artifact, dir string) (sreconcile.Result, error) { var authenticator authn.Authenticator - ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration) + ctxTimeout, cancel := context.WithTimeout(ctx, obj.GetTimeout()) defer cancel() // Remove previously failed source verification status conditions. The @@ -671,7 +671,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour ref name.Reference, keychain authn.Keychain, auth authn.Authenticator, transport *http.Transport, opt ...remote.Option) (soci.VerificationResult, error) { - ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration) + ctxTimeout, cancel := context.WithTimeout(ctx, obj.GetTimeout()) defer cancel() provider := obj.Spec.Verify.Provider diff --git a/internal/controller/ocirepository_controller_test.go b/internal/controller/ocirepository_controller_test.go index 7eb65e3df..45037275d 100644 --- a/internal/controller/ocirepository_controller_test.go +++ b/internal/controller/ocirepository_controller_test.go @@ -51,6 +51,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/utils/ptr" oras "oras.land/oras-go/v2/registry/remote" ctrl "sigs.k8s.io/controller-runtime" @@ -100,7 +101,7 @@ func TestOCIRepositoryReconciler_deleteBeforeFinalizer(t *testing.T) { ocirepo.Name = "test-ocirepo" ocirepo.Namespace = namespaceName ocirepo.Spec = sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, URL: "oci://example.com", } // Add a test finalizer to prevent the object from getting deleted. @@ -120,6 +121,83 @@ func TestOCIRepositoryReconciler_deleteBeforeFinalizer(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) } +func TestOCIRepository_CELValidation(t *testing.T) { + g := NewWithT(t) + + namespaceName := "ocirepo-" + randStringRunes(5) + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespaceName}, + } + g.Expect(k8sClient.Create(ctx, namespace)).ToNot(HaveOccurred()) + t.Cleanup(func() { + g.Expect(k8sClient.Delete(ctx, namespace)).NotTo(HaveOccurred()) + }) + + tests := []struct { + name string + spec map[string]interface{} + wantErr []string + }{ + { + name: "missing spec", + wantErr: []string{"spec.url is required", "spec.interval is required"}, + }, + { + name: "empty spec", + spec: map[string]interface{}{}, + wantErr: []string{"spec.url is required", "spec.interval is required"}, + }, + { + name: "missing url", + spec: map[string]interface{}{ + "interval": "1m", + }, + wantErr: []string{"spec.url is required"}, + }, + { + name: "missing interval", + spec: map[string]interface{}{ + "url": "oci://example.com", + }, + wantErr: []string{"spec.interval is required"}, + }, + { + name: "valid", + spec: map[string]interface{}{ + "url": "oci://example.com", + "interval": "1m", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(sourcev1.GroupVersion.WithKind(sourcev1.OCIRepositoryKind)) + obj.SetName("test-" + randStringRunes(5)) + obj.SetNamespace(namespaceName) + if tt.spec != nil { + obj.Object["spec"] = tt.spec + } + + err := k8sClient.Create(ctx, obj) + if len(tt.wantErr) == 0 { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(k8sClient.Delete(ctx, obj)).ToNot(HaveOccurred()) + return + } + + g.Expect(err).To(HaveOccurred()) + g.Expect(apierrors.IsInvalid(err)).To(BeTrue()) + for _, want := range tt.wantErr { + g.Expect(err.Error()).To(ContainSubstring(want)) + } + }) + } +} + func TestOCIRepository_Reconcile(t *testing.T) { g := NewWithT(t) @@ -197,7 +275,7 @@ func TestOCIRepository_Reconcile(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: tt.url, - Interval: metav1.Duration{Duration: 60 * time.Minute}, + Interval: &metav1.Duration{Duration: 60 * time.Minute}, Reference: &sourcev1.OCIRepositoryRef{}, Insecure: true, }, @@ -365,7 +443,7 @@ func TestOCIRepository_Reconcile_MediaType(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: tt.url, - Interval: metav1.Duration{Duration: 60 * time.Minute}, + Interval: &metav1.Duration{Duration: 60 * time.Minute}, Reference: &sourcev1.OCIRepositoryRef{ Tag: tt.tag, }, @@ -735,7 +813,7 @@ func TestOCIRepository_reconcileSource_authStrategy(t *testing.T) { Generation: 1, }, Spec: sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, }, } @@ -943,7 +1021,7 @@ func TestOCIRepository_CertSecret(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: tt.url, - Interval: metav1.Duration{Duration: 60 * time.Minute}, + Interval: &metav1.Duration{Duration: 60 * time.Minute}, Reference: &sourcev1.OCIRepositoryRef{Digest: tt.digest.String()}, }, } @@ -1069,7 +1147,7 @@ func TestOCIRepository_ProxySecret(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: tt.url, - Interval: metav1.Duration{Duration: 60 * time.Minute}, + Interval: &metav1.Duration{Duration: 60 * time.Minute}, Reference: &sourcev1.OCIRepositoryRef{Digest: tt.digest.String()}, }, } @@ -1282,7 +1360,7 @@ func TestOCIRepository_reconcileSource_remoteReference(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: fmt.Sprintf("oci://%s/podinfo", server.registryHost), - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, Insecure: true, }, @@ -1535,7 +1613,7 @@ func TestOCIRepository_reconcileSource_verifyOCISourceSignatureNotation(t *testi Verify: &sourcev1.OCIRepositoryVerification{ Provider: "notation", }, - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, }, } @@ -1869,7 +1947,7 @@ func TestOCIRepository_reconcileSource_verifyOCISourceTrustPolicyNotation(t *tes Verify: &sourcev1.OCIRepositoryVerification{ Provider: "notation", }, - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, }, } @@ -2194,7 +2272,7 @@ func TestOCIRepository_reconcileSource_verifyOCISourceSignatureCosign(t *testing Verify: &sourcev1.OCIRepositoryVerification{ Provider: "cosign", }, - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, }, } @@ -2421,7 +2499,7 @@ func TestOCIRepository_reconcileSource_verifyOCISourceSignature_keyless(t *testi Verify: &sourcev1.OCIRepositoryVerification{ Provider: "cosign", }, - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, Reference: tt.reference, }, @@ -2604,7 +2682,7 @@ func TestOCIRepository_reconcileSource_noop(t *testing.T) { Spec: sourcev1.OCIRepositorySpec{ URL: fmt.Sprintf("oci://%s/podinfo", server.registryHost), Reference: &sourcev1.OCIRepositoryRef{Tag: "6.1.5"}, - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, Insecure: true, }, @@ -2997,7 +3075,7 @@ func TestOCIRepository_getArtifactRef(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: tt.url, - Interval: metav1.Duration{Duration: interval}, + Interval: &metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, Insecure: true, }, @@ -3036,7 +3114,7 @@ func TestOCIRepository_invalidURL(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: "oci://ghcr.io/test/test:v1", - Interval: metav1.Duration{Duration: 60 * time.Minute}, + Interval: &metav1.Duration{Duration: 60 * time.Minute}, }, } @@ -3086,7 +3164,7 @@ func TestOCIRepository_objectLevelWorkloadIdentityFeatureGate(t *testing.T) { }, Spec: sourcev1.OCIRepositorySpec{ URL: "oci://ghcr.io/stefanprodan/manifests/podinfo", - Interval: metav1.Duration{Duration: 60 * time.Minute}, + Interval: &metav1.Duration{Duration: 60 * time.Minute}, Provider: "aws", ServiceAccountName: "test", },