From bf1cfe180b779c7e8dad0d13beaef8cc1f7b0220 Mon Sep 17 00:00:00 2001 From: Solvik Blum Date: Sat, 23 May 2026 12:20:39 +0200 Subject: [PATCH] feat(helmchart): add minAge field to delay artifact promotion Add spec.minAge on HelmChart to require a chart version to have been published for a minimum duration before it is promoted as an artifact. When the requirement is not met, the controller sets ArtifactInStorage=False with reason ChartVersionTooNew and requeues for exactly the remaining duration. Only applies to HelmRepository sources where the publish timestamp is available from the repo index. --- api/v1/helmchart_types.go | 16 +++++ api/v1/zz_generated.deepcopy.go | 5 ++ .../source.toolkit.fluxcd.io_helmcharts.yaml | 11 +++ docs/spec/v1/helmcharts.md | 72 +++++++++++++++++++ internal/controller/helmchart_controller.go | 27 +++++++ internal/helm/chart/builder.go | 6 ++ internal/helm/chart/builder_remote.go | 1 + 7 files changed, 138 insertions(+) diff --git a/api/v1/helmchart_types.go b/api/v1/helmchart_types.go index 224d8533d..824321e4b 100644 --- a/api/v1/helmchart_types.go +++ b/api/v1/helmchart_types.go @@ -87,6 +87,18 @@ type HelmChartSpec struct { // Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified. // +optional Verify *OCIRepositoryVerification `json:"verify,omitempty"` + + // MinAge is the minimum age a chart version must have before it is + // published as an artifact. This can be used to reduce exposure to + // supply-chain attacks by ensuring a newly published chart version has + // had time for community scrutiny before being deployed. + // The age is computed from the chart's creation timestamp in the Helm + // repository index. When the creation timestamp is unavailable (e.g. + // OCI registries without annotations), the field is ignored. + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$" + // +optional + MinAge *metav1.Duration `json:"minAge,omitempty"` } const ( @@ -163,6 +175,10 @@ const ( // ChartPackageSucceededReason signals that the package of the Helm // chart succeeded. ChartPackageSucceededReason string = "ChartPackageSucceeded" + + // ChartVersionTooNewReason signals that the resolved chart version does + // not yet satisfy the MinAge requirement. + ChartVersionTooNewReason string = "ChartVersionTooNew" ) // GetConditions returns the status conditions of the object. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 14f1ba3c2..d632be5a9 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -591,6 +591,11 @@ func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) { *out = new(OCIRepositoryVerification) (*in).DeepCopyInto(*out) } + if in.MinAge != nil { + in, out := &in.MinAge, &out.MinAge + *out = new(metav1.Duration) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec. diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml index 1ae58d5da..17b27cb92 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -80,6 +80,17 @@ spec: efficient use of resources. pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ type: string + minAge: + description: |- + MinAge is the minimum age a chart version must have before it is + published as an artifact. This can be used to reduce exposure to + supply-chain attacks by ensuring a newly published chart version has + had time for community scrutiny before being deployed. + The age is computed from the chart's creation timestamp in the Helm + repository index. When the creation timestamp is unavailable (e.g. + OCI registries without annotations), the field is ignored. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string reconcileStrategy: default: ChartVersion description: |- diff --git a/docs/spec/v1/helmcharts.md b/docs/spec/v1/helmcharts.md index eae4d5b9c..7533749f0 100644 --- a/docs/spec/v1/helmcharts.md +++ b/docs/spec/v1/helmcharts.md @@ -26,6 +26,7 @@ spec: kind: HelmRepository name: podinfo version: '5.*' + minAge: 168h # only promote chart versions published at least 7 days ago ``` In the above example: @@ -179,6 +180,48 @@ the latest version of the chart with value `*`. Version can be a fixed semver, minor or patch semver range of a specific version (i.e. `4.0.x`) or any semver range (i.e. `>=4.0.0 <5.0.0`). +### Minimum age + +`.spec.minAge` is an optional field to specify the minimum age a chart version +must have been published before it is promoted as an Artifact. The value must be +in a [Go recognized duration string format](https://pkg.go.dev/time#ParseDuration), +e.g. `168h` (7 days) or `24h30m`. + +When set, the controller compares the chart version's publish timestamp (as +recorded in the Helm repository index) against the current time. If the +elapsed time is less than `minAge`, the Artifact is not published and the +controller requeues the object for the exact remaining duration, so it is +promoted as soon as the requirement is met. + +This is useful as a supply chain security measure: delaying the automatic +promotion of a new chart version gives time to detect and respond to a +compromised or malicious release before it reaches your clusters. + +```yaml +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: podinfo + namespace: default +spec: + interval: 1h + chart: podinfo + version: ">=6.0.0" + sourceRef: + kind: HelmRepository + name: podinfo + minAge: 168h # only promote chart versions that are at least 7 days old +``` + +**Note:** `minAge` only applies when the source reference is a `HelmRepository`. +For `GitRepository` and `Bucket` sources the publish timestamp is not available +and the field is ignored. + +When a chart version does not yet meet the minimum age requirement, the +controller sets a Condition on the HelmChart — see +[ChartVersionTooNew](#chartversiontoonew) for details. + ### Values files `.spec.valuesFiles` is an optional field to specify an alternative list of @@ -812,6 +855,35 @@ configuration issue in the HelmChart spec. When a reconciliation fails, the reconciliation is performed again after the failure, the reason is updated to `Progressing`. +#### ChartVersionTooNew + +When [`.spec.minAge`](#minimum-age) is set and the resolved chart version has +not yet been published long enough, the controller marks the Artifact as not +ready and sets a Condition with the following attributes in the HelmChart's +`.status.conditions`: + +- `type: ArtifactInStorage` +- `status: "False"` +- `reason: ChartVersionTooNew` + +The `message` field reports the current age of the chart version and the +configured minimum, for example: + +``` +chart version 6.12.0 was published 2h30m0s ago, waiting for minimum age of 168h0m0s +``` + +The controller requeues the object for exactly the remaining duration, so no +manual intervention is required. Once the minimum age is reached the object +reconciles normally and the Artifact is published. + +An Event with reason `ChartVersionTooNew` is also emitted: + +```console +LAST SEEN TYPE REASON OBJECT MESSAGE +0s Normal ChartVersionTooNew helmchart/podinfo chart version 6.12.0 does not meet minimum age requirement (age: 2h30m0s, minimum: 168h0m0s) +``` + #### Stalled HelmChart The source-controller can mark a HelmChart as _stalled_ when it determines that diff --git a/internal/controller/helmchart_controller.go b/internal/controller/helmchart_controller.go index 963d75dde..b4cc43480 100644 --- a/internal/controller/helmchart_controller.go +++ b/internal/controller/helmchart_controller.go @@ -275,6 +275,7 @@ func (r *HelmChartReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( reconcilers := []helmChartReconcileFunc{ r.reconcileStorage, r.reconcileSource, + r.reconcileMinAge, r.reconcileArtifact, } recResult, retErr = r.reconcile(ctx, serialPatcher, obj, reconcilers) @@ -817,6 +818,32 @@ func (r *HelmChartReconciler) buildFromTarballArtifact(ctx context.Context, obj return sreconcile.ResultSuccess, nil } +// reconcileMinAge checks whether the resolved chart version satisfies the +// MinAge requirement on the object. If the chart was published less than +// MinAge ago (using the creation timestamp from the Helm repository index), +// it sets ArtifactInStorageCondition=False and returns a Waiting error so +// that reconcileArtifact is skipped until the age requirement is met. +func (r *HelmChartReconciler) reconcileMinAge(_ context.Context, _ *patch.SerialPatcher, obj *sourcev1.HelmChart, b *chart.Build) (sreconcile.Result, error) { + if obj.Spec.MinAge == nil || b.CreatedAt.IsZero() { + return sreconcile.ResultSuccess, nil + } + elapsed := time.Since(b.CreatedAt) + if elapsed >= obj.Spec.MinAge.Duration { + return sreconcile.ResultSuccess, nil + } + remaining := obj.Spec.MinAge.Duration - elapsed + conditions.MarkFalse(obj, sourcev1.ArtifactInStorageCondition, sourcev1.ChartVersionTooNewReason, + "chart version %s was published %s ago, waiting for minimum age of %s", + b.Version, elapsed.Truncate(time.Second), obj.Spec.MinAge.Duration) + e := serror.NewWaiting( + fmt.Errorf("chart version %s does not meet minimum age requirement (age: %s, minimum: %s)", + b.Version, elapsed.Truncate(time.Second), obj.Spec.MinAge.Duration), + sourcev1.ChartVersionTooNewReason, + ) + e.RequeueAfter = remaining + return sreconcile.ResultEmpty, e +} + // reconcileArtifact archives a new Artifact to the Storage, if the current // (Status) data on the object does not match the given. // diff --git a/internal/helm/chart/builder.go b/internal/helm/chart/builder.go index 4f15aeff4..be8a0365e 100644 --- a/internal/helm/chart/builder.go +++ b/internal/helm/chart/builder.go @@ -23,6 +23,7 @@ import ( "path/filepath" "regexp" "strings" + "time" sourcefs "github.com/fluxcd/pkg/oci" helmchart "helm.sh/helm/v4/pkg/chart/v2" @@ -156,6 +157,11 @@ type Build struct { // VerifiedResult indicates the results of verifying the chart. // If no verification was performed, this field should be VerificationResultIgnored. VerifiedResult oci.VerificationResult + // CreatedAt is the timestamp at which the chart version was published in + // the source repository, as reported by the Helm repository index. + // It is zero when the source does not provide a creation timestamp + // (e.g. OCI registries without creation annotations). + CreatedAt time.Time } // Summary returns a human-readable summary of the Build. diff --git a/internal/helm/chart/builder_remote.go b/internal/helm/chart/builder_remote.go index dbe3addca..ef732ae9b 100644 --- a/internal/helm/chart/builder_remote.go +++ b/internal/helm/chart/builder_remote.go @@ -179,6 +179,7 @@ func generateBuildResult(cv *repo.ChartVersion, opts BuildOptions) (*Build, bool result.Version = cv.Version result.Name = cv.Name result.VerifiedResult = oci.VerificationResultIgnored + result.CreatedAt = cv.Created // Set build specific metadata if instructed if opts.VersionMetadata != "" {