diff --git a/Makefile b/Makefile index e47a27874..738ee846a 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,8 @@ SOURCE_CRD_VER = $(CRD_DEP_ROOT)/.src-crd-$(SOURCE_VER) # HelmChart source CRD. HELMCHART_SOURCE_CRD ?= $(CRD_DEP_ROOT)/source.toolkit.fluxcd.io_helmcharts.yaml +OCIREPO_CRD ?= $(CRD_DEP_ROOT)/source.toolkit.fluxcd.io_ocirepositories.yaml +EA_CRD ?= $(CRD_DEP_ROOT)/source.toolkit.fluxcd.io_externalartifacts.yaml # API (doc) generation utilities CONTROLLER_GEN_VERSION ?= v0.19.0 @@ -135,8 +137,14 @@ $(SOURCE_CRD_VER): $(HELMCHART_SOURCE_CRD): curl -s https://raw.githubusercontent.com/fluxcd/source-controller/${SOURCE_VER}/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml > $(HELMCHART_SOURCE_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) $(HELMCHART_SOURCE_CRD) +download-crd-deps: $(SOURCE_CRD_VER) $(HELMCHART_SOURCE_CRD) $(OCIREPO_CRD) $(EA_CRD) # Delete the downloaded CRD dependencies. cleanup-crd-deps: diff --git a/api/v2/reference_types.go b/api/v2/reference_types.go index 7e19b2269..9f3ee4a1c 100644 --- a/api/v2/reference_types.go +++ b/api/v2/reference_types.go @@ -50,7 +50,7 @@ type CrossNamespaceSourceReference struct { APIVersion string `json:"apiVersion,omitempty"` // Kind of the referent. - // +kubebuilder:validation:Enum=OCIRepository;HelmChart + // +kubebuilder:validation:Enum=OCIRepository;HelmChart;ExternalArtifact // +required Kind string `json:"kind"` diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml index 91a406347..47c137a33 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -203,6 +203,7 @@ spec: enum: - OCIRepository - HelmChart + - ExternalArtifact type: string name: description: Name of the referent. diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 0a580dbec..60ff96e87 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -2,8 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: helm-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/v2/helmreleases.md b/docs/spec/v2/helmreleases.md index 8e2093dbe..757d8ac42 100644 --- a/docs/spec/v2/helmreleases.md +++ b/docs/spec/v2/helmreleases.md @@ -205,11 +205,16 @@ HelmRelease object. ### Chart reference -`.spec.chartRef` is an optional field used to refer to an [OCIRepository resource](https://fluxcd.io/flux/components/source/ocirepositories/) or a [HelmChart resource](https://fluxcd.io/flux/components/source/helmcharts/) -from which to fetch the Helm chart. The chart is fetched by the controller with the -information provided by `.status.artifact` of the referenced resource. +`.spec.chartRef` is an optional field used to refer to the Source object which has an +Artifact containing the Helm chart. It has two required fields: -For a referenced resource of `kind OCIRepository`, the chart version of the last +- `kind`: The Kind of the referred Source object. Supported Source types: + + [OCIRepository](https://fluxcd.io/flux/components/source/ocirepositories/) + + [HelmChart](https://fluxcd.io/flux/components/source/helmcharts/) + + [ExternalArtifact](https://fluxcd.io/flux/components/source/externalartifacts/) (requires `--feature-gates=ExternalArtifact=true` flag) +- `name`: The Name of the referred Source object. + +For a referenced resource of kind `OCIRepository`, the chart version of the last release attempt is reported in `.status.lastAttemptedRevision`. The version is in the format `+`. The digest of the OCI artifact is appended to the version to ensure that a change in the artifact content triggers a new release. diff --git a/go.mod b/go.mod index a4943e42f..2e7a6092e 100644 --- a/go.mod +++ b/go.mod @@ -21,14 +21,14 @@ require ( github.com/fluxcd/pkg/apis/acl v0.9.0 github.com/fluxcd/pkg/apis/event v0.19.0 github.com/fluxcd/pkg/apis/kustomize v1.12.0 - github.com/fluxcd/pkg/apis/meta v1.20.0 - github.com/fluxcd/pkg/auth v0.29.0 + github.com/fluxcd/pkg/apis/meta v1.21.0 + github.com/fluxcd/pkg/auth v0.30.0 github.com/fluxcd/pkg/cache v0.11.0 - github.com/fluxcd/pkg/chartutil v1.11.0 - github.com/fluxcd/pkg/runtime v0.82.0 + github.com/fluxcd/pkg/chartutil v1.12.0 + github.com/fluxcd/pkg/runtime v0.83.0 github.com/fluxcd/pkg/ssa v0.53.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/go-logr/logr v1.4.3 github.com/google/cel-go v0.26.1 github.com/google/go-cmp v0.7.0 @@ -39,6 +39,7 @@ require ( github.com/opencontainers/go-digest/blake3 v0.0.0-20250116041648-1e56c6daea3b github.com/spf13/pflag v1.0.7 github.com/wI2L/jsondiff v0.7.0 + go.uber.org/zap v1.27.0 golang.org/x/text v0.28.0 helm.sh/helm/v3 v3.18.6 k8s.io/api v0.34.0 @@ -100,7 +101,7 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v28.2.2+incompatible // indirect + github.com/docker/cli v28.3.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect @@ -201,7 +202,6 @@ require ( go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.41.0 // indirect diff --git a/go.sum b/go.sum index 32c829641..7fbe66c72 100644 --- a/go.sum +++ b/go.sum @@ -124,8 +124,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= -github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v28.3.3+incompatible h1:fp9ZHAr1WWPGdIWBM1b3zLtgCF+83gRdVMTJsUeiyAo= +github.com/docker/cli v28.3.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= @@ -152,22 +152,22 @@ github.com/fluxcd/pkg/apis/event v0.19.0 h1:ZJU2voontkzp5rNYA4JMOu40S4tRcrWi4Do5 github.com/fluxcd/pkg/apis/event v0.19.0/go.mod h1:deuIyUb6lh+Z1Ccvwwxhm1wNM3kpSo+vF1IgRnpaZfQ= github.com/fluxcd/pkg/apis/kustomize v1.12.0 h1:KvZN6xwgP/dNSeckL4a/Uv715XqiN1C3xS+jGcPejtE= github.com/fluxcd/pkg/apis/kustomize v1.12.0/go.mod h1:OojLxIdKm1JAAdh3sL4j4F+vfrLKb7kq1vr8bpyEKgg= -github.com/fluxcd/pkg/apis/meta v1.20.0 h1:l9h0kWoDZTcYV0WJkFMgDXq6Q4tSojrJ+bHpFJSsaW0= -github.com/fluxcd/pkg/apis/meta v1.20.0/go.mod h1:XUAEUgT4gkWDAEN79E141tmL+v4SV50tVZ/Ojpc/ueg= -github.com/fluxcd/pkg/auth v0.29.0 h1:lLc63zjodqIqg5ydlU/Kp3Qa+wvh6G2khjop5MHALvk= -github.com/fluxcd/pkg/auth v0.29.0/go.mod h1:bjZ+6RMSGgsQQK+aPfVP8HWuBbb+FLlFxMiqd8ywzik= +github.com/fluxcd/pkg/apis/meta v1.21.0 h1:R+bN02chcs0HUmyVDQhqe/FHmYLjipVDMLnyYfNX850= +github.com/fluxcd/pkg/apis/meta v1.21.0/go.mod h1:XUAEUgT4gkWDAEN79E141tmL+v4SV50tVZ/Ojpc/ueg= +github.com/fluxcd/pkg/auth v0.30.0 h1:7JMnY1ClArvOsadt6hOxceu8Q2hLsYHFMt0DV3BQl4Q= +github.com/fluxcd/pkg/auth v0.30.0/go.mod h1:me38o1nDfSLw6YvnkT9Ce/zqJZICZSA7j5pNMR3JUbc= github.com/fluxcd/pkg/cache v0.11.0 h1:fsE8S+una21fSNw4MDXGUIf0Gf1J+pqa4RbsVKf2aTI= github.com/fluxcd/pkg/cache v0.11.0/go.mod h1:2RTIU6PsJniHmfnllQWFEo7fa5V8KQlnMgn4o0sme40= -github.com/fluxcd/pkg/chartutil v1.11.0 h1:mtMqxsIIHLHSZS+D/9OfFS5ykABIOD2EOQbo3RyvjZY= -github.com/fluxcd/pkg/chartutil v1.11.0/go.mod h1:zQEbp6pHdRnhZN+RBjpQorRf9HKhqiO0bTk4vaOxbNU= -github.com/fluxcd/pkg/runtime v0.82.0 h1:VdPPRJtj8/rcBdqY7GZSffoxe5elFHt+ymwQHNbPOlc= -github.com/fluxcd/pkg/runtime v0.82.0/go.mod h1:rIDynMhU5upbn8ce3bXQhH5L6vtDw5MELycvtJG/+og= +github.com/fluxcd/pkg/chartutil v1.12.0 h1:AsOXWYQIUpZMCtlyQeaPmlWBYqSfHZTQ6A6eTGAhBLc= +github.com/fluxcd/pkg/chartutil v1.12.0/go.mod h1:W1VxE52pIaimf9upYQW/fvonU3BDs2zxM2zdg/48vEE= +github.com/fluxcd/pkg/runtime v0.83.0 h1:XzpwKzo7GqfBE/BKpxG5B4U7cUnojnB407S9Dpp6oLU= +github.com/fluxcd/pkg/runtime v0.83.0/go.mod h1:r8KLvXRguKtpLAa66fA19rIbwPViXm8az038IUabYvw= github.com/fluxcd/pkg/ssa v0.53.0 h1:EtKFAYWXohIGkzPhIrv8NbV5zYr4iZHY6DaNxMR9+bU= github.com/fluxcd/pkg/ssa v0.53.0/go.mod h1:0IZxnnH8XkDkFzWjoMJsFEwPIWPOk3Gc/WR5+gT/KgI= 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/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go index e29739706..16bc3b4e8 100644 --- a/internal/controller/helmrelease_controller.go +++ b/internal/controller/helmrelease_controller.go @@ -87,19 +87,26 @@ type HelmReleaseReconciler struct { kuberecorder.EventRecorder helper.Metrics - GetClusterConfig func() (*rest.Config, error) - ClientOpts runtimeClient.Options - KubeConfigOpts runtimeClient.KubeConfigOptions - APIReader client.Reader - TokenCache *cache.TokenCache - - FieldManager string - DefaultServiceAccount string - DisableChartDigestTracking bool - AdditiveCELDependencyCheck bool + // Kubernetes configuration + + FieldManager string + DefaultServiceAccount string + GetClusterConfig func() (*rest.Config, error) + ClientOpts runtimeClient.Options + KubeConfigOpts runtimeClient.KubeConfigOptions + APIReader client.Reader + TokenCache *cache.TokenCache + + // Retry and requeue configuration - requeueDependency time.Duration - artifactFetchRetries int + DependencyRequeueInterval time.Duration + ArtifactFetchRetries int + + // Feature gates + + AdditiveCELDependencyCheck bool + AllowExternalArtifact bool + DisableChartDigestTracking bool } var ( @@ -221,14 +228,14 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, patchHelpe // Retry on transient errors. msg := fmt.Sprintf("dependencies do not meet ready condition (%s): retrying in %s", - err.Error(), r.requeueDependency.String()) + err.Error(), r.DependencyRequeueInterval.String()) conditions.MarkFalse(obj, meta.ReadyCondition, v2.DependencyNotReadyReason, "%s", err) r.Eventf(obj, corev1.EventTypeNormal, v2.DependencyNotReadyReason, err.Error()) log.Info(msg) // Exponential backoff would cause execution to be prolonged too much, // instead we requeue on a fixed interval. - return ctrl.Result{RequeueAfter: r.requeueDependency}, errWaitForDependency + return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, errWaitForDependency } log.Info("all dependencies are ready") @@ -268,7 +275,7 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, patchHelpe conditions.MarkFalse(obj, meta.ReadyCondition, "SourceNotReady", "%s", msg) // Do not requeue immediately, when the artifact is created // the watcher should trigger a reconciliation. - return jitter.JitteredRequeueInterval(ctrl.Result{RequeueAfter: r.requeueDependency}), errWaitForChart + return jitter.JitteredRequeueInterval(ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}), errWaitForChart } // Remove any stale corresponding Ready=False condition with Unknown. if conditions.HasAnyReason(obj, meta.ReadyCondition, "SourceNotReady") { @@ -293,13 +300,13 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, patchHelpe } // Load chart from artifact. - loadedChart, err := loader.SecureLoadChartFromURL(loader.NewRetryableHTTPClient(ctx, r.artifactFetchRetries), source.GetArtifact().URL, source.GetArtifact().Digest) + loadedChart, err := loader.SecureLoadChartFromURL(loader.NewRetryableHTTPClient(ctx, r.ArtifactFetchRetries), source.GetArtifact().URL, source.GetArtifact().Digest) if err != nil { if errors.Is(err, loader.ErrFileNotFound) { - msg := fmt.Sprintf("Source not ready: artifact not found. Retrying in %s", r.requeueDependency.String()) + msg := fmt.Sprintf("Source not ready: artifact not found. Retrying in %s", r.DependencyRequeueInterval.String()) conditions.MarkFalse(obj, meta.ReadyCondition, v2.ArtifactFailedReason, "%s", msg) log.Info(msg) - return ctrl.Result{RequeueAfter: r.requeueDependency}, errWaitForDependency + return ctrl.Result{RequeueAfter: r.DependencyRequeueInterval}, errWaitForDependency } conditions.MarkFalse(obj, meta.ReadyCondition, v2.ArtifactFailedReason, "Could not load chart: %s", err) @@ -774,6 +781,9 @@ func (r *HelmReleaseReconciler) getSource(ctx context.Context, obj *v2.HelmRelea if obj.Spec.ChartRef.Kind == sourcev1.OCIRepositoryKind { return r.getSourceFromOCIRef(ctx, obj) } + if obj.Spec.ChartRef.Kind == sourcev1.ExternalArtifactKind { + return r.getSourceFromExternalArtifact(ctx, obj) + } name, namespace = obj.Spec.ChartRef.Name, obj.Spec.ChartRef.Namespace if namespace == "" { namespace = obj.GetNamespace() @@ -813,6 +823,31 @@ func (r *HelmReleaseReconciler) getSourceFromOCIRef(ctx context.Context, obj *v2 return &or, nil } +func (r *HelmReleaseReconciler) getSourceFromExternalArtifact(ctx context.Context, obj *v2.HelmRelease) (sourcev1.Source, error) { + name, namespace := obj.Spec.ChartRef.Name, obj.Spec.ChartRef.Namespace + if namespace == "" { + namespace = obj.GetNamespace() + } + sourceRef := types.NamespacedName{Namespace: namespace, Name: name} + + if err := intacl.AllowsAccessTo(obj, sourcev1.ExternalArtifactKind, sourceRef); err != nil { + return nil, err + } + + // Check if ExternalArtifact kind is allowed. + if obj.Spec.ChartRef.Kind == sourcev1.ExternalArtifactKind && !r.AllowExternalArtifact { + return nil, acl.AccessDeniedError( + fmt.Sprintf("can't access '%s/%s/%s', %s feature gate is disabled", + obj.Spec.ChartRef.Kind, namespace, name, features.ExternalArtifact)) + } + + or := sourcev1.ExternalArtifact{} + if err := r.Client.Get(ctx, sourceRef, &or); err != nil { + return nil, err + } + return &or, nil +} + // waitForHistoryCacheSync returns a function that can be used to wait for the // cache backing the Kubernetes client to be in sync with the current state of // the v2.HelmRelease. @@ -832,13 +867,20 @@ func (r *HelmReleaseReconciler) waitForHistoryCacheSync(obj *v2.HelmRelease) wai } func isSourceReady(obj sourcev1.Source) (bool, string) { + if o, ok := obj.(*sourcev1.ExternalArtifact); ok { + if obj.GetArtifact() == nil { + return false, fmt.Sprintf("ExternalArtifact '%s/%s' is not ready: does not have an artifact", + o.GetNamespace(), o.GetName()) + } + return true, "" + } if o, ok := obj.(conditions.Getter); ok { return isReady(o, obj.GetArtifact()) } return false, fmt.Sprintf("unknown sourcev1 type: %T", obj) } -func isReady(obj conditions.Getter, artifact *sourcev1.Artifact) (bool, string) { +func isReady(obj conditions.Getter, artifact *meta.Artifact) (bool, string) { observedGen, err := object.GetStatusObservedGeneration(obj) if err != nil { return false, err.Error() diff --git a/internal/controller/helmrelease_controller_test.go b/internal/controller/helmrelease_controller_test.go index b571e74ff..c4b935c34 100644 --- a/internal/controller/helmrelease_controller_test.go +++ b/internal/controller/helmrelease_controller_test.go @@ -107,14 +107,14 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { WithStatusSubresource(&v2.HelmRelease{}). WithObjects(dependency, obj). Build(), - EventRecorder: record.NewFakeRecorder(32), - requeueDependency: 5 * time.Second, + EventRecorder: record.NewFakeRecorder(32), + DependencyRequeueInterval: 5 * time.Second, } r.APIReader = r.Client res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForDependency)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -235,7 +235,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForChart)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -258,7 +258,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 1, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, Conditions: []metav1.Condition{ { Type: meta.ReadyCondition, @@ -292,7 +292,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForChart)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -314,7 +314,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 2, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, Conditions: []metav1.Condition{ { Type: meta.ReadyCondition, @@ -372,7 +372,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 1, - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ URL: testServer.URL() + "/does-not-exist", }, Conditions: []metav1.Condition{ @@ -400,13 +400,13 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { WithStatusSubresource(&v2.HelmRelease{}). WithObjects(chart, obj). Build(), - requeueDependency: 10 * time.Second, + DependencyRequeueInterval: 10 * time.Second, } r.APIReader = r.Client res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForDependency)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -1112,7 +1112,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForChart)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -1134,7 +1134,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 2, - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ URL: testServer.URL() + "/does-not-exist", }, Conditions: []metav1.Condition{ @@ -1166,13 +1166,13 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) WithStatusSubresource(&v2.HelmRelease{}). WithObjects(chart, obj). Build(), - requeueDependency: 10 * time.Second, - EventRecorder: record.NewFakeRecorder(32), + DependencyRequeueInterval: 10 * time.Second, + EventRecorder: record.NewFakeRecorder(32), } res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForDependency)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -1190,7 +1190,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 1, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, Conditions: []metav1.Condition{ { Type: meta.ReadyCondition, @@ -1211,7 +1211,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 2, - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ URL: testServer.URL() + "/does-not-exist", }, Conditions: []metav1.Condition{ @@ -1246,13 +1246,13 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) WithStatusSubresource(&v2.HelmRelease{}). WithObjects(chart, sharedChart, obj). Build(), - requeueDependency: 10 * time.Second, - EventRecorder: record.NewFakeRecorder(32), + DependencyRequeueInterval: 10 * time.Second, + EventRecorder: record.NewFakeRecorder(32), } res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForDependency)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -1579,7 +1579,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForChart)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -1601,7 +1601,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 2, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, Conditions: []metav1.Condition{ { Type: meta.ReadyCondition, @@ -1663,7 +1663,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 2, - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ URL: testServer.URL() + "/does-not-exist", }, Conditions: []metav1.Condition{ @@ -1695,13 +1695,13 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin WithStatusSubresource(&v2.HelmRelease{}). WithObjects(ocirepo, obj). Build(), - requeueDependency: 10 * time.Second, - EventRecorder: record.NewFakeRecorder(32), + DependencyRequeueInterval: 10 * time.Second, + EventRecorder: record.NewFakeRecorder(32), } res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForDependency)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -1719,7 +1719,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Status: sourcev1.HelmChartStatus{ ObservedGeneration: 1, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, Conditions: []metav1.Condition{ { Type: meta.ReadyCondition, @@ -1740,7 +1740,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 2, - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ URL: testServer.URL() + "/does-not-exist", }, Conditions: []metav1.Condition{ @@ -1775,13 +1775,13 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin WithStatusSubresource(&v2.HelmRelease{}). WithObjects(chart, ocirepo, obj). Build(), - requeueDependency: 10 * time.Second, - EventRecorder: record.NewFakeRecorder(32), + DependencyRequeueInterval: 10 * time.Second, + EventRecorder: record.NewFakeRecorder(32), } res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) g.Expect(err).To(Equal(errWaitForDependency)) - g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.DependencyRequeueInterval)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), @@ -3999,7 +3999,7 @@ func Test_isHelmChartReady(t *testing.T) { Status: metav1.ConditionTrue, }, }, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, }, } @@ -4089,7 +4089,7 @@ func Test_isOCIRepositoryReady(t *testing.T) { Status: metav1.ConditionTrue, }, }, - Artifact: &sourcev1.Artifact{}, + Artifact: &meta.Artifact{}, }, } @@ -4210,7 +4210,7 @@ func Test_TryMutateChartWithSourceRevision(t *testing.T) { s := &sourcev1.OCIRepository{ Status: sourcev1.OCIRepositoryStatus{ - Artifact: &sourcev1.Artifact{ + Artifact: &meta.Artifact{ Revision: tt.revision, }, }, diff --git a/internal/controller/helmrelease_indexers.go b/internal/controller/helmrelease_indexers.go index fab44af50..d7a42f223 100644 --- a/internal/controller/helmrelease_indexers.go +++ b/internal/controller/helmrelease_indexers.go @@ -105,6 +105,48 @@ func (r *HelmReleaseReconciler) requestsForOCIRepositoryChange(ctx context.Conte return reqs } +// requestsForExternalArtifactChange enqueues requests for watched ExternalArtifacts +// according to the specified index. +func (r *HelmReleaseReconciler) requestsForExternalArtifactChange(ctx context.Context, o client.Object) []reconcile.Request { + or, ok := o.(*sourcev1.ExternalArtifact) + if !ok { + err := fmt.Errorf("expected an ExternalArtifact, got %T", o) + ctrl.LoggerFrom(ctx).Error(err, "failed to get requests for ExternalArtifact change") + return nil + } + // If we do not have an artifact, we have no requests to make + if or.GetArtifact() == nil { + return nil + } + + var list v2.HelmReleaseList + if err := r.List(ctx, &list, client.MatchingFields{ + v2.SourceIndexKey: sourcev1.ExternalArtifactKind + "/" + client.ObjectKeyFromObject(or).String(), + }); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "failed to list HelmReleases for ExternalArtifact change") + return nil + } + + var reqs []reconcile.Request + for i, hr := range list.Items { + // If the HelmRelease is ready and the digest of the artifact equals to the + // last attempted revision digest, we should not make a request for this HelmRelease, + // likewise if we cannot retrieve the artifact digest. + digest := extractDigest(or.GetArtifact().Revision) + if digest == "" { + ctrl.LoggerFrom(ctx).Error(fmt.Errorf("wrong digest for %T", or), "failed to get requests for ExternalArtifact change") + continue + } + + if digest == hr.Status.LastAttemptedRevisionDigest { + continue + } + + reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])}) + } + return reqs +} + // requestsForConfigDependency enqueues requests for watched ConfigMaps or Secrets // according to the specified index. func (r *HelmReleaseReconciler) requestsForConfigDependency( diff --git a/internal/controller/helmrelease_manager.go b/internal/controller/helmrelease_manager.go index 2f50c6d35..d0dcf5eba 100644 --- a/internal/controller/helmrelease_manager.go +++ b/internal/controller/helmrelease_manager.go @@ -19,7 +19,6 @@ package controller import ( "context" "fmt" - "time" "github.com/fluxcd/pkg/runtime/predicates" sourcev1 "github.com/fluxcd/source-controller/api/v1" @@ -38,10 +37,9 @@ import ( ) type HelmReleaseReconcilerOptions struct { - HTTPRetry int - DependencyRequeueInterval time.Duration - RateLimiter workqueue.TypedRateLimiter[reconcile.Request] - WatchConfigsPredicate predicate.Predicate + RateLimiter workqueue.TypedRateLimiter[reconcile.Request] + WatchExternalArtifacts bool + WatchConfigsPredicate predicate.Predicate } func (r *HelmReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts HelmReleaseReconcilerOptions) error { @@ -116,10 +114,7 @@ func (r *HelmReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.M return err } - r.requeueDependency = opts.DependencyRequeueInterval - r.artifactFetchRetries = opts.HTTPRetry - - return ctrl.NewControllerManagedBy(mgr). + ctrlBuilder := ctrl.NewControllerManagedBy(mgr). For(&v2.HelmRelease{}, builder.WithPredicates( predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), )). @@ -142,9 +137,16 @@ func (r *HelmReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.M &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.requestsForExternalArtifactChange), + builder.WithPredicates(intpredicates.SourceRevisionChangePredicate{}), + ) + } + + return ctrlBuilder.WithOptions(controller.Options{RateLimiter: opts.RateLimiter}).Complete(r) + } diff --git a/internal/controller_test/external_artifact_test.go b/internal/controller_test/external_artifact_test.go new file mode 100644 index 000000000..ab67201da --- /dev/null +++ b/internal/controller_test/external_artifact_test.go @@ -0,0 +1,196 @@ +/* +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_test + +import ( + "context" + "testing" + "time" + + aclv1 "github.com/fluxcd/pkg/apis/acl" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + . "github.com/onsi/gomega" + "github.com/opencontainers/go-digest" + v1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + v2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/helm-controller/internal/testutil" +) + +func TestExternalArtifact_LifeCycle(t *testing.T) { + g := NewWithT(t) + reconciler.AllowExternalArtifact = true + + // Create a namespace for the test + ns := v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "external-artifact-test", + }, + } + err := k8sClient.Create(context.Background(), &ns) + g.Expect(err).ToNot(HaveOccurred()) + + // Create an ExternalArtifact containing a Helm chart + revision := "1.0.0" + eaKey := client.ObjectKey{ + Namespace: ns.Name, + Name: "test-ea", + } + ea, err := applyExternalArtifact(eaKey, revision) + g.Expect(err).ToNot(HaveOccurred(), "failed to create ExternalArtifact") + + // Create a HelmRelease that references the ExternalArtifact + hr := &v2.HelmRelease{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v2.GroupVersion.String(), + Kind: v2.HelmReleaseKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: ns.Name, + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1.ExternalArtifactKind, + Name: ea.Name, + }, + Interval: metav1.Duration{Duration: time.Hour}, + }, + } + + err = k8sClient.Create(context.Background(), hr) + g.Expect(err).ToNot(HaveOccurred()) + + t.Run("installs from external artifact", func(t *testing.T) { + g.Eventually(func() bool { + err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr) + if err != nil { + return false + } + return apimeta.IsStatusConditionTrue(hr.Status.Conditions, meta.ReadyCondition) + }, 5*time.Second, time.Second).Should(BeTrue(), "HelmRelease did not become ready") + + g.Expect(hr.Status.LastAttemptedRevision).To(Equal(revision)) + g.Expect(hr.Status.LastAttemptedReleaseAction).To(Equal(v2.ReleaseActionInstall)) + }) + + t.Run("upgrades at external artifact revision change", func(t *testing.T) { + newRevision := "2.0.0" + ea, err = applyExternalArtifact(eaKey, newRevision) + g.Expect(err).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr) + if err != nil { + return false + } + return apimeta.IsStatusConditionTrue(hr.Status.Conditions, meta.ReadyCondition) && + hr.Status.LastAttemptedRevision == newRevision + }, 5*time.Second, time.Second).Should(BeTrue(), "HelmRelease did not upgrade") + + g.Expect(hr.Status.LastAttemptedReleaseAction).To(Equal(v2.ReleaseActionUpgrade)) + }) + + t.Run("fails when external artifact feature gate is disable", func(t *testing.T) { + newRevision := "3.0.0" + reconciler.AllowExternalArtifact = false + + ea, err = applyExternalArtifact(eaKey, newRevision) + g.Expect(err).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr) + if err != nil { + return false + } + return apimeta.IsStatusConditionFalse(hr.Status.Conditions, meta.ReadyCondition) + }, 5*time.Second, time.Second).Should(BeTrue()) + + g.Expect(apimeta.IsStatusConditionTrue(hr.Status.Conditions, meta.StalledCondition)).Should(BeTrue()) + readyCondition := apimeta.FindStatusCondition(hr.Status.Conditions, meta.ReadyCondition) + g.Expect(readyCondition.Reason).To(Equal(aclv1.AccessDeniedReason)) + }) + + t.Run("uninstalls successfully", func(t *testing.T) { + err = k8sClient.Delete(context.Background(), hr) + g.Expect(err).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr) + return err != nil && client.IgnoreNotFound(err) == nil + }, 5*time.Second, time.Second).Should(BeTrue(), "HelmRelease was not deleted") + }) +} + +func applyExternalArtifact(objKey client.ObjectKey, revision string) (*sourcev1.ExternalArtifact, error) { + chart := testutil.BuildChart(testutil.ChartWithVersion(revision)) + artifact, err := testutil.SaveChartAsArtifact(chart, digest.SHA256, testServer.URL(), testServer.Root()) + if err != nil { + return nil, err + } + + ea := &sourcev1.ExternalArtifact{ + TypeMeta: metav1.TypeMeta{ + APIVersion: sourcev1.GroupVersion.String(), + Kind: sourcev1.ExternalArtifactKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: objKey.Name, + Namespace: objKey.Namespace, + }, + } + + patchOpts := []client.PatchOption{ + client.ForceOwnership, + client.FieldOwner("kustomize-controller"), + } + + err = k8sClient.Patch(context.Background(), ea, client.Apply, patchOpts...) + if err != nil { + return nil, err + } + + ea.ManagedFields = nil + ea.Status = sourcev1.ExternalArtifactStatus{ + Artifact: artifact, + Conditions: []metav1.Condition{ + { + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: meta.SucceededReason, + ObservedGeneration: 1, + }, + }, + } + + statusOpts := &client.SubResourcePatchOptions{ + PatchOptions: client.PatchOptions{ + FieldManager: "source-controller", + }, + } + + err = k8sClient.Status().Patch(context.Background(), ea, client.Apply, statusOpts) + if err != nil { + return nil, err + } + return ea, nil +} diff --git a/internal/controller_test/suite_test.go b/internal/controller_test/suite_test.go new file mode 100644 index 000000000..545d3c4f5 --- /dev/null +++ b/internal/controller_test/suite_test.go @@ -0,0 +1,171 @@ +/* +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_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + rclient "github.com/fluxcd/pkg/runtime/client" + helper "github.com/fluxcd/pkg/runtime/controller" + "github.com/fluxcd/pkg/runtime/testenv" + "github.com/fluxcd/pkg/testserver" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "go.uber.org/zap/zapcore" + "helm.sh/helm/v3/pkg/kube" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + controllerLog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + v2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/helm-controller/internal/controller" + // +kubebuilder:scaffold:imports +) + +var ( + controllerName = "helm-controller" + testEnv *testenv.Environment + testServer *testserver.HTTPServer + k8sClient client.Client + reconciler *controller.HelmReleaseReconciler + + testCtx = ctrl.SetupSignalHandler() +) + +func NewTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(s)) + utilruntime.Must(apiextensionsv1.AddToScheme(s)) + utilruntime.Must(sourcev1.AddToScheme(s)) + utilruntime.Must(v2.AddToScheme(s)) + return s +} + +func TestMain(m *testing.M) { + // Set logs on development mode and debug level. + controllerLog.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true), zap.Level(zapcore.DebugLevel))) + + // Set the managedFields owner for resources reconciled from Helm charts. + kube.ManagedFieldsManager = controllerName + + // Initialize the test environment with source and helm controller CRDs. + testEnv = testenv.New( + testenv.WithCRDPath( + filepath.Join("..", "..", "build", "config", "crd", "bases"), + filepath.Join("..", "..", "config", "crd", "bases"), + ), + testenv.WithScheme(NewTestScheme()), + ) + + // Start a local test HTTP server to serve chart and artifact files. + var err error + if testServer, err = testserver.NewTempHTTPServer(); err != nil { + panic(fmt.Sprintf("Failed to create a temporary storage server: %v", err)) + } + fmt.Println("Starting the test storage server") + testServer.Start() + + // Start the test environment. + go func() { + fmt.Println("Starting the test environment") + if err := testEnv.Start(testCtx); err != nil { + panic(fmt.Sprintf("Failed to start the test environment manager: %v", err)) + } + }() + <-testEnv.Manager.Elected() + + // Client with caching disabled. + k8sClient, err = client.New(testEnv.Config, client.Options{Scheme: NewTestScheme()}) + if err != nil { + panic(fmt.Sprintf("Failed to create client: %v", err)) + } + + // Start the HelmRelease controller. + if err := StartController(); err != nil { + panic(fmt.Sprintf("Failed to start HelmRelease controller: %v", err)) + } + + code := m.Run() + + fmt.Println("Stopping the test environment") + if err := testEnv.Stop(); err != nil { + panic(fmt.Sprintf("Failed to stop the test environment: %v", err)) + } + + fmt.Println("Stopping the test storage server") + testServer.Stop() + if err := os.RemoveAll(testServer.Root()); err != nil { + panic(fmt.Sprintf("Failed to remove storage server dir: %v", err)) + } + + os.Exit(code) +} + +// GetTestClusterConfig returns a copy of the test cluster config. +func GetTestClusterConfig() (*rest.Config, error) { + return rest.CopyConfig(testEnv.GetConfig()), nil +} + +// StartController adds the HelmReleaseReconciler to the test controller manager +// and starts the reconciliation loops. +func StartController() error { + timeout := time.Second * 10 + reconciler = &controller.HelmReleaseReconciler{ + Client: testEnv, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + Metrics: helper.Metrics{}, + GetClusterConfig: GetTestClusterConfig, + ClientOpts: rclient.Options{ + QPS: 50.0, + Burst: 300, + }, + KubeConfigOpts: rclient.KubeConfigOptions{ + InsecureExecProvider: false, + InsecureTLS: false, + UserAgent: controllerName, + Timeout: &timeout, + }, + APIReader: testEnv, + TokenCache: nil, + FieldManager: controllerName, + DependencyRequeueInterval: 5 * time.Second, + ArtifactFetchRetries: 1, + DefaultServiceAccount: "", + DisableChartDigestTracking: false, + AdditiveCELDependencyCheck: false, + AllowExternalArtifact: false, + } + + watchConfigsPredicate, err := helper.GetWatchConfigsPredicate(helper.WatchOptions{}) + if err != nil { + return err + } + + return (reconciler).SetupWithManager(testCtx, testEnv, controller.HelmReleaseReconcilerOptions{ + WatchConfigsPredicate: watchConfigsPredicate, + WatchExternalArtifacts: true, + }) +} diff --git a/internal/features/features.go b/internal/features/features.go index de5bdb205..998e3721f 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -73,6 +73,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{ @@ -100,6 +103,9 @@ var features = map[string]bool{ // AdditiveCELDependencyCheck // opt-in from v1.4.0 AdditiveCELDependencyCheck: false, + // ExternalArtifact + // opt-in from v1.4.0 + ExternalArtifact: false, } func init() { diff --git a/internal/predicates/source_predicate_test.go b/internal/predicates/source_predicate_test.go index 0accc6693..89328ddf5 100644 --- a/internal/predicates/source_predicate_test.go +++ b/internal/predicates/source_predicate_test.go @@ -27,8 +27,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/event" "github.com/fluxcd/pkg/apis/meta" - - sourcev1 "github.com/fluxcd/source-controller/api/v1" ) func TestSourceRevisionChangePredicate_Update(t *testing.T) { @@ -124,9 +122,9 @@ func (m sourceMock) GetRequeueAfter() time.Duration { return time.Second * 0 } -func (m *sourceMock) GetArtifact() *sourcev1.Artifact { +func (m *sourceMock) GetArtifact() *meta.Artifact { if m.revision != "" { - return &sourcev1.Artifact{ + return &meta.Artifact{ Revision: m.revision, } } diff --git a/internal/testutil/save_chart.go b/internal/testutil/save_chart.go index 6b071299c..55da8cba3 100644 --- a/internal/testutil/save_chart.go +++ b/internal/testutil/save_chart.go @@ -22,12 +22,13 @@ import ( "path/filepath" "strings" - sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/opencontainers/go-digest" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chartutil" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/rand" + + "github.com/fluxcd/pkg/apis/meta" ) // SaveChart saves the given chart to the given directory, and returns the @@ -61,7 +62,7 @@ func SaveChart(c *chart.Chart, outDir string) (string, error) { // SaveChartAsArtifact saves the given chart to the given directory, and // returns an artifact with the chart's metadata. The chart is saved with a // random suffix to avoid name collisions. -func SaveChartAsArtifact(c *chart.Chart, algo digest.Algorithm, baseURL, outDir string) (*sourcev1.Artifact, error) { +func SaveChartAsArtifact(c *chart.Chart, algo digest.Algorithm, baseURL, outDir string) (*meta.Artifact, error) { abs, err := SaveChart(c, outDir) if err != nil { return nil, err @@ -85,7 +86,7 @@ func SaveChartAsArtifact(c *chart.Chart, algo digest.Algorithm, baseURL, outDir } fileURL := strings.TrimSuffix(baseURL, "/") + "/" + rel - return &sourcev1.Artifact{ + return &meta.Artifact{ Path: abs, URL: fileURL, Revision: c.Metadata.Version, diff --git a/main.go b/main.go index 63c7e352b..3a40a218d 100644 --- a/main.go +++ b/main.go @@ -326,6 +326,12 @@ func main() { } } + allowExternalArtifact, err := features.Enabled(features.ExternalArtifact) + if err != nil { + setupLog.Error(err, "unable to check feature gate "+features.ExternalArtifact) + os.Exit(1) + } + if err = (&controller.HelmReleaseReconciler{ Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), @@ -338,11 +344,13 @@ func main() { DisableChartDigestTracking: disableChartDigestTracking, AdditiveCELDependencyCheck: additiveCELDependencyCheck, TokenCache: tokenCache, + DependencyRequeueInterval: requeueDependency, + ArtifactFetchRetries: httpRetry, + AllowExternalArtifact: allowExternalArtifact, }).SetupWithManager(ctx, mgr, controller.HelmReleaseReconcilerOptions{ - DependencyRequeueInterval: requeueDependency, - HTTPRetry: httpRetry, - RateLimiter: helper.GetRateLimiter(rateLimiterOptions), - WatchConfigsPredicate: watchConfigsPredicate, + RateLimiter: helper.GetRateLimiter(rateLimiterOptions), + WatchExternalArtifacts: allowExternalArtifact, + WatchConfigsPredicate: watchConfigsPredicate, }); err != nil { setupLog.Error(err, "unable to create controller", "controller", v2.HelmReleaseKind) os.Exit(1)