diff --git a/README.md b/README.md index f6525b84..977d1388 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,14 @@ Please find more information regarding the extensibility concepts and a detailed # What does this package provide? -The general idea of this controller is to install the [fluxcd](https://fluxcd.io/) controllers together with a [flux gitrepository resource](https://fluxcd.io/docs/components/source/gitrepositories/) and a [flux kustomization resource](https://fluxcd.io/docs/components/kustomize/kustomization/) into newly created shoot clusters. -In consequence, your fresh shoot cluster will be reconciled to the state defined in the Git repository by the fluxcd controllers. +The general idea of this controller is to install the [fluxcd](https://fluxcd.io/) controllers together with a flux source resource ([GitRepository](https://fluxcd.io/docs/components/source/gitrepositories/) or [OCIRepository](https://fluxcd.io/flux/components/source/ocirepositories/)) and a [flux kustomization resource](https://fluxcd.io/docs/components/kustomize/kustomization/) into newly created shoot clusters. +In consequence, your fresh shoot cluster will be reconciled to the state defined in the source repository (Git or OCI) by the fluxcd controllers. Thus, this extension provides a general approach to install addons to shoot clusters. +**Source Types**: +- **Git Repository**: Use traditional Git repositories (GitHub, GitLab, etc.) to store your Kubernetes manifests +- **OCI Repository**: Use OCI-compliant container registries (ghcr.io, Docker Hub, etc.) to store pre-built manifests as OCI artifacts. This is particularly useful when using code generators like CUE, Jsonnet, or KCL in CI pipelines. + ## Example use case Let's say you have a CI-workflow which needs a kubernetes cluster with some basic components, such as [cert-manager](https://cert-manager.io/) or [minio](https://min.io/). Thus, your CI-workflow creates a `Shoot` on which you perform all your actions. @@ -58,6 +62,43 @@ Like the other resources (flux installation) provisioned by this configMap is no from the shoot cluster. This behaviour is intentional to keep the flux installation intact and allow the user to remove it in a controlled manner. Please be aware that the `configMap` is no longer updated when the extension is no longer active. +## Source Configuration Format + +The extension supports both **Git** and **OCI** repositories as Flux sources. The configuration uses a unified format where the source type is determined by the `apiVersion` and `kind` fields within the `template`. + +### Configuration Format + +For **Git repositories**: +```yaml +source: + template: + apiVersion: source.toolkit.fluxcd.io/v1 + kind: GitRepository + spec: + url: https://github.com/example/repo + ref: + branch: main + secretResourceName: my-git-credentials +``` + +For **OCI repositories**: +```yaml +source: + template: + apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: OCIRepository + spec: + url: oci://ghcr.io/example/manifests + ref: + tag: latest + secretResourceName: my-oci-credentials # optional +``` + +**Key Points:** +- The `template` field directly contains the full source resource manifest with `apiVersion` and `kind` +- The source type (Git or OCI) is automatically determined from the `kind` field +- `secretResourceName` is optional and references a secret in the Seed cluster that will be synced to the Shoot + # How to... ## Use it as a gardener operator diff --git a/example/extension.yaml b/example/extension.yaml index b1de1bfb..db607fca 100644 --- a/example/extension.yaml +++ b/example/extension.yaml @@ -23,6 +23,21 @@ spec: # name: flux-system url: https://github.com/fluxcd/flux2-kustomize-helm-example # secretResourceName: flux-ssh-secret + + # Alternatively, use an OCI repository: + # template: + # apiVersion: source.toolkit.fluxcd.io/v1beta2 + # kind: OCIRepository + # spec: + # url: oci://ghcr.io/example/manifests + # ref: + # semver: '>= 1.0.0' + # # or use tag: v1.0.0 + # # or use digest: sha256:abcd1234... + # interval: 10m + # # secretRef: + # # name: flux-system + # # secretResourceName: oci-credentials kustomization: template: apiVersion: kustomize.toolkit.fluxcd.io/v1 diff --git a/hack/api-reference/api.md b/hack/api-reference/api.md index b2fce8c5..4fcd5910 100644 --- a/hack/api-reference/api.md +++ b/hack/api-reference/api.md @@ -228,7 +228,30 @@ The following defaults are applied to omitted field: FluxConfig)

-

Source configures how to bootstrap a Flux source object.

+

Source configures how to bootstrap a Flux source object. +Supported source types: GitRepository, OCIRepository.

+

The Template field contains a raw Kubernetes object (GitRepository or OCIRepository). +The kind field in the template determines which type is used.

+

Example GitRepository:

+
source:
+template:
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: GitRepository
+spec:
+url: https://github.com/example/repo
+ref:
+branch: main
+
+

Example OCIRepository:

+
source:
+template:
+apiVersion: source.toolkit.fluxcd.io/v1beta2
+kind: OCIRepository
+spec:
+url: oci://ghcr.io/example/repo
+ref:
+tag: latest
+

