diff --git a/.gitignore b/.gitignore index fcf0a787..0da023b0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ config/release/ config/crd/bases/ocirepositories.yaml config/crd/bases/gitrepositories.yaml config/crd/bases/buckets.yaml +config/crd/bases/externalartifacts.yaml build/ diff --git a/Makefile b/Makefile index f1c60fbc..0d632ad6 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ ENVTEST_ARCH ?= amd64 GITREPO_CRD ?= config/crd/bases/gitrepositories.yaml BUCKET_CRD ?= config/crd/bases/buckets.yaml OCIREPO_CRD ?= config/crd/bases/ocirepositories.yaml +EA_CRD ?= config/crd/bases/externalartifacts.yaml # Keep a record of the version of the downloaded source CRDs. It is used to # detect and download new CRDs when the SOURCE_VER changes. @@ -90,12 +91,15 @@ $(BUCKET_CRD): $(OCIREPO_CRD): curl -s https://raw.githubusercontent.com/fluxcd/source-controller/${SOURCE_VER}/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml -o $(OCIREPO_CRD) +$(EA_CRD): + curl -s https://raw.githubusercontent.com/fluxcd/source-controller/${SOURCE_VER}/config/crd/bases/source.toolkit.fluxcd.io_externalartifacts.yaml -o $(EA_CRD) + # Download the CRDs the controller depends on -download-crd-deps: $(SOURCE_CRD_VER) $(GITREPO_CRD) $(BUCKET_CRD) $(OCIREPO_CRD) +download-crd-deps: $(SOURCE_CRD_VER) $(GITREPO_CRD) $(BUCKET_CRD) $(OCIREPO_CRD) $(EA_CRD) # Delete the downloaded CRD dependencies. cleanup-crd-deps: - rm -f $(GITREPO_CRD) $(BUCKET_CRD) $(OCIREPO_CRD) + rm -f $(GITREPO_CRD) $(BUCKET_CRD) $(OCIREPO_CRD) $(EA_CRD) # Install CRDs into a cluster install: manifests diff --git a/api/v1/reference_types.go b/api/v1/reference_types.go index 3a2412d7..07eb3b70 100644 --- a/api/v1/reference_types.go +++ b/api/v1/reference_types.go @@ -28,7 +28,7 @@ type CrossNamespaceSourceReference struct { APIVersion string `json:"apiVersion,omitempty"` // Kind of the referent. - // +kubebuilder:validation:Enum=OCIRepository;GitRepository;Bucket + // +kubebuilder:validation:Enum=OCIRepository;GitRepository;Bucket;ExternalArtifact // +required Kind string `json:"kind"` diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml index 24b66c91..debaac7f 100644 --- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml +++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml @@ -494,6 +494,7 @@ spec: - OCIRepository - GitRepository - Bucket + - ExternalArtifact type: string name: description: Name of the referent. diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 68f9bfba..aeceab97 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -2,8 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: kustomize-system resources: -- https://github.com/fluxcd/source-controller/releases/download/v1.6.0/source-controller.crds.yaml -- https://github.com/fluxcd/source-controller/releases/download/v1.6.0/source-controller.deployment.yaml +- https://github.com/fluxcd/source-controller/releases/download/v1.7.0-rc.3/source-controller.crds.yaml +- https://github.com/fluxcd/source-controller/releases/download/v1.7.0-rc.3/source-controller.deployment.yaml - ../crd - ../rbac - ../manager diff --git a/docs/spec/v1/kustomizations.md b/docs/spec/v1/kustomizations.md index 9a51bccf..18dbac3e 100644 --- a/docs/spec/v1/kustomizations.md +++ b/docs/spec/v1/kustomizations.md @@ -117,6 +117,7 @@ Artifact containing the YAML manifests. It has two required fields: + [GitRepository](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/gitrepositories.md) + [OCIRepository](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/ocirepositories.md) + [Bucket](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/buckets.md) + + [ExternalArtifact](https://github.com/fluxcd/source-controller/blob/main/docs/spec/v1/externalartifacts.md) (requires `--feature-gates=ExternalArtifact=true` flag) - `name`: The Name of the referred Source object. #### Cross-namespace references diff --git a/go.mod b/go.mod index 3517015c..bd17d4af 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/fluxcd/pkg/ssa v0.53.0 github.com/fluxcd/pkg/tar v0.14.0 github.com/fluxcd/pkg/testserver v0.13.0 - github.com/fluxcd/source-controller/api v1.6.0 + github.com/fluxcd/source-controller/api v1.7.0-rc.3 github.com/getsops/sops/v3 v3.10.2 github.com/google/cel-go v0.26.1 github.com/hashicorp/vault/api v1.20.0 diff --git a/go.sum b/go.sum index 5b5e5c89..48f3b0f7 100644 --- a/go.sum +++ b/go.sum @@ -217,8 +217,8 @@ github.com/fluxcd/pkg/tar v0.14.0 h1:9Gku8FIvPt2bixKldZnzXJ/t+7SloxePlzyVGOK8GVQ github.com/fluxcd/pkg/tar v0.14.0/go.mod h1:+rOWYk93qLEJ8WwmkvJOkB8i0dna1mrwJFybE8i9Udo= github.com/fluxcd/pkg/testserver v0.13.0 h1:xEpBcEYtD7bwvZ+i0ZmChxKkDo/wfQEV3xmnzVybSSg= github.com/fluxcd/pkg/testserver v0.13.0/go.mod h1:akRYv3FLQUsme15na9ihECRG6hBuqni4XEY9W8kzs8E= -github.com/fluxcd/source-controller/api v1.6.0 h1:IxfjUczJ2pzbXIef6iQ0RHEH4AYA9anJfTGK8dzwODM= -github.com/fluxcd/source-controller/api v1.6.0/go.mod h1:ZJcAi0nemsnBxjVgmJl0WQzNvB0rMETxQMTdoFosmMw= +github.com/fluxcd/source-controller/api v1.7.0-rc.3 h1:+9cd//77LAgp0XRe97CXUaPnu78jsRNWTXq95GHGhgc= +github.com/fluxcd/source-controller/api v1.7.0-rc.3/go.mod h1:sbJibK4Ik+2AuTRRLXPA+n2u6nLUIGaxC07ava+RqeM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= diff --git a/internal/controller/kustomization_acl_test.go b/internal/controller/kustomization_acl_test.go index 6c8e1dbb..7ad962b4 100644 --- a/internal/controller/kustomization_acl_test.go +++ b/internal/controller/kustomization_acl_test.go @@ -120,6 +120,7 @@ stringData: t.Run("fails to reconcile from cross-namespace source", func(t *testing.T) { reconciler.NoCrossNamespaceRefs = true + defer func() { reconciler.NoCrossNamespaceRefs = false }() revision = "v2.0.0" err = applyGitRepository(repositoryName, artifact, revision) @@ -132,5 +133,6 @@ stringData: }, timeout, time.Second).Should(BeTrue()) g.Expect(readyCondition.Reason).To(Equal(apiacl.AccessDeniedReason)) + g.Expect(apimeta.IsStatusConditionTrue(resultK.Status.Conditions, meta.StalledCondition)).Should(BeTrue()) }) } diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go index fba0b4ae..b50d8245 100644 --- a/internal/controller/kustomization_controller.go +++ b/internal/controller/kustomization_controller.go @@ -70,6 +70,7 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" intcache "github.com/fluxcd/kustomize-controller/internal/cache" "github.com/fluxcd/kustomize-controller/internal/decryptor" + "github.com/fluxcd/kustomize-controller/internal/features" "github.com/fluxcd/kustomize-controller/internal/inventory" intruntime "github.com/fluxcd/kustomize-controller/internal/runtime" ) @@ -89,26 +90,37 @@ type KustomizationReconciler struct { kuberecorder.EventRecorder runtimeCtrl.Metrics - artifactFetchRetries int - requeueDependency time.Duration + // Kubernetes options - Mapper apimeta.RESTMapper - APIReader client.Reader - ClusterReader engine.ClusterReaderFactory - ControllerName string - statusManager string - NoCrossNamespaceRefs bool - NoRemoteBases bool + APIReader client.Reader + ClusterReader engine.ClusterReaderFactory + ConcurrentSSA int + ControllerName string + KubeConfigOpts runtimeClient.KubeConfigOptions + Mapper apimeta.RESTMapper + StatusManager string + + // Multi-tenancy and security options + + DefaultServiceAccount string + DisallowedFieldManagers []string + NoCrossNamespaceRefs bool + NoRemoteBases bool + SOPSAgeSecret string + TokenCache *cache.TokenCache + + // Retry and requeue options + + ArtifactFetchRetries int + DependencyRequeueInterval time.Duration + + // Feature gates + + AdditiveCELDependencyCheck bool + AllowExternalArtifact bool FailFast bool - DefaultServiceAccount string - SOPSAgeSecret string - KubeConfigOpts runtimeClient.KubeConfigOptions - ConcurrentSSA int - DisallowedFieldManagers []string - StrictSubstitutions bool GroupChangeLog bool - AdditiveCELDependencyCheck bool - TokenCache *cache.TokenCache + StrictSubstitutions bool } func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { @@ -207,9 +219,9 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques if acl.IsAccessDenied(err) { conditions.MarkFalse(obj, meta.ReadyCondition, apiacl.AccessDeniedReason, "%s", err) - log.Error(err, "Access denied to cross-namespace source") + conditions.MarkStalled(obj, apiacl.AccessDeniedReason, "%s", err) r.event(obj, "", "", eventv1.EventSeverityError, err.Error(), nil) - return ctrl.Result{RequeueAfter: obj.GetRetryInterval()}, nil + return ctrl.Result{}, reconcile.TerminalError(err) } // Retry with backoff on transient errors. @@ -218,10 +230,10 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Requeue the reconciliation if the source artifact is not found. if artifactSource.GetArtifact() == nil { - msg := fmt.Sprintf("Source artifact not found, retrying in %s", r.requeueDependency.String()) + msg := fmt.Sprintf("Source artifact not found, retrying in %s", r.DependencyRequeueInterval.String()) conditions.MarkFalse(obj, meta.ReadyCondition, meta.ArtifactFailedReason, "%s", msg) log.Info(msg) - return ctrl.Result{RequeueAfter: r.requeueDependency}, nil + return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, nil } revision := artifactSource.GetArtifact().Revision originRevision := getOriginRevision(artifactSource) @@ -241,10 +253,10 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Retry on transient errors. conditions.MarkFalse(obj, meta.ReadyCondition, meta.DependencyNotReadyReason, "%s", err) - msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", r.requeueDependency.String()) + msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", r.DependencyRequeueInterval.String()) log.Info(msg) r.event(obj, revision, originRevision, eventv1.EventSeverityInfo, msg, nil) - return ctrl.Result{RequeueAfter: r.requeueDependency}, nil + return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, nil } log.Info("All dependencies are ready, proceeding with reconciliation") } @@ -254,10 +266,10 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Requeue at the specified retry interval if the artifact tarball is not found. if errors.Is(reconcileErr, fetch.ErrFileNotFound) { - msg := fmt.Sprintf("Source is not ready, artifact not found, retrying in %s", r.requeueDependency.String()) + msg := fmt.Sprintf("Source is not ready, artifact not found, retrying in %s", r.DependencyRequeueInterval.String()) conditions.MarkFalse(obj, meta.ReadyCondition, meta.ArtifactFailedReason, "%s", msg) log.Info(msg) - return ctrl.Result{RequeueAfter: r.requeueDependency}, nil + return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, nil } // Broadcast the reconciliation failure and requeue at the specified retry interval. @@ -318,7 +330,7 @@ func (r *KustomizationReconciler) reconcile( // Download artifact and extract files to the tmp dir. fetcher := fetch.New( fetch.WithLogger(ctrl.LoggerFrom(ctx)), - fetch.WithRetries(r.artifactFetchRetries), + fetch.WithRetries(r.ArtifactFetchRetries), fetch.WithMaxDownloadSize(tar.UnlimitedUntarSize), fetch.WithUntar(tar.WithMaxUntarSize(tar.UnlimitedUntarSize)), fetch.WithHostnameOverwrite(os.Getenv("SOURCE_CONTROLLER_LOCALHOST")), @@ -613,6 +625,8 @@ func (r *KustomizationReconciler) evalReadyExpr( return celExpr.EvaluateBoolean(ctx, vars) } +// getSource resolves the source reference and returns the source object containing the artifact. +// It returns an error if the source is not found or if access is denied. func (r *KustomizationReconciler) getSource(ctx context.Context, obj *kustomizev1.Kustomization) (sourcev1.Source, error) { var src sourcev1.Source @@ -625,12 +639,20 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, Name: obj.Spec.SourceRef.Name, } + // Check if cross-namespace references are allowed. if r.NoCrossNamespaceRefs && sourceNamespace != obj.GetNamespace() { return src, acl.AccessDeniedError( fmt.Sprintf("can't access '%s/%s', cross-namespace references have been blocked", obj.Spec.SourceRef.Kind, namespacedName)) } + // Check if ExternalArtifact kind is allowed. + if obj.Spec.SourceRef.Kind == sourcev1.ExternalArtifactKind && !r.AllowExternalArtifact { + return src, acl.AccessDeniedError( + fmt.Sprintf("can't access '%s/%s', %s feature gate is disabled", + obj.Spec.SourceRef.Kind, namespacedName, features.ExternalArtifact)) + } + switch obj.Spec.SourceRef.Kind { case sourcev1.OCIRepositoryKind: var repository sourcev1.OCIRepository @@ -662,6 +684,16 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, return src, fmt.Errorf("unable to get source '%s': %w", namespacedName, err) } src = &bucket + case sourcev1.ExternalArtifactKind: + var ea sourcev1.ExternalArtifact + err := r.Client.Get(ctx, namespacedName, &ea) + if err != nil { + if apierrors.IsNotFound(err) { + return src, err + } + return src, fmt.Errorf("unable to get source '%s': %w", namespacedName, err) + } + src = &ea default: return src, fmt.Errorf("source `%s` kind '%s' not supported", obj.Spec.SourceRef.Name, obj.Spec.SourceRef.Kind) @@ -1194,7 +1226,7 @@ func (r *KustomizationReconciler) patch(ctx context.Context, patchOpts = append(patchOpts, patch.WithOwnedConditions{Conditions: ownedConditions}, patch.WithForceOverwriteConditions{}, - patch.WithFieldOwner(r.statusManager), + patch.WithFieldOwner(r.StatusManager), ) // Patch the object status, conditions and finalizers. diff --git a/internal/controller/kustomization_externalartifact_test.go b/internal/controller/kustomization_externalartifact_test.go new file mode 100644 index 00000000..dedd8a50 --- /dev/null +++ b/internal/controller/kustomization_externalartifact_test.go @@ -0,0 +1,228 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + apiacl "github.com/fluxcd/pkg/apis/acl" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/testserver" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + . "github.com/onsi/gomega" + "github.com/opencontainers/go-digest" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" +) + +func TestKustomizationReconciler_ExternalArtifact(t *testing.T) { + g := NewWithT(t) + id := "ea-" + randStringRunes(5) + revision := "v1.0.0" + reconciler.AllowExternalArtifact = true + + err := createNamespace(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace") + + err = createKubeConfigSecret(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret") + + manifests := func(name string, data string) []testserver.File { + return []testserver.File{ + { + Name: "secret.yaml", + Body: fmt.Sprintf(`--- +apiVersion: v1 +kind: Secret +metadata: + name: %[1]s +stringData: + key: "%[2]s" +`, name, data), + }, + } + } + + artifact, err := testServer.ArtifactFromFiles(manifests(id, randStringRunes(5))) + g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files") + + eaName := types.NamespacedName{ + Name: randStringRunes(5), + Namespace: id, + } + + err = applyExternalArtifact(eaName, artifact, revision) + g.Expect(err).NotTo(HaveOccurred()) + + kustomizationKey := types.NamespacedName{ + Name: fmt.Sprintf("ea-%s", randStringRunes(5)), + Namespace: id, + } + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: kustomizationKey.Name, + Namespace: kustomizationKey.Namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: time.Hour}, + Path: "./", + KubeConfig: &meta.KubeConfigReference{ + SecretRef: &meta.SecretKeyReference{ + Name: "kubeconfig", + }, + }, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Name: eaName.Name, + Namespace: eaName.Namespace, + Kind: sourcev1.ExternalArtifactKind, + }, + TargetNamespace: id, + Wait: true, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + + resultK := &kustomizev1.Kustomization{} + readyCondition := &metav1.Condition{} + + t.Run("reconciles from external artifact source", func(t *testing.T) { + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition) + return resultK.Status.LastAppliedRevision == revision + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason)) + g.Expect(resultK.Status.LastAppliedRevision).To(Equal(revision)) + + events := getEvents(resultK.GetName(), map[string]string{"kustomize.toolkit.fluxcd.io/revision": revision}) + g.Expect(len(events) > 2).To(BeTrue()) + g.Expect(events[0].Reason).To(BeIdenticalTo(meta.ProgressingReason)) + g.Expect(events[0].Message).To(ContainSubstring("created")) + g.Expect(events[1].Reason).To(BeIdenticalTo(meta.ProgressingReason)) + g.Expect(events[1].Message).To(ContainSubstring("check passed")) + g.Expect(events[2].Reason).To(BeIdenticalTo(meta.ReconciliationSucceededReason)) + g.Expect(events[2].Message).To(ContainSubstring("finished")) + }) + + t.Run("watches for external artifact revision change", func(t *testing.T) { + newRev := "v2.0.0" + + err = applyExternalArtifact(eaName, artifact, newRev) + g.Expect(err).NotTo(HaveOccurred()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + return resultK.Status.LastAppliedRevision == newRev + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(resultK.Status.History).To(HaveLen(1)) + g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(2)) + g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason)) + g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(newRev)) + }) + + t.Run("fails when external artifact feature gate is disable", func(t *testing.T) { + newRev := "v3.0.0" + reconciler.AllowExternalArtifact = false + + err = applyExternalArtifact(eaName, artifact, newRev) + g.Expect(err).NotTo(HaveOccurred()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition) + return apimeta.IsStatusConditionFalse(resultK.Status.Conditions, meta.ReadyCondition) + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(readyCondition.Reason).To(Equal(apiacl.AccessDeniedReason)) + g.Expect(apimeta.IsStatusConditionTrue(resultK.Status.Conditions, meta.StalledCondition)).Should(BeTrue()) + + events := getEvents(resultK.GetName(), nil) + g.Expect(events[len(events)-1].Reason).To(BeIdenticalTo(apiacl.AccessDeniedReason)) + g.Expect(events[len(events)-1].Message).To(ContainSubstring("feature gate is disabled")) + }) +} + +func applyExternalArtifact(objKey client.ObjectKey, artifactName string, revision string) error { + ea := &sourcev1.ExternalArtifact{ + TypeMeta: metav1.TypeMeta{ + Kind: sourcev1.ExternalArtifactKind, + APIVersion: sourcev1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: objKey.Name, + Namespace: objKey.Namespace, + }, + } + + b, _ := os.ReadFile(filepath.Join(testServer.Root(), artifactName)) + dig := digest.SHA256.FromBytes(b) + + url := fmt.Sprintf("%s/%s", testServer.URL(), artifactName) + + status := sourcev1.ExternalArtifactStatus{ + Conditions: []metav1.Condition{ + { + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: meta.SucceededReason, + }, + }, + Artifact: &meta.Artifact{ + Path: url, + URL: url, + Revision: revision, + Digest: dig.String(), + LastUpdateTime: metav1.Now(), + }, + } + + patchOpts := []client.PatchOption{ + client.ForceOwnership, + client.FieldOwner("kustomize-controller"), + } + + if err := k8sClient.Patch(context.Background(), ea, client.Apply, patchOpts...); err != nil { + return err + } + + ea.ManagedFields = nil + ea.Status = status + + statusOpts := &client.SubResourcePatchOptions{ + PatchOptions: client.PatchOptions{ + FieldManager: "source-controller", + }, + } + + if err := k8sClient.Status().Patch(context.Background(), ea, client.Apply, statusOpts); err != nil { + return err + } + return nil +} diff --git a/internal/controller/kustomization_indexers.go b/internal/controller/kustomization_indexers.go index 61ab1b1b..7c13c115 100644 --- a/internal/controller/kustomization_indexers.go +++ b/internal/controller/kustomization_indexers.go @@ -20,14 +20,14 @@ import ( "context" "fmt" - "github.com/fluxcd/pkg/runtime/conditions" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/dependency" - sourcev1 "github.com/fluxcd/source-controller/api/v1" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" ) @@ -36,7 +36,7 @@ func (r *KustomizationReconciler) requestsForRevisionChangeOf(indexKey string) h return func(ctx context.Context, obj client.Object) []reconcile.Request { log := ctrl.LoggerFrom(ctx) repo, ok := obj.(interface { - GetArtifact() *sourcev1.Artifact + GetArtifact() *meta.Artifact }) if !ok { log.Error(fmt.Errorf("expected an object conformed with GetArtifact() method, but got a %T", obj), diff --git a/internal/controller/kustomization_manager.go b/internal/controller/kustomization_manager.go index 278f11dd..bd3652bb 100644 --- a/internal/controller/kustomization_manager.go +++ b/internal/controller/kustomization_manager.go @@ -19,10 +19,7 @@ package controller import ( "context" "fmt" - "time" - "github.com/fluxcd/pkg/runtime/predicates" - sourcev1 "github.com/fluxcd/source-controller/api/v1" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/util/workqueue" ctrl "sigs.k8s.io/controller-runtime" @@ -33,15 +30,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/fluxcd/pkg/runtime/predicates" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" ) // KustomizationReconcilerOptions contains options for the KustomizationReconciler. type KustomizationReconcilerOptions struct { - HTTPRetry int - DependencyRequeueInterval time.Duration - RateLimiter workqueue.TypedRateLimiter[reconcile.Request] - WatchConfigsPredicate predicate.Predicate + RateLimiter workqueue.TypedRateLimiter[reconcile.Request] + WatchConfigsPredicate predicate.Predicate + WatchExternalArtifacts bool } // SetupWithManager sets up the controller with the Manager. @@ -49,11 +48,12 @@ type KustomizationReconcilerOptions struct { // changes in those sources, as well as for ConfigMaps and Secrets that the Kustomizations depend on. func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts KustomizationReconcilerOptions) error { const ( - indexOCIRepository = ".metadata.ociRepository" - indexGitRepository = ".metadata.gitRepository" - indexBucket = ".metadata.bucket" - indexConfigMap = ".metadata.configMap" - indexSecret = ".metadata.secret" + indexExternalArtifact = ".metadata.externalArtifact" + indexOCIRepository = ".metadata.ociRepository" + indexGitRepository = ".metadata.gitRepository" + indexBucket = ".metadata.bucket" + indexConfigMap = ".metadata.configMap" + indexSecret = ".metadata.secret" ) // Index the Kustomizations by the OCIRepository references they (may) point at. @@ -74,6 +74,14 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl return fmt.Errorf("failed creating index %s: %w", indexBucket, err) } + // Index the Kustomizations by the ExternalArtifact references they (may) point at (if enabled). + if opts.WatchExternalArtifacts { + if err := mgr.GetCache().IndexField(ctx, &kustomizev1.Kustomization{}, indexExternalArtifact, + r.indexBy(sourcev1.ExternalArtifactKind)); err != nil { + return fmt.Errorf("failed creating index %s: %w", indexExternalArtifact, err) + } + } + // Index the Kustomization by the ConfigMap references they point to. if err := mgr.GetFieldIndexer().IndexField(ctx, &kustomizev1.Kustomization{}, indexConfigMap, func(o client.Object) []string { @@ -121,11 +129,7 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl return fmt.Errorf("failed creating index %s: %w", indexSecret, err) } - r.requeueDependency = opts.DependencyRequeueInterval - r.statusManager = fmt.Sprintf("gotk-%s", r.ControllerName) - r.artifactFetchRetries = opts.HTTPRetry - - return ctrl.NewControllerManagedBy(mgr). + ctrlBuilder := ctrl.NewControllerManagedBy(mgr). For(&kustomizev1.Kustomization{}, builder.WithPredicates( predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), )). @@ -153,9 +157,15 @@ func (r *KustomizationReconciler) SetupWithManager(ctx context.Context, mgr ctrl &corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.requestsForConfigDependency(indexSecret)), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, opts.WatchConfigsPredicate), - ). - WithOptions(controller.Options{ - RateLimiter: opts.RateLimiter, - }). - Complete(r) + ) + + if opts.WatchExternalArtifacts { + ctrlBuilder = ctrlBuilder.Watches( + &sourcev1.ExternalArtifact{}, + handler.EnqueueRequestsFromMapFunc(r.requestsForRevisionChangeOf(indexExternalArtifact)), + builder.WithPredicates(SourceRevisionChangePredicate{}), + ) + } + + return ctrlBuilder.WithOptions(controller.Options{RateLimiter: opts.RateLimiter}).Complete(r) } diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 7ae08583..91a9c6e9 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -174,19 +174,21 @@ func TestMain(m *testing.M) { kstatusInProgressCheck = kcheck.NewInProgressChecker(testEnv.Client) kstatusInProgressCheck.DisableFetch = true reconciler = &KustomizationReconciler{ - ControllerName: controllerName, - Client: testEnv, - Mapper: testEnv.GetRESTMapper(), - APIReader: testEnv, - EventRecorder: testEnv.GetEventRecorderFor(controllerName), - Metrics: testMetricsH, - ConcurrentSSA: 4, - DisallowedFieldManagers: []string{overrideManagerName}, - SOPSAgeSecret: sopsAgeSecret, + ControllerName: controllerName, + StatusManager: fmt.Sprintf("gotk-%s", controllerName), + Client: testEnv, + Mapper: testEnv.GetRESTMapper(), + APIReader: testEnv, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + Metrics: testMetricsH, + DependencyRequeueInterval: 2 * time.Second, + ConcurrentSSA: 4, + DisallowedFieldManagers: []string{overrideManagerName}, + SOPSAgeSecret: sopsAgeSecret, } if err := (reconciler).SetupWithManager(ctx, testEnv, KustomizationReconcilerOptions{ - DependencyRequeueInterval: 2 * time.Second, - WatchConfigsPredicate: predicate.Not(predicate.Funcs{}), + WatchConfigsPredicate: predicate.Not(predicate.Funcs{}), + WatchExternalArtifacts: true, }); err != nil { panic(fmt.Sprintf("Failed to start KustomizationReconciler: %v", err)) } @@ -331,7 +333,7 @@ func applyGitRepository(objKey client.ObjectKey, artifactName string, Reason: sourcev1.GitOperationSucceedReason, }, }, - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ Path: url, URL: url, Revision: revision, diff --git a/internal/features/features.go b/internal/features/features.go index c8adfcd1..2053384b 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -56,6 +56,9 @@ const ( // should be additive, meaning that the built-in readiness check will // be added to the user-defined CEL expressions. AdditiveCELDependencyCheck = "AdditiveCELDependencyCheck" + + // ExternalArtifact controls whether the ExternalArtifact source type is enabled. + ExternalArtifact = "ExternalArtifact" ) var features = map[string]bool{ @@ -77,6 +80,9 @@ var features = map[string]bool{ // AdditiveCELDependencyCheck // opt-in from v1.7 AdditiveCELDependencyCheck: false, + // ExternalArtifact + // opt-in from v1.7 + ExternalArtifact: false, } func init() { diff --git a/main.go b/main.go index 6a4dd85a..5afa1181 100644 --- a/main.go +++ b/main.go @@ -288,6 +288,12 @@ func main() { os.Exit(1) } + allowExternalArtifact, err := features.Enabled(features.ExternalArtifact) + if err != nil { + setupLog.Error(err, "unable to check feature gate "+features.ExternalArtifact) + os.Exit(1) + } + var tokenCache *pkgcache.TokenCache if tokenCacheOptions.MaxSize > 0 { var err error @@ -302,30 +308,33 @@ func main() { } if err = (&controller.KustomizationReconciler{ + AdditiveCELDependencyCheck: additiveCELDependencyCheck, + AllowExternalArtifact: allowExternalArtifact, + APIReader: mgr.GetAPIReader(), + ArtifactFetchRetries: httpRetry, + Client: mgr.GetClient(), + ClusterReader: clusterReader, + ConcurrentSSA: concurrentSSA, ControllerName: controllerName, DefaultServiceAccount: defaultServiceAccount, - SOPSAgeSecret: sopsAgeSecret, - Client: mgr.GetClient(), + DependencyRequeueInterval: requeueDependency, + DisallowedFieldManagers: disallowedFieldManagers, + EventRecorder: eventRecorder, + FailFast: failFast, + GroupChangeLog: groupChangeLog, + KubeConfigOpts: kubeConfigOpts, Mapper: restMapper, - APIReader: mgr.GetAPIReader(), Metrics: metricsH, - EventRecorder: eventRecorder, NoCrossNamespaceRefs: aclOptions.NoCrossNamespaceRefs, NoRemoteBases: noRemoteBases, - FailFast: failFast, - ConcurrentSSA: concurrentSSA, - KubeConfigOpts: kubeConfigOpts, - ClusterReader: clusterReader, - DisallowedFieldManagers: disallowedFieldManagers, + SOPSAgeSecret: sopsAgeSecret, + StatusManager: fmt.Sprintf("gotk-%s", controllerName), StrictSubstitutions: strictSubstitutions, - GroupChangeLog: groupChangeLog, - AdditiveCELDependencyCheck: additiveCELDependencyCheck, TokenCache: tokenCache, }).SetupWithManager(ctx, mgr, controller.KustomizationReconcilerOptions{ - DependencyRequeueInterval: requeueDependency, - HTTPRetry: httpRetry, - RateLimiter: runtimeCtrl.GetRateLimiter(rateLimiterOptions), - WatchConfigsPredicate: watchConfigsPredicate, + RateLimiter: runtimeCtrl.GetRateLimiter(rateLimiterOptions), + WatchConfigsPredicate: watchConfigsPredicate, + WatchExternalArtifacts: allowExternalArtifact, }); err != nil { setupLog.Error(err, "unable to create controller", "controller", controllerName) os.Exit(1)