Skip to content

Commit bf1cfe1

Browse files
committed
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.
1 parent 524cf24 commit bf1cfe1

7 files changed

Lines changed: 138 additions & 0 deletions

File tree

api/v1/helmchart_types.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ type HelmChartSpec struct {
8787
// Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified.
8888
// +optional
8989
Verify *OCIRepositoryVerification `json:"verify,omitempty"`
90+
91+
// MinAge is the minimum age a chart version must have before it is
92+
// published as an artifact. This can be used to reduce exposure to
93+
// supply-chain attacks by ensuring a newly published chart version has
94+
// had time for community scrutiny before being deployed.
95+
// The age is computed from the chart's creation timestamp in the Helm
96+
// repository index. When the creation timestamp is unavailable (e.g.
97+
// OCI registries without annotations), the field is ignored.
98+
// +kubebuilder:validation:Type=string
99+
// +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$"
100+
// +optional
101+
MinAge *metav1.Duration `json:"minAge,omitempty"`
90102
}
91103

92104
const (
@@ -163,6 +175,10 @@ const (
163175
// ChartPackageSucceededReason signals that the package of the Helm
164176
// chart succeeded.
165177
ChartPackageSucceededReason string = "ChartPackageSucceeded"
178+
179+
// ChartVersionTooNewReason signals that the resolved chart version does
180+
// not yet satisfy the MinAge requirement.
181+
ChartVersionTooNewReason string = "ChartVersionTooNew"
166182
)
167183

168184
// GetConditions returns the status conditions of the object.

api/v1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ spec:
8080
efficient use of resources.
8181
pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
8282
type: string
83+
minAge:
84+
description: |-
85+
MinAge is the minimum age a chart version must have before it is
86+
published as an artifact. This can be used to reduce exposure to
87+
supply-chain attacks by ensuring a newly published chart version has
88+
had time for community scrutiny before being deployed.
89+
The age is computed from the chart's creation timestamp in the Helm
90+
repository index. When the creation timestamp is unavailable (e.g.
91+
OCI registries without annotations), the field is ignored.
92+
pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
93+
type: string
8394
reconcileStrategy:
8495
default: ChartVersion
8596
description: |-

docs/spec/v1/helmcharts.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ spec:
2626
kind: HelmRepository
2727
name: podinfo
2828
version: '5.*'
29+
minAge: 168h # only promote chart versions published at least 7 days ago
2930
```
3031
3132
In the above example:
@@ -179,6 +180,48 @@ the latest version of the chart with value `*`.
179180
Version can be a fixed semver, minor or patch semver range of a specific
180181
version (i.e. `4.0.x`) or any semver range (i.e. `>=4.0.0 <5.0.0`).
181182

183+
### Minimum age
184+
185+
`.spec.minAge` is an optional field to specify the minimum age a chart version
186+
must have been published before it is promoted as an Artifact. The value must be
187+
in a [Go recognized duration string format](https://pkg.go.dev/time#ParseDuration),
188+
e.g. `168h` (7 days) or `24h30m`.
189+
190+
When set, the controller compares the chart version's publish timestamp (as
191+
recorded in the Helm repository index) against the current time. If the
192+
elapsed time is less than `minAge`, the Artifact is not published and the
193+
controller requeues the object for the exact remaining duration, so it is
194+
promoted as soon as the requirement is met.
195+
196+
This is useful as a supply chain security measure: delaying the automatic
197+
promotion of a new chart version gives time to detect and respond to a
198+
compromised or malicious release before it reaches your clusters.
199+
200+
```yaml
201+
---
202+
apiVersion: source.toolkit.fluxcd.io/v1
203+
kind: HelmChart
204+
metadata:
205+
name: podinfo
206+
namespace: default
207+
spec:
208+
interval: 1h
209+
chart: podinfo
210+
version: ">=6.0.0"
211+
sourceRef:
212+
kind: HelmRepository
213+
name: podinfo
214+
minAge: 168h # only promote chart versions that are at least 7 days old
215+
```
216+
217+
**Note:** `minAge` only applies when the source reference is a `HelmRepository`.
218+
For `GitRepository` and `Bucket` sources the publish timestamp is not available
219+
and the field is ignored.
220+
221+
When a chart version does not yet meet the minimum age requirement, the
222+
controller sets a Condition on the HelmChart — see
223+
[ChartVersionTooNew](#chartversiontoonew) for details.
224+
182225
### Values files
183226

184227
`.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
812855
reconciliation is performed again after the failure, the reason is updated to
813856
`Progressing`.
814857

858+
#### ChartVersionTooNew
859+
860+
When [`.spec.minAge`](#minimum-age) is set and the resolved chart version has
861+
not yet been published long enough, the controller marks the Artifact as not
862+
ready and sets a Condition with the following attributes in the HelmChart's
863+
`.status.conditions`:
864+
865+
- `type: ArtifactInStorage`
866+
- `status: "False"`
867+
- `reason: ChartVersionTooNew`
868+
869+
The `message` field reports the current age of the chart version and the
870+
configured minimum, for example:
871+
872+
```
873+
chart version 6.12.0 was published 2h30m0s ago, waiting for minimum age of 168h0m0s
874+
```
875+
876+
The controller requeues the object for exactly the remaining duration, so no
877+
manual intervention is required. Once the minimum age is reached the object
878+
reconciles normally and the Artifact is published.
879+
880+
An Event with reason `ChartVersionTooNew` is also emitted:
881+
882+
```console
883+
LAST SEEN TYPE REASON OBJECT MESSAGE
884+
0s Normal ChartVersionTooNew helmchart/podinfo chart version 6.12.0 does not meet minimum age requirement (age: 2h30m0s, minimum: 168h0m0s)
885+
```
886+
815887
#### Stalled HelmChart
816888

817889
The source-controller can mark a HelmChart as _stalled_ when it determines that

internal/controller/helmchart_controller.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ func (r *HelmChartReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
275275
reconcilers := []helmChartReconcileFunc{
276276
r.reconcileStorage,
277277
r.reconcileSource,
278+
r.reconcileMinAge,
278279
r.reconcileArtifact,
279280
}
280281
recResult, retErr = r.reconcile(ctx, serialPatcher, obj, reconcilers)
@@ -817,6 +818,32 @@ func (r *HelmChartReconciler) buildFromTarballArtifact(ctx context.Context, obj
817818
return sreconcile.ResultSuccess, nil
818819
}
819820

821+
// reconcileMinAge checks whether the resolved chart version satisfies the
822+
// MinAge requirement on the object. If the chart was published less than
823+
// MinAge ago (using the creation timestamp from the Helm repository index),
824+
// it sets ArtifactInStorageCondition=False and returns a Waiting error so
825+
// that reconcileArtifact is skipped until the age requirement is met.
826+
func (r *HelmChartReconciler) reconcileMinAge(_ context.Context, _ *patch.SerialPatcher, obj *sourcev1.HelmChart, b *chart.Build) (sreconcile.Result, error) {
827+
if obj.Spec.MinAge == nil || b.CreatedAt.IsZero() {
828+
return sreconcile.ResultSuccess, nil
829+
}
830+
elapsed := time.Since(b.CreatedAt)
831+
if elapsed >= obj.Spec.MinAge.Duration {
832+
return sreconcile.ResultSuccess, nil
833+
}
834+
remaining := obj.Spec.MinAge.Duration - elapsed
835+
conditions.MarkFalse(obj, sourcev1.ArtifactInStorageCondition, sourcev1.ChartVersionTooNewReason,
836+
"chart version %s was published %s ago, waiting for minimum age of %s",
837+
b.Version, elapsed.Truncate(time.Second), obj.Spec.MinAge.Duration)
838+
e := serror.NewWaiting(
839+
fmt.Errorf("chart version %s does not meet minimum age requirement (age: %s, minimum: %s)",
840+
b.Version, elapsed.Truncate(time.Second), obj.Spec.MinAge.Duration),
841+
sourcev1.ChartVersionTooNewReason,
842+
)
843+
e.RequeueAfter = remaining
844+
return sreconcile.ResultEmpty, e
845+
}
846+
820847
// reconcileArtifact archives a new Artifact to the Storage, if the current
821848
// (Status) data on the object does not match the given.
822849
//

internal/helm/chart/builder.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"path/filepath"
2424
"regexp"
2525
"strings"
26+
"time"
2627

2728
sourcefs "github.com/fluxcd/pkg/oci"
2829
helmchart "helm.sh/helm/v4/pkg/chart/v2"
@@ -156,6 +157,11 @@ type Build struct {
156157
// VerifiedResult indicates the results of verifying the chart.
157158
// If no verification was performed, this field should be VerificationResultIgnored.
158159
VerifiedResult oci.VerificationResult
160+
// CreatedAt is the timestamp at which the chart version was published in
161+
// the source repository, as reported by the Helm repository index.
162+
// It is zero when the source does not provide a creation timestamp
163+
// (e.g. OCI registries without creation annotations).
164+
CreatedAt time.Time
159165
}
160166

161167
// Summary returns a human-readable summary of the Build.

internal/helm/chart/builder_remote.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ func generateBuildResult(cv *repo.ChartVersion, opts BuildOptions) (*Build, bool
179179
result.Version = cv.Version
180180
result.Name = cv.Name
181181
result.VerifiedResult = oci.VerificationResultIgnored
182+
result.CreatedAt = cv.Created
182183

183184
// Set build specific metadata if instructed
184185
if opts.VersionMetadata != "" {

0 commit comments

Comments
 (0)