@@ -242,15 +265,17 @@ The following defaults are applied to omitted field: diff --git a/pkg/apis/flux/v1alpha1/defaults.go b/pkg/apis/flux/v1alpha1/defaults.go index 7def6528..d274b95c 100644 --- a/pkg/apis/flux/v1alpha1/defaults.go +++ b/pkg/apis/flux/v1alpha1/defaults.go @@ -6,9 +6,11 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" + "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" ) const ( @@ -24,25 +26,50 @@ func addDefaultingFuncs(scheme *runtime.Scheme) error { return RegisterDefaults(scheme) } +// decodeSourceTemplateForDefaulting decodes a runtime.RawExtension into a Flux source object. +// Returns the decoded object or nil if decoding fails (fails silently to not break defaulting). +func decodeSourceTemplateForDefaulting(raw *runtime.RawExtension) runtime.Object { + obj, _, _ := DecodeSourceTemplate(raw) + return obj +} + func SetDefaults_FluxConfig(obj *FluxConfig) { if obj.Flux == nil { obj.Flux = &FluxInstallation{} } - // validation will ensure that both Source & Kustomization or set or both + // validation will ensure that both Source & Kustomization are set or both // are nil, but we have to handle all cases, since defaulting happens first. if obj.Source != nil && obj.Kustomization != nil { - if sourceName := obj.Source.Template.Name; obj.Kustomization.Template.Spec.SourceRef.Name == "" && sourceName != "" { - obj.Kustomization.Template.Spec.SourceRef.Name = sourceName - } - if sourceNamespace := obj.Source.Template.Namespace; obj.Kustomization.Template.Spec.SourceRef.Namespace == "" && sourceNamespace != "" { - obj.Kustomization.Template.Spec.SourceRef.Namespace = sourceNamespace + // Decode source template to get name and namespace + sourceObj := decodeSourceTemplateForDefaulting(obj.Source.Template) + if sourceObj != nil { + clientObj := sourceObj.(client.Object) + sourceName := clientObj.GetName() + sourceNamespace := clientObj.GetNamespace() + + if obj.Kustomization.Template.Spec.SourceRef.Name == "" && sourceName != "" { + obj.Kustomization.Template.Spec.SourceRef.Name = sourceName + } + if obj.Kustomization.Template.Spec.SourceRef.Namespace == "" && sourceNamespace != "" { + obj.Kustomization.Template.Spec.SourceRef.Namespace = sourceNamespace + } } } if namespace := ptr.Deref(obj.Flux.Namespace, ""); namespace != "" { - if obj.Source != nil && obj.Source.Template.Namespace == "" { - obj.Source.Template.Namespace = namespace + if obj.Source != nil && obj.Source.Template != nil { + // Decode, update namespace if needed, re-encode + sourceObj := decodeSourceTemplateForDefaulting(obj.Source.Template) + if sourceObj != nil { + clientObj := sourceObj.(client.Object) + if clientObj.GetNamespace() == "" { + clientObj.SetNamespace(namespace) + if encoded, err := encodeSourceTemplate(sourceObj); err == nil { + obj.Source.Template = encoded + } + } + } } if obj.Kustomization != nil && obj.Kustomization.Template.Namespace == "" { obj.Kustomization.Template.Namespace = namespace @@ -68,13 +95,56 @@ func SetDefaults_FluxInstallation(obj *FluxInstallation) { } func SetDefaults_Source(obj *Source) { - SetDefaults_Flux_GitRepository(&obj.Template) + if obj.Template == nil { + return + } + + // Decode the template + sourceObj := decodeSourceTemplateForDefaulting(obj.Template) + if sourceObj == nil { + return + } + + // Apply defaults based on source type + switch v := sourceObj.(type) { + case *sourcev1.GitRepository: + oldSource := v.DeepCopy() + SetDefaults_Flux_GitRepository(v) + + // If secretResourceName is set but secretRef is not, create default secretRef + hasSecretRef := v.Spec.SecretRef != nil && v.Spec.SecretRef.Name != "" + hasSecretResourceName := ptr.Deref(obj.SecretResourceName, "") != "" + if hasSecretResourceName && !hasSecretRef { + v.Spec.SecretRef = &meta.LocalObjectReference{ + Name: "flux-system", + } + } + + // Re-encode if we modified the object + if !equality.Semantic.DeepEqual(oldSource, v) { + if encoded, err := encodeSourceTemplate(sourceObj); err == nil { + obj.Template = encoded + } + } + + case *sourcev1.OCIRepository: + oldSource := v.DeepCopy() + SetDefaults_Flux_OCIRepository(v) + + // If secretResourceName is set but secretRef is not, create default secretRef + hasSecretRef := v.Spec.SecretRef != nil && v.Spec.SecretRef.Name != "" + hasSecretResourceName := ptr.Deref(obj.SecretResourceName, "") != "" + if hasSecretResourceName && !hasSecretRef { + v.Spec.SecretRef = &meta.LocalObjectReference{ + Name: "flux-system", + } + } - hasSecretRef := obj.Template.Spec.SecretRef != nil && obj.Template.Spec.SecretRef.Name != "" - hasSecretResourceName := ptr.Deref(obj.SecretResourceName, "") != "" - if hasSecretResourceName && !hasSecretRef { - obj.Template.Spec.SecretRef = &meta.LocalObjectReference{ - Name: "flux-system", + // Re-encode if we modified the object + if !equality.Semantic.DeepEqual(oldSource, v) { + if encoded, err := encodeSourceTemplate(sourceObj); err == nil { + obj.Template = encoded + } } } } @@ -97,6 +167,20 @@ func SetDefaults_Flux_GitRepository(obj *sourcev1.GitRepository) { } } +func SetDefaults_Flux_OCIRepository(obj *sourcev1.OCIRepository) { + if obj.Name == "" { + obj.Name = defaultGitRepositoryName + } + + if obj.Namespace == "" { + obj.Namespace = defaultFluxNamespace + } + + if obj.Spec.Interval.Duration == 0 { + obj.Spec.Interval = metav1.Duration{Duration: time.Minute} + } +} + func SetDefaults_Flux_Kustomization(obj *kustomizev1.Kustomization) { if obj.Name == "" { obj.Name = "flux-system" diff --git a/pkg/apis/flux/v1alpha1/defaults_test.go b/pkg/apis/flux/v1alpha1/defaults_test.go index cf4ba78c..c681e941 100644 --- a/pkg/apis/flux/v1alpha1/defaults_test.go +++ b/pkg/apis/flux/v1alpha1/defaults_test.go @@ -9,6 +9,8 @@ import ( . "github.com/gardener/gardener/pkg/utils/test/matchers" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/utils/ptr" ) @@ -16,16 +18,18 @@ var _ = Describe("FluxConfig defaulting", func() { var obj *FluxConfig BeforeEach(func() { + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + obj = &FluxConfig{ Source: &Source{ - Template: sourcev1.GitRepository{ - Spec: sourcev1.GitRepositorySpec{ - Reference: &sourcev1.GitRepositoryRef{ - Branch: "main", - }, - URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", - }, - }, + Template: encodeSourceTemplateForTest(gitRepo), }, Kustomization: &Kustomization{ Template: kustomizev1.Kustomization{ @@ -42,9 +46,12 @@ var _ = Describe("FluxConfig defaulting", func() { SetObjectDefaults_FluxConfig(obj) - Expect(obj.Source.Template.Spec.Reference).To(DeepEqual(before.Source.Template.Spec.Reference)) - Expect(obj.Source.Template.Spec.URL).To(DeepEqual(before.Source.Template.Spec.URL)) - Expect(obj.Source.Template.Spec.URL).To(DeepEqual(before.Source.Template.Spec.URL)) + // Decode to check that required fields weren't overwritten + beforeGit := decodeSourceTemplateForTest(before.Source.Template).(*sourcev1.GitRepository) + afterGit := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.GitRepository) + + Expect(afterGit.Spec.Reference).To(DeepEqual(beforeGit.Spec.Reference)) + Expect(afterGit.Spec.URL).To(DeepEqual(beforeGit.Spec.URL)) Expect(obj.Kustomization.Template.Spec.Path).To(DeepEqual(before.Kustomization.Template.Spec.Path)) }) @@ -60,13 +67,16 @@ var _ = Describe("FluxConfig defaulting", func() { }) }) - Describe("Source defaulting", func() { + Describe("GitRepository Source defaulting", func() { It("should default all standard fields", func() { SetObjectDefaults_FluxConfig(obj) - Expect(obj.Source.Template.Name).To(Equal("flux-system")) - Expect(obj.Source.Template.Namespace).To(Equal("flux-system")) - Expect(obj.Source.Template.Spec.Interval.Duration).To(Equal(time.Minute)) + // Decode to check defaults + gitRepo := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.GitRepository) + + Expect(gitRepo.Name).To(Equal("flux-system")) + Expect(gitRepo.Namespace).To(Equal("flux-system")) + Expect(gitRepo.Spec.Interval.Duration).To(Equal(time.Minute)) }) It("should default secretRef.name to flux-system if secretResourceName is set", func() { @@ -74,26 +84,141 @@ var _ = Describe("FluxConfig defaulting", func() { SetObjectDefaults_FluxConfig(obj) - Expect(obj.Source.Template.Spec.SecretRef).NotTo(BeNil()) - Expect(obj.Source.Template.Spec.SecretRef.Name).To(Equal("flux-system")) + // Decode to check defaults + gitRepo := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.GitRepository) + + Expect(gitRepo.Spec.SecretRef).NotTo(BeNil()) + Expect(gitRepo.Spec.SecretRef.Name).To(Equal("flux-system")) }) It("should not overwrite secretRef.name if secretResourceName is set", func() { - obj.Source.Template.Spec.SecretRef = &meta.LocalObjectReference{Name: "flux-secret"} + // Create GitRepository with explicit SecretRef + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + SecretRef: &meta.LocalObjectReference{Name: "flux-secret"}, + }, + } + obj.Source.Template = encodeSourceTemplateForTest(gitRepo) obj.Source.SecretResourceName = ptr.To("my-flux-secret") SetObjectDefaults_FluxConfig(obj) - Expect(obj.Source.Template.Spec.SecretRef).NotTo(BeNil()) - Expect(obj.Source.Template.Spec.SecretRef.Name).To(Equal("flux-secret")) + // Decode to check defaults + gitRepoAfter := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.GitRepository) + + Expect(gitRepoAfter.Spec.SecretRef).NotTo(BeNil()) + Expect(gitRepoAfter.Spec.SecretRef.Name).To(Equal("flux-secret")) }) It("should handle if the kustomization is omitted", func() { obj.Kustomization = nil SetObjectDefaults_FluxConfig(obj) - Expect(obj.Source.Template.Name).To(Equal("flux-system")) - Expect(obj.Source.Template.Namespace).To(Equal("flux-system")) + // Decode to check defaults + gitRepo := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.GitRepository) + + Expect(gitRepo.Name).To(Equal("flux-system")) + Expect(gitRepo.Namespace).To(Equal("flux-system")) + }) + }) + + Describe("OCIRepository Source defaulting", func() { + It("should default all standard fields", func() { + // Switch to OCI repository + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + obj.Source = &Source{ + Template: encodeSourceTemplateForTest(ociRepo), + } + + SetObjectDefaults_FluxConfig(obj) + + // Decode to check defaults + ociRepoAfter := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.OCIRepository) + + Expect(ociRepoAfter.Name).To(Equal("flux-system")) + Expect(ociRepoAfter.Namespace).To(Equal("flux-system")) + Expect(ociRepoAfter.Spec.Interval.Duration).To(Equal(time.Minute)) + }) + + It("should default secretRef.name to flux-system for OCIRepository if secretResourceName is set", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + obj.Source = &Source{ + Template: encodeSourceTemplateForTest(ociRepo), + SecretResourceName: ptr.To("my-oci-secret"), + } + + SetObjectDefaults_FluxConfig(obj) + + // Decode to check defaults + ociRepoAfter := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.OCIRepository) + + Expect(ociRepoAfter.Spec.SecretRef).NotTo(BeNil()) + Expect(ociRepoAfter.Spec.SecretRef.Name).To(Equal("flux-system")) + }) + + It("should not overwrite secretRef.name for OCIRepository if secretResourceName is set", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + SecretRef: &meta.LocalObjectReference{Name: "oci-secret"}, + }, + } + obj.Source = &Source{ + Template: encodeSourceTemplateForTest(ociRepo), + SecretResourceName: ptr.To("my-oci-secret"), + } + + SetObjectDefaults_FluxConfig(obj) + + // Decode to check defaults + ociRepoAfter := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.OCIRepository) + + Expect(ociRepoAfter.Spec.SecretRef).NotTo(BeNil()) + Expect(ociRepoAfter.Spec.SecretRef.Name).To(Equal("oci-secret")) + }) + + It("should handle OCIRepository when kustomization is omitted", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + obj.Source = &Source{ + Template: encodeSourceTemplateForTest(ociRepo), + } + obj.Kustomization = nil + + SetObjectDefaults_FluxConfig(obj) + + // Decode to check defaults + ociRepoAfter := decodeSourceTemplateForTest(obj.Source.Template).(*sourcev1.OCIRepository) + + Expect(ociRepoAfter.Name).To(Equal("flux-system")) + Expect(ociRepoAfter.Namespace).To(Equal("flux-system")) }) }) @@ -113,3 +238,53 @@ var _ = Describe("FluxConfig defaulting", func() { }) }) }) + +// Helper functions for encoding/decoding source templates in tests + +var ( + // testScheme is used for encoding/decoding source templates in tests + testScheme = runtime.NewScheme() + testEncoder runtime.Encoder + testDecoder runtime.Decoder +) + +func init() { + // Register Flux source types for test encoding/decoding + _ = sourcev1.AddToScheme(testScheme) + codecFactory := serializer.NewCodecFactory(testScheme) + testEncoder = codecFactory.LegacyCodec(sourcev1.GroupVersion) + testDecoder = codecFactory.UniversalDeserializer() +} + +// encodeSourceTemplateForTest encodes a Flux source object to runtime.RawExtension for use in tests. +func encodeSourceTemplateForTest(obj runtime.Object) *runtime.RawExtension { + // Set the proper GVK if not already set + gvk := obj.GetObjectKind().GroupVersionKind() + if gvk.Kind == "" { + kind := sourcev1.GitRepositoryKind + if _, ok := obj.(*sourcev1.OCIRepository); ok { + kind = sourcev1.OCIRepositoryKind + } + obj.GetObjectKind().SetGroupVersionKind(sourcev1.GroupVersion.WithKind(kind)) + } + + raw, err := runtime.Encode(testEncoder, obj) + if err != nil { + panic(err) + } + + return &runtime.RawExtension{Raw: raw} +} + +// decodeSourceTemplateForTest decodes a runtime.RawExtension into a Flux source object for testing. +func decodeSourceTemplateForTest(raw *runtime.RawExtension) runtime.Object { + if raw == nil || raw.Raw == nil { + return nil + } + + obj, _, err := testDecoder.Decode(raw.Raw, nil, nil) + if err != nil { + panic(err) + } + return obj +} diff --git a/pkg/apis/flux/v1alpha1/source_template.go b/pkg/apis/flux/v1alpha1/source_template.go new file mode 100644 index 00000000..a8fcce02 --- /dev/null +++ b/pkg/apis/flux/v1alpha1/source_template.go @@ -0,0 +1,67 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var ( + // sourceTemplateScheme is used for encoding/decoding source templates + sourceTemplateScheme = runtime.NewScheme() + sourceTemplateDecoder runtime.Decoder + sourceTemplateEncoder runtime.Encoder +) + +func init() { + // Register Flux source types + _ = sourcev1.AddToScheme(sourceTemplateScheme) + codecFactory := serializer.NewCodecFactory(sourceTemplateScheme) + sourceTemplateDecoder = codecFactory.UniversalDeserializer() + sourceTemplateEncoder = codecFactory.LegacyCodec(sourcev1.GroupVersion) +} + +// DecodeSourceTemplate decodes a runtime.RawExtension into a Flux source object. +// Returns the decoded object and its kind string, or an error if decoding fails. +func DecodeSourceTemplate(raw *runtime.RawExtension) (runtime.Object, string, error) { + if raw == nil || raw.Raw == nil { + return nil, "", fmt.Errorf("template is required") + } + + // First peek at the TypeMeta to get the GVK + typeMeta := &metav1.TypeMeta{} + if err := json.Unmarshal(raw.Raw, typeMeta); err != nil { + return nil, "", fmt.Errorf("failed to peek at GVK: %w", err) + } + + gvk := typeMeta.GroupVersionKind() + if gvk.Kind == "" { + return nil, "", fmt.Errorf("could not find 'kind' in template") + } + + // Decode into the specific type + obj, err := sourceTemplateScheme.New(gvk) + if err != nil { + return nil, gvk.Kind, fmt.Errorf("unsupported source type %v: %w", gvk, err) + } + + if err := runtime.DecodeInto(sourceTemplateDecoder, raw.Raw, obj); err != nil { + return nil, gvk.Kind, fmt.Errorf("failed to decode into %v: %w", gvk, err) + } + + return obj, gvk.Kind, nil +} + +// encodeSourceTemplate encodes a Flux source object back to runtime.RawExtension. +func encodeSourceTemplate(obj runtime.Object) (*runtime.RawExtension, error) { + raw, err := runtime.Encode(sourceTemplateEncoder, obj) + if err != nil { + return nil, err + } + + return &runtime.RawExtension{Raw: raw}, nil +} diff --git a/pkg/apis/flux/v1alpha1/types.go b/pkg/apis/flux/v1alpha1/types.go index 07e4ac83..63c57719 100644 --- a/pkg/apis/flux/v1alpha1/types.go +++ b/pkg/apis/flux/v1alpha1/types.go @@ -2,8 +2,8 @@ package v1alpha1 import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" - sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -61,17 +61,47 @@ type FluxInstallation struct { } // Source configures how to bootstrap a Flux source object. +// Supported source types: GitRepository, OCIRepository. +// +// The Template field contains a raw Kubernetes object (GitRepository or OCIRepository). +// The kind field in the template determines which type is used. +// +// Example GitRepository: +// +// source: +// template: +// apiVersion: source.toolkit.fluxcd.io/v1 +// kind: GitRepository +// spec: +// url: https://github.com/example/repo +// ref: +// branch: main +// +// Example OCIRepository: +// +// source: +// template: +// apiVersion: source.toolkit.fluxcd.io/v1beta2 +// kind: OCIRepository +// spec: +// url: oci://ghcr.io/example/repo +// ref: +// tag: latest type Source struct { - // Template is a partial GitRepository object in API version source.toolkit.fluxcd.io/v1. - // Required fields: spec.ref.*, spec.url. - // The following defaults are applied to omitted field: + // Template contains a Flux source object (GitRepository or OCIRepository). + // The kind field determines which type is used. + // Required fields depend on the source type: + // - GitRepository: spec.ref.*, spec.url + // - OCIRepository: spec.ref, spec.url + // The following defaults are applied to omitted fields: // - metadata.name is defaulted to "flux-system" // - metadata.namespace is defaulted to "flux-system" // - spec.interval is defaulted to "1m" - Template sourcev1.GitRepository `json:"template"` + // +optional + Template *runtime.RawExtension `json:"template,omitempty"` // SecretResourceName references a resource under Shoot.spec.resources. - // The secret data from this resource is used to create the GitRepository's credentials secret - // (GitRepository.spec.secretRef.name) if specified in Template. + // The secret data from this resource is used to create the source's credentials secret + // (spec.secretRef.name) if specified in Template. // +optional SecretResourceName *string `json:"secretResourceName,omitempty"` } diff --git a/pkg/apis/flux/v1alpha1/validation/fluxconfig.go b/pkg/apis/flux/v1alpha1/validation/fluxconfig.go index f294f6d3..7a9f7f9f 100644 --- a/pkg/apis/flux/v1alpha1/validation/fluxconfig.go +++ b/pkg/apis/flux/v1alpha1/validation/fluxconfig.go @@ -1,7 +1,10 @@ package validation import ( + "strings" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1helper "github.com/gardener/gardener/pkg/apis/core/v1beta1/helper" @@ -54,33 +57,110 @@ func ValidateFluxInstallation(fluxInstallation *fluxv1alpha1.FluxInstallation, f return allErrs } -var supportedGitRepositoryGVK = sourcev1.GroupVersion.WithKind(sourcev1.GitRepositoryKind) +var ( + supportedGitRepositoryGVK = sourcev1.GroupVersion.WithKind(sourcev1.GitRepositoryKind) + supportedOCIRepositoryGVK = sourcev1.GroupVersion.WithKind(sourcev1.OCIRepositoryKind) +) // ValidateSource validates a Source object. func ValidateSource(source *fluxv1alpha1.Source, shoot *gardencorev1beta1.Shoot, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} - template := source.Template + if source.Template == nil { + allErrs = append(allErrs, field.Required(fldPath.Child("template"), "template is required")) + return allErrs + } + + // Decode the template to determine its type + obj, kind, err := fluxv1alpha1.DecodeSourceTemplate(source.Template) + if err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("template"), source.Template, err.Error())) + return allErrs + } + templatePath := fldPath.Child("template") + // Validate based on the source type + switch v := obj.(type) { + case *sourcev1.GitRepository: + allErrs = append(allErrs, validateGitRepository(v, source.SecretResourceName, shoot, templatePath, fldPath)...) + case *sourcev1.OCIRepository: + allErrs = append(allErrs, validateOCIRepository(v, source.SecretResourceName, shoot, templatePath, fldPath)...) + default: + allErrs = append(allErrs, field.NotSupported(templatePath.Child("kind"), kind, []string{sourcev1.GitRepositoryKind, sourcev1.OCIRepositoryKind})) + } + + return allErrs +} + +// validateGitRepository validates a GitRepository template. +func validateGitRepository(template *sourcev1.GitRepository, secretResourceName *string, shoot *gardencorev1beta1.Shoot, templatePath, parentPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + // Validate GVK if gvk := template.GroupVersionKind(); !gvk.Empty() && gvk != supportedGitRepositoryGVK { allErrs = append(allErrs, field.NotSupported(templatePath.Child("apiVersion"), template.APIVersion, []string{supportedGitRepositoryGVK.GroupVersion().String()})) - allErrs = append(allErrs, field.NotSupported(templatePath.Child("kind"), template.APIVersion, []string{supportedGitRepositoryGVK.Kind})) + allErrs = append(allErrs, field.NotSupported(templatePath.Child("kind"), template.Kind, []string{supportedGitRepositoryGVK.Kind})) } + // Validate spec fields specPath := templatePath.Child("spec") if ref := template.Spec.Reference; ref == nil || apiequality.Semantic.DeepEqual(ref, &sourcev1.GitRepositoryRef{}) { allErrs = append(allErrs, field.Required(specPath.Child("ref"), "GitRepository must have a reference")) } if template.Spec.URL == "" { - allErrs = append(allErrs, field.Required(specPath.Child("url"), "GitRepository must have an URL")) + allErrs = append(allErrs, field.Required(specPath.Child("url"), "GitRepository must have a URL")) + } + + // Validate secret references + allErrs = append(allErrs, validateSourceSecretReferences(template.Spec.SecretRef, secretResourceName, shoot, specPath, parentPath)...) + + return allErrs +} + +// validateOCIRepository validates an OCIRepository template. +func validateOCIRepository(template *sourcev1.OCIRepository, secretResourceName *string, shoot *gardencorev1beta1.Shoot, templatePath, parentPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + // Validate GVK + if gvk := template.GroupVersionKind(); !gvk.Empty() && gvk != supportedOCIRepositoryGVK { + allErrs = append(allErrs, field.NotSupported(templatePath.Child("apiVersion"), template.APIVersion, []string{supportedOCIRepositoryGVK.GroupVersion().String()})) + allErrs = append(allErrs, field.NotSupported(templatePath.Child("kind"), template.Kind, []string{supportedOCIRepositoryGVK.Kind})) + } + + // Validate spec fields + specPath := templatePath.Child("spec") + + // Validate URL + if template.Spec.URL == "" { + allErrs = append(allErrs, field.Required(specPath.Child("url"), "OCIRepository must have a URL")) + } else if !strings.HasPrefix(template.Spec.URL, "oci://") { + allErrs = append(allErrs, field.Invalid(specPath.Child("url"), template.Spec.URL, "must start with oci://")) + } + + // Validate reference + if ref := template.Spec.Reference; ref == nil { + allErrs = append(allErrs, field.Required(specPath.Child("ref"), "OCIRepository must have a reference")) + } else if ref.Tag == "" && ref.SemVer == "" && ref.Digest == "" { + allErrs = append(allErrs, field.Invalid(specPath.Child("ref"), ref, "must specify tag, semver, or digest")) } - hasSecretRef := template.Spec.SecretRef != nil && template.Spec.SecretRef.Name != "" - hasSecretResourceName := ptr.Deref(source.SecretResourceName, "") != "" + // Validate secret references + allErrs = append(allErrs, validateSourceSecretReferences(template.Spec.SecretRef, secretResourceName, shoot, specPath, parentPath)...) + + return allErrs +} + +// validateSourceSecretReferences validates the secret reference consistency between +// spec.secretRef and source.secretResourceName. +func validateSourceSecretReferences(secretRef *meta.LocalObjectReference, secretResourceName *string, shoot *gardencorev1beta1.Shoot, specPath, parentPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + hasSecretRef := secretRef != nil && secretRef.Name != "" + hasSecretResourceName := ptr.Deref(secretResourceName, "") != "" secretRefPath := specPath.Child("secretRef") - secretResourceNamePath := fldPath.Child("secretResourceName") + secretResourceNamePath := parentPath.Child("secretResourceName") if hasSecretRef && !hasSecretResourceName { allErrs = append(allErrs, field.Required(secretResourceNamePath, "must specify a secret resource name if "+secretRefPath.String()+" is specified")) @@ -90,7 +170,7 @@ func ValidateSource(source *fluxv1alpha1.Source, shoot *gardencorev1beta1.Shoot, } if hasSecretResourceName { - allErrs = append(allErrs, validateSecretResource(shoot.Spec.Resources, secretResourceNamePath, *source.SecretResourceName)...) + allErrs = append(allErrs, validateSecretResource(shoot.Spec.Resources, secretResourceNamePath, *secretResourceName)...) } return allErrs diff --git a/pkg/apis/flux/v1alpha1/validation/fluxconfig_test.go b/pkg/apis/flux/v1alpha1/validation/fluxconfig_test.go index 83313cd0..834c79d2 100644 --- a/pkg/apis/flux/v1alpha1/validation/fluxconfig_test.go +++ b/pkg/apis/flux/v1alpha1/validation/fluxconfig_test.go @@ -9,6 +9,8 @@ import ( . "github.com/onsi/gomega" . "github.com/onsi/gomega/gstruct" autoscalingv1 "k8s.io/api/autoscaling/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" @@ -26,16 +28,18 @@ var _ = Describe("FluxConfig validation", func() { BeforeEach(func() { rootFldPath = field.NewPath("root") + gitRepoTemplate := encodeSourceTemplate(&sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + }) + fluxConfig = &FluxConfig{ Source: &Source{ - Template: sourcev1.GitRepository{ - Spec: sourcev1.GitRepositorySpec{ - Reference: &sourcev1.GitRepositoryRef{ - Branch: "main", - }, - URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", - }, - }, + Template: gitRepoTemplate, }, Kustomization: &Kustomization{ Template: kustomizev1.Kustomization{ @@ -91,61 +95,62 @@ var _ = Describe("FluxConfig validation", func() { "Field": Equal("root.source"), })))) }) - Describe("TypeMeta validation", func() { + Describe("Git TypeMeta validation", func() { It("should allow using supported apiVersion and kind", func() { - fluxConfig.Source.Template.APIVersion = "source.toolkit.fluxcd.io/v1" - fluxConfig.Source.Template.Kind = "GitRepository" + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) }) It("should allow omitting apiVersion and kind", func() { - fluxConfig.Source.Template.APIVersion = "" - fluxConfig.Source.Template.Kind = "" + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + // Explicitly clear GVK to test omitting it + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) }) - It("should deny using unsupported apiVersion", func() { - fluxConfig.Source.Template.APIVersion = "source.toolkit.fluxcd.io/v2" - fluxConfig.Source.Template.Kind = "GitRepository" - - Expect( - ValidateFluxConfig(fluxConfig, shoot, rootFldPath), - ).To(ConsistOf( - PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeNotSupported), - "Field": Equal("root.source.template.apiVersion"), - })), - PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeNotSupported), - "Field": Equal("root.source.template.kind"), - })), - )) - }) - - It("should deny using unsupported kind", func() { - fluxConfig.Source.Template.APIVersion = "source.toolkit.fluxcd.io/v1" - fluxConfig.Source.Template.Kind = "Bucket" + It("should validate based on decoded template type", func() { + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect( ValidateFluxConfig(fluxConfig, shoot, rootFldPath), - ).To(ConsistOf( - PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeNotSupported), - "Field": Equal("root.source.template.apiVersion"), - })), - PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeNotSupported), - "Field": Equal("root.source.template.kind"), - })), - )) + ).To(BeEmpty()) }) }) - Describe("Reference validation", func() { + Describe("Git Reference validation", func() { It("should forbid omitting reference", func() { - fluxConfig.Source.Template.Spec.Reference = nil + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: nil, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect( ValidateFluxConfig(fluxConfig, shoot, rootFldPath), @@ -156,7 +161,13 @@ var _ = Describe("FluxConfig validation", func() { }) It("should forbid specifying empty reference", func() { - fluxConfig.Source.Template.Spec.Reference = &sourcev1.GitRepositoryRef{} + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{}, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect( ValidateFluxConfig(fluxConfig, shoot, rootFldPath), @@ -168,8 +179,14 @@ var _ = Describe("FluxConfig validation", func() { It("should allow setting any reference", func() { test := func(mutate func(ref *sourcev1.GitRepositoryRef)) { - fluxConfig.Source.Template.Spec.Reference = &sourcev1.GitRepositoryRef{} - mutate(fluxConfig.Source.Template.Spec.Reference) + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{}, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + mutate(gitRepo.Spec.Reference) + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) ExpectWithOffset(1, ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) } @@ -182,9 +199,17 @@ var _ = Describe("FluxConfig validation", func() { }) }) - Describe("URL validation", func() { + Describe("Git URL validation", func() { It("should forbid omitting URL", func() { - fluxConfig.Source.Template.Spec.URL = "" + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "", + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect( ValidateFluxConfig(fluxConfig, shoot, rootFldPath), @@ -195,18 +220,34 @@ var _ = Describe("FluxConfig validation", func() { }) }) - Describe("Secret validation", func() { + Describe("Git Secret validation", func() { It("should allow omitting both secretRef and secretResourceName", func() { - fluxConfig.Source.Template.Spec.SecretRef = nil - fluxConfig.Source.SecretResourceName = nil + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) }) It("should allow specifying both secretRef and secretResourceName", func() { - fluxConfig.Source.Template.Spec.SecretRef = &meta.LocalObjectReference{ - Name: "flux-secret", + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + SecretRef: &meta.LocalObjectReference{ + Name: "flux-secret", + }, + }, } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) fluxConfig.Source.SecretResourceName = ptr.To("my-flux-secret") shoot.Spec.Resources = []gardencorev1beta1.NamedResourceReference{{ Name: "my-flux-secret", @@ -219,9 +260,18 @@ var _ = Describe("FluxConfig validation", func() { }) It("should deny specifying a secretResourceName without a matching resource", func() { - fluxConfig.Source.Template.Spec.SecretRef = &meta.LocalObjectReference{ - Name: "flux-secret", + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + SecretRef: &meta.LocalObjectReference{ + Name: "flux-secret", + }, + }, } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) fluxConfig.Source.SecretResourceName = ptr.To("my-flux-secret") shoot.Spec.Resources = []gardencorev1beta1.NamedResourceReference{{ Name: "my-other-secret", @@ -236,7 +286,16 @@ var _ = Describe("FluxConfig validation", func() { }) It("should deny omitting secretRef if secretResourceName is set", func() { - fluxConfig.Source.Template.Spec.SecretRef = nil + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + SecretRef: nil, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) fluxConfig.Source.SecretResourceName = ptr.To("my-flux-secret") shoot.Spec.Resources = []gardencorev1beta1.NamedResourceReference{{ Name: "my-flux-secret", @@ -254,9 +313,18 @@ var _ = Describe("FluxConfig validation", func() { }) It("should deny omitting secretResourceName if secretRef is set", func() { - fluxConfig.Source.Template.Spec.SecretRef = &meta.LocalObjectReference{ - Name: "flux-secret", + gitRepo := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{ + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, + URL: "https://github.com/fluxcd/flux2-kustomize-helm-example", + SecretRef: &meta.LocalObjectReference{ + Name: "flux-secret", + }, + }, } + fluxConfig.Source.Template = encodeSourceTemplate(gitRepo) fluxConfig.Source.SecretResourceName = nil Expect( @@ -267,6 +335,388 @@ var _ = Describe("FluxConfig validation", func() { })))) }) }) + + Describe("Source mutex validation", func() { + It("should deny having both GitRepository and OCIRepository set", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(BeEmpty()) + }) + + It("should deny having neither GitRepository nor OCIRepository set", func() { + fluxConfig.Source.Template = nil + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": Equal("root.source.template"), + })))) + }) + }) + + Describe("OCI TypeMeta validation", func() { + BeforeEach(func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source = &Source{ + Template: encodeSourceTemplate(ociRepo), + } + }) + + It("should allow using supported apiVersion and kind", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + + It("should allow omitting apiVersion and kind", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + // Explicitly clear GVK + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + + It("should validate OCI template based on decoded type", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(BeEmpty()) + }) + }) + + Describe("OCI Reference validation", func() { + BeforeEach(func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source = &Source{ + Template: encodeSourceTemplate(ociRepo), + } + }) + + It("should forbid omitting reference", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: nil, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": Equal("root.source.template.spec.ref"), + })))) + }) + + It("should forbid specifying empty reference", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{}, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeInvalid), + "Field": Equal("root.source.template.spec.ref"), + })))) + }) + + It("should allow setting tag reference", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "latest", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + + It("should allow setting semver reference", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + + It("should allow setting digest reference", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Digest: "sha256:abcd1234", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + }) + + Describe("OCI URL validation", func() { + BeforeEach(func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source = &Source{ + Template: encodeSourceTemplate(ociRepo), + } + }) + + It("should forbid omitting URL", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": Equal("root.source.template.spec.url"), + })))) + }) + + It("should forbid non-OCI URL format", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "https://github.com/example/repo", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeInvalid), + "Field": Equal("root.source.template.spec.url"), + })))) + }) + + It("should allow OCI URL format", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + }) + + Describe("OCI Secret validation", func() { + BeforeEach(func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + fluxConfig.Source = &Source{ + Template: encodeSourceTemplate(ociRepo), + } + }) + + It("should allow omitting both secretRef and secretResourceName", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + SecretRef: nil, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + fluxConfig.Source.SecretResourceName = nil + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + + It("should allow specifying both secretRef and secretResourceName", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + SecretRef: &meta.LocalObjectReference{ + Name: "oci-secret", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + fluxConfig.Source.SecretResourceName = ptr.To("my-oci-secret") + shoot.Spec.Resources = []gardencorev1beta1.NamedResourceReference{{ + Name: "my-oci-secret", + ResourceRef: autoscalingv1.CrossVersionObjectReference{ + Kind: "Secret", + }, + }} + + Expect(ValidateFluxConfig(fluxConfig, shoot, rootFldPath)).To(BeEmpty()) + }) + + It("should deny specifying a secretResourceName without a matching resource", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + SecretRef: &meta.LocalObjectReference{ + Name: "oci-secret", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + fluxConfig.Source.SecretResourceName = ptr.To("my-oci-secret") + shoot.Spec.Resources = []gardencorev1beta1.NamedResourceReference{{ + Name: "my-other-secret", + }} + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeInvalid), + "Field": Equal("root.source.secretResourceName"), + })))) + }) + + It("should deny omitting secretRef if secretResourceName is set", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + SecretRef: nil, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + fluxConfig.Source.SecretResourceName = ptr.To("my-oci-secret") + shoot.Spec.Resources = []gardencorev1beta1.NamedResourceReference{{ + Name: "my-oci-secret", + ResourceRef: autoscalingv1.CrossVersionObjectReference{ + Kind: "Secret", + }, + }} + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": Equal("root.source.template.spec.secretRef"), + })))) + }) + + It("should deny omitting secretResourceName if secretRef is set", func() { + ociRepo := &sourcev1.OCIRepository{ + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + SecretRef: &meta.LocalObjectReference{ + Name: "oci-secret", + }, + }, + } + fluxConfig.Source.Template = encodeSourceTemplate(ociRepo) + fluxConfig.Source.SecretResourceName = nil + + Expect( + ValidateFluxConfig(fluxConfig, shoot, rootFldPath), + ).To(ContainElement(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": Equal("root.source.secretResourceName"), + })))) + }) + }) }) Describe("Kustomization validation", func() { @@ -391,3 +841,21 @@ var _ = Describe("FluxConfig validation", func() { }) }) }) + +func encodeSourceTemplate(obj runtime.Object) *runtime.RawExtension { + // If the object already has TypeMeta set (APIVersion and Kind), use those. + // Otherwise, auto-detect based on object type. + existing := obj.GetObjectKind().GroupVersionKind() + if existing.Kind == "" { + gvk := sourcev1.GroupVersion.WithKind(sourcev1.GitRepositoryKind) + if _, ok := obj.(*sourcev1.OCIRepository); ok { + gvk = sourcev1.GroupVersion.WithKind(sourcev1.OCIRepositoryKind) + } + obj.GetObjectKind().SetGroupVersionKind(gvk) + } + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + panic(err) + } + return &runtime.RawExtension{Raw: data} +} diff --git a/pkg/apis/flux/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/flux/v1alpha1/zz_generated.deepcopy.go index 82057e2b..349a590a 100644 --- a/pkg/apis/flux/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/flux/v1alpha1/zz_generated.deepcopy.go @@ -132,7 +132,11 @@ func (in *Kustomization) DeepCopy() *Kustomization { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Source) DeepCopyInto(out *Source) { *out = *in - in.Template.DeepCopyInto(&out.Template) + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } if in.SecretResourceName != nil { in, out := &in.SecretResourceName, &out.SecretResourceName *out = new(string) diff --git a/pkg/controller/extension/actuator.go b/pkg/controller/extension/actuator.go index af5c3e0a..56698a4f 100644 --- a/pkg/controller/extension/actuator.go +++ b/pkg/controller/extension/actuator.go @@ -2,6 +2,7 @@ package extension import ( "context" + "encoding/json" "fmt" "time" @@ -36,6 +37,48 @@ import ( "github.com/stackitcloud/gardener-extension-shoot-flux/pkg/apis/flux/v1alpha1/validation" ) +var ( + // actuatorScheme is used for decoding source templates in the actuator + actuatorScheme = runtime.NewScheme() + actuatorDecoder runtime.Decoder +) + +func init() { + // Register Flux source types + _ = sourcev1.AddToScheme(actuatorScheme) + actuatorDecoder = serializer.NewCodecFactory(actuatorScheme).UniversalDeserializer() +} + +// decodeActuatorSourceTemplate decodes a runtime.RawExtension into a Flux source object for the actuator. +func decodeActuatorSourceTemplate(raw *runtime.RawExtension) (runtime.Object, error) { + if raw == nil || raw.Raw == nil { + return nil, fmt.Errorf("template is required") + } + + // Peek at TypeMeta to get GVK + typeMeta := &metav1.TypeMeta{} + if err := json.Unmarshal(raw.Raw, typeMeta); err != nil { + return nil, fmt.Errorf("failed to peek at GVK: %w", err) + } + + gvk := typeMeta.GroupVersionKind() + if gvk.Kind == "" { + return nil, fmt.Errorf("could not find 'kind' in template") + } + + // Decode into the specific type + obj, err := actuatorScheme.New(gvk) + if err != nil { + return nil, fmt.Errorf("unsupported source type %v: %w", gvk, err) + } + + if err := runtime.DecodeInto(actuatorDecoder, raw.Raw, obj); err != nil { + return nil, fmt.Errorf("failed to decode into %v: %w", gvk, err) + } + + return obj, nil +} + type actuator struct { client client.Client decoder runtime.Decoder @@ -319,7 +362,7 @@ func GenerateInstallManifest(config *fluxv1alpha1.FluxInstallation, manifestsBas return []byte(manifest.Content), nil } -// BootstrapSource creates the GitRepository object specified in the given config and waits for it to get ready. +// BootstrapSource creates the source object (GitRepository or OCIRepository) specified in the given config and waits for it to get ready. func BootstrapSource( ctx context.Context, log logr.Logger, @@ -337,29 +380,105 @@ func bootstrapSource( interval time.Duration, timeout time.Duration, ) error { - log.Info("Bootstrapping Flux GitRepository") + if config.Template == nil { + return fmt.Errorf("source template is required") + } - // Create Namespace in case the GitRepository is located in a different namespace than the Flux components. - namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: config.Template.Namespace}} + // Decode the template to determine source type + obj, err := decodeActuatorSourceTemplate(config.Template) + if err != nil { + return fmt.Errorf("failed to decode source template: %w", err) + } + + // Route to appropriate bootstrap function based on decoded type + switch sourceTemplate := obj.(type) { + case *sourcev1.GitRepository: + return bootstrapGitRepository(ctx, log, shootClient, sourceTemplate, interval, timeout) + case *sourcev1.OCIRepository: + return bootstrapOCIRepository(ctx, log, shootClient, sourceTemplate, interval, timeout) + default: + return fmt.Errorf("unsupported source type: %T", sourceTemplate) + } +} + +func bootstrapGitRepository( + ctx context.Context, + log logr.Logger, + shootClient client.Client, + template *sourcev1.GitRepository, + interval time.Duration, + timeout time.Duration, +) error { + gitRepository := template.DeepCopy() + return bootstrapSourceRepository( + ctx, + log, + shootClient, + gitRepository, + "GitRepository", + interval, + timeout, + func() error { + template.Spec.DeepCopyInto(&gitRepository.Spec) + return nil + }, + ) +} + +func bootstrapOCIRepository( + ctx context.Context, + log logr.Logger, + shootClient client.Client, + template *sourcev1.OCIRepository, + interval time.Duration, + timeout time.Duration, +) error { + ociRepository := template.DeepCopy() + return bootstrapSourceRepository( + ctx, + log, + shootClient, + ociRepository, + "OCIRepository", + interval, + timeout, + func() error { + template.Spec.DeepCopyInto(&ociRepository.Spec) + return nil + }, + ) +} + +// bootstrapSourceRepository is a generic helper for bootstrapping Flux source repositories. +func bootstrapSourceRepository( + ctx context.Context, + log logr.Logger, + shootClient client.Client, + obj client.Object, + resourceType string, + interval time.Duration, + timeout time.Duration, + mutateFn func() error, +) error { + log.Info("Bootstrapping Flux " + resourceType) + + // Create Namespace in case the source is located in a different namespace than the Flux components. + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: obj.GetNamespace()}} if err := shootClient.Create(ctx, namespace); client.IgnoreAlreadyExists(err) != nil { - return fmt.Errorf("error creating %s namespace: %w", config.Template.Namespace, err) + return fmt.Errorf("error creating %s namespace: %w", obj.GetNamespace(), err) } - // Create GitRepository - gitRepository := config.Template.DeepCopy() - if _, err := controllerutil.CreateOrUpdate(ctx, shootClient, gitRepository, func() error { - config.Template.Spec.DeepCopyInto(&gitRepository.Spec) - return nil - }); err != nil { - return fmt.Errorf("error applying GitRepository template: %w", err) + // Create or update the source repository + if _, err := controllerutil.CreateOrUpdate(ctx, shootClient, obj, mutateFn); err != nil { + return fmt.Errorf("error applying %s template: %w", resourceType, err) } - log.Info("Waiting for GitRepository to get ready") - if err := WaitForObject(ctx, shootClient, gitRepository, interval, timeout, CheckFluxObject(gitRepository)); err != nil { - return fmt.Errorf("error waiting for GitRepository to get ready: %w", err) + log.Info("Waiting for " + resourceType + " to get ready") + if err := WaitForObject(ctx, shootClient, obj, interval, timeout, CheckFluxObject(obj)); err != nil { + return fmt.Errorf("error waiting for %s to get ready: %w", resourceType, err) } - log.Info("Successfully bootstrapped Flux GitRepository") + log.Info("Successfully bootstrapped Flux " + resourceType) return nil } diff --git a/pkg/controller/extension/actuator_test.go b/pkg/controller/extension/actuator_test.go index 42c7e598..a42b5951 100644 --- a/pkg/controller/extension/actuator_test.go +++ b/pkg/controller/extension/actuator_test.go @@ -21,6 +21,7 @@ import ( corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -117,43 +118,152 @@ var _ = Describe("BootstrapSource", func() { shootClient client.Client config *fluxv1alpha1.Source ) - BeforeEach(func() { - shootClient = newShootClient() - config = &fluxv1alpha1.Source{ - Template: sourcev1.GitRepository{ + + Context("with GitRepository", func() { + var gitRepo *sourcev1.GitRepository + + BeforeEach(func() { + shootClient = newShootClient() + gitRepo = &sourcev1.GitRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: sourcev1.GroupVersion.String(), + Kind: sourcev1.GitRepositoryKind, + }, ObjectMeta: metav1.ObjectMeta{ Name: "gitrepo", Namespace: "custom-namespace", }, Spec: sourcev1.GitRepositorySpec{ URL: "http://example.com", + Reference: &sourcev1.GitRepositoryRef{ + Branch: "main", + }, }, - }, - } + } + config = &fluxv1alpha1.Source{ + Template: encodeSourceObject(gitRepo), + } + }) + + It("should successfully apply and wait for readiness", func() { + done := testAsync(func() { + Expect( + bootstrapSource(ctx, log, shootClient, config, poll, timeout), + ).To(Succeed()) + }) + repo := gitRepo.DeepCopy() + Eventually(fakeFluxResourceReady(ctx, shootClient, repo)).Should(Succeed()) + Eventually(done).Should(BeClosed()) + + createdRepo := &sourcev1.GitRepository{} + Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(repo), createdRepo)).To(Succeed()) + Expect(createdRepo.Spec.URL).To(Equal("http://example.com")) + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: gitRepo.Namespace}} + Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(ns), ns)).Should(Succeed()) + }) + + It("should fail if the resources do not get ready", func() { + Eventually(testAsync(func() { + Expect( + bootstrapSource(ctx, log, shootClient, config, poll, timeout), + ).To(MatchError(ContainSubstring("error waiting for GitRepository to get ready"))) + })).Should(BeClosed()) + }) }) - It("should succesfully apply and wait for readiness", func() { - done := testAsync(func() { + + Context("with OCIRepository", func() { + var ociRepo *sourcev1.OCIRepository + + BeforeEach(func() { + shootClient = newShootClient() + ociRepo = &sourcev1.OCIRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: sourcev1.GroupVersion.String(), + Kind: sourcev1.OCIRepositoryKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ocirepository", + Namespace: "custom-namespace", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/example/manifests", + Reference: &sourcev1.OCIRepositoryRef{ + Tag: "v1.0.0", + }, + }, + } + config = &fluxv1alpha1.Source{ + Template: encodeSourceObject(ociRepo), + } + }) + + It("should successfully apply and wait for readiness", func() { + done := testAsync(func() { + Expect( + bootstrapSource(ctx, log, shootClient, config, poll, timeout), + ).To(Succeed()) + }) + repo := ociRepo.DeepCopy() + Eventually(fakeFluxResourceReady(ctx, shootClient, repo)).Should(Succeed()) + Eventually(done).Should(BeClosed()) + + createdRepo := &sourcev1.OCIRepository{} + Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(repo), createdRepo)).To(Succeed()) + Expect(createdRepo.Spec.URL).To(Equal("oci://ghcr.io/example/manifests")) + Expect(createdRepo.Spec.Reference.Tag).To(Equal("v1.0.0")) + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ociRepo.Namespace}} + Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(ns), ns)).Should(Succeed()) + }) + + It("should fail if the resources do not get ready", func() { + Eventually(testAsync(func() { + Expect( + bootstrapSource(ctx, log, shootClient, config, poll, timeout), + ).To(MatchError(ContainSubstring("error waiting for OCIRepository to get ready"))) + })).Should(BeClosed()) + }) + + It("should handle OCI with semver reference", func() { + ociRepo.Spec.Reference = &sourcev1.OCIRepositoryRef{ + SemVer: ">= 1.0.0", + } + config.Template = encodeSourceObject(ociRepo) + + done := testAsync(func() { + Expect( + bootstrapSource(ctx, log, shootClient, config, poll, timeout), + ).To(Succeed()) + }) + repo := ociRepo.DeepCopy() + Eventually(fakeFluxResourceReady(ctx, shootClient, repo)).Should(Succeed()) + Eventually(done).Should(BeClosed()) + + createdRepo := &sourcev1.OCIRepository{} + Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(repo), createdRepo)).To(Succeed()) + Expect(createdRepo.Spec.Reference.SemVer).To(Equal(">= 1.0.0")) + }) + }) + + Context("with invalid source", func() { + It("should fail when template is nil", func() { + config = &fluxv1alpha1.Source{} + Expect( bootstrapSource(ctx, log, shootClient, config, poll, timeout), - ).To(Succeed()) + ).To(MatchError(ContainSubstring("source template is required"))) }) - repo := config.Template.DeepCopy() - Eventually(fakeFluxResourceReady(ctx, shootClient, repo)).Should(Succeed()) - Eventually(done).Should(BeClosed()) - createdRepo := &sourcev1.GitRepository{} - Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(repo), createdRepo)) - Expect(createdRepo.Spec.URL).To(Equal("http://example.com")) + It("should fail when template contains invalid JSON", func() { + config = &fluxv1alpha1.Source{ + Template: &runtime.RawExtension{Raw: []byte(`{invalid json}`)}, + } - ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: config.Template.Namespace}} - Expect(shootClient.Get(ctx, client.ObjectKeyFromObject(ns), ns)).Should(Succeed()) - }) - It("should fail if the resources do not get ready", func() { - Eventually(testAsync(func() { Expect( bootstrapSource(ctx, log, shootClient, config, poll, timeout), - ).To(MatchError(ContainSubstring("error waiting for GitRepository to get ready"))) - })).Should(BeClosed()) + ).To(MatchError(ContainSubstring("failed to decode source template"))) + }) }) }) @@ -340,6 +450,25 @@ func fakeFluxResourceReady(ctx context.Context, c client.Client, obj fluxmeta.Ob } } +// encodeSourceObject encodes a Flux source object (GitRepository or OCIRepository) into a runtime.RawExtension +func encodeSourceObject(obj runtime.Object) *runtime.RawExtension { + scheme := runtime.NewScheme() + _ = sourcev1.AddToScheme(scheme) + + gvk := sourcev1.GroupVersion.WithKind(sourcev1.GitRepositoryKind) + if _, ok := obj.(*sourcev1.OCIRepository); ok { + gvk = sourcev1.GroupVersion.WithKind(sourcev1.OCIRepositoryKind) + } + obj.GetObjectKind().SetGroupVersionKind(gvk) + + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + panic(err) + } + + return &runtime.RawExtension{Raw: data} +} + func fakeFluxReady(ctx context.Context, c client.Client, namespace string) func() error { return func() error { gitRepoCRD := &apiextensionsv1.CustomResourceDefinition{ diff --git a/pkg/controller/extension/extension_suite_test.go b/pkg/controller/extension/extension_suite_test.go index a3e5c569..5abd3ee5 100644 --- a/pkg/controller/extension/extension_suite_test.go +++ b/pkg/controller/extension/extension_suite_test.go @@ -92,6 +92,7 @@ func newShootClient() client.Client { WithStatusSubresource( &kustomizev1.Kustomization{}, &sourcev1.GitRepository{}, + &sourcev1.OCIRepository{}, ). Build() } diff --git a/pkg/controller/extension/secrets.go b/pkg/controller/extension/secrets.go index 48375716..420b4f3f 100644 --- a/pkg/controller/extension/secrets.go +++ b/pkg/controller/extension/secrets.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" + sourcev1 "github.com/fluxcd/source-controller/api/v1" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" v1beta1helper "github.com/gardener/gardener/pkg/apis/core/v1beta1/helper" @@ -37,10 +38,26 @@ func ReconcileSecrets( secretResources := config.AdditionalSecretResources if config.Source != nil && config.Source.SecretResourceName != nil { - secretResources = append(secretResources, fluxv1alpha1.AdditionalResource{ - Name: *config.Source.SecretResourceName, - TargetName: ptr.To(config.Source.Template.Spec.SecretRef.Name), - }) + // Decode the source template to extract the secret reference name + if obj, err := decodeActuatorSourceTemplate(config.Source.Template); err == nil { + var secretRefName string + switch v := obj.(type) { + case *sourcev1.GitRepository: + if v.Spec.SecretRef != nil { + secretRefName = v.Spec.SecretRef.Name + } + case *sourcev1.OCIRepository: + if v.Spec.SecretRef != nil { + secretRefName = v.Spec.SecretRef.Name + } + } + if secretRefName != "" { + secretResources = append(secretResources, fluxv1alpha1.AdditionalResource{ + Name: *config.Source.SecretResourceName, + TargetName: ptr.To(secretRefName), + }) + } + } } for _, resource := range secretResources { name, err := copySecretToShoot(ctx, log, seedClient, shootClient, seedNamespace, shootNamespace, resources, resource) diff --git a/pkg/controller/extension/secrets_test.go b/pkg/controller/extension/secrets_test.go index 6977e500..d485a19c 100644 --- a/pkg/controller/extension/secrets_test.go +++ b/pkg/controller/extension/secrets_test.go @@ -28,19 +28,24 @@ var _ = Describe("ReconcileSecrets", Ordered, func() { BeforeAll(func() { shootClient = newShootClient() seedClient = newSeedClient() + gitRepo := &sourcev1.GitRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: sourcev1.GroupVersion.String(), + Kind: sourcev1.GitRepositoryKind, + }, + Spec: sourcev1.GitRepositorySpec{ + SecretRef: &fluxmeta.LocalObjectReference{ + Name: "ssh-target-name", + }, + }, + } config = &fluxv1alpha1.FluxConfig{ Flux: &fluxv1alpha1.FluxInstallation{ Namespace: ptr.To("flux-system"), }, Source: &fluxv1alpha1.Source{ + Template: encodeSourceObject(gitRepo), SecretResourceName: ptr.To("source-secret"), - Template: sourcev1.GitRepository{ - Spec: sourcev1.GitRepositorySpec{ - SecretRef: &fluxmeta.LocalObjectReference{ - Name: "ssh-target-name", - }, - }, - }, }, AdditionalSecretResources: []fluxv1alpha1.AdditionalResource{{ Name: "extra-secret",
template
- -source.toolkit.fluxcd.io/v1.GitRepository - +k8s.io/apimachinery/pkg/runtime.RawExtension
-

Template is a partial GitRepository object in API version source.toolkit.fluxcd.io/v1. -Required fields: spec.ref.*, spec.url. -The following defaults are applied to omitted field: +(Optional) +

Template contains a Flux source object (GitRepository or OCIRepository). +The kind field determines which type is used. +Required fields depend on the source type: +- GitRepository: spec.ref.*, spec.url +- OCIRepository: spec.ref, spec.url +The following defaults are applied to omitted fields: - metadata.name is defaulted to “flux-system” - metadata.namespace is defaulted to “flux-system” - spec.interval is defaulted to “1m”

@@ -266,8 +291,8 @@ string
(Optional)

SecretResourceName references a resource under Shoot.spec.resources. -The secret data from this resource is used to create the GitRepository’s credentials secret -(GitRepository.spec.secretRef.name) if specified in Template.

+The secret data from this resource is used to create the source’s credentials secret +(spec.secretRef.name) if specified in Template.