Skip to content

Commit 22217e1

Browse files
fvrk4nbrandond
authored andcommitted
feat(chart): add configurable Helm storage driver to HelmChart spec
Allow users to configure the Helm storage driver via spec.driver on HelmChart resources. Accepts 'secret' (default) and 'configmap'. This enables installation of large charts whose release metadata exceeds the effective capacity of Kubernetes Secrets. The driver field is immutable after creation to prevent accidental loss of release metadata when switching storage backends. Made getChartReleaseRevision() driver-aware so that EXPECTED_RELEASE_REVISION is correctly injected for the klipper-helm revision guard when using the configmap driver. Fixes #317 Signed-off-by: Furkan Akman <hi@furkanakman.dev>
1 parent ba20446 commit 22217e1

7 files changed

Lines changed: 368 additions & 54 deletions

File tree

doc/helmchart.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ _Appears in:_
162162
| `dockerRegistrySecret` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#localobjectreference-v1-core)_ | Reference to Secret of type kubernetes.io/dockerconfigjson holding Docker auth credentials for the OCI-based registry acting as the Chart repo. | | |
163163
| `podSecurityContext` _[PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#podsecuritycontext-v1-core)_ | Custom PodSecurityContext for the helm job pod. | | |
164164
| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#securitycontext-v1-core)_ | custom SecurityContext for the helm job pod. | | |
165+
| `driver` _[HelmDriver](#helmdriver)_ | Helm storage driver to use for this chart's release metadata.<br />`secret` stores releases in Kubernetes Secrets (default).<br />`configmap` stores releases in ConfigMaps.<br />This field is effectively immutable after the first install; changing the storage backend is not a supported migration path.<br />Helm CLI environment variable: `HELM_DRIVER` | secret | Enum: [secret configmap] <br /> |
165166

166167

167168
#### HelmChartStatus
@@ -181,6 +182,20 @@ _Appears in:_
181182
| `conditions` _[HelmChartCondition](#helmchartcondition) array_ | `JobCreated` indicates that a job has been created to install or upgrade the chart.<br />`Failed` indicates that the helm job has failed and the failure policy is set to `abort`. | | |
182183

183184

185+
#### HelmDriver
186+
187+
_Underlying type:_ _string_
188+
189+
190+
191+
_Validation:_
192+
- Enum: [secret configmap]
193+
194+
_Appears in:_
195+
- [HelmChartSpec](#helmchartspec)
196+
197+
198+
184199
#### SecretSpec
185200

186201

pkg/apis/helm.cattle.io/v1/types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import (
1313
// +kubebuilder:validation:Enum={"abort","reinstall"}
1414
type FailurePolicy string
1515

16+
// +kubebuilder:validation:Enum={"secret","configmap"}
17+
type HelmDriver string
18+
1619
// +genclient
1720
// +kubebuilder:subresource:status
1821
// +kubebuilder:resource:shortName=hc
@@ -108,6 +111,14 @@ type HelmChartSpec struct {
108111
PodSecurityContext *corev1.PodSecurityContext `json:"podSecurityContext,omitempty"`
109112
// custom SecurityContext for the helm job pod.
110113
SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"`
114+
// Helm storage driver to use for this chart's release metadata.
115+
// `secret` stores releases in Kubernetes Secrets (default).
116+
// `configmap` stores releases in ConfigMaps.
117+
// This field is effectively immutable after the first install; changing the storage backend is not a supported migration path.
118+
// Helm CLI environment variable: `HELM_DRIVER`
119+
// +kubebuilder:default=secret
120+
// +kubebuilder:validation:XValidation:rule="!oldSelf.hasValue() || self == oldSelf.value()",message="driver is immutable after creation",optionalOldSelf=true
121+
Driver HelmDriver `json:"driver,omitempty"`
111122
}
112123

113124
// HelmChartStatus represents the resulting state from processing HelmChart events

pkg/controllers/chart/chart.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,22 @@ type Controller struct {
105105
confCache helmcontroller.HelmChartConfigCache
106106
jobs batchcontroller.JobController
107107
jobCache batchcontroller.JobCache
108-
secrets corecontroller.SecretController
108+
configMaps configMapLister
109+
secrets secretLister
109110
secretCache corecontroller.SecretCache
110111
apply apply.Apply
111112
recorder record.EventRecorder
112113
apiServerPort string
113114
}
114115

116+
type configMapLister interface {
117+
List(namespace string, opts metav1.ListOptions) (*corev1.ConfigMapList, error)
118+
}
119+
120+
type secretLister interface {
121+
List(namespace string, opts metav1.ListOptions) (*corev1.SecretList, error)
122+
}
123+
115124
func Register(
116125
ctx context.Context,
117126
systemNamespace,
@@ -142,6 +151,7 @@ func Register(
142151
confCache: confCache,
143152
jobs: jobs,
144153
jobCache: jobCache,
154+
configMaps: cm,
145155
secrets: s,
146156
secretCache: sCache,
147157
recorder: recorder,
@@ -565,20 +575,40 @@ func (c *Controller) getJobAndRelatedResources(chart *v1.HelmChart) (*batch.Job,
565575
}
566576

567577
func (c *Controller) getChartReleaseRevision(chart *v1.HelmChart) (int64, error) {
568-
var version int64
569-
fs := fields.OneTermEqualSelector("type", ReleaseType)
570578
ls := labels.Set{"owner": "helm", "name": chart.Name}.AsSelector()
579+
580+
if helmDriver(chart) == "configmap" {
581+
cmList, err := c.configMaps.List(chart.Spec.TargetNamespace, metav1.ListOptions{LabelSelector: ls.String()})
582+
if err != nil {
583+
return 0, err
584+
}
585+
objects := make([]metav1.ObjectMeta, len(cmList.Items))
586+
for i := range cmList.Items {
587+
objects[i] = cmList.Items[i].ObjectMeta
588+
}
589+
return maxReleaseRevision(objects), nil
590+
}
591+
592+
fs := fields.OneTermEqualSelector("type", ReleaseType)
571593
secretList, err := c.secrets.List(chart.Spec.TargetNamespace, metav1.ListOptions{FieldSelector: fs.String(), LabelSelector: ls.String()})
572594
if err != nil {
573-
return version, err
595+
return 0, err
596+
}
597+
objects := make([]metav1.ObjectMeta, len(secretList.Items))
598+
for i := range secretList.Items {
599+
objects[i] = secretList.Items[i].ObjectMeta
574600
}
575-
for _, secret := range secretList.Items {
576-
if sv, err := strconv.ParseInt(secret.ObjectMeta.Labels["version"], 10, 64); err == nil && sv > version {
601+
return maxReleaseRevision(objects), nil
602+
}
603+
604+
func maxReleaseRevision(objects []metav1.ObjectMeta) int64 {
605+
var version int64
606+
for _, obj := range objects {
607+
if sv, err := strconv.ParseInt(obj.Labels["version"], 10, 64); err == nil && sv > version {
577608
version = sv
578609
}
579610
}
580-
581-
return version, nil
611+
return version
582612
}
583613

584614
func chartBySecret(chart *v1.HelmChart) ([]string, error) {
@@ -668,7 +698,7 @@ func job(chart *v1.HelmChart, apiServerPort string) (*batch.Job, *corev1.Secret,
668698
},
669699
{
670700
Name: "HELM_DRIVER",
671-
Value: "secret",
701+
Value: helmDriver(chart),
672702
},
673703
{
674704
Name: "CHART_NAMESPACE",
@@ -1329,6 +1359,13 @@ func templateChanged(oldJob, newJob *batch.Job) bool {
13291359
return !equality.Semantic.DeepEqual(oldPodTemplate, newPodTemplate)
13301360
}
13311361

1362+
func helmDriver(chart *v1.HelmChart) string {
1363+
if chart.Spec.Driver != "" {
1364+
return string(chart.Spec.Driver)
1365+
}
1366+
return "secret"
1367+
}
1368+
13321369
func objectToJob(obj runtime.Object) (*batch.Job, error) {
13331370
if job, ok := obj.(*batch.Job); ok {
13341371
return job, nil

pkg/controllers/chart/chart_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import (
1111
"github.com/rancher/wrangler/v3/pkg/yaml"
1212
"github.com/sirupsen/logrus"
1313
"github.com/stretchr/testify/assert"
14+
corev1 "k8s.io/api/core/v1"
1415
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
"k8s.io/apimachinery/pkg/fields"
17+
"k8s.io/apimachinery/pkg/labels"
1518
"k8s.io/apimachinery/pkg/runtime"
1619
"k8s.io/apimachinery/pkg/util/intstr"
1720
)
@@ -199,6 +202,146 @@ func TestDeleteArgs(t *testing.T) {
199202
assert.Equal("delete", stringArgs)
200203
}
201204

205+
func TestDriverField(t *testing.T) {
206+
tests := []struct {
207+
name string
208+
driver v1.HelmDriver
209+
expected string
210+
}{
211+
{"default driver", "", "secret"},
212+
{"secret driver", "secret", "secret"},
213+
{"configmap driver", "configmap", "configmap"},
214+
}
215+
216+
for _, tt := range tests {
217+
t.Run(tt.name, func(t *testing.T) {
218+
assert := assert.New(t)
219+
chart := NewChart()
220+
chart.Spec.Driver = tt.driver
221+
j, _, _ := job(chart, "6443")
222+
envs := j.Spec.Template.Spec.Containers[0].Env
223+
var helmDriver string
224+
for _, e := range envs {
225+
if e.Name == "HELM_DRIVER" {
226+
helmDriver = e.Value
227+
break
228+
}
229+
}
230+
assert.Equal(tt.expected, helmDriver)
231+
})
232+
}
233+
}
234+
235+
func TestMaxReleaseRevision(t *testing.T) {
236+
tests := []struct {
237+
name string
238+
objects []metav1.ObjectMeta
239+
expected int64
240+
}{
241+
{"no objects", nil, 0},
242+
{"single revision", []metav1.ObjectMeta{
243+
{Labels: map[string]string{"version": "1"}},
244+
}, 1},
245+
{"multiple revisions returns max", []metav1.ObjectMeta{
246+
{Labels: map[string]string{"version": "1"}},
247+
{Labels: map[string]string{"version": "3"}},
248+
{Labels: map[string]string{"version": "2"}},
249+
}, 3},
250+
{"invalid version label ignored", []metav1.ObjectMeta{
251+
{Labels: map[string]string{"version": "abc"}},
252+
{Labels: map[string]string{"version": "2"}},
253+
}, 2},
254+
{"missing version label ignored", []metav1.ObjectMeta{
255+
{Labels: map[string]string{"owner": "helm"}},
256+
{Labels: map[string]string{"version": "5"}},
257+
}, 5},
258+
}
259+
260+
for _, tt := range tests {
261+
t.Run(tt.name, func(t *testing.T) {
262+
assert := assert.New(t)
263+
assert.Equal(tt.expected, maxReleaseRevision(tt.objects))
264+
})
265+
}
266+
}
267+
268+
func TestGetChartReleaseRevision(t *testing.T) {
269+
t.Run("configmap driver uses configmap storage", func(t *testing.T) {
270+
assert := assert.New(t)
271+
var called bool
272+
c := &Controller{
273+
configMaps: fakeConfigMapLister{
274+
list: func(namespace string, opts metav1.ListOptions) (*corev1.ConfigMapList, error) {
275+
called = true
276+
assert.Equal("target-ns", namespace)
277+
assert.Equal(labels.Set{"owner": "helm", "name": "traefik"}.AsSelector().String(), opts.LabelSelector)
278+
assert.Empty(opts.FieldSelector)
279+
return &corev1.ConfigMapList{
280+
Items: []corev1.ConfigMap{
281+
{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"version": "1"}}},
282+
{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"version": "3"}}},
283+
},
284+
}, nil
285+
},
286+
},
287+
}
288+
289+
chart := NewChart()
290+
chart.Spec.Driver = "configmap"
291+
chart.Spec.TargetNamespace = "target-ns"
292+
293+
revision, err := c.getChartReleaseRevision(chart)
294+
assert.NoError(err)
295+
assert.True(called)
296+
assert.Equal(int64(3), revision)
297+
})
298+
299+
t.Run("default driver uses secret storage", func(t *testing.T) {
300+
assert := assert.New(t)
301+
var called bool
302+
c := &Controller{
303+
secrets: fakeSecretLister{
304+
list: func(namespace string, opts metav1.ListOptions) (*corev1.SecretList, error) {
305+
called = true
306+
assert.Equal("target-ns", namespace)
307+
assert.Equal(labels.Set{"owner": "helm", "name": "traefik"}.AsSelector().String(), opts.LabelSelector)
308+
assert.Equal(fields.OneTermEqualSelector("type", ReleaseType).String(), opts.FieldSelector)
309+
return &corev1.SecretList{
310+
Items: []corev1.Secret{
311+
{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"version": "2"}}},
312+
{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"version": "5"}}},
313+
},
314+
}, nil
315+
},
316+
},
317+
}
318+
319+
chart := NewChart()
320+
chart.Spec.TargetNamespace = "target-ns"
321+
322+
revision, err := c.getChartReleaseRevision(chart)
323+
assert.NoError(err)
324+
assert.True(called)
325+
assert.Equal(int64(5), revision)
326+
})
327+
}
328+
329+
type fakeConfigMapLister struct {
330+
list func(namespace string, opts metav1.ListOptions) (*corev1.ConfigMapList, error)
331+
}
332+
333+
func (f fakeConfigMapLister) List(namespace string, opts metav1.ListOptions) (*corev1.ConfigMapList, error) {
334+
return f.list(namespace, opts)
335+
}
336+
337+
type fakeSecretLister struct {
338+
list func(namespace string, opts metav1.ListOptions) (*corev1.SecretList, error)
339+
}
340+
341+
func (f fakeSecretLister) List(namespace string, opts metav1.ListOptions) (*corev1.SecretList, error) {
342+
return f.list(namespace, opts)
343+
}
344+
202345
func NewChart() *v1.HelmChart {
203346
return v1.NewHelmChart("kube-system", "traefik", v1.HelmChart{
204347
Spec: v1.HelmChartSpec{

pkg/crds/yaml/generated/helm.cattle.io_helmcharts.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ spec:
126126
type: string
127127
type: object
128128
x-kubernetes-map-type: atomic
129+
driver:
130+
default: secret
131+
description: |-
132+
Helm storage driver to use for this chart's release metadata.
133+
`secret` stores releases in Kubernetes Secrets (default).
134+
`configmap` stores releases in ConfigMaps.
135+
This field is effectively immutable after the first install; changing the storage backend is not a supported migration path.
136+
Helm CLI environment variable: `HELM_DRIVER`
137+
enum:
138+
- secret
139+
- configmap
140+
type: string
141+
x-kubernetes-validations:
142+
- message: driver is immutable after creation
143+
optionalOldSelf: true
144+
rule: '!oldSelf.hasValue() || self == oldSelf.value()'
129145
failurePolicy:
130146
default: reinstall
131147
description: |-

test/framework/framework.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func (f *Framework) NewHelmChartConfig(name, values, valuesContent string) *v1.H
158158
}
159159
}
160160

161-
func (f *Framework) ListReleases(chart *v1.HelmChart) ([]corev1.Secret, error) {
161+
func (f *Framework) ListSecretReleases(chart *v1.HelmChart) ([]corev1.Secret, error) {
162162
labelSelector := labels.SelectorFromSet(labels.Set{
163163
"owner": "helm",
164164
"name": chart.Name,
@@ -180,6 +180,27 @@ func (f *Framework) ListReleases(chart *v1.HelmChart) ([]corev1.Secret, error) {
180180
return secretList.Items, nil
181181
}
182182

183+
func (f *Framework) ListConfigMapReleases(chart *v1.HelmChart) ([]corev1.ConfigMap, error) {
184+
labelSelector := labels.SelectorFromSet(labels.Set{
185+
"owner": "helm",
186+
"name": chart.Name,
187+
})
188+
namespace := chart.Namespace
189+
if chart.Spec.TargetNamespace != "" {
190+
namespace = chart.Spec.TargetNamespace
191+
}
192+
193+
cmList, err := f.ClientSet.CoreV1().ConfigMaps(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector.String()})
194+
if err != nil {
195+
if apierrors.IsNotFound(err) {
196+
return nil, nil
197+
}
198+
return nil, err
199+
}
200+
201+
return cmList.Items, nil
202+
}
203+
183204
// GetDeployedRelease fetches the secret containing meta data about the currently deployed helm release.
184205
func (f *Framework) GetDeployedRelease(chart *v1.HelmChart) (*corev1.Secret, error) {
185206
labelSelector := labels.SelectorFromSet(labels.Set{

0 commit comments

Comments
 (0)