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..187a2f6f 100644 --- a/pkg/app/context.go +++ b/pkg/app/context.go @@ -54,6 +54,11 @@ func (c *appContext) CreateHelm(stop <-chan struct{}) error { "ChartName": c.appMeta.ChartName(), "Namespace": c.appMeta.Namespace(), }).Info("creating a chart") + + if c.config.AddChecksumAnnotations { + c.precomputeConfigFileNames() + } + var templates []helmify.Template var filenames []string for i, obj := range c.objects { @@ -103,3 +108,27 @@ func (c *appContext) process(obj *unstructured.Unstructured) (helmify.Template, _, t, err := c.defaultProcessor.Process(c.appMeta, obj) return t, err } + +// 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 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[name] = filename + case metadata.SecretGVK: + secretFiles[name] = filename + } + } + c.appMeta.SetConfigMapFiles(configMapFiles) + c.appMeta.SetSecretFiles(secretFiles) +} diff --git a/pkg/app/context_test.go b/pkg/app/context_test.go new file mode 100644 index 00000000..0c985886 --- /dev/null +++ b/pkg/app/context_test.go @@ -0,0 +1,61 @@ +package app + +import ( + "testing" + + "github.com/arttor/helmify/internal" + "github.com/arttor/helmify/pkg/config" + "github.com/stretchr/testify/assert" +) + +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) + + cmObj := internal.GenerateObj(`apiVersion: v1 +kind: ConfigMap +metadata: + 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`) + + ctx.Add(cmObj, "configmap.yaml") + ctx.Add(secObj, "secret.yaml") + ctx.Add(deplObj, "deployment.yaml") + + ctx.precomputeConfigFileNames() + + 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("uses trimmed name for stdin input", func(t *testing.T) { + conf := config.Config{ChartName: "my-app", AddChecksumAnnotations: true} + ctx := New(conf, nil) + + cmObj := internal.GenerateObj(`apiVersion: v1 +kind: ConfigMap +metadata: + name: my-app-config`) + secObj := internal.GenerateObj(`apiVersion: v1 +kind: Secret +metadata: + name: my-app-secret`) + + ctx.Add(cmObj, "") // empty filename = stdin + ctx.Add(secObj, "") + + ctx.precomputeConfigFileNames() + + 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/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..4fa6c51b 100644 --- a/pkg/helmify/model.go +++ b/pkg/helmify/model.go @@ -49,4 +49,16 @@ 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 + + // 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 6039d951..14f51ec0 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -25,15 +25,38 @@ var crdGVK = schema.GroupVersionKind{ Kind: "CustomResourceDefinition", } +// ConfigMapGVK is the GroupVersionKind for core/v1 ConfigMap. +var ConfigMapGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", +} + +// SecretGVK is the GroupVersionKind for core/v1 Secret. +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{} + configMapFiles map[string]string + secretFiles map[string]string + conf config.Config } func (a *Service) Config() config.Config { @@ -58,6 +81,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 +123,44 @@ 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 +} + +// 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/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.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/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/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 new file mode 100644 index 00000000..dcca0c71 --- /dev/null +++ b/pkg/processor/pod/checksum.go @@ -0,0 +1,101 @@ +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 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"). +// +// 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 lines []string + for name := range configMaps { + trimmed := appMeta.TrimName(name) + lines = append(lines, checksumAnnotation("configmap", trimmed, configMapFiles[name])) + } + for name := range secrets { + trimmed := appMeta.TrimName(name) + lines = append(lines, checksumAnnotation("secret", trimmed, secretFiles[name])) + } + sort.Strings(lines) + + 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 +// 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 { + 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{}{} + } + } + } + } + + return configMaps, secrets +} + +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..ecdc153a --- /dev/null +++ b/pkg/processor/pod/checksum_test.go @@ -0,0 +1,328 @@ +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" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +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` + +// 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 { + envFrom = append(envFrom, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }) + } + for _, name := range envFromSecrets { + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }) + } + + var env []corev1.EnvVar + for _, name := range envCMKeyRefs { + env = append(env, corev1.EnvVar{ + Name: "VAR", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + Key: "key1", + }, + }, + }) + } + for _, name := range envSecretKeyRefs { + 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 { + volumes = append(volumes, corev1.Volume{ + Name: "vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + }) + } + for _, name := range volumeSecrets { + volumes = append(volumes, corev1.Volume{ + Name: "vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: name}, + }, + }) + } + + return corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest", EnvFrom: envFrom, Env: env}, + }, + Volumes: volumes, + } +} + +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, 6) + assert.Equal(t, "", result) + }) + + t.Run("configmap via envFrom", 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"} + 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"}) + spec := deploymentWithSpec(nil, []string{"my-app-secret"}, nil, nil, nil, nil) + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + result := ChecksumAnnotations(meta, spec, nil, secFiles, 6) + assert.Contains(t, result, "checksum/secret/secret:") + assert.Contains(t, result, `/secret.yaml")`) + }) + + t.Run("configmap via env valueFrom", func(t *testing.T) { + meta := setupChecksumMeta(t, []string{"my-app-config"}, nil) + spec := deploymentWithSpec(nil, nil, []string{"my-app-config"}, nil, nil, nil) + 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("secret via env valueFrom", func(t *testing.T) { + meta := setupChecksumMeta(t, nil, []string{"my-app-secret"}) + spec := deploymentWithSpec(nil, nil, nil, []string{"my-app-secret"}, nil, nil) + secFiles := map[string]string{"my-app-secret": "secret.yaml"} + 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) + spec := deploymentWithSpec(nil, nil, nil, nil, []string{"my-app-config"}, nil) + 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("secret via volume", func(t *testing.T) { + meta := setupChecksumMeta(t, 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, 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) + 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, 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"}) + 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, 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:") + }) + + t.Run("nil metadata maps safe", func(t *testing.T) { + meta := &metadata.Service{} + 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) + 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, spec, cmFiles, nil, 6) + assert.Equal(t, 1, strings.Count(result, "checksum/")) + }) + + t.Run("uses actual filename for path", 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": "input.yaml"} + result := ChecksumAnnotations(meta, spec, cmFiles, nil, 6) + assert.Contains(t, result, `/input.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")`) + }) +} + +func setupChecksumMeta(t *testing.T, configMaps, secrets []string) *metadata.Service { + t.Helper() + meta := metadata.New(config.Config{ChartName: "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.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 { 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) + }) +}