From 751b6d00c1de1f29aadd13a9533162bfdb4c6365 Mon Sep 17 00:00:00 2001 From: Robert Aistleitner Date: Sun, 5 Apr 2026 21:15:50 +0000 Subject: [PATCH 1/3] add --add-checksum-annotations flag for rolling restarts on config changes Implements #168: opt-in flag that adds sha256sum checksum annotations to pod templates for referenced ConfigMaps and Secrets. Annotations are injected during the output phase using actual resolved template filenames, so paths are correct regardless of single-file or multi-file input. Covers Deployments, DaemonSets, and StatefulSets with detection across envFrom, env valueFrom, volumes, and projected volumes. --- cmd/helmify/flags.go | 1 + cmd/helmify/flags_test.go | 1 + pkg/app/context.go | 181 ++++++++ pkg/app/context_test.go | 90 ++++ pkg/config/config.go | 3 + pkg/helmify/model.go | 5 + pkg/metadata/metadata.go | 53 ++- pkg/metadata/metadata_test.go | 26 ++ pkg/processor/daemonset/daemonset_test.go | 1 + pkg/processor/pod/checksum.go | 91 ++++ pkg/processor/pod/checksum_test.go | 405 ++++++++++++++++++ pkg/processor/statefulset/statefulset_test.go | 56 +++ 12 files changed, 908 insertions(+), 5 deletions(-) create mode 100644 pkg/app/context_test.go create mode 100644 pkg/processor/pod/checksum.go create mode 100644 pkg/processor/pod/checksum_test.go create mode 100644 pkg/processor/statefulset/statefulset_test.go diff --git a/cmd/helmify/flags.go b/cmd/helmify/flags.go index bdce7a8e..9ed52af7 100644 --- a/cmd/helmify/flags.go +++ b/cmd/helmify/flags.go @@ -76,6 +76,7 @@ func ReadFlags() (config.Config, error) { flag.BoolVar(&result.PreserveNs, "preserve-ns", false, "Use the object's original namespace instead of adding all the resources to a common namespace.") flag.BoolVar(&result.AddWebhookOption, "add-webhook-option", false, "Allows the user to add webhook option in values.yaml.") flag.BoolVar(&result.OptionalCRDs, "optional-crds", false, "Enable optional CRD installation through values. (cannot be used with 'crd-dir')") + flag.BoolVar(&result.AddChecksumAnnotations, "add-checksum-annotations", false, "Add checksum annotations to pod templates for referenced ConfigMaps and Secrets. Triggers rolling restarts on config changes.") flag.Parse() if h || help { diff --git a/cmd/helmify/flags_test.go b/cmd/helmify/flags_test.go index fac48c75..0dc81484 100644 --- a/cmd/helmify/flags_test.go +++ b/cmd/helmify/flags_test.go @@ -180,6 +180,7 @@ func TestReadFlags_DefaultValuesMatchFlagDefaults(t *testing.T) { {"original-name", func(cfg config.Config) bool { return cfg.OriginalName }}, {"preserve-ns", func(cfg config.Config) bool { return cfg.PreserveNs }}, {"add-webhook-option", func(cfg config.Config) bool { return cfg.AddWebhookOption }}, + {"add-checksum-annotations", func(cfg config.Config) bool { return cfg.AddChecksumAnnotations }}, } for _, tt := range stringTests { diff --git a/pkg/app/context.go b/pkg/app/context.go index 613f8396..6acdffea 100644 --- a/pkg/app/context.go +++ b/pkg/app/context.go @@ -1,11 +1,21 @@ package app import ( + "bytes" + "fmt" + "io" + "strings" + "github.com/arttor/helmify/pkg/config" "github.com/arttor/helmify/pkg/helmify" "github.com/arttor/helmify/pkg/metadata" + "github.com/arttor/helmify/pkg/processor/pod" "github.com/sirupsen/logrus" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" ) // appContext helm processing context. Stores processed objects. @@ -56,6 +66,8 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { }).Info("creating a chart") var templates []helmify.Template var filenames []string + // objIndices tracks which c.objects index produced each template. + var objIndices []int for i, obj := range c.objects { template, err := c.process(obj) if err != nil { @@ -68,6 +80,7 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { filename = c.fileNames[i] } filenames = append(filenames, filename) + objIndices = append(objIndices, i) } select { case <-stop: @@ -75,6 +88,11 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { default: } } + + if c.config.AddChecksumAnnotations { + templates = c.addChecksumAnnotations(templates, filenames, objIndices) + } + return c.output.Create(c.config.ChartDir, c.config.ChartName, c.config.Crd, c.config.CertManagerAsSubchart, c.config.CertManagerVersion, c.config.CertManagerInstallCRD, templates, filenames) } @@ -103,3 +121,166 @@ func (c *appContext) process(obj *unstructured.Unstructured) (helmify.Template, _, t, err := c.defaultProcessor.Process(c.appMeta, obj) return t, err } + +var ( + configMapGVK = schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + secretGVK = schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"} +) + +// workloadGVKs lists the resource kinds whose pod templates should get checksum annotations. +var workloadGVKs = map[schema.GroupVersionKind]bool{ + {Group: "apps", Version: "v1", Kind: "Deployment"}: true, + {Group: "apps", Version: "v1", Kind: "DaemonSet"}: true, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}: true, +} + +// addChecksumAnnotations wraps workload templates to inject checksum annotations +// for referenced ConfigMaps and Secrets. It uses the actual resolved filenames +// so that the template paths are correct regardless of how input files are organized. +func (c *appContext) addChecksumAnnotations(templates []helmify.Template, filenames []string, objIndices []int) []helmify.Template { + // Build maps: object name -> actual template filename for ConfigMaps and Secrets. + configMapFiles := map[string]string{} + secretFiles := map[string]string{} + for i, tmplIdx := range objIndices { + obj := c.objects[tmplIdx] + switch obj.GroupVersionKind() { + case configMapGVK: + configMapFiles[obj.GetName()] = filenames[i] + case secretGVK: + secretFiles[obj.GetName()] = filenames[i] + } + } + + if len(configMapFiles) == 0 && len(secretFiles) == 0 { + return templates + } + + // Wrap workload templates with checksum annotations. + result := make([]helmify.Template, len(templates)) + copy(result, templates) + for i, tmplIdx := range objIndices { + obj := c.objects[tmplIdx] + if !workloadGVKs[obj.GroupVersionKind()] { + continue + } + podSpec := extractPodSpec(obj) + if podSpec == nil { + continue + } + checksumAnns := pod.ChecksumAnnotations(c.appMeta, *podSpec, configMapFiles, secretFiles) + if checksumAnns != "" { + result[i] = &checksumTemplate{ + wrapped: templates[i], + annotations: checksumAnns, + } + } + } + + return result +} + +// extractPodSpec extracts the PodSpec from a workload object. +func extractPodSpec(obj *unstructured.Unstructured) *corev1.PodSpec { + switch obj.GroupVersionKind() { + case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}: + var d appsv1.Deployment + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { + return nil + } + return &d.Spec.Template.Spec + case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DaemonSet"}: + var d appsv1.DaemonSet + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { + return nil + } + return &d.Spec.Template.Spec + case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "StatefulSet"}: + var s appsv1.StatefulSet + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &s); err != nil { + return nil + } + return &s.Spec.Template.Spec + } + return nil +} + +// checksumTemplate wraps a Template to inject checksum annotations into its output. +type checksumTemplate struct { + wrapped helmify.Template + annotations string +} + +func (t *checksumTemplate) Filename() string { + return t.wrapped.Filename() +} + +func (t *checksumTemplate) Values() helmify.Values { + return t.wrapped.Values() +} + +func (t *checksumTemplate) Write(writer io.Writer) error { + var buf bytes.Buffer + if err := t.wrapped.Write(&buf); err != nil { + return err + } + output := buf.String() + output = injectAnnotations(output, t.annotations) + _, err := fmt.Fprint(writer, output) + return err +} + +// injectAnnotations injects checksum annotations into the pod template metadata +// section of a workload YAML. It looks for the `template:\n metadata:` pattern +// and adds an `annotations:` block (or appends to an existing one). +func injectAnnotations(yaml string, annotations string) string { + lines := strings.Split(yaml, "\n") + var result []string + injected := false + + for i := 0; i < len(lines); i++ { + result = append(result, lines[i]) + + if injected { + continue + } + + // Look for " template:" (the pod template, not other uses of "template") + trimmed := strings.TrimRight(lines[i], " ") + if trimmed != " template:" && trimmed != " template:" { + continue + } + templateIndent := strings.Repeat(" ", len(lines[i])-len(strings.TrimLeft(lines[i], " "))) + + // Find the metadata: line within the next few lines + for j := i + 1; j < len(lines) && j <= i+2; j++ { + if strings.TrimSpace(lines[j]) != "metadata:" { + continue + } + metadataIndent := templateIndent + " " + annIndent := metadataIndent + " " + + result = append(result, lines[j]) + i = j + + // Check if there's already an annotations: block right after metadata: + if j+1 < len(lines) && strings.TrimSpace(lines[j+1]) == "annotations:" { + result = append(result, lines[j+1]) + i = j + 1 + // Insert our annotations after the existing annotations: key + for _, ann := range strings.Split(annotations, "\n") { + result = append(result, annIndent+" "+ann) + } + } else { + // Add new annotations block + result = append(result, annIndent+"annotations:") + for _, ann := range strings.Split(annotations, "\n") { + result = append(result, annIndent+" "+ann) + } + } + injected = true + break + } + } + + return strings.Join(result, "\n") +} diff --git a/pkg/app/context_test.go b/pkg/app/context_test.go new file mode 100644 index 00000000..e743c23e --- /dev/null +++ b/pkg/app/context_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_injectAnnotations(t *testing.T) { + t.Run("injects into deployment template metadata", func(t *testing.T) { + yaml := `apiVersion: apps/v1 +kind: Deployment +spec: + template: + metadata: + labels: + app: test + spec: + containers: + - name: app` + + annotations := `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}` + result := injectAnnotations(yaml, annotations) + assert.Contains(t, result, " annotations:") + assert.Contains(t, result, " checksum/configmap/config:") + }) + + t.Run("appends to existing annotations block", func(t *testing.T) { + yaml := `apiVersion: apps/v1 +kind: Deployment +spec: + template: + metadata: + annotations: + existing: value + labels: + app: test + spec: + containers: + - name: app` + + annotations := `checksum/configmap/config: {{ sha256sum }}` + result := injectAnnotations(yaml, annotations) + assert.Contains(t, result, " existing: value") + assert.Contains(t, result, " checksum/configmap/config:") + }) + + t.Run("injects multiple annotations", func(t *testing.T) { + yaml := `apiVersion: apps/v1 +kind: Deployment +spec: + template: + metadata: + labels: + app: test + spec: + containers: + - name: app` + + annotations := "checksum/configmap/config: hash1\nchecksum/secret/db: hash2" + result := injectAnnotations(yaml, annotations) + assert.Contains(t, result, " checksum/configmap/config: hash1") + assert.Contains(t, result, " checksum/secret/db: hash2") + }) + + t.Run("no injection when no template metadata", func(t *testing.T) { + yaml := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test` + + result := injectAnnotations(yaml, "checksum/x: hash") + assert.NotContains(t, result, "annotations:") + }) + + t.Run("handles deeper nesting for cronjob-style templates", func(t *testing.T) { + // StatefulSet and DaemonSet use " template:" at 2 spaces indent + yaml := `spec: + template: + metadata: + labels: + app: test + spec: + containers: []` + + result := injectAnnotations(yaml, "checksum/configmap/config: hash") + assert.Contains(t, result, " annotations:") + assert.Contains(t, result, " checksum/configmap/config: hash") + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4c2d6d10..59c6e2b1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,6 +45,9 @@ type Config struct { AddWebhookOption bool // OptionalCRDs - Enable optional CRD installation through values. OptionalCRDs bool + // AddChecksumAnnotations - Add checksum annotations for ConfigMaps and Secrets referenced by workloads. + // This triggers rolling restarts when referenced ConfigMap/Secret content changes. + AddChecksumAnnotations bool } func (c *Config) Validate() error { diff --git a/pkg/helmify/model.go b/pkg/helmify/model.go index c7f7d404..32f79ddd 100644 --- a/pkg/helmify/model.go +++ b/pkg/helmify/model.go @@ -49,4 +49,9 @@ type AppMetadata interface { TrimName(objName string) string Config() config.Config + + // HasConfigMap returns true if a ConfigMap with the given name is part of the chart. + HasConfigMap(name string) bool + // HasSecret returns true if a Secret with the given name is part of the chart. + HasSecret(name string) bool } diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 6039d951..3bc667e8 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -25,15 +25,34 @@ var crdGVK = schema.GroupVersionKind{ Kind: "CustomResourceDefinition", } +var configMapGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", +} + +var secretGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", +} + func New(conf config.Config) *Service { - return &Service{names: make(map[string]struct{}), conf: conf} + return &Service{ + names: make(map[string]struct{}), + configMapNames: make(map[string]struct{}), + secretNames: make(map[string]struct{}), + conf: conf, + } } type Service struct { - commonPrefix string - namespace string - names map[string]struct{} - conf config.Config + commonPrefix string + namespace string + names map[string]struct{} + configMapNames map[string]struct{} + secretNames map[string]struct{} + conf config.Config } func (a *Service) Config() config.Config { @@ -58,6 +77,12 @@ var _ helmify.AppMetadata = &Service{} // other app meta information. func (a *Service) Load(obj *unstructured.Unstructured) { a.names[obj.GetName()] = struct{}{} + switch obj.GroupVersionKind() { + case configMapGVK: + a.configMapNames[obj.GetName()] = struct{}{} + case secretGVK: + a.secretNames[obj.GetName()] = struct{}{} + } a.commonPrefix = detectCommonPrefix(obj, a.commonPrefix) objNs := extractAppNamespace(obj) if objNs == "" { @@ -94,6 +119,24 @@ func (a *Service) TemplatedName(name string) string { return fmt.Sprintf(nameTeml, a.conf.ChartName, name) } +// HasConfigMap returns true if a ConfigMap with the given name is part of the chart. +func (a *Service) HasConfigMap(name string) bool { + if a.configMapNames == nil { + return false + } + _, ok := a.configMapNames[name] + return ok +} + +// HasSecret returns true if a Secret with the given name is part of the chart. +func (a *Service) HasSecret(name string) bool { + if a.secretNames == nil { + return false + } + _, ok := a.secretNames[name] + return ok +} + func (a *Service) TemplatedString(str string) string { name := a.TrimName(str) return fmt.Sprintf(nameTeml, a.conf.ChartName, name) diff --git a/pkg/metadata/metadata_test.go b/pkg/metadata/metadata_test.go index 3496582d..62b24744 100644 --- a/pkg/metadata/metadata_test.go +++ b/pkg/metadata/metadata_test.go @@ -117,6 +117,32 @@ func Test_Service(t *testing.T) { }) } +func Test_HasConfigMapAndSecret(t *testing.T) { + t.Run("tracks configmaps", func(t *testing.T) { + svc := New(config.Config{}) + svc.Load(internal.GenerateObj(`apiVersion: v1 +kind: ConfigMap +metadata: + name: my-config + namespace: ns`)) + assert.True(t, svc.HasConfigMap("my-config")) + assert.False(t, svc.HasConfigMap("other-config")) + assert.False(t, svc.HasSecret("my-config")) + }) + t.Run("tracks secrets", func(t *testing.T) { + svc := New(config.Config{}) + svc.Load(createRes("my-secret", "ns")) + assert.True(t, svc.HasSecret("my-secret")) + assert.False(t, svc.HasSecret("other-secret")) + assert.False(t, svc.HasConfigMap("my-secret")) + }) + t.Run("nil maps safe", func(t *testing.T) { + svc := &Service{} + assert.False(t, svc.HasConfigMap("anything")) + assert.False(t, svc.HasSecret("anything")) + }) +} + func createRes(name, ns string) *unstructured.Unstructured { objYaml := fmt.Sprintf(res, name, ns) return internal.GenerateObj(objYaml) diff --git a/pkg/processor/daemonset/daemonset_test.go b/pkg/processor/daemonset/daemonset_test.go index 28abe1f3..bb6f30b1 100644 --- a/pkg/processor/daemonset/daemonset_test.go +++ b/pkg/processor/daemonset/daemonset_test.go @@ -74,3 +74,4 @@ func Test_daemonset_Process(t *testing.T) { assert.Equal(t, false, processed) }) } + diff --git a/pkg/processor/pod/checksum.go b/pkg/processor/pod/checksum.go new file mode 100644 index 00000000..9aa61790 --- /dev/null +++ b/pkg/processor/pod/checksum.go @@ -0,0 +1,91 @@ +package pod + +import ( + "fmt" + "sort" + "strings" + + "github.com/arttor/helmify/pkg/helmify" + corev1 "k8s.io/api/core/v1" +) + +// ChecksumAnnotations scans a PodSpec for references to ConfigMaps and Secrets +// that are part of the chart, and returns checksum annotation lines to be added +// to the pod template metadata. This ensures pods are restarted when referenced +// ConfigMaps or Secrets change. +// +// configMapFiles and secretFiles map original object names to their actual +// template filenames on disk (e.g. "my-app-config" -> "input.yaml"). +func ChecksumAnnotations(appMeta helmify.AppMetadata, spec corev1.PodSpec, configMapFiles, secretFiles map[string]string) string { + configMaps := map[string]struct{}{} + secrets := map[string]struct{}{} + + collectFromContainers := func(containers []corev1.Container) { + for _, c := range containers { + for _, e := range c.EnvFrom { + if e.ConfigMapRef != nil && appMeta.HasConfigMap(e.ConfigMapRef.Name) { + configMaps[e.ConfigMapRef.Name] = struct{}{} + } + if e.SecretRef != nil && appMeta.HasSecret(e.SecretRef.Name) { + secrets[e.SecretRef.Name] = struct{}{} + } + } + for _, e := range c.Env { + if e.ValueFrom == nil { + continue + } + if e.ValueFrom.ConfigMapKeyRef != nil && appMeta.HasConfigMap(e.ValueFrom.ConfigMapKeyRef.Name) { + configMaps[e.ValueFrom.ConfigMapKeyRef.Name] = struct{}{} + } + if e.ValueFrom.SecretKeyRef != nil && appMeta.HasSecret(e.ValueFrom.SecretKeyRef.Name) { + secrets[e.ValueFrom.SecretKeyRef.Name] = struct{}{} + } + } + } + } + + collectFromContainers(spec.Containers) + collectFromContainers(spec.InitContainers) + + for _, v := range spec.Volumes { + if v.ConfigMap != nil && appMeta.HasConfigMap(v.ConfigMap.Name) { + configMaps[v.ConfigMap.Name] = struct{}{} + } + if v.Secret != nil && appMeta.HasSecret(v.Secret.SecretName) { + secrets[v.Secret.SecretName] = struct{}{} + } + if v.Projected != nil { + for _, src := range v.Projected.Sources { + if src.ConfigMap != nil && appMeta.HasConfigMap(src.ConfigMap.Name) { + configMaps[src.ConfigMap.Name] = struct{}{} + } + if src.Secret != nil && appMeta.HasSecret(src.Secret.Name) { + secrets[src.Secret.Name] = struct{}{} + } + } + } + } + + if len(configMaps) == 0 && len(secrets) == 0 { + return "" + } + + var annotations []string + for name := range configMaps { + trimmed := appMeta.TrimName(name) + filename := configMapFiles[name] + annotations = append(annotations, checksumAnnotation("configmap", trimmed, filename)) + } + for name := range secrets { + trimmed := appMeta.TrimName(name) + filename := secretFiles[name] + annotations = append(annotations, checksumAnnotation("secret", trimmed, filename)) + } + sort.Strings(annotations) + + return strings.Join(annotations, "\n") +} + +func checksumAnnotation(kind, trimmedName, filename string) string { + return fmt.Sprintf(`checksum/%s/%s: {{ include (print $.Template.BasePath "/%s") . | sha256sum }}`, kind, trimmedName, filename) +} diff --git a/pkg/processor/pod/checksum_test.go b/pkg/processor/pod/checksum_test.go new file mode 100644 index 00000000..f5f9ebb5 --- /dev/null +++ b/pkg/processor/pod/checksum_test.go @@ -0,0 +1,405 @@ +package pod + +import ( + "fmt" + "strings" + "testing" + + "github.com/arttor/helmify/internal" + "github.com/arttor/helmify/pkg/config" + "github.com/arttor/helmify/pkg/metadata" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +const checksumConfigMapYaml = `apiVersion: v1 +kind: ConfigMap +metadata: + name: %s + namespace: my-app-system` + +const checksumSecretYaml = `apiVersion: v1 +kind: Secret +metadata: + name: %s + namespace: my-app-system` + +const checksumDeployYaml = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app-deploy + namespace: my-app-system` + +func TestChecksumAnnotations(t *testing.T) { + t.Run("no references", func(t *testing.T) { + meta := metadata.New(config.Config{}) + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + } + result := ChecksumAnnotations(meta, spec, nil, nil) + assert.Equal(t, "", result) + }) + + t.Run("configmap via envFrom", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Contains(t, result, "checksum/configmap/config:") + assert.Contains(t, result, `{{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`) + }) + + t.Run("secret via envFrom", func(t *testing.T) { + meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, nil, secFiles) + assert.Contains(t, result, "checksum/secret/secret:") + assert.Contains(t, result, `{{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}`) + }) + + t.Run("configmap via env valueFrom", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + Env: []corev1.EnvVar{ + { + Name: "MY_VAR", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + Key: "key1", + }, + }, + }, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Contains(t, result, "checksum/configmap/config:") + }) + + t.Run("secret via env valueFrom", func(t *testing.T) { + meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + Env: []corev1.EnvVar{ + { + Name: "MY_SECRET", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, + Key: "password", + }, + }, + }, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, nil, secFiles) + assert.Contains(t, result, "checksum/secret/secret:") + }) + + t.Run("configmap via volume", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Contains(t, result, "checksum/configmap/config:") + }) + + t.Run("secret via volume", func(t *testing.T) { + meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + Volumes: []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "my-app-secret", + }, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, nil, secFiles) + assert.Contains(t, result, "checksum/secret/secret:") + }) + + t.Run("projected volume with configmap and secret", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, []string{"my-app-secret"}) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + Volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + {ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + {Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, + }}, + }, + }, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, secFiles) + assert.Contains(t, result, "checksum/configmap/config:") + assert.Contains(t, result, "checksum/secret/secret:") + }) + + t.Run("external configmap skipped", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "external-config"}, + }}, + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Contains(t, result, "checksum/configmap/config:") + assert.NotContains(t, result, "external-config") + }) + + t.Run("initContainers references", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + InitContainers: []corev1.Container{ + { + Name: "init", + Image: "busybox:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Contains(t, result, "checksum/configmap/config:") + }) + + t.Run("multiple configmaps and secrets sorted", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config-a", "my-app-config-b"}, []string{"my-app-secret-x"}) + cmFiles := map[string]string{ + "my-app-config-a": "config-a.yaml", + "my-app-config-b": "config-b.yaml", + } + secFiles := map[string]string{"my-app-secret-x": "secret-x.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config-b"}, + }}, + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config-a"}, + }}, + {SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret-x"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, secFiles) + assert.Contains(t, result, "checksum/configmap/config-a:") + assert.Contains(t, result, "checksum/configmap/config-b:") + assert.Contains(t, result, "checksum/secret/secret-x:") + }) + + t.Run("nil metadata maps safe", func(t *testing.T) { + meta := &metadata.Service{} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "some-config"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, nil, nil) + assert.Equal(t, "", result) + }) + + t.Run("deduplicates same configmap from multiple sources", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Equal(t, `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`, result) + }) + + t.Run("no collision when configmap and secret have same trimmed name", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-credentials"}, []string{"my-app-credentials"}) + cmFiles := map[string]string{"my-app-credentials": "credentials.yaml"} + secFiles := map[string]string{"my-app-credentials": "credentials.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-credentials"}, + }}, + {SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-credentials"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, secFiles) + assert.Contains(t, result, "checksum/configmap/credentials:") + assert.Contains(t, result, "checksum/secret/credentials:") + assert.Equal(t, 2, len(strings.Split(result, "\n"))) + }) + + t.Run("uses actual filename not trimmed name for path", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + // Simulate all resources in a single input file + cmFiles := map[string]string{"my-app-config": "input.yaml"} + spec := corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "nginx:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + }, + }, + }, + } + result := ChecksumAnnotations(meta, spec, cmFiles, nil) + assert.Contains(t, result, `/input.yaml")`) + assert.NotContains(t, result, `/config.yaml")`) + }) +} + +// setupChecksumMeta creates a metadata.Service with the given configmaps and secrets loaded, +// plus a deployment to establish a common prefix of "my-app-". +func setupChecksumMeta(t *testing.T, configMaps, secrets []string) *metadata.Service { + t.Helper() + meta := metadata.New(config.Config{ChartName: "my-app"}) + // Load a deployment to establish common prefix "my-app-" + meta.Load(internal.GenerateObj(checksumDeployYaml)) + for _, name := range configMaps { + meta.Load(internal.GenerateObj(fmt.Sprintf(checksumConfigMapYaml, name))) + } + for _, name := range secrets { + meta.Load(internal.GenerateObj(fmt.Sprintf(checksumSecretYaml, name))) + } + return meta +} diff --git a/pkg/processor/statefulset/statefulset_test.go b/pkg/processor/statefulset/statefulset_test.go new file mode 100644 index 00000000..932272a1 --- /dev/null +++ b/pkg/processor/statefulset/statefulset_test.go @@ -0,0 +1,56 @@ +package statefulset + +import ( + "testing" + + "github.com/arttor/helmify/internal" + "github.com/arttor/helmify/pkg/metadata" + "github.com/stretchr/testify/assert" +) + +const strStatefulSet = `apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: my-app-web + namespace: my-app-system +spec: + serviceName: my-app-web + replicas: 3 + selector: + matchLabels: + app: my-app-web + template: + metadata: + labels: + app: my-app-web + spec: + containers: + - name: web + image: my-app/web:v1.0.0 + envFrom: + - configMapRef: + name: my-app-web-config + env: + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: my-app-web-secret + key: secret-key +` + +func Test_statefulset_Process(t *testing.T) { + var testInstance statefulset + + t.Run("processed", func(t *testing.T) { + obj := internal.GenerateObj(strStatefulSet) + processed, _, err := testInstance.Process(&metadata.Service{}, obj) + assert.NoError(t, err) + assert.Equal(t, true, processed) + }) + t.Run("skipped", func(t *testing.T) { + obj := internal.TestNs + processed, _, err := testInstance.Process(&metadata.Service{}, obj) + assert.NoError(t, err) + assert.Equal(t, false, processed) + }) +} From 7d7e8ddbead1e398aa4729e8639be7bbc8eb7626 Mon Sep 17 00:00:00 2001 From: Robert Aistleitner Date: Mon, 6 Apr 2026 19:10:43 +0000 Subject: [PATCH 2/3] refactor checksum annotations: simplify and harden injection logic Move PodSpec extraction and GVK constants into checksum.go, eliminating duplicate definitions from context.go. Replace three parallel slices with a processedTemplate struct. Fix spec: parent lookback scanning past intervening lines (selector, replicas, etc.) by searching until a lower-indent line is found instead of a fixed 3-line window. --- pkg/app/context.go | 191 +++++++-------- pkg/app/context_test.go | 100 ++++++-- pkg/metadata/metadata.go | 10 +- pkg/processor/pod/checksum.go | 97 ++++++-- pkg/processor/pod/checksum_test.go | 372 ++++++++--------------------- 5 files changed, 343 insertions(+), 427 deletions(-) diff --git a/pkg/app/context.go b/pkg/app/context.go index 6acdffea..058d1528 100644 --- a/pkg/app/context.go +++ b/pkg/app/context.go @@ -11,11 +11,7 @@ import ( "github.com/arttor/helmify/pkg/metadata" "github.com/arttor/helmify/pkg/processor/pod" "github.com/sirupsen/logrus" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" ) // appContext helm processing context. Stores processed objects. @@ -58,29 +54,36 @@ func (c *appContext) Add(obj *unstructured.Unstructured, filename string) { c.fileNames = append(c.fileNames, filename) } +// processedTemplate pairs a processed template with the original object index. +type processedTemplate struct { + template helmify.Template + filename string + objIndex int +} + // CreateHelm creates helm chart from context k8s objects. func (c *appContext) CreateHelm(stop <-chan struct{}) error { logrus.WithFields(logrus.Fields{ "ChartName": c.appMeta.ChartName(), "Namespace": c.appMeta.Namespace(), }).Info("creating a chart") - var templates []helmify.Template - var filenames []string - // objIndices tracks which c.objects index produced each template. - var objIndices []int + + var processed []processedTemplate for i, obj := range c.objects { template, err := c.process(obj) if err != nil { return err } if template != nil { - templates = append(templates, template) filename := template.Filename() if c.fileNames[i] != "" { filename = c.fileNames[i] } - filenames = append(filenames, filename) - objIndices = append(objIndices, i) + processed = append(processed, processedTemplate{ + template: template, + filename: filename, + objIndex: i, + }) } select { case <-stop: @@ -90,7 +93,14 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { } if c.config.AddChecksumAnnotations { - templates = c.addChecksumAnnotations(templates, filenames, objIndices) + c.addChecksumAnnotations(processed) + } + + templates := make([]helmify.Template, len(processed)) + filenames := make([]string, len(processed)) + for i, p := range processed { + templates[i] = p.template + filenames[i] = p.filename } return c.output.Create(c.config.ChartDir, c.config.ChartName, c.config.Crd, c.config.CertManagerAsSubchart, c.config.CertManagerVersion, c.config.CertManagerInstallCRD, templates, filenames) @@ -122,86 +132,38 @@ func (c *appContext) process(obj *unstructured.Unstructured) (helmify.Template, return t, err } -var ( - configMapGVK = schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} - secretGVK = schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"} -) - -// workloadGVKs lists the resource kinds whose pod templates should get checksum annotations. -var workloadGVKs = map[schema.GroupVersionKind]bool{ - {Group: "apps", Version: "v1", Kind: "Deployment"}: true, - {Group: "apps", Version: "v1", Kind: "DaemonSet"}: true, - {Group: "apps", Version: "v1", Kind: "StatefulSet"}: true, -} - // addChecksumAnnotations wraps workload templates to inject checksum annotations // for referenced ConfigMaps and Secrets. It uses the actual resolved filenames // so that the template paths are correct regardless of how input files are organized. -func (c *appContext) addChecksumAnnotations(templates []helmify.Template, filenames []string, objIndices []int) []helmify.Template { +func (c *appContext) addChecksumAnnotations(processed []processedTemplate) { // Build maps: object name -> actual template filename for ConfigMaps and Secrets. configMapFiles := map[string]string{} secretFiles := map[string]string{} - for i, tmplIdx := range objIndices { - obj := c.objects[tmplIdx] + for _, p := range processed { + obj := c.objects[p.objIndex] switch obj.GroupVersionKind() { - case configMapGVK: - configMapFiles[obj.GetName()] = filenames[i] - case secretGVK: - secretFiles[obj.GetName()] = filenames[i] + case metadata.ConfigMapGVK: + configMapFiles[obj.GetName()] = p.filename + case metadata.SecretGVK: + secretFiles[obj.GetName()] = p.filename } } if len(configMapFiles) == 0 && len(secretFiles) == 0 { - return templates + return } // Wrap workload templates with checksum annotations. - result := make([]helmify.Template, len(templates)) - copy(result, templates) - for i, tmplIdx := range objIndices { - obj := c.objects[tmplIdx] - if !workloadGVKs[obj.GroupVersionKind()] { - continue - } - podSpec := extractPodSpec(obj) - if podSpec == nil { - continue - } - checksumAnns := pod.ChecksumAnnotations(c.appMeta, *podSpec, configMapFiles, secretFiles) + for i, p := range processed { + obj := c.objects[p.objIndex] + checksumAnns := pod.ChecksumAnnotations(c.appMeta, obj, configMapFiles, secretFiles) if checksumAnns != "" { - result[i] = &checksumTemplate{ - wrapped: templates[i], + processed[i].template = &checksumTemplate{ + wrapped: p.template, annotations: checksumAnns, } } } - - return result -} - -// extractPodSpec extracts the PodSpec from a workload object. -func extractPodSpec(obj *unstructured.Unstructured) *corev1.PodSpec { - switch obj.GroupVersionKind() { - case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}: - var d appsv1.Deployment - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { - return nil - } - return &d.Spec.Template.Spec - case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DaemonSet"}: - var d appsv1.DaemonSet - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { - return nil - } - return &d.Spec.Template.Spec - case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "StatefulSet"}: - var s appsv1.StatefulSet - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &s); err != nil { - return nil - } - return &s.Spec.Template.Spec - } - return nil } // checksumTemplate wraps a Template to inject checksum annotations into its output. @@ -223,15 +185,15 @@ func (t *checksumTemplate) Write(writer io.Writer) error { if err := t.wrapped.Write(&buf); err != nil { return err } - output := buf.String() - output = injectAnnotations(output, t.annotations) + output := injectAnnotations(buf.String(), t.annotations) _, err := fmt.Fprint(writer, output) return err } // injectAnnotations injects checksum annotations into the pod template metadata -// section of a workload YAML. It looks for the `template:\n metadata:` pattern -// and adds an `annotations:` block (or appends to an existing one). +// section of a workload YAML. It looks for the "spec:" → "template:" → "metadata:" +// pattern (2-space indent, which is what helmify always produces) and inserts or +// appends to an annotations block. func injectAnnotations(yaml string, annotations string) string { lines := strings.Split(yaml, "\n") var result []string @@ -244,42 +206,59 @@ func injectAnnotations(yaml string, annotations string) string { continue } - // Look for " template:" (the pod template, not other uses of "template") - trimmed := strings.TrimRight(lines[i], " ") - if trimmed != " template:" && trimmed != " template:" { + if strings.TrimSpace(lines[i]) != "template:" { + continue + } + indent := len(lines[i]) - len(strings.TrimLeft(lines[i], " ")) + if indent < 2 { continue } - templateIndent := strings.Repeat(" ", len(lines[i])-len(strings.TrimLeft(lines[i], " "))) - // Find the metadata: line within the next few lines - for j := i + 1; j < len(lines) && j <= i+2; j++ { - if strings.TrimSpace(lines[j]) != "metadata:" { + // Verify parent "spec:" at indent-2 by scanning back until we find + // a line at a lower or equal indent level (the parent block). + hasSpec := false + for k := i - 1; k >= 0; k-- { + kIndent := len(lines[k]) - len(strings.TrimLeft(lines[k], " ")) + trimmed := strings.TrimSpace(lines[k]) + if trimmed == "" { continue } - metadataIndent := templateIndent + " " - annIndent := metadataIndent + " " - - result = append(result, lines[j]) - i = j - - // Check if there's already an annotations: block right after metadata: - if j+1 < len(lines) && strings.TrimSpace(lines[j+1]) == "annotations:" { - result = append(result, lines[j+1]) - i = j + 1 - // Insert our annotations after the existing annotations: key - for _, ann := range strings.Split(annotations, "\n") { - result = append(result, annIndent+" "+ann) - } - } else { - // Add new annotations block - result = append(result, annIndent+"annotations:") - for _, ann := range strings.Split(annotations, "\n") { - result = append(result, annIndent+" "+ann) - } + if kIndent < indent { + hasSpec = trimmed == "spec:" && kIndent == indent-2 + break } - injected = true - break } + if !hasSpec { + continue + } + + // Expect "metadata:" at indent+2. + if i+1 >= len(lines) { + continue + } + nextIndent := len(lines[i+1]) - len(strings.TrimLeft(lines[i+1], " ")) + if strings.TrimSpace(lines[i+1]) != "metadata:" || nextIndent != indent+2 { + continue + } + + // Found pod template metadata — inject annotations. + result = append(result, lines[i+1]) // metadata: line + i = i + 1 + + annKeyIndent := strings.Repeat(" ", indent+4) + annValueIndent := strings.Repeat(" ", indent+6) + + if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "annotations:" { + result = append(result, lines[i+1]) + i = i + 1 + } else { + result = append(result, annKeyIndent+"annotations:") + } + for _, ann := range strings.Split(annotations, "\n") { + result = append(result, annValueIndent+ann) + } + + injected = true } return strings.Join(result, "\n") diff --git a/pkg/app/context_test.go b/pkg/app/context_test.go index e743c23e..00aa87d4 100644 --- a/pkg/app/context_test.go +++ b/pkg/app/context_test.go @@ -1,6 +1,7 @@ package app import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -19,8 +20,7 @@ spec: containers: - name: app` - annotations := `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}` - result := injectAnnotations(yaml, annotations) + result := injectAnnotations(yaml, `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`) assert.Contains(t, result, " annotations:") assert.Contains(t, result, " checksum/configmap/config:") }) @@ -34,31 +34,21 @@ spec: annotations: existing: value labels: - app: test - spec: - containers: - - name: app` + app: test` - annotations := `checksum/configmap/config: {{ sha256sum }}` - result := injectAnnotations(yaml, annotations) + result := injectAnnotations(yaml, "checksum/configmap/config: hash") assert.Contains(t, result, " existing: value") - assert.Contains(t, result, " checksum/configmap/config:") + assert.Contains(t, result, " checksum/configmap/config: hash") }) t.Run("injects multiple annotations", func(t *testing.T) { - yaml := `apiVersion: apps/v1 -kind: Deployment -spec: + yaml := `spec: template: metadata: labels: - app: test - spec: - containers: - - name: app` + app: test` - annotations := "checksum/configmap/config: hash1\nchecksum/secret/db: hash2" - result := injectAnnotations(yaml, annotations) + result := injectAnnotations(yaml, "checksum/configmap/config: hash1\nchecksum/secret/db: hash2") assert.Contains(t, result, " checksum/configmap/config: hash1") assert.Contains(t, result, " checksum/secret/db: hash2") }) @@ -73,18 +63,80 @@ metadata: assert.NotContains(t, result, "annotations:") }) - t.Run("handles deeper nesting for cronjob-style templates", func(t *testing.T) { - // StatefulSet and DaemonSet use " template:" at 2 spaces indent + t.Run("cronjob-style deeper nesting", func(t *testing.T) { yaml := `spec: + jobTemplate: + spec: + template: + metadata: + labels: + app: test` + + result := injectAnnotations(yaml, "checksum/configmap/config: hash") + assert.Contains(t, result, " annotations:") + assert.Contains(t, result, " checksum/configmap/config: hash") + }) + + t.Run("does not inject into template: without parent spec:", func(t *testing.T) { + yaml := `apiVersion: v1 +kind: SomeResource +template: + metadata: + name: test` + + result := injectAnnotations(yaml, "checksum/x: hash") + assert.NotContains(t, result, "annotations:") + }) + + t.Run("does not inject into unrelated template: under data:", func(t *testing.T) { + yaml := `apiVersion: v1 +kind: ConfigMap +data: + template: + metadata: + something: else` + + result := injectAnnotations(yaml, "checksum/x: hash") + assert.NotContains(t, result, "annotations:") + }) + + t.Run("preserves all original lines", func(t *testing.T) { + yaml := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deploy +spec: template: metadata: labels: app: test spec: - containers: []` + containers: + - name: app + image: nginx:latest` - result := injectAnnotations(yaml, "checksum/configmap/config: hash") - assert.Contains(t, result, " annotations:") - assert.Contains(t, result, " checksum/configmap/config: hash") + result := injectAnnotations(yaml, "checksum/x: hash") + assert.Contains(t, result, "kind: Deployment") + assert.Contains(t, result, " name: my-deploy") + assert.Contains(t, result, " app: test") + assert.Contains(t, result, " image: nginx:latest") + assert.Contains(t, result, " checksum/x: hash") + }) + + t.Run("only injects once", func(t *testing.T) { + yaml := `spec: + template: + metadata: + labels: + app: first +--- +spec: + template: + metadata: + labels: + app: second` + + result := injectAnnotations(yaml, "checksum/x: hash") + assert.Equal(t, 1, strings.Count(result, "annotations:")) }) } diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 3bc667e8..0e2a2231 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -25,13 +25,15 @@ var crdGVK = schema.GroupVersionKind{ Kind: "CustomResourceDefinition", } -var configMapGVK = schema.GroupVersionKind{ +// ConfigMapGVK is the GroupVersionKind for core/v1 ConfigMap. +var ConfigMapGVK = schema.GroupVersionKind{ Group: "", Version: "v1", Kind: "ConfigMap", } -var secretGVK = schema.GroupVersionKind{ +// SecretGVK is the GroupVersionKind for core/v1 Secret. +var SecretGVK = schema.GroupVersionKind{ Group: "", Version: "v1", Kind: "Secret", @@ -78,9 +80,9 @@ var _ helmify.AppMetadata = &Service{} func (a *Service) Load(obj *unstructured.Unstructured) { a.names[obj.GetName()] = struct{}{} switch obj.GroupVersionKind() { - case configMapGVK: + case ConfigMapGVK: a.configMapNames[obj.GetName()] = struct{}{} - case secretGVK: + case SecretGVK: a.secretNames[obj.GetName()] = struct{}{} } a.commonPrefix = detectCommonPrefix(obj, a.commonPrefix) diff --git a/pkg/processor/pod/checksum.go b/pkg/processor/pod/checksum.go index 9aa61790..127111cf 100644 --- a/pkg/processor/pod/checksum.go +++ b/pkg/processor/pod/checksum.go @@ -6,19 +6,57 @@ import ( "strings" "github.com/arttor/helmify/pkg/helmify" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" ) -// ChecksumAnnotations scans a PodSpec for references to ConfigMaps and Secrets -// that are part of the chart, and returns checksum annotation lines to be added -// to the pod template metadata. This ensures pods are restarted when referenced -// ConfigMaps or Secrets change. -// -// configMapFiles and secretFiles map original object names to their actual +// WorkloadGVKs lists the resource kinds whose pod templates can get checksum annotations. +var WorkloadGVKs = map[schema.GroupVersionKind]bool{ + {Group: "apps", Version: "v1", Kind: "Deployment"}: true, + {Group: "apps", Version: "v1", Kind: "DaemonSet"}: true, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}: true, +} + +// ChecksumAnnotations extracts the PodSpec from a workload object, scans it for +// references to chart-local ConfigMaps and Secrets, and returns checksum annotation +// lines. configMapFiles and secretFiles map original object names to their actual // template filenames on disk (e.g. "my-app-config" -> "input.yaml"). -func ChecksumAnnotations(appMeta helmify.AppMetadata, spec corev1.PodSpec, configMapFiles, secretFiles map[string]string) string { - configMaps := map[string]struct{}{} - secrets := map[string]struct{}{} +// +// Returns empty string if the object is not a supported workload or has no +// chart-local config references. +func ChecksumAnnotations(appMeta helmify.AppMetadata, obj *unstructured.Unstructured, configMapFiles, secretFiles map[string]string) string { + podSpec := extractPodSpec(obj) + if podSpec == nil { + return "" + } + + configMaps, secrets := collectConfigRefs(appMeta, *podSpec) + if len(configMaps) == 0 && len(secrets) == 0 { + return "" + } + + var annotations []string + for name := range configMaps { + trimmed := appMeta.TrimName(name) + annotations = append(annotations, checksumAnnotation("configmap", trimmed, configMapFiles[name])) + } + for name := range secrets { + trimmed := appMeta.TrimName(name) + annotations = append(annotations, checksumAnnotation("secret", trimmed, secretFiles[name])) + } + sort.Strings(annotations) + + return strings.Join(annotations, "\n") +} + +// collectConfigRefs scans a PodSpec for references to ConfigMaps and Secrets +// that are part of the chart. +func collectConfigRefs(appMeta helmify.AppMetadata, spec corev1.PodSpec) (configMaps, secrets map[string]struct{}) { + configMaps = map[string]struct{}{} + secrets = map[string]struct{}{} collectFromContainers := func(containers []corev1.Container) { for _, c := range containers { @@ -66,24 +104,33 @@ func ChecksumAnnotations(appMeta helmify.AppMetadata, spec corev1.PodSpec, confi } } - if len(configMaps) == 0 && len(secrets) == 0 { - return "" - } + return configMaps, secrets +} - var annotations []string - for name := range configMaps { - trimmed := appMeta.TrimName(name) - filename := configMapFiles[name] - annotations = append(annotations, checksumAnnotation("configmap", trimmed, filename)) - } - for name := range secrets { - trimmed := appMeta.TrimName(name) - filename := secretFiles[name] - annotations = append(annotations, checksumAnnotation("secret", trimmed, filename)) +// extractPodSpec extracts the PodSpec from a supported workload object. +// Returns nil if the object is not a supported workload type. +func extractPodSpec(obj *unstructured.Unstructured) *corev1.PodSpec { + switch obj.GroupVersionKind() { + case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}: + var d appsv1.Deployment + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { + return nil + } + return &d.Spec.Template.Spec + case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DaemonSet"}: + var d appsv1.DaemonSet + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { + return nil + } + return &d.Spec.Template.Spec + case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "StatefulSet"}: + var s appsv1.StatefulSet + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &s); err != nil { + return nil + } + return &s.Spec.Template.Spec } - sort.Strings(annotations) - - return strings.Join(annotations, "\n") + return nil } func checksumAnnotation(kind, trimmedName, filename string) string { diff --git a/pkg/processor/pod/checksum_test.go b/pkg/processor/pod/checksum_test.go index f5f9ebb5..449dcd15 100644 --- a/pkg/processor/pod/checksum_test.go +++ b/pkg/processor/pod/checksum_test.go @@ -9,7 +9,6 @@ import ( "github.com/arttor/helmify/pkg/config" "github.com/arttor/helmify/pkg/metadata" "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" ) const checksumConfigMapYaml = `apiVersion: v1 @@ -30,261 +29,160 @@ metadata: name: my-app-deploy namespace: my-app-system` +// deploymentWith builds a Deployment YAML that references the given configmaps/secrets. +func deploymentWith(envFromCMs, envFromSecrets, envCMKeyRefs, envSecretKeyRefs, volumeCMs, volumeSecrets []string) string { + var envFromParts, envParts, volumeParts []string + + for _, name := range envFromCMs { + envFromParts = append(envFromParts, fmt.Sprintf(` - configMapRef: + name: %s`, name)) + } + for _, name := range envFromSecrets { + envFromParts = append(envFromParts, fmt.Sprintf(` - secretRef: + name: %s`, name)) + } + for _, name := range envCMKeyRefs { + envParts = append(envParts, fmt.Sprintf(` - name: VAR + valueFrom: + configMapKeyRef: + name: %s + key: key1`, name)) + } + for _, name := range envSecretKeyRefs { + envParts = append(envParts, fmt.Sprintf(` - name: VAR + valueFrom: + secretKeyRef: + name: %s + key: key1`, name)) + } + for _, name := range volumeCMs { + volumeParts = append(volumeParts, fmt.Sprintf(` - name: vol + configMap: + name: %s`, name)) + } + for _, name := range volumeSecrets { + volumeParts = append(volumeParts, fmt.Sprintf(` - name: vol + secret: + secretName: %s`, name)) + } + + y := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app-deploy + namespace: my-app-system +spec: + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - name: app + image: nginx:latest` + + if len(envFromParts) > 0 { + y += "\n envFrom:\n" + strings.Join(envFromParts, "\n") + } + if len(envParts) > 0 { + y += "\n env:\n" + strings.Join(envParts, "\n") + } + if len(volumeParts) > 0 { + y += "\n volumes:\n" + strings.Join(volumeParts, "\n") + } + return y +} + func TestChecksumAnnotations(t *testing.T) { t.Run("no references", func(t *testing.T) { meta := metadata.New(config.Config{}) - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - } - result := ChecksumAnnotations(meta, spec, nil, nil) + obj := internal.GenerateObj(deploymentWith(nil, nil, nil, nil, nil, nil)) + result := ChecksumAnnotations(meta, obj, nil, nil) + assert.Equal(t, "", result) + }) + + t.Run("non-workload returns empty", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(fmt.Sprintf(checksumConfigMapYaml, "my-app-config")) + result := ChecksumAnnotations(meta, obj, map[string]string{"my-app-config": "config.yaml"}, nil) assert.Equal(t, "", result) }) t.Run("configmap via envFrom", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(deploymentWith([]string{"my-app-config"}, nil, nil, nil, nil, nil)) cmFiles := map[string]string{"my-app-config": "config.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) + result := ChecksumAnnotations(meta, obj, cmFiles, nil) assert.Contains(t, result, "checksum/configmap/config:") assert.Contains(t, result, `{{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`) }) t.Run("secret via envFrom", func(t *testing.T) { meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + obj := internal.GenerateObj(deploymentWith(nil, []string{"my-app-secret"}, nil, nil, nil, nil)) secFiles := map[string]string{"my-app-secret": "secret.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, nil, secFiles) + result := ChecksumAnnotations(meta, obj, nil, secFiles) assert.Contains(t, result, "checksum/secret/secret:") assert.Contains(t, result, `{{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}`) }) t.Run("configmap via env valueFrom", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(deploymentWith(nil, nil, []string{"my-app-config"}, nil, nil, nil)) cmFiles := map[string]string{"my-app-config": "config.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - Env: []corev1.EnvVar{ - { - Name: "MY_VAR", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - Key: "key1", - }, - }, - }, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) + result := ChecksumAnnotations(meta, obj, cmFiles, nil) assert.Contains(t, result, "checksum/configmap/config:") }) t.Run("secret via env valueFrom", func(t *testing.T) { meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + obj := internal.GenerateObj(deploymentWith(nil, nil, nil, []string{"my-app-secret"}, nil, nil)) secFiles := map[string]string{"my-app-secret": "secret.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - Env: []corev1.EnvVar{ - { - Name: "MY_SECRET", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, - Key: "password", - }, - }, - }, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, nil, secFiles) + result := ChecksumAnnotations(meta, obj, nil, secFiles) assert.Contains(t, result, "checksum/secret/secret:") }) t.Run("configmap via volume", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(deploymentWith(nil, nil, nil, nil, []string{"my-app-config"}, nil)) cmFiles := map[string]string{"my-app-config": "config.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - Volumes: []corev1.Volume{ - { - Name: "config-vol", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) + result := ChecksumAnnotations(meta, obj, cmFiles, nil) assert.Contains(t, result, "checksum/configmap/config:") }) t.Run("secret via volume", func(t *testing.T) { meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + obj := internal.GenerateObj(deploymentWith(nil, nil, nil, nil, nil, []string{"my-app-secret"})) secFiles := map[string]string{"my-app-secret": "secret.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - Volumes: []corev1.Volume{ - { - Name: "secret-vol", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: "my-app-secret", - }, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, nil, secFiles) - assert.Contains(t, result, "checksum/secret/secret:") - }) - - t.Run("projected volume with configmap and secret", func(t *testing.T) { - meta := setupChecksumMeta(t, []string{"my-app-config"}, []string{"my-app-secret"}) - cmFiles := map[string]string{"my-app-config": "config.yaml"} - secFiles := map[string]string{"my-app-secret": "secret.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - Volumes: []corev1.Volume{ - { - Name: "projected-vol", - VolumeSource: corev1.VolumeSource{ - Projected: &corev1.ProjectedVolumeSource{ - Sources: []corev1.VolumeProjection{ - {ConfigMap: &corev1.ConfigMapProjection{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }}, - {Secret: &corev1.SecretProjection{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, - }}, - }, - }, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, secFiles) - assert.Contains(t, result, "checksum/configmap/config:") + result := ChecksumAnnotations(meta, obj, nil, secFiles) assert.Contains(t, result, "checksum/secret/secret:") }) t.Run("external configmap skipped", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(deploymentWith([]string{"external-config", "my-app-config"}, nil, nil, nil, nil, nil)) cmFiles := map[string]string{"my-app-config": "config.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "external-config"}, - }}, - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) + result := ChecksumAnnotations(meta, obj, cmFiles, nil) assert.Contains(t, result, "checksum/configmap/config:") assert.NotContains(t, result, "external-config") }) - t.Run("initContainers references", func(t *testing.T) { - meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - cmFiles := map[string]string{"my-app-config": "config.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - InitContainers: []corev1.Container{ - { - Name: "init", - Image: "busybox:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) - assert.Contains(t, result, "checksum/configmap/config:") - }) - t.Run("multiple configmaps and secrets sorted", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config-a", "my-app-config-b"}, []string{"my-app-secret-x"}) + obj := internal.GenerateObj(deploymentWith( + []string{"my-app-config-b", "my-app-config-a"}, + []string{"my-app-secret-x"}, + nil, nil, nil, nil, + )) cmFiles := map[string]string{ "my-app-config-a": "config-a.yaml", "my-app-config-b": "config-b.yaml", } secFiles := map[string]string{"my-app-secret-x": "secret-x.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config-b"}, - }}, - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config-a"}, - }}, - {SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret-x"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, secFiles) + result := ChecksumAnnotations(meta, obj, cmFiles, secFiles) assert.Contains(t, result, "checksum/configmap/config-a:") assert.Contains(t, result, "checksum/configmap/config-b:") assert.Contains(t, result, "checksum/secret/secret-x:") @@ -292,74 +190,25 @@ func TestChecksumAnnotations(t *testing.T) { t.Run("nil metadata maps safe", func(t *testing.T) { meta := &metadata.Service{} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "some-config"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, nil, nil) + obj := internal.GenerateObj(deploymentWith([]string{"some-config"}, nil, nil, nil, nil, nil)) + result := ChecksumAnnotations(meta, obj, nil, nil) assert.Equal(t, "", result) }) t.Run("deduplicates same configmap from multiple sources", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(deploymentWith([]string{"my-app-config"}, nil, nil, nil, []string{"my-app-config"}, nil)) cmFiles := map[string]string{"my-app-config": "config.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }}, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: "config-vol", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) + result := ChecksumAnnotations(meta, obj, cmFiles, nil) assert.Equal(t, `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`, result) }) t.Run("no collision when configmap and secret have same trimmed name", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-credentials"}, []string{"my-app-credentials"}) + obj := internal.GenerateObj(deploymentWith([]string{"my-app-credentials"}, []string{"my-app-credentials"}, nil, nil, nil, nil)) cmFiles := map[string]string{"my-app-credentials": "credentials.yaml"} secFiles := map[string]string{"my-app-credentials": "credentials.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-credentials"}, - }}, - {SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-credentials"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, secFiles) + result := ChecksumAnnotations(meta, obj, cmFiles, secFiles) assert.Contains(t, result, "checksum/configmap/credentials:") assert.Contains(t, result, "checksum/secret/credentials:") assert.Equal(t, 2, len(strings.Split(result, "\n"))) @@ -367,22 +216,10 @@ func TestChecksumAnnotations(t *testing.T) { t.Run("uses actual filename not trimmed name for path", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + obj := internal.GenerateObj(deploymentWith([]string{"my-app-config"}, nil, nil, nil, nil, nil)) // Simulate all resources in a single input file cmFiles := map[string]string{"my-app-config": "input.yaml"} - spec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "nginx:latest", - EnvFrom: []corev1.EnvFromSource{ - {ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, - }}, - }, - }, - }, - } - result := ChecksumAnnotations(meta, spec, cmFiles, nil) + result := ChecksumAnnotations(meta, obj, cmFiles, nil) assert.Contains(t, result, `/input.yaml")`) assert.NotContains(t, result, `/config.yaml")`) }) @@ -393,7 +230,6 @@ func TestChecksumAnnotations(t *testing.T) { func setupChecksumMeta(t *testing.T, configMaps, secrets []string) *metadata.Service { t.Helper() meta := metadata.New(config.Config{ChartName: "my-app"}) - // Load a deployment to establish common prefix "my-app-" meta.Load(internal.GenerateObj(checksumDeployYaml)) for _, name := range configMaps { meta.Load(internal.GenerateObj(fmt.Sprintf(checksumConfigMapYaml, name))) From 5f7ca07fd66a3fd063acc5e486737f951dce4807 Mon Sep 17 00:00:00 2001 From: Robert Aistleitner Date: Mon, 6 Apr 2026 20:11:47 +0000 Subject: [PATCH 3/3] refactor: move checksum annotations into processors, eliminate post-processing Replace the injectAnnotations string manipulation with direct annotation generation inside each processor. ChecksumAnnotations now returns pre-formatted YAML lines that processors append to their podAnnotations string (Deployment, DaemonSet) or splice into the marshaled spec (StatefulSet). Filename maps are pre-computed in context.go and passed through AppMetadata, so processors have everything they need. Removes checksumTemplate wrapper, injectAnnotations, and extractPodSpec from context.go. --- pkg/app/context.go | 177 ++------------ pkg/app/context_test.go | 155 +++--------- pkg/helmify/model.go | 7 + pkg/metadata/metadata.go | 22 ++ pkg/processor/daemonset/daemonset.go | 24 +- pkg/processor/deployment/deployment.go | 25 +- pkg/processor/pod/checksum.go | 73 ++---- pkg/processor/pod/checksum_test.go | 293 +++++++++++++++-------- pkg/processor/statefulset/statefulset.go | 12 + 9 files changed, 348 insertions(+), 440 deletions(-) diff --git a/pkg/app/context.go b/pkg/app/context.go index 058d1528..187a2f6f 100644 --- a/pkg/app/context.go +++ b/pkg/app/context.go @@ -1,15 +1,9 @@ package app import ( - "bytes" - "fmt" - "io" - "strings" - "github.com/arttor/helmify/pkg/config" "github.com/arttor/helmify/pkg/helmify" "github.com/arttor/helmify/pkg/metadata" - "github.com/arttor/helmify/pkg/processor/pod" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -54,13 +48,6 @@ func (c *appContext) Add(obj *unstructured.Unstructured, filename string) { c.fileNames = append(c.fileNames, filename) } -// processedTemplate pairs a processed template with the original object index. -type processedTemplate struct { - template helmify.Template - filename string - objIndex int -} - // CreateHelm creates helm chart from context k8s objects. func (c *appContext) CreateHelm(stop <-chan struct{}) error { logrus.WithFields(logrus.Fields{ @@ -68,22 +55,24 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { "Namespace": c.appMeta.Namespace(), }).Info("creating a chart") - var processed []processedTemplate + if c.config.AddChecksumAnnotations { + c.precomputeConfigFileNames() + } + + var templates []helmify.Template + var filenames []string for i, obj := range c.objects { template, err := c.process(obj) if err != nil { return err } if template != nil { + templates = append(templates, template) filename := template.Filename() if c.fileNames[i] != "" { filename = c.fileNames[i] } - processed = append(processed, processedTemplate{ - template: template, - filename: filename, - objIndex: i, - }) + filenames = append(filenames, filename) } select { case <-stop: @@ -91,18 +80,6 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { default: } } - - if c.config.AddChecksumAnnotations { - c.addChecksumAnnotations(processed) - } - - templates := make([]helmify.Template, len(processed)) - filenames := make([]string, len(processed)) - for i, p := range processed { - templates[i] = p.template - filenames[i] = p.filename - } - return c.output.Create(c.config.ChartDir, c.config.ChartName, c.config.Crd, c.config.CertManagerAsSubchart, c.config.CertManagerVersion, c.config.CertManagerInstallCRD, templates, filenames) } @@ -132,134 +109,26 @@ func (c *appContext) process(obj *unstructured.Unstructured) (helmify.Template, return t, err } -// addChecksumAnnotations wraps workload templates to inject checksum annotations -// for referenced ConfigMaps and Secrets. It uses the actual resolved filenames -// so that the template paths are correct regardless of how input files are organized. -func (c *appContext) addChecksumAnnotations(processed []processedTemplate) { - // Build maps: object name -> actual template filename for ConfigMaps and Secrets. +// precomputeConfigFileNames builds the ConfigMap/Secret name → filename maps +// and stores them on appMeta so processors can generate checksum annotations. +// For objects from file input, the input filename is used. For stdin input, +// the filename follows the processor convention: trimmedName + ".yaml". +func (c *appContext) precomputeConfigFileNames() { configMapFiles := map[string]string{} secretFiles := map[string]string{} - for _, p := range processed { - obj := c.objects[p.objIndex] + for i, obj := range c.objects { + name := obj.GetName() + filename := c.fileNames[i] + if filename == "" { + filename = c.appMeta.TrimName(name) + ".yaml" + } switch obj.GroupVersionKind() { case metadata.ConfigMapGVK: - configMapFiles[obj.GetName()] = p.filename + configMapFiles[name] = filename case metadata.SecretGVK: - secretFiles[obj.GetName()] = p.filename + secretFiles[name] = filename } } - - if len(configMapFiles) == 0 && len(secretFiles) == 0 { - return - } - - // Wrap workload templates with checksum annotations. - for i, p := range processed { - obj := c.objects[p.objIndex] - checksumAnns := pod.ChecksumAnnotations(c.appMeta, obj, configMapFiles, secretFiles) - if checksumAnns != "" { - processed[i].template = &checksumTemplate{ - wrapped: p.template, - annotations: checksumAnns, - } - } - } -} - -// checksumTemplate wraps a Template to inject checksum annotations into its output. -type checksumTemplate struct { - wrapped helmify.Template - annotations string -} - -func (t *checksumTemplate) Filename() string { - return t.wrapped.Filename() -} - -func (t *checksumTemplate) Values() helmify.Values { - return t.wrapped.Values() -} - -func (t *checksumTemplate) Write(writer io.Writer) error { - var buf bytes.Buffer - if err := t.wrapped.Write(&buf); err != nil { - return err - } - output := injectAnnotations(buf.String(), t.annotations) - _, err := fmt.Fprint(writer, output) - return err -} - -// injectAnnotations injects checksum annotations into the pod template metadata -// section of a workload YAML. It looks for the "spec:" → "template:" → "metadata:" -// pattern (2-space indent, which is what helmify always produces) and inserts or -// appends to an annotations block. -func injectAnnotations(yaml string, annotations string) string { - lines := strings.Split(yaml, "\n") - var result []string - injected := false - - for i := 0; i < len(lines); i++ { - result = append(result, lines[i]) - - if injected { - continue - } - - if strings.TrimSpace(lines[i]) != "template:" { - continue - } - indent := len(lines[i]) - len(strings.TrimLeft(lines[i], " ")) - if indent < 2 { - continue - } - - // Verify parent "spec:" at indent-2 by scanning back until we find - // a line at a lower or equal indent level (the parent block). - hasSpec := false - for k := i - 1; k >= 0; k-- { - kIndent := len(lines[k]) - len(strings.TrimLeft(lines[k], " ")) - trimmed := strings.TrimSpace(lines[k]) - if trimmed == "" { - continue - } - if kIndent < indent { - hasSpec = trimmed == "spec:" && kIndent == indent-2 - break - } - } - if !hasSpec { - continue - } - - // Expect "metadata:" at indent+2. - if i+1 >= len(lines) { - continue - } - nextIndent := len(lines[i+1]) - len(strings.TrimLeft(lines[i+1], " ")) - if strings.TrimSpace(lines[i+1]) != "metadata:" || nextIndent != indent+2 { - continue - } - - // Found pod template metadata — inject annotations. - result = append(result, lines[i+1]) // metadata: line - i = i + 1 - - annKeyIndent := strings.Repeat(" ", indent+4) - annValueIndent := strings.Repeat(" ", indent+6) - - if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "annotations:" { - result = append(result, lines[i+1]) - i = i + 1 - } else { - result = append(result, annKeyIndent+"annotations:") - } - for _, ann := range strings.Split(annotations, "\n") { - result = append(result, annValueIndent+ann) - } - - injected = true - } - - return strings.Join(result, "\n") + c.appMeta.SetConfigMapFiles(configMapFiles) + c.appMeta.SetSecretFiles(secretFiles) } diff --git a/pkg/app/context_test.go b/pkg/app/context_test.go index 00aa87d4..0c985886 100644 --- a/pkg/app/context_test.go +++ b/pkg/app/context_test.go @@ -1,142 +1,61 @@ package app import ( - "strings" "testing" + "github.com/arttor/helmify/internal" + "github.com/arttor/helmify/pkg/config" "github.com/stretchr/testify/assert" ) -func Test_injectAnnotations(t *testing.T) { - t.Run("injects into deployment template metadata", func(t *testing.T) { - yaml := `apiVersion: apps/v1 -kind: Deployment -spec: - template: - metadata: - labels: - app: test - spec: - containers: - - name: app` - - result := injectAnnotations(yaml, `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`) - assert.Contains(t, result, " annotations:") - assert.Contains(t, result, " checksum/configmap/config:") - }) +func Test_precomputeConfigFileNames(t *testing.T) { + t.Run("maps configmaps and secrets with input filenames", func(t *testing.T) { + conf := config.Config{ChartName: "my-app", AddChecksumAnnotations: true} + ctx := New(conf, nil) - t.Run("appends to existing annotations block", func(t *testing.T) { - yaml := `apiVersion: apps/v1 -kind: Deployment -spec: - template: - metadata: - annotations: - existing: value - labels: - app: test` - - result := injectAnnotations(yaml, "checksum/configmap/config: hash") - assert.Contains(t, result, " existing: value") - assert.Contains(t, result, " checksum/configmap/config: hash") - }) - - t.Run("injects multiple annotations", func(t *testing.T) { - yaml := `spec: - template: - metadata: - labels: - app: test` - - result := injectAnnotations(yaml, "checksum/configmap/config: hash1\nchecksum/secret/db: hash2") - assert.Contains(t, result, " checksum/configmap/config: hash1") - assert.Contains(t, result, " checksum/secret/db: hash2") - }) - - t.Run("no injection when no template metadata", func(t *testing.T) { - yaml := `apiVersion: v1 + cmObj := internal.GenerateObj(`apiVersion: v1 kind: ConfigMap metadata: - name: test` + name: my-app-config`) + secObj := internal.GenerateObj(`apiVersion: v1 +kind: Secret +metadata: + name: my-app-secret`) + deplObj := internal.GenerateObj(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app-web`) - result := injectAnnotations(yaml, "checksum/x: hash") - assert.NotContains(t, result, "annotations:") - }) + ctx.Add(cmObj, "configmap.yaml") + ctx.Add(secObj, "secret.yaml") + ctx.Add(deplObj, "deployment.yaml") - t.Run("cronjob-style deeper nesting", func(t *testing.T) { - yaml := `spec: - jobTemplate: - spec: - template: - metadata: - labels: - app: test` + ctx.precomputeConfigFileNames() - result := injectAnnotations(yaml, "checksum/configmap/config: hash") - assert.Contains(t, result, " annotations:") - assert.Contains(t, result, " checksum/configmap/config: hash") + assert.Equal(t, "configmap.yaml", ctx.appMeta.ConfigMapFiles()["my-app-config"]) + assert.Equal(t, "secret.yaml", ctx.appMeta.SecretFiles()["my-app-secret"]) + assert.Empty(t, ctx.appMeta.ConfigMapFiles()["my-app-web"]) }) - t.Run("does not inject into template: without parent spec:", func(t *testing.T) { - yaml := `apiVersion: v1 -kind: SomeResource -template: - metadata: - name: test` + t.Run("uses trimmed name for stdin input", func(t *testing.T) { + conf := config.Config{ChartName: "my-app", AddChecksumAnnotations: true} + ctx := New(conf, nil) - result := injectAnnotations(yaml, "checksum/x: hash") - assert.NotContains(t, result, "annotations:") - }) - - t.Run("does not inject into unrelated template: under data:", func(t *testing.T) { - yaml := `apiVersion: v1 + cmObj := internal.GenerateObj(`apiVersion: v1 kind: ConfigMap -data: - template: - metadata: - something: else` - - result := injectAnnotations(yaml, "checksum/x: hash") - assert.NotContains(t, result, "annotations:") - }) - - t.Run("preserves all original lines", func(t *testing.T) { - yaml := `apiVersion: apps/v1 -kind: Deployment metadata: - name: my-deploy -spec: - template: - metadata: - labels: - app: test - spec: - containers: - - name: app - image: nginx:latest` + name: my-app-config`) + secObj := internal.GenerateObj(`apiVersion: v1 +kind: Secret +metadata: + name: my-app-secret`) - result := injectAnnotations(yaml, "checksum/x: hash") - assert.Contains(t, result, "kind: Deployment") - assert.Contains(t, result, " name: my-deploy") - assert.Contains(t, result, " app: test") - assert.Contains(t, result, " image: nginx:latest") - assert.Contains(t, result, " checksum/x: hash") - }) + ctx.Add(cmObj, "") // empty filename = stdin + ctx.Add(secObj, "") - t.Run("only injects once", func(t *testing.T) { - yaml := `spec: - template: - metadata: - labels: - app: first ---- -spec: - template: - metadata: - labels: - app: second` + ctx.precomputeConfigFileNames() - result := injectAnnotations(yaml, "checksum/x: hash") - assert.Equal(t, 1, strings.Count(result, "annotations:")) + assert.Equal(t, "config.yaml", ctx.appMeta.ConfigMapFiles()["my-app-config"]) + assert.Equal(t, "secret.yaml", ctx.appMeta.SecretFiles()["my-app-secret"]) }) } diff --git a/pkg/helmify/model.go b/pkg/helmify/model.go index 32f79ddd..4fa6c51b 100644 --- a/pkg/helmify/model.go +++ b/pkg/helmify/model.go @@ -54,4 +54,11 @@ type AppMetadata interface { HasConfigMap(name string) bool // HasSecret returns true if a Secret with the given name is part of the chart. HasSecret(name string) bool + + // ConfigMapFiles returns a map of ConfigMap object names to their template filenames. + // Only populated when AddChecksumAnnotations is enabled. + ConfigMapFiles() map[string]string + // SecretFiles returns a map of Secret object names to their template filenames. + // Only populated when AddChecksumAnnotations is enabled. + SecretFiles() map[string]string } diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 0e2a2231..14f51ec0 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -54,6 +54,8 @@ type Service struct { names map[string]struct{} configMapNames map[string]struct{} secretNames map[string]struct{} + configMapFiles map[string]string + secretFiles map[string]string conf config.Config } @@ -139,6 +141,26 @@ func (a *Service) HasSecret(name string) bool { return ok } +// SetConfigMapFiles sets the map of ConfigMap names to template filenames. +func (a *Service) SetConfigMapFiles(files map[string]string) { + a.configMapFiles = files +} + +// SetSecretFiles sets the map of Secret names to template filenames. +func (a *Service) SetSecretFiles(files map[string]string) { + a.secretFiles = files +} + +// ConfigMapFiles returns the map of ConfigMap names to template filenames. +func (a *Service) ConfigMapFiles() map[string]string { + return a.configMapFiles +} + +// SecretFiles returns the map of Secret names to template filenames. +func (a *Service) SecretFiles() map[string]string { + return a.secretFiles +} + func (a *Service) TemplatedString(str string) string { name := a.TrimName(str) return fmt.Sprintf(nameTeml, a.conf.ChartName, name) diff --git a/pkg/processor/daemonset/daemonset.go b/pkg/processor/daemonset/daemonset.go index 7caac534..783db87a 100644 --- a/pkg/processor/daemonset/daemonset.go +++ b/pkg/processor/daemonset/daemonset.go @@ -89,13 +89,27 @@ func (d daemonset) Process(appMeta helmify.AppMetadata, obj *unstructured.Unstru podLabels += fmt.Sprintf("\n {{- include \"%s.selectorLabels\" . | nindent 8 }}", appMeta.ChartName()) podAnnotations := "" + if appMeta.Config().AddChecksumAnnotations { + checksumAnns := pod.ChecksumAnnotations(appMeta, dae.Spec.Template.Spec, appMeta.ConfigMapFiles(), appMeta.SecretFiles(), 6) + if checksumAnns != "" { + podAnnotations = "\n" + checksumAnns + } + } if len(dae.Spec.Template.ObjectMeta.Annotations) != 0 { - podAnnotations, err = yamlformat.Marshal(map[string]interface{}{"annotations": dae.Spec.Template.ObjectMeta.Annotations}, 6) - if err != nil { - return true, nil, err + existingAnns, err2 := yamlformat.Marshal(map[string]interface{}{"annotations": dae.Spec.Template.ObjectMeta.Annotations}, 6) + if err2 != nil { + return true, nil, err2 + } + if podAnnotations == "" { + podAnnotations = "\n" + existingAnns + } else { + for _, line := range strings.Split(existingAnns, "\n") { + if strings.TrimSpace(line) == "annotations:" { + continue + } + podAnnotations += "\n" + line + } } - - podAnnotations = "\n" + podAnnotations } nameCamel := strcase.ToLowerCamel(name) diff --git a/pkg/processor/deployment/deployment.go b/pkg/processor/deployment/deployment.go index ad7c9234..d669ce75 100644 --- a/pkg/processor/deployment/deployment.go +++ b/pkg/processor/deployment/deployment.go @@ -115,13 +115,28 @@ func (d deployment) Process(appMeta helmify.AppMetadata, obj *unstructured.Unstr podLabels += fmt.Sprintf("\n {{- include \"%s.selectorLabels\" . | nindent 8 }}", appMeta.ChartName()) podAnnotations := "" + if appMeta.Config().AddChecksumAnnotations { + checksumAnns := pod.ChecksumAnnotations(appMeta, depl.Spec.Template.Spec, appMeta.ConfigMapFiles(), appMeta.SecretFiles(), 6) + if checksumAnns != "" { + podAnnotations = "\n" + checksumAnns + } + } if len(depl.Spec.Template.ObjectMeta.Annotations) != 0 { - podAnnotations, err = yamlformat.Marshal(map[string]interface{}{"annotations": depl.Spec.Template.ObjectMeta.Annotations}, 6) - if err != nil { - return true, nil, err + existingAnns, err2 := yamlformat.Marshal(map[string]interface{}{"annotations": depl.Spec.Template.ObjectMeta.Annotations}, 6) + if err2 != nil { + return true, nil, err2 + } + if podAnnotations == "" { + podAnnotations = "\n" + existingAnns + } else { + // Append existing annotation values under the annotations: key already created by checksums. + for _, line := range strings.Split(existingAnns, "\n") { + if strings.TrimSpace(line) == "annotations:" { + continue + } + podAnnotations += "\n" + line + } } - - podAnnotations = "\n" + podAnnotations } nameCamel := strcase.ToLowerCamel(name) diff --git a/pkg/processor/pod/checksum.go b/pkg/processor/pod/checksum.go index 127111cf..dcca0c71 100644 --- a/pkg/processor/pod/checksum.go +++ b/pkg/processor/pod/checksum.go @@ -6,50 +6,39 @@ import ( "strings" "github.com/arttor/helmify/pkg/helmify" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" ) -// WorkloadGVKs lists the resource kinds whose pod templates can get checksum annotations. -var WorkloadGVKs = map[schema.GroupVersionKind]bool{ - {Group: "apps", Version: "v1", Kind: "Deployment"}: true, - {Group: "apps", Version: "v1", Kind: "DaemonSet"}: true, - {Group: "apps", Version: "v1", Kind: "StatefulSet"}: true, -} - -// ChecksumAnnotations extracts the PodSpec from a workload object, scans it for -// references to chart-local ConfigMaps and Secrets, and returns checksum annotation -// lines. configMapFiles and secretFiles map original object names to their actual -// template filenames on disk (e.g. "my-app-config" -> "input.yaml"). +// ChecksumAnnotations scans a PodSpec for references to chart-local ConfigMaps +// and Secrets and returns a formatted annotations YAML string ready for inclusion +// in a pod template. configMapFiles and secretFiles map original object names to +// their template filenames (e.g. "my-app-config" -> "config.yaml"). // -// Returns empty string if the object is not a supported workload or has no -// chart-local config references. -func ChecksumAnnotations(appMeta helmify.AppMetadata, obj *unstructured.Unstructured, configMapFiles, secretFiles map[string]string) string { - podSpec := extractPodSpec(obj) - if podSpec == nil { - return "" - } - - configMaps, secrets := collectConfigRefs(appMeta, *podSpec) +// The indent parameter controls the base indentation of the "annotations:" key. +// Returns empty string if no chart-local config references are found. +func ChecksumAnnotations(appMeta helmify.AppMetadata, spec corev1.PodSpec, configMapFiles, secretFiles map[string]string, indent int) string { + configMaps, secrets := collectConfigRefs(appMeta, spec) if len(configMaps) == 0 && len(secrets) == 0 { return "" } - var annotations []string + var lines []string for name := range configMaps { trimmed := appMeta.TrimName(name) - annotations = append(annotations, checksumAnnotation("configmap", trimmed, configMapFiles[name])) + lines = append(lines, checksumAnnotation("configmap", trimmed, configMapFiles[name])) } for name := range secrets { trimmed := appMeta.TrimName(name) - annotations = append(annotations, checksumAnnotation("secret", trimmed, secretFiles[name])) + lines = append(lines, checksumAnnotation("secret", trimmed, secretFiles[name])) } - sort.Strings(annotations) + sort.Strings(lines) - return strings.Join(annotations, "\n") + valueIndent := strings.Repeat(" ", indent+2) + var result []string + for _, line := range lines { + result = append(result, valueIndent+line) + } + return strings.Repeat(" ", indent) + "annotations:\n" + strings.Join(result, "\n") } // collectConfigRefs scans a PodSpec for references to ConfigMaps and Secrets @@ -107,32 +96,6 @@ func collectConfigRefs(appMeta helmify.AppMetadata, spec corev1.PodSpec) (config return configMaps, secrets } -// extractPodSpec extracts the PodSpec from a supported workload object. -// Returns nil if the object is not a supported workload type. -func extractPodSpec(obj *unstructured.Unstructured) *corev1.PodSpec { - switch obj.GroupVersionKind() { - case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}: - var d appsv1.Deployment - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { - return nil - } - return &d.Spec.Template.Spec - case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DaemonSet"}: - var d appsv1.DaemonSet - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &d); err != nil { - return nil - } - return &d.Spec.Template.Spec - case schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "StatefulSet"}: - var s appsv1.StatefulSet - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &s); err != nil { - return nil - } - return &s.Spec.Template.Spec - } - return nil -} - func checksumAnnotation(kind, trimmedName, filename string) string { return fmt.Sprintf(`checksum/%s/%s: {{ include (print $.Template.BasePath "/%s") . | sha256sum }}`, kind, trimmedName, filename) } diff --git a/pkg/processor/pod/checksum_test.go b/pkg/processor/pod/checksum_test.go index 449dcd15..ecdc153a 100644 --- a/pkg/processor/pod/checksum_test.go +++ b/pkg/processor/pod/checksum_test.go @@ -9,6 +9,9 @@ import ( "github.com/arttor/helmify/pkg/config" "github.com/arttor/helmify/pkg/metadata" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" ) const checksumConfigMapYaml = `apiVersion: v1 @@ -29,160 +32,187 @@ metadata: name: my-app-deploy namespace: my-app-system` -// deploymentWith builds a Deployment YAML that references the given configmaps/secrets. -func deploymentWith(envFromCMs, envFromSecrets, envCMKeyRefs, envSecretKeyRefs, volumeCMs, volumeSecrets []string) string { - var envFromParts, envParts, volumeParts []string - +// deploymentWithSpec builds a Deployment YAML with various ConfigMap/Secret references. +func deploymentWithSpec(envFromCMs, envFromSecrets, envCMKeyRefs, envSecretKeyRefs, volumeCMs, volumeSecrets []string) corev1.PodSpec { + var envFrom []corev1.EnvFromSource for _, name := range envFromCMs { - envFromParts = append(envFromParts, fmt.Sprintf(` - configMapRef: - name: %s`, name)) + envFrom = append(envFrom, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }) } for _, name := range envFromSecrets { - envFromParts = append(envFromParts, fmt.Sprintf(` - secretRef: - name: %s`, name)) + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }) } + + var env []corev1.EnvVar for _, name := range envCMKeyRefs { - envParts = append(envParts, fmt.Sprintf(` - name: VAR - valueFrom: - configMapKeyRef: - name: %s - key: key1`, name)) + env = append(env, corev1.EnvVar{ + Name: "VAR", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + Key: "key1", + }, + }, + }) } for _, name := range envSecretKeyRefs { - envParts = append(envParts, fmt.Sprintf(` - name: VAR - valueFrom: - secretKeyRef: - name: %s - key: key1`, name)) + env = append(env, corev1.EnvVar{ + Name: "VAR", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + Key: "key1", + }, + }, + }) } + + var volumes []corev1.Volume for _, name := range volumeCMs { - volumeParts = append(volumeParts, fmt.Sprintf(` - name: vol - configMap: - name: %s`, name)) + volumes = append(volumes, corev1.Volume{ + Name: "vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + }) } for _, name := range volumeSecrets { - volumeParts = append(volumeParts, fmt.Sprintf(` - name: vol - secret: - secretName: %s`, name)) + volumes = append(volumes, corev1.Volume{ + Name: "vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: name}, + }, + }) } - y := `apiVersion: apps/v1 -kind: Deployment -metadata: - name: my-app-deploy - namespace: my-app-system -spec: - selector: - matchLabels: - app: test - template: - metadata: - labels: - app: test - spec: - containers: - - name: app - image: nginx:latest` - - if len(envFromParts) > 0 { - y += "\n envFrom:\n" + strings.Join(envFromParts, "\n") + return corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest", EnvFrom: envFrom, Env: env}, + }, + Volumes: volumes, } - if len(envParts) > 0 { - y += "\n env:\n" + strings.Join(envParts, "\n") - } - if len(volumeParts) > 0 { - y += "\n volumes:\n" + strings.Join(volumeParts, "\n") - } - return y } func TestChecksumAnnotations(t *testing.T) { t.Run("no references", func(t *testing.T) { meta := metadata.New(config.Config{}) - obj := internal.GenerateObj(deploymentWith(nil, nil, nil, nil, nil, nil)) - result := ChecksumAnnotations(meta, obj, nil, nil) - assert.Equal(t, "", result) - }) - - t.Run("non-workload returns empty", func(t *testing.T) { - meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(fmt.Sprintf(checksumConfigMapYaml, "my-app-config")) - result := ChecksumAnnotations(meta, obj, map[string]string{"my-app-config": "config.yaml"}, nil) + spec := corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + } + result := ChecksumAnnotations(meta, spec, nil, nil, 6) assert.Equal(t, "", result) }) t.Run("configmap via envFrom", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(deploymentWith([]string{"my-app-config"}, nil, nil, nil, nil, nil)) + spec := deploymentWithSpec([]string{"my-app-config"}, nil, nil, nil, nil, nil) cmFiles := map[string]string{"my-app-config": "config.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, nil) - assert.Contains(t, result, "checksum/configmap/config:") - assert.Contains(t, result, `{{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`) + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) + assert.Contains(t, result, " annotations:") + assert.Contains(t, result, `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`) }) t.Run("secret via envFrom", func(t *testing.T) { meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) - obj := internal.GenerateObj(deploymentWith(nil, []string{"my-app-secret"}, nil, nil, nil, nil)) + spec := deploymentWithSpec(nil, []string{"my-app-secret"}, nil, nil, nil, nil) secFiles := map[string]string{"my-app-secret": "secret.yaml"} - result := ChecksumAnnotations(meta, obj, nil, secFiles) + result := ChecksumAnnotations(meta, spec, nil, secFiles, 6) assert.Contains(t, result, "checksum/secret/secret:") - assert.Contains(t, result, `{{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}`) + assert.Contains(t, result, `/secret.yaml")`) }) t.Run("configmap via env valueFrom", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(deploymentWith(nil, nil, []string{"my-app-config"}, nil, nil, nil)) + spec := deploymentWithSpec(nil, nil, []string{"my-app-config"}, nil, nil, nil) cmFiles := map[string]string{"my-app-config": "config.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, nil) + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) assert.Contains(t, result, "checksum/configmap/config:") }) t.Run("secret via env valueFrom", func(t *testing.T) { meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) - obj := internal.GenerateObj(deploymentWith(nil, nil, nil, []string{"my-app-secret"}, nil, nil)) + spec := deploymentWithSpec(nil, nil, nil, []string{"my-app-secret"}, nil, nil) secFiles := map[string]string{"my-app-secret": "secret.yaml"} - result := ChecksumAnnotations(meta, obj, nil, secFiles) + result := ChecksumAnnotations(meta, spec, nil, secFiles, 6) assert.Contains(t, result, "checksum/secret/secret:") }) t.Run("configmap via volume", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(deploymentWith(nil, nil, nil, nil, []string{"my-app-config"}, nil)) + spec := deploymentWithSpec(nil, nil, nil, nil, []string{"my-app-config"}, nil) cmFiles := map[string]string{"my-app-config": "config.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, nil) + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) assert.Contains(t, result, "checksum/configmap/config:") }) t.Run("secret via volume", func(t *testing.T) { meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) - obj := internal.GenerateObj(deploymentWith(nil, nil, nil, nil, nil, []string{"my-app-secret"})) + spec := deploymentWithSpec(nil, nil, nil, nil, nil, []string{"my-app-secret"}) secFiles := map[string]string{"my-app-secret": "secret.yaml"} - result := ChecksumAnnotations(meta, obj, nil, secFiles) + result := ChecksumAnnotations(meta, spec, nil, secFiles, 6) + assert.Contains(t, result, "checksum/secret/secret:") + }) + + t.Run("projected volume", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, []string{"my-app-secret"}) + spec := corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + Volumes: []corev1.Volume{ + { + Name: "projected", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + {ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + {Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-secret"}, + }}, + }, + }, + }, + }, + }, + } + cmFiles := map[string]string{"my-app-config": "config.yaml"} + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + result := ChecksumAnnotations(meta, spec, cmFiles, secFiles, 6) + assert.Contains(t, result, "checksum/configmap/config:") assert.Contains(t, result, "checksum/secret/secret:") }) t.Run("external configmap skipped", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(deploymentWith([]string{"external-config", "my-app-config"}, nil, nil, nil, nil, nil)) + spec := deploymentWithSpec([]string{"external-config", "my-app-config"}, nil, nil, nil, nil, nil) cmFiles := map[string]string{"my-app-config": "config.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, nil) + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) assert.Contains(t, result, "checksum/configmap/config:") assert.NotContains(t, result, "external-config") }) t.Run("multiple configmaps and secrets sorted", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config-a", "my-app-config-b"}, []string{"my-app-secret-x"}) - obj := internal.GenerateObj(deploymentWith( + spec := deploymentWithSpec( []string{"my-app-config-b", "my-app-config-a"}, []string{"my-app-secret-x"}, nil, nil, nil, nil, - )) + ) cmFiles := map[string]string{ "my-app-config-a": "config-a.yaml", "my-app-config-b": "config-b.yaml", } secFiles := map[string]string{"my-app-secret-x": "secret-x.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, secFiles) + result := ChecksumAnnotations(meta, spec, cmFiles, secFiles, 6) assert.Contains(t, result, "checksum/configmap/config-a:") assert.Contains(t, result, "checksum/configmap/config-b:") assert.Contains(t, result, "checksum/secret/secret-x:") @@ -190,43 +220,100 @@ func TestChecksumAnnotations(t *testing.T) { t.Run("nil metadata maps safe", func(t *testing.T) { meta := &metadata.Service{} - obj := internal.GenerateObj(deploymentWith([]string{"some-config"}, nil, nil, nil, nil, nil)) - result := ChecksumAnnotations(meta, obj, nil, nil) + spec := deploymentWithSpec([]string{"some-config"}, nil, nil, nil, nil, nil) + result := ChecksumAnnotations(meta, spec, nil, nil, 6) assert.Equal(t, "", result) }) t.Run("deduplicates same configmap from multiple sources", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(deploymentWith([]string{"my-app-config"}, nil, nil, nil, []string{"my-app-config"}, nil)) + spec := deploymentWithSpec([]string{"my-app-config"}, nil, nil, nil, []string{"my-app-config"}, nil) cmFiles := map[string]string{"my-app-config": "config.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, nil) - assert.Equal(t, `checksum/configmap/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }}`, result) + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) + assert.Equal(t, 1, strings.Count(result, "checksum/")) }) - t.Run("no collision when configmap and secret have same trimmed name", func(t *testing.T) { - meta := setupChecksumMeta(t, []string{"my-app-credentials"}, []string{"my-app-credentials"}) - obj := internal.GenerateObj(deploymentWith([]string{"my-app-credentials"}, []string{"my-app-credentials"}, nil, nil, nil, nil)) - cmFiles := map[string]string{"my-app-credentials": "credentials.yaml"} - secFiles := map[string]string{"my-app-credentials": "credentials.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, secFiles) - assert.Contains(t, result, "checksum/configmap/credentials:") - assert.Contains(t, result, "checksum/secret/credentials:") - assert.Equal(t, 2, len(strings.Split(result, "\n"))) - }) - - t.Run("uses actual filename not trimmed name for path", func(t *testing.T) { + t.Run("uses actual filename for path", func(t *testing.T) { meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) - obj := internal.GenerateObj(deploymentWith([]string{"my-app-config"}, nil, nil, nil, nil, nil)) - // Simulate all resources in a single input file + spec := deploymentWithSpec([]string{"my-app-config"}, nil, nil, nil, nil, nil) cmFiles := map[string]string{"my-app-config": "input.yaml"} - result := ChecksumAnnotations(meta, obj, cmFiles, nil) + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) assert.Contains(t, result, `/input.yaml")`) - assert.NotContains(t, result, `/config.yaml")`) + }) + + t.Run("initContainers references", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + spec := corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + InitContainers: []corev1.Container{ + { + Name: "init", Image: "busybox:latest", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-app-config"}, + }}, + }, + }, + }, + } + cmFiles := map[string]string{"my-app-config": "config.yaml"} + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) + assert.Contains(t, result, "checksum/configmap/config:") + }) + + t.Run("indent parameter controls output", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + spec := deploymentWithSpec([]string{"my-app-config"}, nil, nil, nil, nil, nil) + cmFiles := map[string]string{"my-app-config": "config.yaml"} + + result6 := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) + assert.True(t, strings.HasPrefix(result6, " annotations:")) + + result4 := ChecksumAnnotations(meta, spec, cmFiles, nil, 4) + assert.True(t, strings.HasPrefix(result4, " annotations:")) + }) +} + +func TestChecksumAnnotations_Integration(t *testing.T) { + t.Run("deployment processor picks up checksums", func(t *testing.T) { + deplYaml := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app-web + namespace: my-app-system +spec: + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: nginx:latest + envFrom: + - configMapRef: + name: my-app-config` + + meta := metadata.New(config.Config{ChartName: "my-app", AddChecksumAnnotations: true}) + meta.Load(internal.GenerateObj(fmt.Sprintf(checksumConfigMapYaml, "my-app-config"))) + meta.Load(internal.GenerateObj(deplYaml)) + meta.SetConfigMapFiles(map[string]string{"my-app-config": "config.yaml"}) + + obj := internal.GenerateObj(deplYaml) + var depl appsv1.Deployment + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &depl) + assert.NoError(t, err) + + checksumAnns := ChecksumAnnotations(meta, depl.Spec.Template.Spec, meta.ConfigMapFiles(), meta.SecretFiles(), 6) + assert.Contains(t, checksumAnns, "annotations:") + assert.Contains(t, checksumAnns, "checksum/configmap/config:") + assert.Contains(t, checksumAnns, `/config.yaml")`) }) } -// setupChecksumMeta creates a metadata.Service with the given configmaps and secrets loaded, -// plus a deployment to establish a common prefix of "my-app-". func setupChecksumMeta(t *testing.T, configMaps, secrets []string) *metadata.Service { t.Helper() meta := metadata.New(config.Config{ChartName: "my-app"}) diff --git a/pkg/processor/statefulset/statefulset.go b/pkg/processor/statefulset/statefulset.go index 7306da27..e45754cf 100644 --- a/pkg/processor/statefulset/statefulset.go +++ b/pkg/processor/statefulset/statefulset.go @@ -108,6 +108,12 @@ func (d statefulset) Process(appMeta helmify.AppMetadata, obj *unstructured.Unst } } + // Compute checksum annotations before ProcessSpec modifies ConfigMap/Secret names. + checksumAnns := "" + if appMeta.Config().AddChecksumAnnotations { + checksumAnns = pod.ChecksumAnnotations(appMeta, ssSpec.Template.Spec, appMeta.ConfigMapFiles(), appMeta.SecretFiles(), 6) + } + // process pod spec: podSpecMap, podValues, err := pod.ProcessSpec(nameCamel, appMeta, ssSpec.Template.Spec, 0) if err != nil { @@ -128,6 +134,12 @@ func (d statefulset) Process(appMeta helmify.AppMetadata, obj *unstructured.Unst } spec = strings.ReplaceAll(spec, "'", "") + if checksumAnns != "" { + // Insert checksum annotations after the pod template metadata line. + // The marshaled spec has " metadata:\n" under " template:\n". + spec = strings.Replace(spec, " metadata:\n", " metadata:\n"+checksumAnns+"\n", 1) + } + return true, &result{ values: values, data: struct {