From e96a27734a36533a1820fa168bcf199049099453 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:19:57 +0800 Subject: [PATCH 01/17] bump helm-diff to v3.15.7 (#2591) Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/1ee2cfe9-dd7e-4477-80f9-ff62b0cdeab7 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --- .github/workflows/ci.yaml | 12 ++++++------ test/integration/run.sh | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b7d4fcbb..23668f5a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -96,35 +96,35 @@ jobs: - helm-version: v3.18.6 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.3 + plugin-diff-version: 3.15.7 extra-helmfile-flags: '' # In case you need to test some optional helmfile features, # enable it via extra-helmfile-flags below. - helm-version: v3.18.6 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.3 + plugin-diff-version: 3.15.7 extra-helmfile-flags: '--enable-live-output' - helm-version: v3.21.0 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.3 + plugin-diff-version: 3.15.7 extra-helmfile-flags: '' - helm-version: v3.21.0 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.3 + plugin-diff-version: 3.15.7 extra-helmfile-flags: '--enable-live-output' # Helmfile now supports both Helm 3.x and Helm 4.x - helm-version: v4.2.0 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.3 + plugin-diff-version: 3.15.7 extra-helmfile-flags: '' - helm-version: v4.2.0 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.3 + plugin-diff-version: 3.15.7 extra-helmfile-flags: '--enable-live-output' steps: - uses: actions/checkout@v6 diff --git a/test/integration/run.sh b/test/integration/run.sh index 2637ae0c..be0a9c26 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -27,7 +27,7 @@ export HELM_DATA_HOME="${helm_dir}/data" export HELM_HOME="${HELM_DATA_HOME}" export HELM_PLUGINS="${HELM_DATA_HOME}/plugins" export HELM_CONFIG_HOME="${helm_dir}/config" -HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.3}" +HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.7}" HELM_GIT_VERSION="${HELM_GIT_VERSION:-1.4.1}" HELM_SECRETS_VERSION="${HELM_SECRETS_VERSION:-4.7.4}" export GNUPGHOME="${PWD}/${dir}/.gnupg" From 23802e7a953e44490126588360d57565c8eef2b2 Mon Sep 17 00:00:00 2001 From: Shane Starcher Date: Mon, 18 May 2026 16:41:41 -0500 Subject: [PATCH 02/17] fix: refresh Chart.lock after rewriting file:// dependencies (#2587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: refresh Chart.lock after rewriting file:// dependencies `rewriteChartDependencies` rewrites relative `file://` repository URLs in Chart.yaml to absolute paths so chartify can resolve them from a temp directory. That mutates the Chart.yaml dependencies block, which invalidates the Chart.lock digest (helm computes it as `sha256(json.Marshal([2][]Dependency{req, lock}))` over the dependencies). Once the lock is out of sync, downstream `helm dependency build` errors with "the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml)" and chartify falls back to `helm dependency update`. `dep update` then re-resolves Chart.yaml's version constraints against the chart repo, so any constraint that admits newer versions (e.g. `version: "*"`, `~1.0`) silently picks up a newer dependency on every render — even though Chart.lock pins a specific version. Repro: - Chart.yaml has `version: "*"` for some-dep, Chart.lock pins 4.1.0, upstream now publishes 4.2.0. - `helm template .` honors the cached `charts/some-dep-4.1.0.tgz`. - `helmfile template` produces 4.2.0, because it triggered chartify (via jsonPatches/strategic-merge/kustomize/etc), which copied the chart, ran `dep build` against an out-of-sync lock, fell back to `dep up`, and re-resolved the wildcard. This commit refreshes Chart.lock alongside Chart.yaml in the temp copy: - Mirror the rewritten file:// repository URLs onto matching entries in Chart.lock's dependencies. Without this, `helm dep build` would resolve the lock's relative `file://` paths against the temp chart directory and fail with "directory ... not found". - Recompute the digest using helm's resolver.HashReq algorithm (`sha256(json.Marshal([2][]chart.Dependency{req, lock}))`). The algorithm is small and stable; resolver.HashReq itself lives in an internal package, so it's inlined here. - Locked versions are preserved verbatim — only the repository URL is updated and the digest recomputed. Chart.lock remains the source of truth for which versions get installed. - The original Chart.lock on disk is never modified; only the temp copy is rewritten. Adds TestRewriteChartDependencies_RefreshesChartLock covering digest recomputation, file:// URL mirroring, version preservation, untouched non-file:// deps, and original-on-disk integrity. Signed-off-by: Shane Starcher * fix: address Copilot review issues for Chart.lock refresh - Map all helm Dependency fields (alias, condition, tags, import-values, enabled) when building the request slice for digest computation, not just name/version/repository. This ensures the recomputed digest matches Helm's resolver.HashReq for all dependency shapes. - Match lock entries by Name + Alias (not Name alone) to correctly handle charts with duplicate dependency names distinguished by alias. - Log a warning when reading Chart.lock fails with a non-NotExist error, while still treating a missing Chart.lock as expected. - Add test case exercising dependencies with alias, condition, tags, and import-values fields, including same-name deps disambiguated by alias. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Shane Starcher * build(deps): bump github.com/helmfile/chartify to v0.26.4 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Shane Starcher * fix: normalize import-values for JSON marshaling and improve test coverage - Normalize import-values using maputil.RecursivelyStringifyMapKey before assigning to helmchart.Dependency.ImportValues. When go-yaml v2 decodes nested maps (e.g. import-values entries with child/parent keys), they become map[interface{}]interface{} which json.Marshal cannot encode. This would silently prevent Chart.lock rewriting. The normalization converts all map keys to strings, making the value JSON-safe. - Improve TestRewriteChartDependencies_RefreshesChartLockWithExtraFields to prove that extra fields (condition, tags, import-values) actually affect the computed digest by comparing digests with and without those fields and asserting they differ. Signed-off-by: Shane Starcher Co-Authored-By: Claude Opus 4.6 * fix: normalize lock ImportValues and fix digest test isolation - Normalize lock.Dependencies ImportValues via RecursivelyStringifyMapKey before json.Marshal, preventing failures when go-yaml v2 decodes nested maps as map[interface{}]interface{}. - Fix TestRewriteChartDependencies_RefreshesChartLockWithExtraFields to use a shared root directory so both chart variants resolve file:// paths to the same absolute location, isolating digest differences to field content. - Add TestRewriteChartDependencies_GoYamlV2ImportValues exercising the HELMFILE_GO_YAML_V3=false path with import-values containing nested maps. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Shane Starcher * fix: add exact digest verification test against Helm's HashReq Add TestRewriteChartDependencies_DigestMatchesHelmHashReq which computes the expected digest independently using the same algorithm as Helm's resolver.HashReq and asserts the rewritten Chart.lock matches exactly. This guards against producing a digest that is "different" yet still rejected by `helm dependency build`. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Shane Starcher --------- Signed-off-by: Shane Starcher Co-authored-by: Shane Starcher Co-authored-by: Claude Opus 4.6 --- go.mod | 2 +- go.sum | 4 +- pkg/state/chart_dependencies_rewrite_test.go | 464 +++++++++++++++++++ pkg/state/state.go | 119 +++++ 4 files changed, 586 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 366266b6..e5f6af63 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/hashicorp/go-cty-funcs v0.1.0 github.com/hashicorp/go-getter/v2 v2.2.3 github.com/hashicorp/hcl/v2 v2.24.0 - github.com/helmfile/chartify v0.26.3 + github.com/helmfile/chartify v0.26.4 github.com/helmfile/vals v0.44.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 913fc734..2b2ffcea 100644 --- a/go.sum +++ b/go.sum @@ -532,8 +532,8 @@ github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e h1:xwy/1T0cxHW github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= -github.com/helmfile/chartify v0.26.3 h1:2wR0yfqtP/yG9y6uqM6nSKZ7W0E+nhhGGRsl14TOVVs= -github.com/helmfile/chartify v0.26.3/go.mod h1:/ReUGTnbNHIV5tKAGXODkRtS7HwnUiJi2EXbJ34RzgY= +github.com/helmfile/chartify v0.26.4 h1:pIzVe+mqBiBMlJEH3qUVKgFQKV/m4vGOVccdYWY4VbI= +github.com/helmfile/chartify v0.26.4/go.mod h1:jnMhinkuwSMfgPPNb3JYges/13xkXPEdUVnh1eGxTOQ= github.com/helmfile/vals v0.44.0 h1:9Yf5JDIl3JUHE1XWR9GopurvAbuXowCSsgUShB4aWcI= github.com/helmfile/vals v0.44.0/go.mod h1:siAvy7f4VPPCrgLGzDOW21ZbvR6Tbf9g7oGRme9fMH4= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= diff --git a/pkg/state/chart_dependencies_rewrite_test.go b/pkg/state/chart_dependencies_rewrite_test.go index d185b650..be1c51dc 100644 --- a/pkg/state/chart_dependencies_rewrite_test.go +++ b/pkg/state/chart_dependencies_rewrite_test.go @@ -1,6 +1,9 @@ package state import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" @@ -9,8 +12,10 @@ import ( "testing" "go.uber.org/zap" + helmchart "helm.sh/helm/v3/pkg/chart" "github.com/helmfile/helmfile/pkg/filesystem" + "github.com/helmfile/helmfile/pkg/runtime" "github.com/helmfile/helmfile/pkg/yaml" ) @@ -645,3 +650,462 @@ dependencies: t.Errorf("expected original dependency repository %q, got %q", wantRepository, chartMeta.Dependencies[0].Repository) } } + +// TestRewriteChartDependencies_RefreshesChartLock verifies that when Chart.yaml has +// its file:// dependencies rewritten to absolute paths, an existing Chart.lock is +// also updated in the temp copy: the digest is recomputed (otherwise `helm dep +// build` would error with "lock out of sync") and matching file:// repository URLs +// are mirrored over from the rewritten Chart.yaml (otherwise `helm dep build` would +// resolve the lock's relative file:// path against the temp directory and fail). +// Locked versions are preserved verbatim. +func TestRewriteChartDependencies_RefreshesChartLock(t *testing.T) { + tempDir := t.TempDir() + + chartYaml := `apiVersion: v2 +name: parent-chart +version: 1.0.0 +dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + - name: remote-dep + repository: https://example.com/charts + version: "*" +` + if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil { + t.Fatalf("writing Chart.yaml: %v", err) + } + + const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + chartLock := `dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + - name: remote-dep + repository: https://example.com/charts + version: 1.2.3 +digest: ` + originalDigest + ` +generated: "2024-01-01T00:00:00Z" +` + if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil { + t.Fatalf("writing Chart.lock: %v", err) + } + + logger := zap.NewNop().Sugar() + st := &HelmState{ + basePath: tempDir, + fs: filesystem.DefaultFileSystem(), + logger: logger, + } + + rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) + if err != nil { + t.Fatalf("rewriteChartDependencies failed: %v", err) + } + defer cleanup() + + if rewrittenPath == tempDir { + t.Fatalf("expected a temp copy to be created, got original path %q", rewrittenPath) + } + + lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock")) + if err != nil { + t.Fatalf("reading rewritten Chart.lock: %v", err) + } + + var lock struct { + Dependencies []struct { + Name string `yaml:"name"` + Repository string `yaml:"repository"` + Version string `yaml:"version"` + } `yaml:"dependencies"` + Digest string `yaml:"digest"` + Generated string `yaml:"generated"` + } + if err := yaml.Unmarshal(lockData, &lock); err != nil { + t.Fatalf("parsing rewritten Chart.lock: %v", err) + } + + if lock.Digest == originalDigest { + t.Errorf("expected digest to be recomputed; still %q", lock.Digest) + } + if !strings.HasPrefix(lock.Digest, "sha256:") { + t.Errorf("expected sha256 digest, got %q", lock.Digest) + } + + if len(lock.Dependencies) != 2 { + t.Fatalf("expected 2 lock dependencies, got %d", len(lock.Dependencies)) + } + + // The local file:// dependency's repository must be mirrored to the absolute + // path so `helm dep build` can resolve it from the temp chart directory. + localDep := lock.Dependencies[0] + if localDep.Name != "local-dep" { + t.Fatalf("expected first lock dep name 'local-dep', got %q", localDep.Name) + } + if !filepath.IsAbs(strings.TrimPrefix(localDep.Repository, "file://")) { + t.Errorf("expected local-dep repository to be an absolute file:// path, got %q", localDep.Repository) + } + if localDep.Version != "1.0.0" { + t.Errorf("expected local-dep version preserved as 1.0.0, got %q", localDep.Version) + } + + // Remote (non-file://) deps must be untouched. + remoteDep := lock.Dependencies[1] + if remoteDep.Repository != "https://example.com/charts" { + t.Errorf("expected remote dep repository unchanged, got %q", remoteDep.Repository) + } + if remoteDep.Version != "1.2.3" { + t.Errorf("expected remote dep version preserved as 1.2.3, got %q", remoteDep.Version) + } + + // The original Chart.lock on disk must be untouched. + originalLock, err := os.ReadFile(filepath.Join(tempDir, "Chart.lock")) + if err != nil { + t.Fatalf("reading original Chart.lock: %v", err) + } + if string(originalLock) != chartLock { + t.Errorf("original Chart.lock was modified; expected unchanged content") + } +} + +// TestRewriteChartDependencies_RefreshesChartLockWithExtraFields verifies that +// Chart.lock digest recomputation includes all dependency fields (alias, condition, +// tags, import-values, enabled) — not just name/repository/version — so the digest +// stays compatible with Helm's resolver.HashReq for charts using those fields. +// It proves field coverage by running two chart variants under a shared root +// (so file:// paths resolve to the same absolute location) and asserting the +// digests differ only due to extra fields. +func TestRewriteChartDependencies_RefreshesChartLockWithExtraFields(t *testing.T) { + // Use a shared root so both chart variants resolve file://../local-dep to the + // same absolute path — isolating the digest difference to field content only. + sharedRoot := t.TempDir() + chartDir := filepath.Join(sharedRoot, "parent") + if err := os.MkdirAll(chartDir, 0755); err != nil { + t.Fatalf("creating chart dir: %v", err) + } + + // Run rewriteChartDependencies for a given Chart.yaml and return the recomputed digest. + getDigest := func(t *testing.T, chartYaml, chartLock string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil { + t.Fatalf("writing Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(chartDir, "Chart.lock"), []byte(chartLock), 0644); err != nil { + t.Fatalf("writing Chart.lock: %v", err) + } + logger := zap.NewNop().Sugar() + st := &HelmState{ + basePath: chartDir, + fs: filesystem.DefaultFileSystem(), + logger: logger, + } + rewrittenPath, cleanup, err := st.rewriteChartDependencies(chartDir) + if err != nil { + t.Fatalf("rewriteChartDependencies failed: %v", err) + } + defer cleanup() + lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock")) + if err != nil { + t.Fatalf("reading rewritten Chart.lock: %v", err) + } + var lock struct { + Digest string `yaml:"digest"` + } + if err := yaml.Unmarshal(lockData, &lock); err != nil { + t.Fatalf("parsing rewritten Chart.lock: %v", err) + } + return lock.Digest + } + + const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + baseLock := `dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + alias: my-local + - name: local-dep + repository: file://../local-dep-alt + version: 2.0.0 + alias: my-local-alt +digest: ` + originalDigest + ` +generated: "2024-01-01T00:00:00Z" +` + + // Chart.yaml with extra fields (alias, condition, tags, import-values). + chartYamlWithExtras := `apiVersion: v2 +name: parent-chart +version: 1.0.0 +dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + alias: my-local + condition: local-dep.enabled + tags: + - frontend + - optional + import-values: + - child: config + parent: global.config + - name: local-dep + repository: file://../local-dep-alt + version: 2.0.0 + alias: my-local-alt +` + + // Same chart without condition/tags/import-values — only alias remains. + chartYamlWithoutExtras := `apiVersion: v2 +name: parent-chart +version: 1.0.0 +dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + alias: my-local + - name: local-dep + repository: file://../local-dep-alt + version: 2.0.0 + alias: my-local-alt +` + + digestWith := getDigest(t, chartYamlWithExtras, baseLock) + digestWithout := getDigest(t, chartYamlWithoutExtras, baseLock) + + if !strings.HasPrefix(digestWith, "sha256:") { + t.Errorf("expected sha256 digest, got %q", digestWith) + } + if digestWith == originalDigest { + t.Errorf("expected digest to be recomputed; still %q", digestWith) + } + if digestWith == digestWithout { + t.Errorf("digest should differ when extra fields (condition, tags, import-values) are present, but both are %q", digestWith) + } + + // Also verify alias-based matching: both deps have name "local-dep" but + // different aliases; both should get their file:// paths rewritten. + if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYamlWithExtras), 0644); err != nil { + t.Fatalf("writing Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(chartDir, "Chart.lock"), []byte(baseLock), 0644); err != nil { + t.Fatalf("writing Chart.lock: %v", err) + } + logger := zap.NewNop().Sugar() + st := &HelmState{ + basePath: chartDir, + fs: filesystem.DefaultFileSystem(), + logger: logger, + } + rewrittenPath, cleanup, err := st.rewriteChartDependencies(chartDir) + if err != nil { + t.Fatalf("rewriteChartDependencies failed: %v", err) + } + defer cleanup() + + lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock")) + if err != nil { + t.Fatalf("reading rewritten Chart.lock: %v", err) + } + var lock struct { + Dependencies []struct { + Name string `yaml:"name"` + Repository string `yaml:"repository"` + Version string `yaml:"version"` + Alias string `yaml:"alias"` + } `yaml:"dependencies"` + } + if err := yaml.Unmarshal(lockData, &lock); err != nil { + t.Fatalf("parsing rewritten Chart.lock: %v", err) + } + if len(lock.Dependencies) != 2 { + t.Fatalf("expected 2 lock dependencies, got %d", len(lock.Dependencies)) + } + + dep1 := lock.Dependencies[0] + if dep1.Alias != "my-local" { + t.Errorf("expected first lock dep alias 'my-local', got %q", dep1.Alias) + } + if !filepath.IsAbs(strings.TrimPrefix(dep1.Repository, "file://")) { + t.Errorf("expected first dep repository to be an absolute file:// path, got %q", dep1.Repository) + } + + dep2 := lock.Dependencies[1] + if dep2.Alias != "my-local-alt" { + t.Errorf("expected second lock dep alias 'my-local-alt', got %q", dep2.Alias) + } + if !filepath.IsAbs(strings.TrimPrefix(dep2.Repository, "file://")) { + t.Errorf("expected second dep repository to be an absolute file:// path, got %q", dep2.Repository) + } +} + +// TestRewriteChartDependencies_GoYamlV2ImportValues verifies that Chart.lock +// refresh works under go-yaml v2 (HELMFILE_GO_YAML_V3=false), where nested +// maps in import-values decode as map[interface{}]interface{} which json.Marshal +// cannot handle without normalization. +func TestRewriteChartDependencies_GoYamlV2ImportValues(t *testing.T) { + prev := runtime.GoYamlV3 + runtime.GoYamlV3 = false + t.Cleanup(func() { + runtime.GoYamlV3 = prev + }) + + tempDir := t.TempDir() + + chartYaml := `apiVersion: v2 +name: parent-chart +version: 1.0.0 +dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + import-values: + - child: config + parent: global.config +` + chartLock := `dependencies: + - name: local-dep + repository: file://../local-dep + version: 1.0.0 + import-values: + - child: config + parent: global.config +digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 +generated: "2024-01-01T00:00:00Z" +` + + if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil { + t.Fatalf("writing Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil { + t.Fatalf("writing Chart.lock: %v", err) + } + + logger := zap.NewNop().Sugar() + st := &HelmState{ + basePath: tempDir, + fs: filesystem.DefaultFileSystem(), + logger: logger, + } + + rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) + if err != nil { + t.Fatalf("rewriteChartDependencies failed: %v", err) + } + defer cleanup() + + lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock")) + if err != nil { + t.Fatalf("reading rewritten Chart.lock: %v", err) + } + + var lock struct { + Digest string `yaml:"digest"` + } + if err := yaml.Unmarshal(lockData, &lock); err != nil { + t.Fatalf("parsing rewritten Chart.lock: %v", err) + } + + if !strings.HasPrefix(lock.Digest, "sha256:") { + t.Errorf("expected sha256 digest, got %q", lock.Digest) + } + const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + if lock.Digest == originalDigest { + t.Errorf("expected digest to be recomputed; still %q", lock.Digest) + } +} + +// TestRewriteChartDependencies_DigestMatchesHelmHashReq verifies the recomputed +// digest matches what Helm's resolver.HashReq would produce for a known input. +// This guards against producing a digest that is "different" but still rejected +// by `helm dependency build`. +func TestRewriteChartDependencies_DigestMatchesHelmHashReq(t *testing.T) { + tempDir := t.TempDir() + + chartYaml := `apiVersion: v2 +name: test-chart +version: 1.0.0 +dependencies: + - name: dep-a + repository: file://../dep-a + version: 2.0.0 + condition: dep-a.enabled + tags: + - backend +` + chartLock := `dependencies: + - name: dep-a + repository: file://../dep-a + version: 2.0.0 +digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 +generated: "2024-01-01T00:00:00Z" +` + + if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil { + t.Fatalf("writing Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil { + t.Fatalf("writing Chart.lock: %v", err) + } + + logger := zap.NewNop().Sugar() + st := &HelmState{ + basePath: tempDir, + fs: filesystem.DefaultFileSystem(), + logger: logger, + } + + rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) + if err != nil { + t.Fatalf("rewriteChartDependencies failed: %v", err) + } + defer cleanup() + + lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock")) + if err != nil { + t.Fatalf("reading rewritten Chart.lock: %v", err) + } + + var lock struct { + Dependencies []*helmchart.Dependency `yaml:"dependencies"` + Digest string `yaml:"digest"` + } + if err := yaml.Unmarshal(lockData, &lock); err != nil { + t.Fatalf("parsing rewritten Chart.lock: %v", err) + } + + // Compute the expected digest independently using Helm's HashReq algorithm: + // sha256(json.Marshal([2][]*chart.Dependency{req, lock})) + // where req = rewritten Chart.yaml deps, lock = rewritten Chart.lock deps. + absDepA, err := filepath.Abs(filepath.Join(tempDir, "../dep-a")) + if err != nil { + t.Fatalf("resolving absolute path: %v", err) + } + + req := []*helmchart.Dependency{ + { + Name: "dep-a", + Repository: "file://" + absDepA, + Version: "2.0.0", + Condition: "dep-a.enabled", + Tags: []string{"backend"}, + }, + } + lockDeps := []*helmchart.Dependency{ + { + Name: "dep-a", + Repository: "file://" + absDepA, + Version: "2.0.0", + }, + } + + payload, err := json.Marshal([2][]*helmchart.Dependency{req, lockDeps}) + if err != nil { + t.Fatalf("marshaling expected digest payload: %v", err) + } + sum := sha256.Sum256(payload) + expectedDigest := "sha256:" + hex.EncodeToString(sum[:]) + + if lock.Digest != expectedDigest { + t.Errorf("digest mismatch with Helm's HashReq algorithm:\n got: %s\n want: %s", lock.Digest, expectedDigest) + } +} diff --git a/pkg/state/state.go b/pkg/state/state.go index d63f6404..fbb22adf 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -4,7 +4,9 @@ import ( "bytes" gocontext "context" "crypto/sha1" + "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -27,6 +29,7 @@ import ( "github.com/helmfile/vals" "github.com/tatsushid/go-prettytable" "go.uber.org/zap" + helmchart "helm.sh/helm/v3/pkg/chart" cliv3 "helm.sh/helm/v3/pkg/cli" cliv4 "helm.sh/helm/v4/pkg/cli" @@ -36,6 +39,7 @@ import ( "github.com/helmfile/helmfile/pkg/event" "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/helmexec" + "github.com/helmfile/helmfile/pkg/maputil" "github.com/helmfile/helmfile/pkg/remote" "github.com/helmfile/helmfile/pkg/tmpl" "github.com/helmfile/helmfile/pkg/yaml" @@ -1527,6 +1531,121 @@ func (st *HelmState) rewriteChartDependencies(chartPath string) (string, func(), st.logger.Debugf("Rewrote Chart.yaml with absolute dependency paths at %s", tempChartYamlPath) + // Rewriting Chart.yaml invalidates Chart.lock's digest, since helm computes the + // digest over the JSON-marshaled dependencies block. If the lock isn't refreshed, + // downstream `helm dependency build` errors with "lock file is out of sync with + // the dependencies file" and falls back to `dependency update`, which re-resolves + // version constraints (e.g. `version: "*"`) against the chart repo and silently + // pulls newer dependency versions. The version pins in the lock are still the + // intended truth — only the rewritten file:// repository URL changed. Mirror the + // rewrite into the lock and recompute the digest so `dep build` accepts it. + tempChartLockPath := filepath.Join(tempDir, "Chart.lock") + lockData, lockErr := st.fs.ReadFile(tempChartLockPath) + if lockErr != nil && !os.IsNotExist(lockErr) { + st.logger.Warnf("Failed to read Chart.lock at %s: %v", tempChartLockPath, lockErr) + } + if lockErr == nil { + var lock struct { + Dependencies []*helmchart.Dependency `yaml:"dependencies,omitempty"` + Digest string `yaml:"digest,omitempty"` + Generated string `yaml:"generated,omitempty"` + } + if err := yaml.Unmarshal(lockData, &lock); err != nil { + st.logger.Warnf("Failed to parse Chart.lock at %s: %v", tempChartLockPath, err) + } else { + // Build the request slice (rewritten Chart.yaml dependencies) using helm's + // own chart.Dependency type so the JSON used for hashing matches helm's + // exactly. All supported fields must be mapped, not just name/repository/ + // version, because helm's digest algorithm hashes the full Dependency struct. + req := make([]*helmchart.Dependency, 0, len(chartMeta.Dependencies)) + for _, d := range chartMeta.Dependencies { + dep := &helmchart.Dependency{ + Name: d.Name, + Repository: d.Repository, + } + if v, ok := d.Data["version"].(string); ok { + dep.Version = v + } + if v, ok := d.Data["condition"].(string); ok { + dep.Condition = v + } + if v, ok := d.Data["alias"].(string); ok { + dep.Alias = v + } + if v, ok := d.Data["enabled"].(bool); ok { + dep.Enabled = v + } + if v, ok := d.Data["tags"].([]interface{}); ok { + tags := make([]string, 0, len(v)) + for _, t := range v { + if s, ok := t.(string); ok { + tags = append(tags, s) + } + } + dep.Tags = tags + } + if v, ok := d.Data["import-values"].([]interface{}); ok { + normalized, err := maputil.RecursivelyStringifyMapKey(v) + if err != nil { + st.logger.Warnf("Failed to normalize import-values for dependency %s: %v", d.Name, err) + } else { + dep.ImportValues = normalized.([]interface{}) + } + } + req = append(req, dep) + } + + // Mirror the rewritten file:// repository URLs onto matching lock entries. + // Without this, `helm dependency build` would resolve the lock's relative + // file:// paths against the (moved) chart directory and fail with + // "directory ... not found". Versions in the lock are left untouched. + // Match on Name + Alias to handle charts with duplicate dependency names + // distinguished by alias. + for _, ld := range lock.Dependencies { + if !strings.HasPrefix(ld.Repository, "file://") { + continue + } + for _, rd := range req { + if rd.Name == ld.Name && rd.Alias == ld.Alias && strings.HasPrefix(rd.Repository, "file://") { + ld.Repository = rd.Repository + break + } + } + } + + // Normalize lock.Dependencies ImportValues to avoid json.Marshal failures + // when go-yaml v2 decodes nested maps as map[interface{}]interface{}. + for _, ld := range lock.Dependencies { + if ld.ImportValues != nil { + normalized, err := maputil.RecursivelyStringifyMapKey(ld.ImportValues) + if err != nil { + st.logger.Warnf("Failed to normalize import-values in Chart.lock for dependency %s: %v", ld.Name, err) + } else { + ld.ImportValues = normalized.([]interface{}) + } + } + } + + // Replicates helm's resolver.HashReq: + // json.Marshal([2][]*chart.Dependency{req, lock}) → sha256 hex. + // resolver.HashReq lives in helm.sh/helm/v3/internal/resolver, so we + // inline the (small, stable) algorithm rather than importing it. + if payload, err := json.Marshal([2][]*helmchart.Dependency{req, lock.Dependencies}); err != nil { + st.logger.Warnf("Failed to marshal deps for Chart.lock digest at %s: %v", tempChartLockPath, err) + } else { + sum := sha256.Sum256(payload) + lock.Digest = "sha256:" + hex.EncodeToString(sum[:]) + if updated, err := yaml.Marshal(&lock); err != nil { + st.logger.Warnf("Failed to marshal Chart.lock at %s: %v", tempChartLockPath, err) + } else if err := st.fs.WriteFile(tempChartLockPath, updated, 0644); err != nil { + st.logger.Warnf("Failed to write Chart.lock at %s: %v", tempChartLockPath, err) + } else { + st.logger.Debugf("Refreshed Chart.lock digest at %s after Chart.yaml rewrite", tempChartLockPath) + } + } + } + } + cleanup := func() { if removeErr := st.fs.RemoveAll(tempDir); removeErr != nil { st.logger.Warnf("Failed to remove temp chart directory %s: %v", tempDir, removeErr) From c15cbb096aee1b30256464e0a40249a096cdf36a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 19 May 2026 14:43:28 +0200 Subject: [PATCH 03/17] feat: support HELMFILE_KUBE_CONTEXT env var for default kube context (#2593) * feat: support HELMFILE_KUBE_CONTEXT env var for default kube context Mirrors the existing HELMFILE_ENVIRONMENT pattern: the --kube-context CLI flag takes precedence, falling back to HELMFILE_KUBE_CONTEXT when unset. Refs #1213. Signed-off-by: Dominik Schmidt * docs: mention HELMFILE_KUBE_CONTEXT in cli.md and templating.md Signed-off-by: Dominik Schmidt --------- Signed-off-by: Dominik Schmidt --- cmd/root.go | 2 +- docs/cli.md | 2 +- docs/templating.md | 1 + pkg/config/global.go | 12 +++++++++++- pkg/config/global_test.go | 37 +++++++++++++++++++++++++++++++++++++ pkg/envvar/const.go | 1 + 6 files changed, 52 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 40876dbc..eda150de 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -136,7 +136,7 @@ func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalO fs.BoolVar(&globalOptions.HelmOCIPlainHTTP, "oci-plain-http", false, `use plain HTTP for OCI registries (required for local/insecure registries in Helm 4)`) fs.BoolVarP(&globalOptions.Quiet, "quiet", "q", false, "Silence output. Equivalent to log-level warn") fs.StringVar(&globalOptions.Kubeconfig, "kubeconfig", "", "Use a particular kubeconfig file") - fs.StringVar(&globalOptions.KubeContext, "kube-context", "", "Set kubectl context. Uses current context by default") + fs.StringVar(&globalOptions.KubeContext, "kube-context", "", `Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default`) fs.BoolVar(&globalOptions.Debug, "debug", false, "Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect") fs.BoolVar(&globalOptions.Color, "color", false, "Output with color") fs.BoolVar(&globalOptions.NoColor, "no-color", false, "Output without color") diff --git a/docs/cli.md b/docs/cli.md index cc264720..cbde76a3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -49,7 +49,7 @@ Flags: -b, --helm-binary string Path to the helm binary (default "helm") -h, --help help for helmfile -i, --interactive Request confirmation before attempting to modify clusters - --kube-context string Set kubectl context. Uses current context by default + --kube-context string Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default -k, --kustomize-binary string Path to the kustomize binary (default "kustomize") --log-level string Set log level, default info (default "info") -n, --namespace string Set namespace. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }} diff --git a/docs/templating.md b/docs/templating.md index d994c8f1..9a596a27 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -66,6 +66,7 @@ Helmfile uses some OS environment variables to override default behaviour: * `HELMFILE_USE_HELM_STATUS_TO_CHECK_RELEASE_EXISTENCE` - expecting non-empty value to use `helm status` to check release existence, instead of `helm list` which is the default behaviour * `HELMFILE_EXPERIMENTAL` - enable experimental features, expecting `true` lower case * `HELMFILE_ENVIRONMENT` - specify [Helmfile environment](environments.md), it has lower priority than CLI argument `--environment` +* `HELMFILE_KUBE_CONTEXT` - specify the kubectl context, it has lower priority than CLI argument `--kube-context` * `HELMFILE_TEMPDIR` - specify directory to store temporary files * `HELMFILE_UPGRADE_NOTICE_DISABLED` - expecting any non-empty value to skip the check for the latest version of Helmfile in [helmfile version](cli.md#version) * `HELMFILE_GO_YAML_V3` - use *go.yaml.in/yaml/v3* instead of *go.yaml.in/yaml/v2*. It's `false` by default in Helmfile v0.x, and `true` in Helmfile v1.x. diff --git a/pkg/config/global.go b/pkg/config/global.go index f17ea2d9..8811ff37 100644 --- a/pkg/config/global.go +++ b/pkg/config/global.go @@ -124,7 +124,17 @@ func (g *GlobalImpl) Kubeconfig() string { // KubeContext returns the name of the kubectl context to use. func (g *GlobalImpl) KubeContext() string { - return g.GlobalOptions.KubeContext + var kubeContext string + + switch { + case g.GlobalOptions.KubeContext != "": + kubeContext = g.GlobalOptions.KubeContext + case os.Getenv("HELMFILE_KUBE_CONTEXT") != "": + kubeContext = os.Getenv("HELMFILE_KUBE_CONTEXT") + default: + kubeContext = "" + } + return kubeContext } // Namespace returns the namespace to use. diff --git a/pkg/config/global_test.go b/pkg/config/global_test.go index d59e6ca8..b77751dc 100644 --- a/pkg/config/global_test.go +++ b/pkg/config/global_test.go @@ -45,3 +45,40 @@ func TestFileOrDir(t *testing.T) { } os.Unsetenv(envvar.FilePath) } + +// TestKubeContext tests the kube-context flag and HELMFILE_KUBE_CONTEXT env var fallback +func TestKubeContext(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected string + }{ + { + opts: GlobalOptions{}, + env: "", + expected: "", + }, + { + opts: GlobalOptions{}, + env: "envset", + expected: "envset", + }, + { + opts: GlobalOptions{KubeContext: "flagset"}, + env: "", + expected: "flagset", + }, + { + opts: GlobalOptions{KubeContext: "flagset"}, + env: "envset", + expected: "flagset", + }, + } + + for _, test := range tests { + os.Setenv(envvar.KubeContext, test.env) + received := NewGlobalImpl(&test.opts).KubeContext() + require.Equalf(t, test.expected, received, "KubeContext expected %s, received %s", test.expected, received) + } + os.Unsetenv(envvar.KubeContext) +} diff --git a/pkg/envvar/const.go b/pkg/envvar/const.go index cd4d52bc..f220e505 100644 --- a/pkg/envvar/const.go +++ b/pkg/envvar/const.go @@ -9,6 +9,7 @@ const ( DisableRunnerUniqueID = "HELMFILE_DISABLE_RUNNER_UNIQUE_ID" Experimental = "HELMFILE_EXPERIMENTAL" // environment variable for experimental features, expecting "true" lower case Environment = "HELMFILE_ENVIRONMENT" + KubeContext = "HELMFILE_KUBE_CONTEXT" FilePath = "HELMFILE_FILE_PATH" TempDir = "HELMFILE_TEMPDIR" UpgradeNoticeDisabled = "HELMFILE_UPGRADE_NOTICE_DISABLED" From 31ac91851261c053ec6b4738ef9e28e9c88ccb6f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 19 May 2026 15:43:11 +0200 Subject: [PATCH 04/17] feat: support HELMFILE_NAMESPACE env var for default namespace (#2592) * feat: support HELMFILE_NAMESPACE env var for default namespace Mirrors the existing HELMFILE_ENVIRONMENT pattern: the --namespace CLI flag takes precedence, falling back to HELMFILE_NAMESPACE when unset. Signed-off-by: Dominik Schmidt * docs: mention HELMFILE_NAMESPACE in cli.md and templating.md Signed-off-by: Dominik Schmidt --------- Signed-off-by: Dominik Schmidt --- cmd/root.go | 2 +- docs/cli.md | 2 +- docs/templating.md | 1 + pkg/config/global.go | 12 +++++++++++- pkg/config/global_test.go | 37 +++++++++++++++++++++++++++++++++++++ pkg/envvar/const.go | 1 + 6 files changed, 52 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index eda150de..c47f8688 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -141,7 +141,7 @@ func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalO fs.BoolVar(&globalOptions.Color, "color", false, "Output with color") fs.BoolVar(&globalOptions.NoColor, "no-color", false, "Output without color") fs.StringVar(&globalOptions.LogLevel, "log-level", "info", "Set log level, default info") - fs.StringVarP(&globalOptions.Namespace, "namespace", "n", "", "Set namespace. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}") + fs.StringVarP(&globalOptions.Namespace, "namespace", "n", "", `Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}`) fs.StringVarP(&globalOptions.Chart, "chart", "c", "", "Set chart. Uses the chart set in release by default, and is available in template as {{ .Chart }}") fs.StringArrayVarP(&globalOptions.Selector, "selector", "l", nil, `Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar. A release must match all labels in a group in order to be used. Multiple groups can be specified at once. diff --git a/docs/cli.md b/docs/cli.md index cbde76a3..217966b7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -52,7 +52,7 @@ Flags: --kube-context string Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default -k, --kustomize-binary string Path to the kustomize binary (default "kustomize") --log-level string Set log level, default info (default "info") - -n, --namespace string Set namespace. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }} + -n, --namespace string Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }} --no-color Output without color -q, --quiet Silence output. Equivalent to log-level warn -l, --selector stringArray Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar. diff --git a/docs/templating.md b/docs/templating.md index 9a596a27..b9e9bff1 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -67,6 +67,7 @@ Helmfile uses some OS environment variables to override default behaviour: * `HELMFILE_EXPERIMENTAL` - enable experimental features, expecting `true` lower case * `HELMFILE_ENVIRONMENT` - specify [Helmfile environment](environments.md), it has lower priority than CLI argument `--environment` * `HELMFILE_KUBE_CONTEXT` - specify the kubectl context, it has lower priority than CLI argument `--kube-context` +* `HELMFILE_NAMESPACE` - specify the namespace, it has lower priority than CLI argument `--namespace` * `HELMFILE_TEMPDIR` - specify directory to store temporary files * `HELMFILE_UPGRADE_NOTICE_DISABLED` - expecting any non-empty value to skip the check for the latest version of Helmfile in [helmfile version](cli.md#version) * `HELMFILE_GO_YAML_V3` - use *go.yaml.in/yaml/v3* instead of *go.yaml.in/yaml/v2*. It's `false` by default in Helmfile v0.x, and `true` in Helmfile v1.x. diff --git a/pkg/config/global.go b/pkg/config/global.go index 8811ff37..d96655a5 100644 --- a/pkg/config/global.go +++ b/pkg/config/global.go @@ -139,7 +139,17 @@ func (g *GlobalImpl) KubeContext() string { // Namespace returns the namespace to use. func (g *GlobalImpl) Namespace() string { - return g.GlobalOptions.Namespace + var namespace string + + switch { + case g.GlobalOptions.Namespace != "": + namespace = g.GlobalOptions.Namespace + case os.Getenv("HELMFILE_NAMESPACE") != "": + namespace = os.Getenv("HELMFILE_NAMESPACE") + default: + namespace = "" + } + return namespace } // Chart returns the chart to use. diff --git a/pkg/config/global_test.go b/pkg/config/global_test.go index b77751dc..e5b91f4c 100644 --- a/pkg/config/global_test.go +++ b/pkg/config/global_test.go @@ -82,3 +82,40 @@ func TestKubeContext(t *testing.T) { } os.Unsetenv(envvar.KubeContext) } + +// TestNamespace tests the namespace flag and HELMFILE_NAMESPACE env var fallback +func TestNamespace(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected string + }{ + { + opts: GlobalOptions{}, + env: "", + expected: "", + }, + { + opts: GlobalOptions{}, + env: "envset", + expected: "envset", + }, + { + opts: GlobalOptions{Namespace: "flagset"}, + env: "", + expected: "flagset", + }, + { + opts: GlobalOptions{Namespace: "flagset"}, + env: "envset", + expected: "flagset", + }, + } + + for _, test := range tests { + os.Setenv(envvar.Namespace, test.env) + received := NewGlobalImpl(&test.opts).Namespace() + require.Equalf(t, test.expected, received, "Namespace expected %s, received %s", test.expected, received) + } + os.Unsetenv(envvar.Namespace) +} diff --git a/pkg/envvar/const.go b/pkg/envvar/const.go index f220e505..d6f1575f 100644 --- a/pkg/envvar/const.go +++ b/pkg/envvar/const.go @@ -10,6 +10,7 @@ const ( Experimental = "HELMFILE_EXPERIMENTAL" // environment variable for experimental features, expecting "true" lower case Environment = "HELMFILE_ENVIRONMENT" KubeContext = "HELMFILE_KUBE_CONTEXT" + Namespace = "HELMFILE_NAMESPACE" FilePath = "HELMFILE_FILE_PATH" TempDir = "HELMFILE_TEMPDIR" UpgradeNoticeDisabled = "HELMFILE_UPGRADE_NOTICE_DISABLED" From 81ab674017791c2367d8a43d2783d2dad70cb4c3 Mon Sep 17 00:00:00 2001 From: yxxhero <11087727+yxxhero@users.noreply.github.com> Date: Wed, 20 May 2026 08:44:14 +0800 Subject: [PATCH 05/17] fix: normalize dependency chart path before DirectoryExistsAt check (#2598) When helmfile.d contains multiple release files and one release has a local chart dependency (e.g. chart: ../chart), the dependency path was passed to DirectoryExistsAt without normalizing against basePath. This caused the path to be resolved against CWD instead of the helmfile directory, so the local chart was not detected and helmfile tried to resolve it as a remote repo, failing with: 'failed reading adhoc dependencies: no helm list entry found for repository' Fixes #2596 Signed-off-by: yxxhero --- pkg/state/helmx.go | 3 +- pkg/state/issue_2596_test.go | 138 ++++++++++++++++++ test/integration/run.sh | 1 + .../issue-2596-local-deps-multiple-files.sh | 49 +++++++ .../input/chart/Chart.yaml | 24 +++ .../input/helmfile.d/release1.yaml | 8 + .../input/helmfile.d/release2.yaml | 5 + 7 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 pkg/state/issue_2596_test.go create mode 100644 test/integration/test-cases/issue-2596-local-deps-multiple-files.sh create mode 100644 test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml create mode 100644 test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml create mode 100644 test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go index bf7bd46a..d8edd80a 100644 --- a/pkg/state/helmx.go +++ b/pkg/state/helmx.go @@ -448,7 +448,8 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp for _, d := range release.Dependencies { chart := d.Chart - if st.fs.DirectoryExistsAt(chart) { + normalizedChart := normalizeChart(st.basePath, chart) + if st.fs.DirectoryExistsAt(normalizedChart) { var err error // Otherwise helm-dependency-up on the temporary chart generated by chartify ends up errors like: diff --git a/pkg/state/issue_2596_test.go b/pkg/state/issue_2596_test.go new file mode 100644 index 00000000..f8685bce --- /dev/null +++ b/pkg/state/issue_2596_test.go @@ -0,0 +1,138 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/helmfile/helmfile/pkg/filesystem" +) + +// TestLocalDependencyChartPathNormalization tests that relative chart paths in +// release dependencies (like "../chart") are normalized to absolute paths +// relative to basePath before checking if the directory exists. +// This is a regression test for issue #2596. +// +// Background: When helmfile.d/ contains multiple release files and one release +// has a local chart dependency (chart: ../chart), the dependency chart path was +// passed to DirectoryExistsAt without normalization, causing it to be resolved +// relative to the CWD instead of basePath. This made helmfile fail to detect +// the local chart and instead try to resolve it as a remote repo, resulting in +// "failed reading adhoc dependencies: no helm list entry found for repository". +func TestLocalDependencyChartPathNormalization(t *testing.T) { + tempDir := t.TempDir() + + chartDir := filepath.Join(tempDir, "chart") + require.NoError(t, os.MkdirAll(chartDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(` +apiVersion: v2 +name: test-chart +version: 0.1.0 +`), 0644)) + + helmfileDir := filepath.Join(tempDir, "helmfile.d") + require.NoError(t, os.MkdirAll(helmfileDir, 0755)) + + tests := []struct { + name string + chartPath string + basePath string + expectLocal bool + }{ + { + name: "relative path ../chart normalized from helmfile.d", + chartPath: "../chart", + basePath: helmfileDir, + expectLocal: true, + }, + { + name: "absolute path works unchanged", + chartPath: chartDir, + basePath: helmfileDir, + expectLocal: true, + }, + { + name: "non-existent relative path not detected as local", + chartPath: "../nonexistent", + basePath: helmfileDir, + expectLocal: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizedChart := normalizeChart(tt.basePath, tt.chartPath) + fs := filesystem.DefaultFileSystem() + isLocal := fs.DirectoryExistsAt(normalizedChart) + assert.Equal(t, tt.expectLocal, isLocal, + "normalizeChart(%q, %q) = %q, DirectoryExistsAt = %v, want %v", + tt.basePath, tt.chartPath, normalizedChart, isLocal, tt.expectLocal) + }) + } +} + +// TestDependencyChartPathResolutionWithPrepareChartify verifies that the dependency +// chart path is normalized using basePath before calling DirectoryExistsAt, +// which is the core of the fix for issue #2596. +func TestDependencyChartPathResolutionWithPrepareChartify(t *testing.T) { + tempDir := t.TempDir() + + chartDir := filepath.Join(tempDir, "chart") + require.NoError(t, os.MkdirAll(chartDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(` +apiVersion: v2 +name: test-chart +version: 0.1.0 +`), 0644)) + + helmfileDir := filepath.Join(tempDir, "helmfile.d") + require.NoError(t, os.MkdirAll(helmfileDir, 0755)) + + fs := filesystem.DefaultFileSystem() + + tests := []struct { + name string + depChartPath string + basePath string + expectDetected bool + }{ + { + name: "relative ../chart from helmfile.d detected as local", + depChartPath: "../chart", + basePath: helmfileDir, + expectDetected: true, + }, + { + name: "absolute path detected as local", + depChartPath: chartDir, + basePath: helmfileDir, + expectDetected: true, + }, + { + name: "non-existent relative path not detected", + depChartPath: "../nonexistent", + basePath: helmfileDir, + expectDetected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizedChart := normalizeChart(tt.basePath, tt.depChartPath) + isLocal := fs.DirectoryExistsAt(normalizedChart) + assert.Equal(t, tt.expectDetected, isLocal, + "normalizeChart(%q, %q) = %q, DirectoryExistsAt = %v, want %v", + tt.basePath, tt.depChartPath, normalizedChart, isLocal, tt.expectDetected) + + if tt.expectDetected && !filepath.IsAbs(tt.depChartPath) { + absChart, err := filepath.Abs(filepath.Join(tt.basePath, tt.depChartPath)) + require.NoError(t, err) + assert.Equal(t, absChart, normalizedChart, + "normalized path should match expected absolute path") + } + }) + } +} diff --git a/test/integration/run.sh b/test/integration/run.sh index be0a9c26..4f44bf12 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -143,6 +143,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes . ${dir}/test-cases/issue-2424-sequential-values-paths.sh . ${dir}/test-cases/issue-2431.sh . ${dir}/test-cases/issue-2544.sh +. ${dir}/test-cases/issue-2596-local-deps-multiple-files.sh . ${dir}/test-cases/kubedog-tracking.sh # ALL DONE ----------------------------------------------------------------------------------------------------------- diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files.sh b/test/integration/test-cases/issue-2596-local-deps-multiple-files.sh new file mode 100644 index 00000000..84a06443 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files.sh @@ -0,0 +1,49 @@ +# Integration test for issue #2596: Local dependencies with multiple release files +# https://github.com/helmfile/helmfile/issues/2596 +# Reproduction: https://github.com/vgivanov/helmfile-deps-local-chart +# +# This test uses the exact same structure as the reproduction repo: +# chart/Chart.yaml +# helmfile.d/release1.yaml (chart: ../chart with dependencies) +# helmfile.d/release2.yaml (chart: ../chart without dependencies) +# +# Before the fix, running `helmfile template` from this directory would fail with: +# "failed reading adhoc dependencies: no helm list entry found for repository" +# because the relative dependency chart path "../chart" was not normalized against +# basePath before calling DirectoryExistsAt. + +issue_2596_input_dir="${cases_dir}/issue-2596-local-deps-multiple-files/input" +issue_2596_tmp="" + +cleanup_issue_2596() { + if [ -n "${issue_2596_tmp}" ] && [ -d "${issue_2596_tmp}" ]; then + rm -rf "${issue_2596_tmp}" + fi +} +trap cleanup_issue_2596 EXIT + +issue_2596_tmp=$(mktemp -d) +helmfile_real="$(pwd)/${helmfile}" + +test_start "issue #2596: local deps with multiple release files" + +info "Testing helmfile template with local chart dependencies across multiple release files" + +cd "${issue_2596_input_dir}" + +${helmfile_real} template > "${issue_2596_tmp}/output.yaml" 2>&1 +result=$? + +cd - > /dev/null + +if [ $result -ne 0 ]; then + cat "${issue_2596_tmp}/output.yaml" + fail "helmfile template with local chart dependencies should not fail" +fi + +info "Local chart dependencies with multiple release files works correctly" + +cleanup_issue_2596 +trap - EXIT + +test_pass "issue #2596: local deps with multiple release files" diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml new file mode 100644 index 00000000..df2d97f9 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: chart +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml new file mode 100644 index 00000000..f2fbfe41 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml @@ -0,0 +1,8 @@ +--- +releases: + - name: release1 + chart: ../chart + version: "*" + dependencies: + - chart: ../chart + version: "*" diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml new file mode 100644 index 00000000..4182b9c6 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml @@ -0,0 +1,5 @@ +--- +releases: + - name: release2 + chart: ../chart + version: "*" From 2f2e8617ada325b177eada5871bb232d060379e7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 17:02:45 +0800 Subject: [PATCH 06/17] Add jsonPatches regression coverage for chartify lookup rendering (#2586) * test: cover jsonPatches lookup regression Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/72b7ba14-50b8-4407-ba37-6da202609603 Co-authored-by: zhaque44 <20215376+zhaque44@users.noreply.github.com> * test: simplify json patch fixture Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/72b7ba14-50b8-4407-ba37-6da202609603 Co-authored-by: zhaque44 <20215376+zhaque44@users.noreply.github.com> * test: target existing json patch path Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/72b7ba14-50b8-4407-ba37-6da202609603 Co-authored-by: zhaque44 <20215376+zhaque44@users.noreply.github.com> * test: validate diff exit codes in issue-2271 Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/5615c543-f96b-44ed-be25-ca1559ee6ab0 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: zhaque44 <20215376+zhaque44@users.noreply.github.com> Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --- test/integration/test-cases/issue-2271.sh | 54 ++++++++++++------- .../issue-2271/input/helmfile-jsonpatch.yaml | 16 ++++++ 2 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml diff --git a/test/integration/test-cases/issue-2271.sh b/test/integration/test-cases/issue-2271.sh index 660af0c1..0aa58d1f 100755 --- a/test/integration/test-cases/issue-2271.sh +++ b/test/integration/test-cases/issue-2271.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Test for issue #2271: lookup function should work with strategicMergePatches +# Test for issue #2271: lookup function should work with strategicMergePatches and jsonPatches # Without this fix, helm template runs client-side and lookup() returns empty values issue_2271_input_dir="${cases_dir}/issue-2271/input" @@ -8,7 +8,7 @@ issue_2271_tmp_dir=$(mktemp -d) cd "${issue_2271_input_dir}" -test_start "issue-2271: lookup function with strategicMergePatches" +test_start "issue-2271: lookup function with strategicMergePatches and jsonPatches" # Test 1: Install chart without kustomize patches info "Installing chart without kustomize patches" @@ -36,26 +36,44 @@ fi info "ConfigMap value updated to: $current_value" +assert_lookup_preserved() { + local label="$1" + local helmfile_path="$2" + local output_path="$3" + local code + + info "Testing diff with ${label} - lookup should preserve value" + + ${helmfile} -f "${helmfile_path}" diff > "${output_path}" 2>&1 + code=$? + + if [ $code -ne 0 ] && [ $code -ne 2 ]; then + cat "${output_path}" + rm -rf "${issue_2271_tmp_dir}" + fail "Unexpected error during diff with ${label}" + fi + + # Check if the diff contains the preserved value (not "initial-value") + if grep -q "preserved-value.*test-preserved-value" "${output_path}"; then + info "SUCCESS: lookup function preserved the value with ${label}" + elif grep -q "preserved-value.*initial-value" "${output_path}"; then + cat "${output_path}" + rm -rf "${issue_2271_tmp_dir}" + fail "Issue #2271 regression: lookup function returned empty value with ${label}" + else + # No diff for ConfigMap means value is perfectly preserved + info "SUCCESS: No ConfigMap changes detected for ${label} (value perfectly preserved)" + fi +} + # Test 3: Diff with strategicMergePatches should preserve the lookup value -info "Testing diff with strategicMergePatches - lookup should preserve value" +assert_lookup_preserved "strategicMergePatches" "helmfile.yaml" "${issue_2271_tmp_dir}/test-2271-strategic-diff.txt" -${helmfile} -f helmfile.yaml diff > "${issue_2271_tmp_dir}/test-2271-diff.txt" 2>&1 -code=$? - -# Check if the diff contains the preserved value (not "initial-value") -if grep -q "preserved-value.*test-preserved-value" "${issue_2271_tmp_dir}/test-2271-diff.txt"; then - info "SUCCESS: lookup function preserved the value with kustomize patches" -elif grep -q "preserved-value.*initial-value" "${issue_2271_tmp_dir}/test-2271-diff.txt"; then - cat "${issue_2271_tmp_dir}/test-2271-diff.txt" - rm -rf "${issue_2271_tmp_dir}" - fail "Issue #2271 regression: lookup function returned empty value with kustomize" -else - # No diff for ConfigMap means value is perfectly preserved - info "SUCCESS: No ConfigMap changes detected (value perfectly preserved)" -fi +# Test 4: Diff with jsonPatches should preserve the lookup value +assert_lookup_preserved "jsonPatches" "helmfile-jsonpatch.yaml" "${issue_2271_tmp_dir}/test-2271-json-diff.txt" # Cleanup ${helm} uninstall test-release-2271 --namespace default 2>/dev/null || true rm -rf "${issue_2271_tmp_dir}" -test_pass "issue-2271: lookup function with strategicMergePatches" +test_pass "issue-2271: lookup function with strategicMergePatches and jsonPatches" diff --git a/test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml b/test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml new file mode 100644 index 00000000..5a6e62fb --- /dev/null +++ b/test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml @@ -0,0 +1,16 @@ +releases: + - name: test-release-2271 + namespace: default + chart: ./test-chart + installed: true + jsonPatches: + - target: + group: apps + version: v1 + kind: Deployment + name: test-release-2271-app + namespace: default + patch: + - op: add + path: /spec/template/metadata/labels/hello + value: world From 781d28a47adebb841f068e500dfdf69221122657 Mon Sep 17 00:00:00 2001 From: yxxhero <11087727+yxxhero@users.noreply.github.com> Date: Wed, 20 May 2026 18:21:03 +0800 Subject: [PATCH 07/17] feat: add defaultInherit for automatic release template inheritance (#2600) * feat: add defaultInherit for automatic release template inheritance Add a top-level defaultInherit field to helmfile.yaml that automatically applies template inheritance to all releases without requiring explicit inherit on each release. The field accepts a single template name as a string or a list of template names. Releases that already explicitly inherit from the same template are not duplicated. Fixes #2599 Signed-off-by: yxxhero * style: fix gci formatting in app_template_test.go Signed-off-by: yxxhero * fix: correct relative chart path in integration test Signed-off-by: yxxhero * fix: use absolute chart path in bad-helmfile test Signed-off-by: yxxhero * fix: use clean chart path in bad-helmfile test Signed-off-by: yxxhero * fix: use dir variable for chart path Signed-off-by: yxxhero * test: fix flaky defaultInherit integration assertions Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/d0884e8e-8b1b-456d-8250-dec1566b8a37 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: tighten defaultInherit integration assertions Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/d0884e8e-8b1b-456d-8250-dec1566b8a37 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: harden release block parsing in issue-2599 case Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/d0884e8e-8b1b-456d-8250-dec1566b8a37 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: make issue-2599 assertions format-tolerant Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/d0884e8e-8b1b-456d-8250-dec1566b8a37 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: fix section extraction and regex matching in issue-2599 case Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/d0884e8e-8b1b-456d-8250-dec1566b8a37 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: sanitize defaultInherit values and dedupe applied templates Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/85a8e815-3701-4b48-a28d-6bb2d50a3b40 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * chore: address validation feedback on defaultInherit fixes Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/85a8e815-3701-4b48-a28d-6bb2d50a3b40 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: sanitize releaseInherit entries in applyDefaultInherit; add cleanup trap and quote vars in integration test Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/1fbf62d5-7ce2-42e5-898b-30151c0c1ef9 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * refactor: combine releaseInherit loops in applyDefaultInherit to avoid double TrimSpace; clarify test comment Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/1fbf62d5-7ce2-42e5-898b-30151c0c1ef9 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: align default inherit tests with yaml wrapper and assertions Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/3ea9b8e4-633f-43c4-899f-e063ec576486 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: address review feedback on defaultInherit tests Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/3ea9b8e4-633f-43c4-899f-e063ec576486 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: fix issue-2599 integration script helmfile invocation Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/9452bb65-7086-459f-b5ae-0b00c1e021eb Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --------- Signed-off-by: yxxhero Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docs/configuration.md | 1 + docs/writing-helmfile.md | 42 ++++ pkg/app/app_template_test.go | 236 ++++++++++++++++++ pkg/state/state.go | 44 ++++ pkg/state/state_exec_tmpl.go | 38 ++- pkg/state/state_exec_tmpl_test.go | 210 ++++++++++++++++ test/integration/run.sh | 1 + .../test-cases/issue-2599-default-inherit.sh | 75 ++++++ .../input/common.yaml | 1 + .../input/helmfile.yaml | 19 ++ 10 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 test/integration/test-cases/issue-2599-default-inherit.sh create mode 100644 test/integration/test-cases/issue-2599-default-inherit/input/common.yaml create mode 100644 test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml diff --git a/docs/configuration.md b/docs/configuration.md index 7fd94b0a..0ffc5bf9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,7 @@ A `helmfile.yaml` has these top-level sections: | `values` | Default values available in templates | | `commonLabels` | Labels applied to all releases | | `templates` | Reusable release templates | +| `defaultInherit` | Default template(s) for all releases to inherit | | `hooks` | Global lifecycle hooks | | `apiVersions` / `kubeVersion` | Kubernetes version capabilities | diff --git a/docs/writing-helmfile.md b/docs/writing-helmfile.md index e0bc2f8c..a9e0d9ce 100644 --- a/docs/writing-helmfile.md +++ b/docs/writing-helmfile.md @@ -159,6 +159,48 @@ See [issue helmfile/helmfile#435](https://github.com/helmfile/helmfile/issues/43 You might also find [issue roboll/helmfile#428](https://github.com/roboll/helmfile/issues/428) useful for more context on how we originally designed the release template and what it's supposed to solve. +### Default Template Inheritance + +When all releases share the same template, specifying `inherit` on each one becomes repetitive. Use `defaultInherit` to apply a template to all releases automatically: + +```yaml +templates: + default: + namespace: kube-system + missingFileHandler: Warn + values: + - config/{{`{{ .Release.Name }}`}}/values.yaml + +defaultInherit: default + +releases: +- name: heapster + chart: stable/heapster + version: 0.3.2 + # inherits from "default" automatically +- name: kubernetes-dashboard + chart: stable/kubernetes-dashboard + version: 0.10.0 + inherit: + - template: default + except: + - values +``` + +`defaultInherit` accepts a single template name or a list: + +```yaml +# Single template +defaultInherit: default + +# Multiple templates (merged in order) +defaultInherit: + - ns-template + - defaults +``` + +If a release already inherits from the same template explicitly, the default is not duplicated. Use `except` in the release's `inherit` to exclude specific fields when needed. + ## Layering Release Values Please note, that it is not possible to layer `values` sections. If `values` is defined in the release and in the release template, only the `values` defined in the release will be considered. The same applies to `secrets` and `set`. diff --git a/pkg/app/app_template_test.go b/pkg/app/app_template_test.go index f2e5f50c..c468c22d 100644 --- a/pkg/app/app_template_test.go +++ b/pkg/app/app_template_test.go @@ -529,3 +529,239 @@ releases: }) }) } + +func TestTemplate_DefaultInherit(t *testing.T) { + type testcase struct { + error string + templated []exectest.Release + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + var helm = &exectest.Helm{ + FailOnUnexpectedList: true, + FailOnUnexpectedDiff: true, + DiffMutex: &sync.Mutex{}, + ChartsMutex: &sync.Mutex{}, + ReleasesMutex: &sync.Mutex{}, + } + + _ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) { + t.Helper() + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Errorf("unexpected error creating vals runtime: %v", err) + } + + files := map[string]string{ + "/path/to/helmfile.yaml": ` +templates: + default: + namespace: default-ns + labels: + managed: "true" +defaultInherit: default +releases: +- name: app1 + chart: incubator/raw +- name: app2 + chart: incubator/raw + inherit: + - template: default + except: + - labels +`, + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: &ffs.FileSystem{Glob: filepath.Glob}, + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + helms: map[helmKey]helmexec.Interface{ + createHelmKey("helm", "default"): helm, + }, + valsRuntime: valsRuntime, + }, files) + + tmplErr := app.Template(applyConfig{ + concurrency: 1, + logger: logger, + }) + + var gotErr string + if tmplErr != nil { + gotErr = tmplErr.Error() + } + + if d := cmp.Diff(tc.error, gotErr); d != "" { + t.Fatalf("unexpected error: want (-), got (+): %s", d) + } + + require.Equal(t, tc.templated, helm.Templated) + }) + } + + t.Run("default inherit applies template to all releases", func(t *testing.T) { + check(t, testcase{ + templated: []exectest.Release{ + {Name: "app1", Flags: []string{"--kube-context", "default", "--namespace", "default-ns"}}, + {Name: "app2", Flags: []string{"--kube-context", "default", "--namespace", "default-ns"}}, + }, + }) + }) +} + +func TestTemplate_DefaultInherit_Multiple(t *testing.T) { + type testcase struct { + error string + templated []exectest.Release + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + var helm = &exectest.Helm{ + FailOnUnexpectedList: true, + FailOnUnexpectedDiff: true, + DiffMutex: &sync.Mutex{}, + ChartsMutex: &sync.Mutex{}, + ReleasesMutex: &sync.Mutex{}, + } + + _ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) { + t.Helper() + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Errorf("unexpected error creating vals runtime: %v", err) + } + + files := map[string]string{ + "/path/to/helmfile.yaml": ` +templates: + ns: + namespace: from-ns-template + override: + namespace: from-ctx-template +defaultInherit: + - ns + - override +releases: +- name: app1 + chart: incubator/raw +`, + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: &ffs.FileSystem{Glob: filepath.Glob}, + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + helms: map[helmKey]helmexec.Interface{ + createHelmKey("helm", "default"): helm, + }, + valsRuntime: valsRuntime, + }, files) + + tmplErr := app.Template(applyConfig{ + concurrency: 1, + logger: logger, + }) + + var gotErr string + if tmplErr != nil { + gotErr = tmplErr.Error() + } + + if d := cmp.Diff(tc.error, gotErr); d != "" { + t.Fatalf("unexpected error: want (-), got (+): %s", d) + } + + require.Equal(t, tc.templated, helm.Templated) + }) + } + + t.Run("multiple default inherits are applied in order", func(t *testing.T) { + check(t, testcase{ + templated: []exectest.Release{ + {Name: "app1", Flags: []string{"--kube-context", "default", "--namespace", "from-ctx-template"}}, + }, + }) + }) +} + +func TestTemplate_DefaultInherit_NonExistent(t *testing.T) { + type testcase struct { + error string + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + var helm = &exectest.Helm{ + FailOnUnexpectedList: true, + FailOnUnexpectedDiff: true, + DiffMutex: &sync.Mutex{}, + ChartsMutex: &sync.Mutex{}, + ReleasesMutex: &sync.Mutex{}, + } + + _ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) { + t.Helper() + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Errorf("unexpected error creating vals runtime: %v", err) + } + + files := map[string]string{ + "/path/to/helmfile.yaml": ` +defaultInherit: nonexistent +releases: +- name: app1 + chart: incubator/raw +`, + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: &ffs.FileSystem{Glob: filepath.Glob}, + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + helms: map[helmKey]helmexec.Interface{ + createHelmKey("helm", "default"): helm, + }, + valsRuntime: valsRuntime, + }, files) + + tmplErr := app.Template(applyConfig{ + concurrency: 1, + logger: logger, + }) + + var gotErr string + if tmplErr != nil { + gotErr = tmplErr.Error() + } + + if d := cmp.Diff(tc.error, gotErr); d != "" { + t.Fatalf("unexpected error: want (-), got (+): %s", d) + } + }) + } + + t.Run("fail due to non-existent template in defaultInherit", func(t *testing.T) { + check(t, testcase{ + error: `in ./helmfile.yaml: failed executing release templates in "helmfile.yaml": release "app1" tried to inherit inexistent release template "nonexistent"`, + }) + }) +} diff --git a/pkg/state/state.go b/pkg/state/state.go index fbb22adf..b5e027ce 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -95,6 +95,11 @@ type ReleaseSetSpec struct { Templates map[string]TemplateSpec `yaml:"templates"` + // DefaultInherit is a list of template names that all releases inherit by default. + // Each release will automatically inherit these templates unless it already explicitly + // inherits from the same template. + DefaultInherit DefaultInherits `yaml:"defaultInherit,omitempty"` + Env environment.Environment `yaml:"-"` // If set to "Error", return an error when a subhelmfile points to a @@ -514,6 +519,45 @@ func (r *Inherits) UnmarshalYAML(unmarshal func(any) error) error { return nil } +type DefaultInherits []string + +func (r *DefaultInherits) UnmarshalYAML(unmarshal func(any) error) error { + var list []string + if err := unmarshal(&list); err == nil { + *r = normalizeDefaultInherits(list) + return nil + } + + var single string + if err := unmarshal(&single); err != nil { + return err + } + *r = normalizeDefaultInherits([]string{single}) + return nil +} + +// normalizeDefaultInherits trims names, drops empty entries, and returns nil for an empty result. +func normalizeDefaultInherits(in []string) []string { + if len(in) == 0 { + return nil + } + + out := make([]string, 0, len(in)) + for _, name := range in { + name = strings.TrimSpace(name) + if name == "" { + continue + } + out = append(out, name) + } + + if len(out) == 0 { + return nil + } + + return out +} + // ChartPathOrName returns ChartPath if it is non-empty, and returns Chart otherwise. // This is useful to redirect helm commands like `helm template`, `helm dependency update`, `helm diff`, and `helm upgrade --install` to // our modified version of the chart, in case the user configured Helmfile to do modify the chart before being passed to Helm. diff --git a/pkg/state/state_exec_tmpl.go b/pkg/state/state_exec_tmpl.go index 2a8e3877..db32af84 100644 --- a/pkg/state/state_exec_tmpl.go +++ b/pkg/state/state_exec_tmpl.go @@ -85,7 +85,10 @@ func (st *HelmState) ExecuteTemplates() (*HelmState, error) { vals := st.Values() for i, rt := range st.Releases { - release, err := st.releaseWithInheritedTemplate(&rt, nil) + rtWithDefaults := rt + rtWithDefaults.Inherit = st.applyDefaultInherit(rt.Inherit) + + release, err := st.releaseWithInheritedTemplate(&rtWithDefaults, nil) if err != nil { var cyclicInheritanceErr CyclicReleaseTemplateInheritanceError if errors.As(err, &cyclicInheritanceErr) { @@ -224,3 +227,36 @@ func (st *HelmState) releaseWithInheritedTemplate(r *ReleaseSpec, inheritancePat return &merged, nil } + +// applyDefaultInherit prepends default inherit templates to the release's inherit list. +// Templates that are already explicitly referenced by the release are not duplicated. +func (st *HelmState) applyDefaultInherit(releaseInherit Inherits) Inherits { + if len(st.DefaultInherit) == 0 { + return releaseInherit + } + + // Build the deduplication set and filter out blank entries in one pass. + existing := make(map[string]bool, len(releaseInherit)) + filtered := make(Inherits, 0, len(releaseInherit)) + for _, inh := range releaseInherit { + if name := strings.TrimSpace(inh.Template); name != "" { + existing[name] = true + filtered = append(filtered, inh) + } + } + + result := make(Inherits, 0, len(st.DefaultInherit)+len(filtered)) + for _, name := range st.DefaultInherit { + name = strings.TrimSpace(name) + if name == "" { + continue + } + + if !existing[name] { + result = append(result, Inherit{Template: name}) + existing[name] = true + } + } + result = append(result, filtered...) + return result +} diff --git a/pkg/state/state_exec_tmpl_test.go b/pkg/state/state_exec_tmpl_test.go index 33a0a47c..92ec36df 100644 --- a/pkg/state/state_exec_tmpl_test.go +++ b/pkg/state/state_exec_tmpl_test.go @@ -7,9 +7,12 @@ import ( "testing" "github.com/go-test/deep" + "go.uber.org/zap" "github.com/helmfile/helmfile/pkg/environment" "github.com/helmfile/helmfile/pkg/filesystem" + "github.com/helmfile/helmfile/pkg/runtime" + "github.com/helmfile/helmfile/pkg/yaml" ) func boolPtrToString(ptr *bool) string { @@ -294,3 +297,210 @@ func TestHelmState_recursiveRefsTemplates(t *testing.T) { }) } } + +func TestApplyDefaultInherit(t *testing.T) { + tests := []struct { + name string + defaultInherit DefaultInherits + releaseInherit Inherits + want Inherits + }{ + { + name: "no default inherit", + defaultInherit: nil, + releaseInherit: Inherits{{Template: "foo"}}, + want: Inherits{{Template: "foo"}}, + }, + { + name: "default inherit prepended", + defaultInherit: DefaultInherits{"default"}, + releaseInherit: Inherits{{Template: "foo"}}, + want: Inherits{{Template: "default"}, {Template: "foo"}}, + }, + { + name: "default inherit already in release inherit is not duplicated", + defaultInherit: DefaultInherits{"default"}, + releaseInherit: Inherits{{Template: "default"}, {Template: "foo"}}, + want: Inherits{{Template: "default"}, {Template: "foo"}}, + }, + { + name: "multiple default inherits", + defaultInherit: DefaultInherits{"a", "b"}, + releaseInherit: Inherits{{Template: "c"}}, + want: Inherits{{Template: "a"}, {Template: "b"}, {Template: "c"}}, + }, + { + name: "release inherit empty with defaults", + defaultInherit: DefaultInherits{"default"}, + releaseInherit: nil, + want: Inherits{{Template: "default"}}, + }, + { + name: "default inherit deduplicates and skips empty values", + defaultInherit: DefaultInherits{"default", " ", "default", "ops"}, + releaseInherit: Inherits{{Template: "foo"}}, + want: Inherits{{Template: "default"}, {Template: "ops"}, {Template: "foo"}}, + }, + { + // Whitespace-only template names in releaseInherit are used verbatim for dedup + // (trimmed for map lookup), so the user's explicit entry is preserved in the output + // and the default is not prepended again. + name: "release inherit with whitespace template is deduplicated correctly", + defaultInherit: DefaultInherits{"default"}, + releaseInherit: Inherits{{Template: " default "}, {Template: "foo"}}, + want: Inherits{{Template: " default "}, {Template: "foo"}}, + }, + { + name: "release inherit with blank template is skipped", + defaultInherit: DefaultInherits{"default"}, + releaseInherit: Inherits{{Template: ""}, {Template: "foo"}}, + want: Inherits{{Template: "default"}, {Template: "foo"}}, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + st := &HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + DefaultInherit: tt.defaultInherit, + }, + } + got := st.applyDefaultInherit(tt.releaseInherit) + if len(got) != len(tt.want) { + t.Fatalf("expected %d inherits, got %d", len(tt.want), len(got)) + } + for j := range got { + if got[j].Template != tt.want[j].Template { + t.Errorf("inherit[%d]: expected template %q, got %q", j, tt.want[j].Template, got[j].Template) + } + if len(got[j].Except) != len(tt.want[j].Except) { + t.Errorf("inherit[%d]: expected %d except, got %d", j, len(tt.want[j].Except), len(got[j].Except)) + } + } + }) + } +} + +func TestHelmState_executeTemplatesWithDefaultTemplates(t *testing.T) { + logger := zap.NewNop().Sugar() + state := &HelmState{ + logger: logger, + fs: &filesystem.FileSystem{ + Glob: func(s string) ([]string, error) { return nil, nil }, + }, + basePath: ".", + ReleaseSetSpec: ReleaseSetSpec{ + HelmDefaults: HelmSpec{ + KubeContext: "test_context", + }, + Env: environment.Environment{Name: "test_env"}, + Templates: map[string]TemplateSpec{ + "default": { + ReleaseSpec: ReleaseSpec{ + Namespace: "default-ns", + Labels: map[string]string{"managed": "true"}, + }, + }, + }, + DefaultInherit: DefaultInherits{"default"}, + Releases: []ReleaseSpec{ + { + Name: "app1", + Chart: "test-chart", + }, + { + Name: "app2", + Chart: "test-chart-2", + Inherit: Inherits{ + {Template: "default", Except: []string{"labels"}}, + }, + }, + }, + }, + RenderedValues: map[string]any{}, + } + + r, err := state.ExecuteTemplates() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + app1 := r.Releases[0] + if app1.Namespace != "default-ns" { + t.Errorf("app1: expected namespace %q, got %q", "default-ns", app1.Namespace) + } + if app1.Labels["managed"] != "true" { + t.Errorf("app1: expected label managed=true, got %v", app1.Labels) + } + + app2 := r.Releases[1] + if app2.Namespace != "default-ns" { + t.Errorf("app2: expected namespace %q, got %q", "default-ns", app2.Namespace) + } + if _, ok := app2.Labels["managed"]; ok { + t.Errorf("app2: expected labels to be excluded, but got %v", app2.Labels) + } +} + +func TestDefaultInherits_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + input string + want DefaultInherits + }{ + { + name: "single string", + input: `default`, + want: DefaultInherits{"default"}, + }, + { + name: "list of strings", + input: `["a", "b"]`, + want: DefaultInherits{"a", "b"}, + }, + { + name: "null value", + input: `null`, + want: nil, + }, + { + name: "empty string value", + input: `""`, + want: nil, + }, + { + name: "list trims and drops empty names", + input: `[" a ", "", " ", "b"]`, + want: DefaultInherits{"a", "b"}, + }, + } + + for _, enableGoYamlV3 := range []bool{true, false} { + t.Run(fmt.Sprintf("GoYamlV3=%t", enableGoYamlV3), func(t *testing.T) { + prev := runtime.GoYamlV3 + runtime.GoYamlV3 = enableGoYamlV3 + defer func() { + runtime.GoYamlV3 = prev + }() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got DefaultInherits + err := yaml.Unmarshal([]byte(tt.input), &got) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("expected %d items, got %d", len(tt.want), len(got)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("item[%d]: expected %q, got %q", i, tt.want[i], got[i]) + } + } + }) + } + }) + } +} diff --git a/test/integration/run.sh b/test/integration/run.sh index 4f44bf12..bf9bcf25 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -144,6 +144,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes . ${dir}/test-cases/issue-2431.sh . ${dir}/test-cases/issue-2544.sh . ${dir}/test-cases/issue-2596-local-deps-multiple-files.sh +. ${dir}/test-cases/issue-2599-default-inherit.sh . ${dir}/test-cases/kubedog-tracking.sh # ALL DONE ----------------------------------------------------------------------------------------------------------- diff --git a/test/integration/test-cases/issue-2599-default-inherit.sh b/test/integration/test-cases/issue-2599-default-inherit.sh new file mode 100644 index 00000000..c579668c --- /dev/null +++ b/test/integration/test-cases/issue-2599-default-inherit.sh @@ -0,0 +1,75 @@ +# Issue #2599: Test that defaultInherit applies template inheritance to all releases +# https://github.com/helmfile/helmfile/issues/2599 +# +# This test verifies that: +# - defaultInherit as a single string applies the template to all releases +# - Releases without explicit inherit still get the template +# - Releases with explicit inherit + except are not duplicated +# - Non-existent template in defaultInherit produces a clear error + +issue_2599_input_dir="${cases_dir}/issue-2599-default-inherit/input" +issue_2599_tmp="" + +cleanup_issue_2599() { + if [ -n "${issue_2599_tmp}" ] && [ -d "${issue_2599_tmp}" ]; then + rm -rf "${issue_2599_tmp}" + fi +} +trap cleanup_issue_2599 EXIT + +issue_2599_tmp=$(mktemp -d) + +test_start "issue 2599 default inherit" + +# Test 1: defaultInherit applies template to all releases +info "Running helmfile build with defaultInherit" +${helmfile} -f "${issue_2599_input_dir}/helmfile.yaml" build \ + > "${issue_2599_tmp}/output.log" 2>&1 \ + || { cat "${issue_2599_tmp}/output.log"; fail "helmfile build with defaultInherit shouldn't fail"; } + +# Verify namespace from template is applied to both releases +grep -q "namespace: default-ns" "${issue_2599_tmp}/output.log" \ + || fail "namespace from default template should be applied" + +# Verify both releases are processed +grep -q "app1" "${issue_2599_tmp}/output.log" \ + || fail "release app1 should be in output" + +grep -q "app2" "${issue_2599_tmp}/output.log" \ + || fail "release app2 should be in output" +grep -q "^templates:" "${issue_2599_tmp}/output.log" \ + || fail "templates section should be in build output" + +# Verify inherited values and labels per release +sed -n '/name: app1/,/name: app2/p' "${issue_2599_tmp}/output.log" > "${issue_2599_tmp}/app1.log" +sed -n '/name: app2/,/^templates:/{/^templates:/!p}' "${issue_2599_tmp}/output.log" > "${issue_2599_tmp}/app2.log" +[ -s "${issue_2599_tmp}/app1.log" ] || fail "failed to extract release app1 section from build output" +[ -s "${issue_2599_tmp}/app2.log" ] || fail "failed to extract release app2 section from build output" + +grep -Eq 'managed:[[:space:]]*"?true"?([[:space:]]|$)' "${issue_2599_tmp}/app1.log" \ + || fail "release app1 should inherit managed label from default template" +grep -q "common.yaml" "${issue_2599_tmp}/app1.log" \ + || fail "release app1 should inherit values from common.yaml" +grep -q "common.yaml" "${issue_2599_tmp}/app2.log" \ + || fail "release app2 should inherit values from common.yaml" +if grep -Eq 'managed:[[:space:]]*"?true"?([[:space:]]|$)' "${issue_2599_tmp}/app2.log"; then + fail "release app2 should not inherit managed label due to except" +fi + +# Test 2: non-existent template in defaultInherit should fail +info "Running helmfile build with non-existent defaultInherit template" +cat > "${issue_2599_tmp}/bad-helmfile.yaml" < "${issue_2599_tmp}/error.log" 2>&1 \ + && fail "helmfile build with non-existent defaultInherit template should fail" + +grep -q "inexistent release template" "${issue_2599_tmp}/error.log" \ + || fail "error message should mention inexistent release template" + +test_pass "issue 2599 default inherit" diff --git a/test/integration/test-cases/issue-2599-default-inherit/input/common.yaml b/test/integration/test-cases/issue-2599-default-inherit/input/common.yaml new file mode 100644 index 00000000..80b68919 --- /dev/null +++ b/test/integration/test-cases/issue-2599-default-inherit/input/common.yaml @@ -0,0 +1 @@ +testKey: testValue diff --git a/test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml b/test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml new file mode 100644 index 00000000..65d475bd --- /dev/null +++ b/test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml @@ -0,0 +1,19 @@ +templates: + default: + namespace: default-ns + labels: + managed: "true" + values: + - common.yaml + +defaultInherit: default + +releases: +- name: app1 + chart: ../../../charts/raw +- name: app2 + chart: ../../../charts/raw + inherit: + - template: default + except: + - labels From 27015e8d5316dc61fbb18ff0606552ce4c809e67 Mon Sep 17 00:00:00 2001 From: yxxhero <11087727+yxxhero@users.noreply.github.com> Date: Wed, 20 May 2026 20:53:03 +0800 Subject: [PATCH 08/17] fix: restore kubedog status progress output during tracking (#2602) * fix: restore kubedog status progress output during tracking The refactor in commit bda57b74 that replaced multitrack.Multitrack() with individual resource trackers only read from Ready/Failed/Succeeded channels, ignoring Status, Added, EventMsg, PodLogChunk, PodError, and AddedPod channels. This caused kubedog status messages to no longer be displayed. Additionally, IgnoreLogs was not passed to tracker.Options, so the trackLogs setting was effectively ignored. This fix restores the original multitrack-style table display using the same kubedog utils.Table and indicators packages for: - Formatted status tables with DEPLOYMENT/REPLICAS/AVAILABLE/UP-TO-DATE columns - Pod sub-tables showing POD/READY/RESTARTS/STATUS with tree structure - ANSI color coding (green=ready, yellow=in-progress, red=failed) - Progress indicators showing value transitions (e.g. 1->3) - Waiting messages in blue Fixes #2601 Signed-off-by: yxxhero * fix: address review feedback - caption coloring, termWidth, O(1) pod detection, display tests Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/147fc763-c3f2-4a7e-9591-6f972fb62667 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: use status.FailedReason for canary final display, fix test name typo Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/147fc763-c3f2-4a7e-9591-6f972fb62667 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: correct gci import grouping in display.go and display_test.go Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/7e8f8219-5979-44fb-9729-6138c3aae08b Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: force ANSI color output in display_test.go for CI non-TTY environments Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/ff37ccd9-f4d1-4d42-a7d0-4903e2b9d253 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --------- Signed-off-by: yxxhero Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- go.sum | 14 -- pkg/kubedog/display.go | 312 +++++++++++++++++++++++++ pkg/kubedog/display_test.go | 453 ++++++++++++++++++++++++++++++++++++ pkg/kubedog/tracker.go | 134 ++++++++++- 4 files changed, 894 insertions(+), 19 deletions(-) create mode 100644 pkg/kubedog/display.go create mode 100644 pkg/kubedog/display_test.go diff --git a/go.sum b/go.sum index 2b2ffcea..711cd6c9 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,6 @@ github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -269,7 +267,6 @@ github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -482,7 +479,6 @@ github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukg github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -617,12 +613,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= -github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= -github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM= -github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -789,10 +779,6 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/pkg/kubedog/display.go b/pkg/kubedog/display.go new file mode 100644 index 00000000..79a08436 --- /dev/null +++ b/pkg/kubedog/display.go @@ -0,0 +1,312 @@ +package kubedog + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/werf/kubedog/pkg/tracker/daemonset" + "github.com/werf/kubedog/pkg/tracker/deployment" + "github.com/werf/kubedog/pkg/tracker/indicators" + "github.com/werf/kubedog/pkg/tracker/job" + "github.com/werf/kubedog/pkg/tracker/pod" + "github.com/werf/kubedog/pkg/tracker/statefulset" + "github.com/werf/kubedog/pkg/utils" + "golang.org/x/term" +) + +var statusProgressTableRatio = []float64{.58, .11, .12, .19} +var statusProgressSubTableRatio = []float64{.40, .15, .20, .25} + +func writeOut(out io.Writer, s string) { + _, _ = fmt.Fprint(out, s) +} + +func displayDeploymentStatusProgress(out io.Writer, resourceCaption string, status deployment.DeploymentStatus, prevStatus *deployment.DeploymentStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + replicas := "-" + if status.ReplicasIndicator != nil { + replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + WithTargetValue: true, + }) + } + available := "-" + if status.AvailableIndicator != nil { + available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + uptodate := "-" + if status.UpToDateIndicator != nil { + uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("DEPLOYMENT", "REPLICAS", "AVAILABLE", "UP-TO-DATE") + + args := []interface{}{resourceCaption, replicas, available, uptodate} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } + t.Row(args...) + + displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages) + + writeOut(out, t.Render()) +} + +func displayStatefulSetStatusProgress(out io.Writer, resourceCaption string, status statefulset.StatefulSetStatus, prevStatus *statefulset.StatefulSetStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + replicas := "-" + if status.ReplicasIndicator != nil { + replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + WithTargetValue: true, + }) + } + ready := "-" + if status.ReadyIndicator != nil { + ready = status.ReadyIndicator.FormatTableElem(prevStatus.ReadyIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + uptodate := "-" + if status.UpToDateIndicator != nil { + uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("STATEFULSET", "REPLICAS", "READY", "UP-TO-DATE") + + args := []interface{}{resourceCaption, replicas, ready, uptodate} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } else { + for _, w := range status.WarningMessages { + args = append(args, formatResourceWarning(w)) + } + } + t.Row(args...) + + displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages) + + writeOut(out, t.Render()) +} + +func displayDaemonSetStatusProgress(out io.Writer, resourceCaption string, status daemonset.DaemonSetStatus, prevStatus *daemonset.DaemonSetStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + replicas := "-" + if status.ReplicasIndicator != nil { + replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + WithTargetValue: true, + }) + } + available := "-" + if status.AvailableIndicator != nil { + available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + uptodate := "-" + if status.UpToDateIndicator != nil { + uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("DAEMONSET", "REPLICAS", "AVAILABLE", "UP-TO-DATE") + + args := []interface{}{resourceCaption, replicas, available, uptodate} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } + t.Row(args...) + + displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages) + + writeOut(out, t.Render()) +} + +func displayJobStatusProgress(out io.Writer, resourceCaption string, status job.JobStatus, prevStatus *job.JobStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + succeeded := "-" + if status.SucceededIndicator != nil { + succeeded = status.SucceededIndicator.FormatTableElem(prevStatus.SucceededIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("JOB", "ACTIVE", "DURATION", "SUCCEEDED/FAILED") + + var active interface{} = "-" + if status.Active != 0 { + active = status.Active + } + failed := fmt.Sprintf("%d", status.Failed) + + args := []interface{}{resourceCaption, active, status.Age, strings.Join([]string{succeeded, failed}, "/")} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } + t.Row(args...) + + if len(status.Pods) > 0 { + st := displayChildPodsStatusProgress(&t, prevStatus.Pods, status.Pods, nil, showProgress) + extraMsg := "" + if len(status.WaitingForMessages) > 0 { + extraMsg += "---\n" + extraMsg += utils.BlueF("Waiting for: %s", strings.Join(status.WaitingForMessages, ", ")) + } + st.Commit(extraMsg) + } + + writeOut(out, t.Render()) +} + +func displayChildPodsAndWaiting(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, waitingForMessages []string) { + if len(pods) > 0 { + st := displayChildPodsStatusProgress(t, prevPods, pods, newPodsNames, true) + extraMsg := "" + if len(waitingForMessages) > 0 { + extraMsg += "---\n" + extraMsg += utils.BlueF("Waiting for: %s", strings.Join(waitingForMessages, ", ")) + } + st.Commit(extraMsg) + } +} + +func displayChildPodsStatusProgress(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, showProgress bool) *utils.Table { + subT := t.SubTable(statusProgressSubTableRatio...) + st := &subT + + st.Header("POD", "READY", "RESTARTS", "STATUS") + + podsNames := make([]string, 0, len(pods)) + for podName := range pods { + podsNames = append(podsNames, podName) + } + sort.Strings(podsNames) + + var podRows [][]interface{} + + newPodSet := make(map[string]struct{}, len(newPodsNames)) + for _, name := range newPodsNames { + newPodSet[name] = struct{}{} + } + + for _, podName := range podsNames { + var podRow []interface{} + + _, isPodNew := newPodSet[podName] + + prevPodStatus := prevPods[podName] + podStatus := pods[podName] + + isReady := false + if podStatus.StatusIndicator != nil { + isReady = podStatus.StatusIndicator.IsReady() + } + + resource := formatPodResourceCaption(podName, isReady, podStatus.IsFailed, isPodNew) + ready := fmt.Sprintf("%d/%d", podStatus.ReadyContainers, podStatus.TotalContainers) + + status := "-" + if podStatus.StatusIndicator != nil { + status = podStatus.StatusIndicator.FormatTableElem(prevPodStatus.StatusIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + IsResourceNew: isPodNew, + }) + } + + podRow = append(podRow, resource, ready, podStatus.Restarts, status) + if podStatus.IsFailed { + podRow = append(podRow, formatResourceError(podStatus.FailedReason)) + } + + podRows = append(podRows, podRow) + } + + st.Rows(podRows...) + + return st +} + +func formatResourceCaption(caption string, isReady, isFailed bool) string { + switch { + case isReady: + return utils.GreenF("%s", caption) + case isFailed: + return utils.RedF("%s", caption) + default: + return utils.YellowF("%s", caption) + } +} + +func formatPodResourceCaption(podName string, isReady, isFailed, isNew bool) string { + if !isNew { + return podName + } + return formatResourceCaption(podName, isReady, isFailed) +} + +func formatResourceError(reason string) string { + return utils.RedF("error: %s", reason) +} + +func formatResourceWarning(reason string) string { + return utils.YellowF("warning: %s", reason) +} + +func termWidth() int { + if w, _, err := term.GetSize(int(os.Stderr.Fd())); err == nil && w > 0 { + return w + } + return 140 +} + +func displayCanaryStatus(out io.Writer, resourceCaption string, status CanaryStatusView) { + var parts []string + if status.Phase != "" { + parts = append(parts, fmt.Sprintf("phase %s", status.Phase)) + } + if status.Age != "" { + parts = append(parts, fmt.Sprintf("age %s", status.Age)) + } + msg := fmt.Sprintf("%s: %s", resourceCaption, strings.Join(parts, ", ")) + if status.IsFailed { + msg = utils.RedF("%s", msg) + } + _, _ = fmt.Fprintln(out, msg) +} + +type CanaryStatusView struct { + Phase string + Age string + IsFailed bool +} + +func statusOutput() io.Writer { + return os.Stderr +} diff --git a/pkg/kubedog/display_test.go b/pkg/kubedog/display_test.go new file mode 100644 index 00000000..254f3522 --- /dev/null +++ b/pkg/kubedog/display_test.go @@ -0,0 +1,453 @@ +package kubedog + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/gookit/color" + "github.com/stretchr/testify/assert" + "github.com/werf/kubedog/pkg/tracker/daemonset" + "github.com/werf/kubedog/pkg/tracker/deployment" + "github.com/werf/kubedog/pkg/tracker/job" + "github.com/werf/kubedog/pkg/tracker/pod" + "github.com/werf/kubedog/pkg/tracker/statefulset" +) + +// TestMain forces ANSI color output so that tests asserting on escape codes +// pass in non-TTY environments such as CI runners. +func TestMain(m *testing.M) { + color.ForceColor() + os.Exit(m.Run()) +} + +// --- formatResourceCaption --- + +func TestFormatResourceCaption_Ready(t *testing.T) { + result := formatResourceCaption("deploy/myapp", true, false) + assert.Contains(t, result, "deploy/myapp") + // Green ANSI escape should be present + assert.Contains(t, result, "\033[") +} + +func TestFormatResourceCaption_Failed(t *testing.T) { + result := formatResourceCaption("deploy/myapp", false, true) + assert.Contains(t, result, "deploy/myapp") + assert.Contains(t, result, "\033[") +} + +func TestFormatResourceCaption_InProgress(t *testing.T) { + result := formatResourceCaption("deploy/myapp", false, false) + assert.Contains(t, result, "deploy/myapp") + // Yellow for in-progress + assert.Contains(t, result, "\033[") +} + +func TestFormatResourceCaption_ReadyTakesPrecedence(t *testing.T) { + // isReady=true should win over isFailed=true + resultReady := formatResourceCaption("x", true, false) + resultFailed := formatResourceCaption("x", false, true) + // Colors should differ + assert.NotEqual(t, resultReady, resultFailed) +} + +// --- formatPodResourceCaption --- + +func TestFormatPodResourceCaption_NotNew(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", true, false, false) + // Not a new pod: no coloring applied, just the plain name + assert.Equal(t, "my-pod-abc", result) +} + +func TestFormatPodResourceCaption_NewAndReady(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", true, false, true) + assert.Contains(t, result, "my-pod-abc") + assert.Contains(t, result, "\033[") +} + +func TestFormatPodResourceCaption_NewAndFailed(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", false, true, true) + assert.Contains(t, result, "my-pod-abc") + assert.Contains(t, result, "\033[") +} + +func TestFormatPodResourceCaption_NewInProgress(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", false, false, true) + assert.Contains(t, result, "my-pod-abc") + assert.Contains(t, result, "\033[") +} + +// --- formatResourceError / formatResourceWarning --- + +func TestFormatResourceError(t *testing.T) { + result := formatResourceError("CrashLoopBackOff") + assert.Contains(t, result, "error:") + assert.Contains(t, result, "CrashLoopBackOff") +} + +func TestFormatResourceWarning(t *testing.T) { + result := formatResourceWarning("PodNotScheduled") + assert.Contains(t, result, "warning:") + assert.Contains(t, result, "PodNotScheduled") +} + +// --- termWidth --- + +func TestTermWidth_ReturnsPositive(t *testing.T) { + w := termWidth() + assert.Greater(t, w, 0) +} + +// --- displayDeploymentStatusProgress --- + +func TestDisplayDeploymentStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + var prev deployment.DeploymentStatus + status := deployment.DeploymentStatus{} + + // Must not panic and must produce some output + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.NotEmpty(t, out) + assert.Contains(t, out, "DEPLOYMENT") +} + +func TestDisplayDeploymentStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, true) + var prev deployment.DeploymentStatus + status := deployment.DeploymentStatus{ + IsFailed: true, + FailedReason: "ImagePullBackOff", + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "ImagePullBackOff") +} + +func TestDisplayDeploymentStatusProgress_WithWaitingMessage(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + var prev deployment.DeploymentStatus + // WaitingForMessages is only rendered when there are pods + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + WaitingForMessages: []string{"up-to-date 1->3"}, + Pods: map[string]pod.PodStatus{ + "myapp-pod-abc": {ReadyContainers: 1, TotalContainers: 1}, + }, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "Waiting for:") + assert.Contains(t, out, "up-to-date 1->3") +} + +func TestDisplayDeploymentStatusProgress_WithPods(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + prev := deployment.DeploymentStatus{} + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + Pods: map[string]pod.PodStatus{ + "myapp-abc-123": {ReadyContainers: 1, TotalContainers: 1}, + }, + NewPodsNames: []string{"myapp-abc-123"}, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "POD") + assert.Contains(t, out, "myapp-abc-123") +} + +// --- displayStatefulSetStatusProgress --- + +func TestDisplayStatefulSetStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("sts/myapp", false, false) + var prev statefulset.StatefulSetStatus + status := statefulset.StatefulSetStatus{} + + assert.NotPanics(t, func() { + displayStatefulSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "STATEFULSET") +} + +func TestDisplayStatefulSetStatusProgress_WithWarnings(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("sts/myapp", false, false) + var prev statefulset.StatefulSetStatus + status := statefulset.StatefulSetStatus{ + WarningMessages: []string{"PodNotScheduled: insufficient resources"}, + } + + assert.NotPanics(t, func() { + displayStatefulSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "warning:") + assert.Contains(t, out, "PodNotScheduled") +} + +func TestDisplayStatefulSetStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("sts/myapp", false, true) + var prev statefulset.StatefulSetStatus + status := statefulset.StatefulSetStatus{ + IsFailed: true, + FailedReason: "timeout waiting for ready", + } + + assert.NotPanics(t, func() { + displayStatefulSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "timeout waiting for ready") +} + +// --- displayDaemonSetStatusProgress --- + +func TestDisplayDaemonSetStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("ds/myapp", false, false) + var prev daemonset.DaemonSetStatus + status := daemonset.DaemonSetStatus{} + + assert.NotPanics(t, func() { + displayDaemonSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "DAEMONSET") +} + +func TestDisplayDaemonSetStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("ds/myapp", false, true) + var prev daemonset.DaemonSetStatus + status := daemonset.DaemonSetStatus{ + IsFailed: true, + FailedReason: "node not ready", + } + + assert.NotPanics(t, func() { + displayDaemonSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "node not ready") +} + +// --- displayJobStatusProgress --- + +func TestDisplayJobStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, false) + var prev job.JobStatus + status := job.JobStatus{} + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "JOB") +} + +func TestDisplayJobStatusProgress_Active(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, false) + var prev job.JobStatus + status := job.JobStatus{ + StatusGeneration: 1, + } + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "ACTIVE") +} + +func TestDisplayJobStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, true) + var prev job.JobStatus + status := job.JobStatus{ + IsFailed: true, + FailedReason: "BackoffLimitExceeded", + } + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "BackoffLimitExceeded") +} + +func TestDisplayJobStatusProgress_WithWaitingMessage(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, false) + var prev job.JobStatus + status := job.JobStatus{ + WaitingForMessages: []string{"succeeded 0->1"}, + Pods: map[string]pod.PodStatus{ + "myjob-abc": {ReadyContainers: 0, TotalContainers: 1}, + }, + } + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "Waiting for:") + assert.Contains(t, out, "succeeded 0->1") +} + +// --- displayChildPodsStatusProgress --- + +func TestDisplayChildPodsStatusProgress_Empty(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + // With no pods, only the header should be rendered + prev := deployment.DeploymentStatus{} + status := deployment.DeploymentStatus{ + Pods: map[string]pod.PodStatus{}, + } + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + // No POD sub-table header when pods is empty + out := buf.String() + assert.NotContains(t, out, "POD") +} + +func TestDisplayChildPodsStatusProgress_NewPodSet(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + prev := deployment.DeploymentStatus{} + // Two pods: one new, one old + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + Pods: map[string]pod.PodStatus{ + "pod-new-abc": {ReadyContainers: 0, TotalContainers: 1}, + "pod-old-xyz": {ReadyContainers: 1, TotalContainers: 1}, + }, + NewPodsNames: []string{"pod-new-abc"}, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "pod-new-abc") + assert.Contains(t, out, "pod-old-xyz") +} + +func TestDisplayChildPodsStatusProgress_ManyPodsO1Check(t *testing.T) { + // Verifies O(1) new-pod detection works correctly for many pods + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + prev := deployment.DeploymentStatus{} + + pods := make(map[string]pod.PodStatus) + newNames := make([]string, 0, 10) + for i := 0; i < 20; i++ { + name := strings.Repeat("a", i+1) + pods[name] = pod.PodStatus{ReadyContainers: 1, TotalContainers: 1} + if i%2 == 0 { + newNames = append(newNames, name) + } + } + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + Pods: pods, + NewPodsNames: newNames, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + assert.NotEmpty(t, buf.String()) +} + +// --- displayCanaryStatus --- + +func TestDisplayCanaryStatus_Normal(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", false, false) + view := CanaryStatusView{Phase: "Progressing", Age: "1m"} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + out := buf.String() + assert.Contains(t, out, "Progressing") + assert.Contains(t, out, "1m") +} + +func TestDisplayCanaryStatus_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", false, true) + view := CanaryStatusView{Phase: "Failed", IsFailed: true} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + out := buf.String() + assert.Contains(t, out, "Failed") +} + +func TestDisplayCanaryStatus_Succeeded(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", true, false) + view := CanaryStatusView{Phase: "Succeeded"} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + out := buf.String() + assert.Contains(t, out, "Succeeded") +} + +func TestDisplayCanaryStatus_EmptyPhaseAndAge(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", false, false) + view := CanaryStatusView{} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + // Should still produce output (at least the caption + newline) + assert.NotEmpty(t, buf.String()) +} + +// --- writeOut --- + +func TestWriteOut(t *testing.T) { + var buf bytes.Buffer + writeOut(&buf, "hello world") + assert.Equal(t, "hello world", buf.String()) +} + +func TestWriteOut_Empty(t *testing.T) { + var buf bytes.Buffer + writeOut(&buf, "") + assert.Equal(t, "", buf.String()) +} diff --git a/pkg/kubedog/tracker.go b/pkg/kubedog/tracker.go index 867116f8..cc4e11e5 100644 --- a/pkg/kubedog/tracker.go +++ b/pkg/kubedog/tracker.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/werf/kubedog/pkg/display" "github.com/werf/kubedog/pkg/informer" "github.com/werf/kubedog/pkg/tracker" "github.com/werf/kubedog/pkg/tracker/canary" @@ -234,6 +235,7 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso ParentContext: ctx, Timeout: t.trackOptions.Timeout, LogsFromTime: time.Now().Add(-t.trackOptions.LogsSince), + IgnoreLogs: !t.trackOptions.Logs, } var wg sync.WaitGroup @@ -312,13 +314,35 @@ func (t *Tracker) runDeploymentTracker(ctx context.Context, tr *deployment.Track } func (t *Tracker) waitDeploymentTracker(ctx context.Context, tr *deployment.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus deployment.DeploymentStatus + out := statusOutput() + resourceName := fmt.Sprintf("deploy/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Ready: - t.logger.Debugf("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName) + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("deployment %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("deploy/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("deploy/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message) + case <-tr.AddedReplicaSet: + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -338,13 +362,34 @@ func (t *Tracker) runStatefulSetTracker(ctx context.Context, tr *statefulset.Tra } func (t *Tracker) waitStatefulSetTracker(ctx context.Context, tr *statefulset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus statefulset.StatefulSetStatus + out := statusOutput() + resourceName := fmt.Sprintf("sts/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Ready: - t.logger.Debugf("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName) + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("statefulset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("sts/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("sts/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message) + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -364,13 +409,34 @@ func (t *Tracker) runDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker } func (t *Tracker) waitDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus daemonset.DaemonSetStatus + out := statusOutput() + resourceName := fmt.Sprintf("ds/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Ready: - t.logger.Debugf("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName) + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("daemonset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("ds/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("ds/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message) + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -390,13 +456,34 @@ func (t *Tracker) runJobTracker(ctx context.Context, tr *job.Tracker, errCh chan } func (t *Tracker) waitJobTracker(ctx context.Context, tr *job.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus job.JobStatus + out := statusOutput() + resourceName := fmt.Sprintf("job/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Succeeded: - t.logger.Debugf("Job %s/%s succeeded", tr.Namespace, tr.ResourceName) + displayJobStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("Job %s/%s succeeded", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayJobStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("job %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("job/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("job/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message) + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -416,13 +503,44 @@ func (t *Tracker) runCanaryTracker(ctx context.Context, tr *canary.Tracker, errC } func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + out := statusOutput() + resourceName := fmt.Sprintf("canary/%s", tr.ResourceName) + var lastView CanaryStatusView + for { select { + case status := <-tr.Added: + view := CanaryStatusView{ + Phase: string(status.CanaryStatus.Phase), + IsFailed: status.IsFailed, + } + displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view) + lastView = view case <-tr.Succeeded: - t.logger.Debugf("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName) + displayCanaryStatus(out, formatResourceCaption(resourceName, true, false), CanaryStatusView{Phase: lastView.Phase}) + t.logger.Infof("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayCanaryStatus(out, formatResourceCaption(resourceName, false, true), CanaryStatusView{ + Phase: status.FailedReason, + IsFailed: true, + }) return fmt.Errorf("canary %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + view := CanaryStatusView{ + Phase: func() string { + if status.StatusIndicator != nil { + return status.StatusIndicator.Value + } + return "" + }(), + Age: status.Age, + IsFailed: status.IsFailed, + } + displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view) + lastView = view + case msg := <-tr.EventMsg: + t.logger.Infof("canary/%s: %s", tr.ResourceName, msg) case err := <-trackErrCh: return err case <-doneCh: @@ -433,6 +551,12 @@ func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, tra } } +func (t *Tracker) logPodLogChunk(podName string, logLines []display.LogLine) { + for _, line := range logLines { + t.logger.Infof("po/%s [%s] %s", podName, line.Timestamp, line.Message) + } +} + func (t *Tracker) buildTargets(resources []*resource.Resource) []trackTarget { var targets []trackTarget for _, res := range resources { From 2a1574b383e84ea0bc92a1fd794fec78de349257 Mon Sep 17 00:00:00 2001 From: Hani Harzallah Date: Thu, 21 May 2026 14:47:00 +0200 Subject: [PATCH 09/17] feat: show diff preview when sync --interactive is used (#2603) * feat: show diff preview when sync --interactive is used Signed-off-by: vomba --- cmd/sync.go | 15 +++ pkg/app/app.go | 100 +++++++++++++++--- pkg/app/app_sync_test.go | 220 ++++++++++++++++++++++++++++++++++++++- pkg/config/sync.go | 80 ++++++++++++++ 4 files changed, 397 insertions(+), 18 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index 4b8985ca..cd735e3f 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -60,5 +60,20 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.BoolVar(&syncOptions.TrackFailOnError, "track-fail-on-error", false, "Fail with non-zero exit code when kubedog tracking fails") f.StringVar(&syncOptions.Description, "description", "", `Set description for all releases. If set, overrides descriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`) + // Diff-related flags for --interactive mode + f.IntVar(&syncOptions.Context, "context", 0, "output NUM lines of context around changes (interactive preview only)") + f.StringVar(&syncOptions.DiffOutput, "output", "", "output format for diff plugin (interactive preview only)") + f.StringVar(&syncOptions.DiffArgs, "diff-args", "", "pass args to helm-diff (interactive preview only)") + f.StringArrayVar(&syncOptions.Suppress, "suppress", nil, "suppress specified Kubernetes objects in the diff output (interactive preview only). Can be provided multiple times. For example: --suppress KeycloakClient --suppress VaultSecret") + f.BoolVar(&syncOptions.SuppressSecrets, "suppress-secrets", false, "suppress secrets in the diff output (interactive preview only). highly recommended to specify on CI/CD use-cases") + f.BoolVar(&syncOptions.ShowSecrets, "show-secrets", false, "do not redact secret values in the diff output (interactive preview only). should be used for debug purpose only") + f.BoolVar(&syncOptions.NoHooks, "no-hooks", false, "do not diff changes made by hooks (interactive preview only)") + f.BoolVar(&syncOptions.SuppressDiff, "suppress-diff", false, "suppress diff in the output (interactive preview only). Usable in new installs") + f.BoolVar(&syncOptions.SkipDiffOnInstall, "skip-diff-on-install", false, "Skips running helm-diff on releases being newly installed on this sync (interactive preview only). Useful when the release manifests are too huge to be reviewed, or it's too time-consuming to diff at all") + f.BoolVar(&syncOptions.IncludeTests, "include-tests", false, "enable the diffing of the helm test hooks (interactive preview only)") + f.BoolVar(&syncOptions.DetailedExitcode, "detailed-exitcode", false, "return a non-zero exit code 2 instead of 0 when releases are synced (use --interactive to also see a diff preview)") + f.BoolVar(&syncOptions.StripTrailingCR, "strip-trailing-cr", false, "strip trailing carriage return on input (interactive preview only)") + f.StringArrayVar(&syncOptions.SuppressOutputLineRegex, "suppress-output-line-regex", nil, "a list of regex patterns to suppress output lines from diff output (interactive preview only)") + return cmd } diff --git a/pkg/app/app.go b/pkg/app/app.go index 64f12b68..f26f8941 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -484,7 +484,11 @@ func (a *App) Fetch(c FetchConfigProvider) error { } func (a *App) Sync(c SyncConfigProvider) error { - return a.ForEachState(func(run *Run) (ok bool, errs []error) { + var any bool + + mut := &sync.Mutex{} + + err := a.ForEachState(func(run *Run) (ok bool, errs []error) { includeCRDs := !c.SkipCRDs() prepErr := run.WithPreparedCharts("sync", state.ChartPrepareOptions{ @@ -500,7 +504,14 @@ func (a *App) Sync(c SyncConfigProvider) error { Validate: c.Validate(), Concurrency: c.Concurrency(), }, func() []error { - ok, errs = a.SyncState(run, c) + matched, updated, es := a.SyncState(run, c) + + mut.Lock() + any = any || updated + mut.Unlock() + + ok = matched + errs = es return errs }) @@ -510,6 +521,18 @@ func (a *App) Sync(c SyncConfigProvider) error { return }, c.IncludeTransitiveNeeds()) + + if err != nil { + return err + } + + if ec, ok := c.(interface{ DetailedExitcode() bool }); ok && ec.DetailedExitcode() && any { + code := 2 + + return &Error{msg: "", code: &code} + } + + return nil } func (a *App) Apply(c ApplyConfigProvider) error { @@ -2210,16 +2233,16 @@ func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) { return true, errs } -func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, []error) { +func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, bool, []error) { st := r.state helm := r.helm releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds()) if err != nil { - return false, []error{err} + return false, false, []error{err} } if len(releasesWithNeeds) == 0 { - return false, nil + return false, false, nil } // Do build deps and prepare only on selected releases so that we won't waste time @@ -2228,7 +2251,7 @@ func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, []error) { toDelete, err := st.DetectReleasesToBeDeletedForSync(helm, releasesWithNeeds) if err != nil { - return false, []error{err} + return false, false, []error{err} } releasesToDelete := map[string]state.ReleaseSpec{} @@ -2279,9 +2302,57 @@ func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, []error) { // Make the output deterministic for testing purpose sort.Strings(names) - infoMsg := fmt.Sprintf(`Affected releases are: + interactive := c.Interactive() + + var infoMsg string + var errs []error + + r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...) + + operationsAttempted := false + + if interactive { + if diffC, ok := c.(DiffConfigProvider); ok { + detectedKubeVersion := a.detectKubeVersion(st) + diffOpts := &state.DiffOpts{ + Context: diffC.Context(), + Output: diffC.DiffOutput(), + Color: diffC.Color(), + NoColor: diffC.NoColor(), + Set: diffC.Set(), + DiffArgs: diffC.DiffArgs(), + SkipDiffOnInstall: diffC.SkipDiffOnInstall(), + ReuseValues: diffC.ReuseValues(), + ResetValues: diffC.ResetValues(), + PostRenderer: diffC.PostRenderer(), + PostRendererArgs: diffC.PostRendererArgs(), + SkipSchemaValidation: diffC.SkipSchemaValidation(), + SuppressOutputLineRegex: diffC.SuppressOutputLineRegex(), + TakeOwnership: diffC.TakeOwnership(), + DetectedKubeVersion: detectedKubeVersion, + } + infoMsgPtr, _, _, diffErrs := r.diff(false, diffC.DetailedExitcode(), diffC, diffOpts) + if len(diffErrs) > 0 { + return false, false, diffErrs + } + if infoMsgPtr != nil { + infoMsg = *infoMsgPtr + } else { + infoMsg = fmt.Sprintf(`Affected releases are: %s `, strings.Join(names, "\n")) + } + } else { + infoMsg = fmt.Sprintf(`Affected releases are: +%s +`, strings.Join(names, "\n")) + } + } else { + infoMsg = fmt.Sprintf(`Affected releases are: +%s +`, strings.Join(names, "\n")) + a.Logger.Debug(infoMsg) + } confMsg := fmt.Sprintf(`%s Do you really want to sync? @@ -2289,15 +2360,6 @@ Do you really want to sync? `, infoMsg) - interactive := c.Interactive() - if !interactive { - a.Logger.Debug(infoMsg) - } - - var errs []error - - r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...) - // Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies st.Releases = selectedAndNeededReleases @@ -2305,6 +2367,7 @@ Do you really want to sync? if !interactive || interactive && r.askForConfirmation(confMsg) { if len(releasesToDelete) > 0 { + operationsAttempted = true _, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error { var rs []state.ReleaseSpec @@ -2326,6 +2389,7 @@ Do you really want to sync? } if len(releasesToUpdate) > 0 { + operationsAttempted = true _, syncErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error { var rs []state.ReleaseSpec @@ -2378,7 +2442,9 @@ Do you really want to sync? } } - return true, errs + changesApplied := operationsAttempted && len(errs) == 0 + + return true, changesApplied, errs } func (a *App) template(r *Run, c TemplateConfigProvider) (bool, []error) { diff --git a/pkg/app/app_sync_test.go b/pkg/app/app_sync_test.go index ce38e77e..7f10f30e 100644 --- a/pkg/app/app_sync_test.go +++ b/pkg/app/app_sync_test.go @@ -14,6 +14,187 @@ import ( "github.com/helmfile/helmfile/pkg/helmexec" ) +func TestSyncInteractive(t *testing.T) { + type testcase struct { + interactive bool + confirm bool + error string + files map[string]string + selectors []string + lists map[exectest.ListKey]string + diffs map[exectest.DiffKey]error + wantDiffs int + upgraded []exectest.Release + deleted []exectest.Release + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + wantUpgrades := tc.upgraded + wantDeletes := tc.deleted + + var helm = &exectest.Helm{ + FailOnUnexpectedList: true, + FailOnUnexpectedDiff: true, + Lists: tc.lists, + Diffs: tc.diffs, + DiffMutex: &sync.Mutex{}, + ChartsMutex: &sync.Mutex{}, + ReleasesMutex: &sync.Mutex{}, + } + + bs := runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) { + t.Helper() + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Errorf("unexpected error creating vals runtime: %v", err) + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: ffs.DefaultFileSystem(), + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + helms: map[helmKey]helmexec.Interface{ + createHelmKey("helm", "default"): helm, + }, + valsRuntime: valsRuntime, + }, tc.files) + + if tc.selectors != nil { + app.Selectors = tc.selectors + } + + // Use ForEachState to gain access to the Run so we can inject Ask + forEachErr := app.ForEachState(func(run *Run) (bool, []error) { + run.Ask = func(msg string) bool { + return tc.confirm + } + ok, _, errs := app.SyncState(run, applyConfig{ + concurrency: 1, + interactive: tc.interactive, + skipNeeds: true, + logger: logger, + }) + return ok, errs + }, false) + + var gotErr string + if forEachErr != nil { + gotErr = forEachErr.Error() + } + + if d := cmp.Diff(tc.error, gotErr); d != "" { + t.Fatalf("unexpected error: want (-), got (+): %s", d) + } + + if len(wantUpgrades) > len(helm.Releases) { + t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades)) + } + + for relIdx := range wantUpgrades { + if wantUpgrades[relIdx].Name != helm.Releases[relIdx].Name { + t.Errorf("releases[%d].name: got %q, want %q", relIdx, helm.Releases[relIdx].Name, wantUpgrades[relIdx].Name) + } + for flagIdx := range wantUpgrades[relIdx].Flags { + if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] { + t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx]) + } + } + } + + if len(helm.Diffed) != tc.wantDiffs { + t.Fatalf("unexpected number of diffs: got %d, want %d", len(helm.Diffed), tc.wantDiffs) + } + + if len(wantDeletes) > len(helm.Deleted) { + t.Fatalf("insufficient number of deletes: got %d, want %d", len(helm.Deleted), len(wantDeletes)) + } + }) + + _ = bs + } + + t.Run("non-interactive: sync proceeds without diff", func(t *testing.T) { + check(t, testcase{ + interactive: false, + confirm: false, + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: my-release + chart: incubator/raw + namespace: default +`, + }, + upgraded: []exectest.Release{ + {Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + }) + }) + + t.Run("interactive with diff: user confirms", func(t *testing.T) { + check(t, testcase{ + interactive: true, + confirm: true, + wantDiffs: 1, + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: my-release + chart: incubator/raw + namespace: default +`, + }, + upgraded: []exectest.Release{ + {Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}}, + }, + diffs: map[exectest.DiffKey]error{ + {Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values"}: helmexec.ExitError{Code: 2}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + }) + }) + + t.Run("interactive with diff: user rejects", func(t *testing.T) { + check(t, testcase{ + interactive: true, + confirm: false, + wantDiffs: 1, + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: my-release + chart: incubator/raw + namespace: default +`, + }, + upgraded: []exectest.Release{}, + diffs: map[exectest.DiffKey]error{ + {Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values"}: helmexec.ExitError{Code: 2}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + }) + }) +} + func TestSync(t *testing.T) { type fields struct { skipNeeds bool @@ -27,7 +208,9 @@ func TestSync(t *testing.T) { concurrency int timeout int skipDiffOnInstall bool + detailedExitcode bool error string + errorCode int files map[string]string selectors []string lists map[exectest.ListKey]string @@ -88,6 +271,7 @@ func TestSync(t *testing.T) { skipNeeds: tc.fields.skipNeeds, includeNeeds: tc.fields.includeNeeds, includeTransitiveNeeds: tc.fields.includeTransitiveNeeds, + detailedExitcode: tc.detailedExitcode, }) var gotErr string @@ -99,6 +283,16 @@ func TestSync(t *testing.T) { t.Fatalf("unexpected error: want (-), got (+): %s", d) } + if tc.errorCode >= 0 { + var gotCode int + if appErr, ok := syncErr.(*Error); ok && appErr != nil { + gotCode = appErr.Code() + } + if tc.errorCode != gotCode { + t.Fatalf("unexpected error code: got %d, want %d", gotCode, tc.errorCode) + } + } + if len(wantUpgrades) > len(helm.Releases) { t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades)) } @@ -109,7 +303,7 @@ func TestSync(t *testing.T) { } for flagIdx := range wantUpgrades[relIdx].Flags { if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] { - t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx]) + t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx]) } } } @@ -499,6 +693,30 @@ releases: lists: map[exectest.ListKey]string{ {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + }) + }) + + t.Run("detailed-exitcode returns exit code 2 on successful sync", func(t *testing.T) { + check(t, testcase{ + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: my-release + chart: incubator/raw + namespace: default +`, + }, + detailedExitcode: true, + errorCode: 2, + concurrency: 1, + upgraded: []exectest.Release{ + {Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default `, }, }) diff --git a/pkg/config/sync.go b/pkg/config/sync.go index a838cc1f..4b9b58af 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -63,6 +63,21 @@ type SyncOptions struct { TrackFailOnError bool // Description is the description that will be passed to helm upgrade --description Description string + + // Diff-related options for --interactive mode + SuppressOutputLineRegex []string + IncludeTests bool + Suppress []string + SuppressSecrets bool + ShowSecrets bool + NoHooks bool + SuppressDiff bool + SkipDiffOnInstall bool + DiffArgs string + DetailedExitcode bool + StripTrailingCR bool + Context int + DiffOutput string } // NewSyncOptions creates a new Apply @@ -228,6 +243,71 @@ func (t *SyncImpl) Description() string { return t.SyncOptions.Description } +// SuppressOutputLineRegex returns the SuppressOutputLineRegex. +func (t *SyncImpl) SuppressOutputLineRegex() []string { + return t.SyncOptions.SuppressOutputLineRegex +} + +// IncludeTests returns the IncludeTests. +func (t *SyncImpl) IncludeTests() bool { + return t.SyncOptions.IncludeTests +} + +// Suppress returns the Suppress. +func (t *SyncImpl) Suppress() []string { + return t.SyncOptions.Suppress +} + +// SuppressSecrets returns the SuppressSecrets. +func (t *SyncImpl) SuppressSecrets() bool { + return t.SyncOptions.SuppressSecrets +} + +// ShowSecrets returns the ShowSecrets. +func (t *SyncImpl) ShowSecrets() bool { + return t.SyncOptions.ShowSecrets +} + +// NoHooks returns the NoHooks. +func (t *SyncImpl) NoHooks() bool { + return t.SyncOptions.NoHooks +} + +// SuppressDiff returns the SuppressDiff. +func (t *SyncImpl) SuppressDiff() bool { + return t.SyncOptions.SuppressDiff +} + +// SkipDiffOnInstall returns the SkipDiffOnInstall. +func (t *SyncImpl) SkipDiffOnInstall() bool { + return t.SyncOptions.SkipDiffOnInstall +} + +// DiffArgs returns the DiffArgs. +func (t *SyncImpl) DiffArgs() string { + return t.SyncOptions.DiffArgs +} + +// DetailedExitcode returns the DetailedExitcode. +func (t *SyncImpl) DetailedExitcode() bool { + return t.SyncOptions.DetailedExitcode +} + +// StripTrailingCR returns the StripTrailingCR. +func (t *SyncImpl) StripTrailingCR() bool { + return t.SyncOptions.StripTrailingCR +} + +// Context returns the Context. +func (t *SyncImpl) Context() int { + return t.SyncOptions.Context +} + +// DiffOutput returns the DiffOutput. +func (t *SyncImpl) DiffOutput() string { + return t.SyncOptions.DiffOutput +} + func (t *SyncImpl) ValidateConfig() error { validTrackModes := []string{"helm", "helm-legacy", "kubedog"} if t.SyncOptions.TrackMode != "" && !slices.Contains(validTrackModes, t.SyncOptions.TrackMode) { From 8e2ddb863f0b48bab73470b27fa61cfa6361601f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 06:32:32 +0800 Subject: [PATCH 10/17] build(deps): bump github.com/containerd/containerd from 1.7.30 to 1.7.32 (#2607) Bumps [github.com/containerd/containerd](https://github.com/containerd/containerd) from 1.7.30 to 1.7.32. - [Release notes](https://github.com/containerd/containerd/releases) - [Changelog](https://github.com/containerd/containerd/blob/main/RELEASES.md) - [Commits](https://github.com/containerd/containerd/compare/v1.7.30...v1.7.32) --- updated-dependencies: - dependency-name: github.com/containerd/containerd dependency-version: 1.7.32 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index e5f6af63..ad1686fd 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/gofrs/flock v0.13.0 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 + github.com/gookit/color v1.5.4 github.com/gosuri/uitable v0.0.4 github.com/hashicorp/go-cty-funcs v0.1.0 github.com/hashicorp/go-getter/v2 v2.2.3 @@ -192,7 +193,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect - github.com/containerd/containerd v1.7.30 // indirect + github.com/containerd/containerd v1.7.32 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect @@ -254,7 +255,6 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/gookit/color v1.5.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect diff --git a/go.sum b/go.sum index 711cd6c9..9e4ac6eb 100644 --- a/go.sum +++ b/go.sum @@ -227,12 +227,14 @@ github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= -github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE= -github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= +github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= +github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -267,6 +269,7 @@ github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= +github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -479,6 +482,7 @@ github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukg github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -613,6 +617,12 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= +github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM= +github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -779,6 +789,10 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From 33eadc993e0ee77de91914afd0ab00042c498232 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Fri, 22 May 2026 03:16:52 +0200 Subject: [PATCH 11/17] feat: support HELMFILE_* env vars for more global flags (#2606) * feat: support more HELMFILE_* env vars as flag fallbacks Adds env-var fallbacks for global flags, mirroring the existing HELMFILE_ENVIRONMENT / HELMFILE_KUBE_CONTEXT pattern: * --helm-binary -> HELMFILE_HELM_BINARY * --kustomize-binary -> HELMFILE_KUSTOMIZE_BINARY * --log-level -> HELMFILE_LOG_LEVEL * --debug -> HELMFILE_DEBUG (expecting "true" lower case) * --quiet -> HELMFILE_QUIET (expecting "true" lower case) * --no-color -> HELMFILE_NO_COLOR (expecting "true" lower case), additionally honors NO_COLOR per no-color.org (any non-empty value disables color) Flag values still take precedence; env vars are consulted only when the flag is unset. The string-flag default values ("helm", "kustomize", "info") move into the accessor methods so the env-var fallback can actually trigger when no flag is passed. Signed-off-by: Dominik Schmidt * docs: mention new HELMFILE_* env vars in cli.md and templating.md Signed-off-by: Dominik Schmidt * fix: make Color/NoColor/env interaction consistent Two issues with the env-aware NoColor() introduced together with HELMFILE_NO_COLOR / NO_COLOR support: 1. Color() consulted the raw GlobalOptions.NoColor field instead of NoColor(), so in a TTY with only the env set, Color() fell through to terminal autodetect and ValidateConfig() spuriously errored with "--color and --no-color cannot be specified at the same time". 2. NoColor() returned true via env even when --color was explicitly passed, so `helmfile --color` with NO_COLOR (or HELMFILE_NO_COLOR=true) in the environment hit the same ValidateConfig() error. A flag should always win over an env var. Fix both by routing Color() through NoColor() and giving NoColor() an explicit --color short-circuit. Regression tests added for both paths. Signed-off-by: Dominik Schmidt --------- Signed-off-by: Dominik Schmidt --- cmd/root.go | 22 +-- docs/cli.md | 12 +- docs/templating.md | 6 + pkg/config/global.go | 72 ++++++++- pkg/config/global_test.go | 301 ++++++++++++++++++++++++++++++++++++++ pkg/envvar/const.go | 6 + 6 files changed, 397 insertions(+), 22 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index c47f8688..6089aa8a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,6 +48,8 @@ func toCLIError(g *config.GlobalImpl, err error) error { // NewRootCmd creates the root command for the CLI. func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { + globalImpl := config.NewGlobalImpl(globalConfig) + cmd := &cobra.Command{ Use: "helmfile", Short: globalUsage, @@ -58,11 +60,11 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { PersistentPreRunE: func(c *cobra.Command, args []string) error { // Valid levels: // https://github.com/uber-go/zap/blob/7e7e266a8dbce911a49554b945538c5b950196b8/zapcore/level.go#L126 - logLevel := globalConfig.LogLevel + logLevel := globalImpl.LogLevel() switch { - case globalConfig.Debug: + case globalImpl.Debug(): logLevel = "debug" - case globalConfig.Quiet: + case globalImpl.Quiet(): logLevel = "warn" } @@ -83,8 +85,6 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { flags.ParseErrorsAllowlist.UnknownFlags = true - globalImpl := config.NewGlobalImpl(globalConfig) - // when set environment HELMFILE_UPGRADE_NOTICE_DISABLED any value, skip upgrade notice. var versionOpts []extension.CobraOption if os.Getenv(envvar.UpgradeNoticeDisabled) == "" { @@ -121,8 +121,8 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { } func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalOptions) { - fs.StringVarP(&globalOptions.HelmBinary, "helm-binary", "b", app.DefaultHelmBinary, "Path to the helm binary") - fs.StringVarP(&globalOptions.KustomizeBinary, "kustomize-binary", "k", app.DefaultKustomizeBinary, "Path to the kustomize binary") + fs.StringVarP(&globalOptions.HelmBinary, "helm-binary", "b", "", fmt.Sprintf(`Path to the helm binary. Overrides "HELMFILE_HELM_BINARY" OS environment variable when specified (default %q)`, app.DefaultHelmBinary)) + fs.StringVarP(&globalOptions.KustomizeBinary, "kustomize-binary", "k", "", fmt.Sprintf(`Path to the kustomize binary. Overrides "HELMFILE_KUSTOMIZE_BINARY" OS environment variable when specified (default %q)`, app.DefaultKustomizeBinary)) fs.StringVarP(&globalOptions.File, "file", "f", "", "load config from file or directory. defaults to \"`helmfile.yaml`\" or \"helmfile.yaml.gotmpl\" or \"helmfile.d\" (means \"helmfile.d/*.yaml\" or \"helmfile.d/*.yaml.gotmpl\") in this preference. Specify - to load the config from the standard input.") fs.StringVarP(&globalOptions.Environment, "environment", "e", "", `specify the environment name. Overrides "HELMFILE_ENVIRONMENT" OS environment variable when specified. defaults to "default"`) fs.StringArrayVar(&globalOptions.StateValuesSet, "state-values-set", nil, "set state values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2). Used to override .Values within the helmfile template (not values template).") @@ -134,13 +134,13 @@ func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalO fs.BoolVar(&globalOptions.DisableForceUpdate, "disable-force-update", false, `do not force helm repos to update when executing "helm repo add" (Helm 3 only)`) fs.BoolVar(&globalOptions.EnforcePluginVerification, "enforce-plugin-verification", false, `fail plugin installation if verification is not supported (for security purposes)`) fs.BoolVar(&globalOptions.HelmOCIPlainHTTP, "oci-plain-http", false, `use plain HTTP for OCI registries (required for local/insecure registries in Helm 4)`) - fs.BoolVarP(&globalOptions.Quiet, "quiet", "q", false, "Silence output. Equivalent to log-level warn") + fs.BoolVarP(&globalOptions.Quiet, "quiet", "q", false, `Silence output. Equivalent to log-level warn. Overrides "HELMFILE_QUIET" OS environment variable when specified`) fs.StringVar(&globalOptions.Kubeconfig, "kubeconfig", "", "Use a particular kubeconfig file") fs.StringVar(&globalOptions.KubeContext, "kube-context", "", `Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default`) - fs.BoolVar(&globalOptions.Debug, "debug", false, "Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect") + fs.BoolVar(&globalOptions.Debug, "debug", false, `Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect. Overrides "HELMFILE_DEBUG" OS environment variable when specified`) fs.BoolVar(&globalOptions.Color, "color", false, "Output with color") - fs.BoolVar(&globalOptions.NoColor, "no-color", false, "Output without color") - fs.StringVar(&globalOptions.LogLevel, "log-level", "info", "Set log level, default info") + fs.BoolVar(&globalOptions.NoColor, "no-color", false, `Output without color. Overrides "HELMFILE_NO_COLOR" and "NO_COLOR" OS environment variables when specified`) + fs.StringVar(&globalOptions.LogLevel, "log-level", "", `Set log level. Overrides "HELMFILE_LOG_LEVEL" OS environment variable when specified (default "info")`) fs.StringVarP(&globalOptions.Namespace, "namespace", "n", "", `Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}`) fs.StringVarP(&globalOptions.Chart, "chart", "c", "", "Set chart. Uses the chart set in release by default, and is available in template as {{ .Chart }}") fs.StringArrayVarP(&globalOptions.Selector, "selector", "l", nil, `Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar. diff --git a/docs/cli.md b/docs/cli.md index 217966b7..0636d4fe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,21 +40,21 @@ Flags: --allow-no-matching-release Do not exit with an error code if the provided selector has no matching releases. -c, --chart string Set chart. Uses the chart set in release by default, and is available in template as {{ .Chart }} --color Output with color - --debug Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect + --debug Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect. Overrides "HELMFILE_DEBUG" OS environment variable when specified --disable-force-update do not force helm repos to update when executing "helm repo add" --enable-live-output Show live output from the Helm binary Stdout/Stderr into Helmfile own Stdout/Stderr. It only applies for the Helm CLI commands, Stdout/Stderr for Hooks are still displayed only when it's execution finishes. -e, --environment string specify the environment name. Overrides "HELMFILE_ENVIRONMENT" OS environment variable when specified. defaults to "default" -f, --file helmfile.yaml load config from file or directory. defaults to "helmfile.yaml" or "helmfile.yaml.gotmpl" or "helmfile.d" (means "helmfile.d/*.yaml" or "helmfile.d/*.yaml.gotmpl") in this preference. Specify - to load the config from the standard input. - -b, --helm-binary string Path to the helm binary (default "helm") + -b, --helm-binary string Path to the helm binary. Overrides "HELMFILE_HELM_BINARY" OS environment variable when specified (default "helm") -h, --help help for helmfile -i, --interactive Request confirmation before attempting to modify clusters --kube-context string Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default - -k, --kustomize-binary string Path to the kustomize binary (default "kustomize") - --log-level string Set log level, default info (default "info") + -k, --kustomize-binary string Path to the kustomize binary. Overrides "HELMFILE_KUSTOMIZE_BINARY" OS environment variable when specified (default "kustomize") + --log-level string Set log level. Overrides "HELMFILE_LOG_LEVEL" OS environment variable when specified (default "info") -n, --namespace string Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }} - --no-color Output without color - -q, --quiet Silence output. Equivalent to log-level warn + --no-color Output without color. Overrides "HELMFILE_NO_COLOR" and "NO_COLOR" OS environment variables when specified + -q, --quiet Silence output. Equivalent to log-level warn. Overrides "HELMFILE_QUIET" OS environment variable when specified -l, --selector stringArray Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar. A release must match all labels in a group in order to be used. Multiple groups can be specified at once. "--selector tier=frontend,tier!=proxy --selector tier=backend" will match all frontend, non-proxy releases AND all backend releases. diff --git a/docs/templating.md b/docs/templating.md index b9e9bff1..5c1d1b96 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -68,6 +68,12 @@ Helmfile uses some OS environment variables to override default behaviour: * `HELMFILE_ENVIRONMENT` - specify [Helmfile environment](environments.md), it has lower priority than CLI argument `--environment` * `HELMFILE_KUBE_CONTEXT` - specify the kubectl context, it has lower priority than CLI argument `--kube-context` * `HELMFILE_NAMESPACE` - specify the namespace, it has lower priority than CLI argument `--namespace` +* `HELMFILE_HELM_BINARY` - specify the path to the helm binary, it has lower priority than CLI argument `--helm-binary` +* `HELMFILE_KUSTOMIZE_BINARY` - specify the path to the kustomize binary, it has lower priority than CLI argument `--kustomize-binary` +* `HELMFILE_LOG_LEVEL` - specify the log level, it has lower priority than CLI argument `--log-level` +* `HELMFILE_DEBUG` - enable debug output, expecting `true` lower case. The same as `--debug` CLI flag +* `HELMFILE_QUIET` - silence output (equivalent to log-level warn), expecting `true` lower case. The same as `--quiet`/`-q` CLI flag +* `HELMFILE_NO_COLOR` - disable colored output, expecting `true` lower case. The same as `--no-color` CLI flag. `NO_COLOR` (any non-empty value, per [no-color.org](https://no-color.org/)) is also honored * `HELMFILE_TEMPDIR` - specify directory to store temporary files * `HELMFILE_UPGRADE_NOTICE_DISABLED` - expecting any non-empty value to skip the check for the latest version of Helmfile in [helmfile version](cli.md#version) * `HELMFILE_GO_YAML_V3` - use *go.yaml.in/yaml/v3* instead of *go.yaml.in/yaml/v2*. It's `false` by default in Helmfile v0.x, and `true` in Helmfile v1.x. diff --git a/pkg/config/global.go b/pkg/config/global.go index d96655a5..b7378f10 100644 --- a/pkg/config/global.go +++ b/pkg/config/global.go @@ -109,12 +109,63 @@ func (g *GlobalImpl) SetSet(set map[string]any) { // HelmBinary returns the path to the Helm binary. func (g *GlobalImpl) HelmBinary() string { - return g.GlobalOptions.HelmBinary + var helmBinary string + + switch { + case g.GlobalOptions.HelmBinary != "": + helmBinary = g.GlobalOptions.HelmBinary + case os.Getenv("HELMFILE_HELM_BINARY") != "": + helmBinary = os.Getenv("HELMFILE_HELM_BINARY") + default: + helmBinary = state.DefaultHelmBinary + } + return helmBinary } // KustomizeBinary returns the path to the Kustomize binary. func (g *GlobalImpl) KustomizeBinary() string { - return g.GlobalOptions.KustomizeBinary + var kustomizeBinary string + + switch { + case g.GlobalOptions.KustomizeBinary != "": + kustomizeBinary = g.GlobalOptions.KustomizeBinary + case os.Getenv("HELMFILE_KUSTOMIZE_BINARY") != "": + kustomizeBinary = os.Getenv("HELMFILE_KUSTOMIZE_BINARY") + default: + kustomizeBinary = state.DefaultKustomizeBinary + } + return kustomizeBinary +} + +// LogLevel returns the log level to use. +func (g *GlobalImpl) LogLevel() string { + var logLevel string + + switch { + case g.GlobalOptions.LogLevel != "": + logLevel = g.GlobalOptions.LogLevel + case os.Getenv("HELMFILE_LOG_LEVEL") != "": + logLevel = os.Getenv("HELMFILE_LOG_LEVEL") + default: + logLevel = "info" + } + return logLevel +} + +// Debug returns whether debug output is enabled. +func (g *GlobalImpl) Debug() bool { + if g.GlobalOptions.Debug { + return true + } + return os.Getenv(envvar.Debug) == "true" +} + +// Quiet returns whether quiet output is enabled. +func (g *GlobalImpl) Quiet() bool { + if g.GlobalOptions.Quiet { + return true + } + return os.Getenv(envvar.Quiet) == "true" } // Kubeconfig returns the path to the kubeconfig file to use. @@ -242,7 +293,7 @@ func (g *GlobalImpl) Color() bool { return c } - if g.GlobalOptions.NoColor { + if g.NoColor() { return false } @@ -259,7 +310,18 @@ func (g *GlobalImpl) Color() bool { // NoColor returns the no color flag func (g *GlobalImpl) NoColor() bool { - return g.GlobalOptions.NoColor + if g.GlobalOptions.NoColor { + return true + } + // Explicit --color short-circuits env-derived no-color: a flag must win over an env var. + if g.GlobalOptions.Color { + return false + } + if os.Getenv(envvar.NoColor) == "true" { + return true + } + // Honor the de-facto https://no-color.org/ standard: any non-empty value disables color. + return os.Getenv("NO_COLOR") != "" } // Env returns the environment to use. @@ -296,7 +358,7 @@ func (g *GlobalImpl) Interactive() bool { // Args returns the args to use for helm func (g *GlobalImpl) Args() string { args := g.GlobalOptions.Args - enableHelmDebug := g.Debug + enableHelmDebug := g.Debug() if enableHelmDebug { args = fmt.Sprintf("%s %s", args, "--debug") diff --git a/pkg/config/global_test.go b/pkg/config/global_test.go index e5b91f4c..15547309 100644 --- a/pkg/config/global_test.go +++ b/pkg/config/global_test.go @@ -119,3 +119,304 @@ func TestNamespace(t *testing.T) { } os.Unsetenv(envvar.Namespace) } + +// TestHelmBinary tests the helm-binary flag and HELMFILE_HELM_BINARY env var fallback +func TestHelmBinary(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected string + }{ + { + opts: GlobalOptions{}, + env: "", + expected: "helm", + }, + { + opts: GlobalOptions{}, + env: "envset", + expected: "envset", + }, + { + opts: GlobalOptions{HelmBinary: "flagset"}, + env: "", + expected: "flagset", + }, + { + opts: GlobalOptions{HelmBinary: "flagset"}, + env: "envset", + expected: "flagset", + }, + } + + for _, test := range tests { + os.Setenv(envvar.HelmBinary, test.env) + received := NewGlobalImpl(&test.opts).HelmBinary() + require.Equalf(t, test.expected, received, "HelmBinary expected %s, received %s", test.expected, received) + } + os.Unsetenv(envvar.HelmBinary) +} + +// TestKustomizeBinary tests the kustomize-binary flag and HELMFILE_KUSTOMIZE_BINARY env var fallback +func TestKustomizeBinary(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected string + }{ + { + opts: GlobalOptions{}, + env: "", + expected: "kustomize", + }, + { + opts: GlobalOptions{}, + env: "envset", + expected: "envset", + }, + { + opts: GlobalOptions{KustomizeBinary: "flagset"}, + env: "", + expected: "flagset", + }, + { + opts: GlobalOptions{KustomizeBinary: "flagset"}, + env: "envset", + expected: "flagset", + }, + } + + for _, test := range tests { + os.Setenv(envvar.KustomizeBinary, test.env) + received := NewGlobalImpl(&test.opts).KustomizeBinary() + require.Equalf(t, test.expected, received, "KustomizeBinary expected %s, received %s", test.expected, received) + } + os.Unsetenv(envvar.KustomizeBinary) +} + +// TestLogLevel tests the log-level flag and HELMFILE_LOG_LEVEL env var fallback +func TestLogLevel(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected string + }{ + { + opts: GlobalOptions{}, + env: "", + expected: "info", + }, + { + opts: GlobalOptions{}, + env: "envset", + expected: "envset", + }, + { + opts: GlobalOptions{LogLevel: "flagset"}, + env: "", + expected: "flagset", + }, + { + opts: GlobalOptions{LogLevel: "flagset"}, + env: "envset", + expected: "flagset", + }, + } + + for _, test := range tests { + os.Setenv(envvar.LogLevel, test.env) + received := NewGlobalImpl(&test.opts).LogLevel() + require.Equalf(t, test.expected, received, "LogLevel expected %s, received %s", test.expected, received) + } + os.Unsetenv(envvar.LogLevel) +} + +// TestDebug tests the debug flag and HELMFILE_DEBUG env var fallback +func TestDebug(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected bool + }{ + { + opts: GlobalOptions{}, + env: "", + expected: false, + }, + { + opts: GlobalOptions{}, + env: "true", + expected: true, + }, + { + opts: GlobalOptions{}, + env: "anything", + expected: false, + }, + { + opts: GlobalOptions{Debug: true}, + env: "", + expected: true, + }, + { + opts: GlobalOptions{Debug: true}, + env: "true", + expected: true, + }, + } + + for _, test := range tests { + os.Setenv(envvar.Debug, test.env) + received := NewGlobalImpl(&test.opts).Debug() + require.Equalf(t, test.expected, received, "Debug expected %t, received %t", test.expected, received) + } + os.Unsetenv(envvar.Debug) +} + +// TestQuiet tests the quiet flag and HELMFILE_QUIET env var fallback +func TestQuiet(t *testing.T) { + tests := []struct { + opts GlobalOptions + env string + expected bool + }{ + { + opts: GlobalOptions{}, + env: "", + expected: false, + }, + { + opts: GlobalOptions{}, + env: "true", + expected: true, + }, + { + opts: GlobalOptions{}, + env: "anything", + expected: false, + }, + { + opts: GlobalOptions{Quiet: true}, + env: "", + expected: true, + }, + { + opts: GlobalOptions{Quiet: true}, + env: "true", + expected: true, + }, + } + + for _, test := range tests { + os.Setenv(envvar.Quiet, test.env) + received := NewGlobalImpl(&test.opts).Quiet() + require.Equalf(t, test.expected, received, "Quiet expected %t, received %t", test.expected, received) + } + os.Unsetenv(envvar.Quiet) +} + +// TestNoColor tests the no-color flag, HELMFILE_NO_COLOR and NO_COLOR env var fallbacks +func TestNoColor(t *testing.T) { + tests := []struct { + opts GlobalOptions + helmfileEnv string + standardEnv string + expected bool + }{ + { + opts: GlobalOptions{}, + helmfileEnv: "", + standardEnv: "", + expected: false, + }, + { + opts: GlobalOptions{}, + helmfileEnv: "true", + standardEnv: "", + expected: true, + }, + { + opts: GlobalOptions{}, + helmfileEnv: "anything", + standardEnv: "", + expected: false, + }, + { + opts: GlobalOptions{}, + helmfileEnv: "", + standardEnv: "1", + expected: true, + }, + { + opts: GlobalOptions{}, + helmfileEnv: "", + standardEnv: "anything", + expected: true, + }, + { + opts: GlobalOptions{NoColor: true}, + helmfileEnv: "", + standardEnv: "", + expected: true, + }, + } + + for _, test := range tests { + os.Setenv(envvar.NoColor, test.helmfileEnv) + os.Setenv("NO_COLOR", test.standardEnv) + received := NewGlobalImpl(&test.opts).NoColor() + require.Equalf(t, test.expected, received, "NoColor expected %t, received %t", test.expected, received) + } + os.Unsetenv(envvar.NoColor) + os.Unsetenv("NO_COLOR") +} + +// TestColorRespectsNoColorEnv guards against ValidateConfig() firing when +// HELMFILE_NO_COLOR / NO_COLOR is set without an explicit --color/--no-color flag. +// Color() must consult NoColor() (which is env-aware) before falling back to TTY autodetect. +func TestColorRespectsNoColorEnv(t *testing.T) { + tests := []struct { + name string + helmfileEnv string + standardEnv string + }{ + {name: "HELMFILE_NO_COLOR=true", helmfileEnv: "true"}, + {name: "NO_COLOR set", standardEnv: "1"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv(envvar.NoColor, test.helmfileEnv) + t.Setenv("NO_COLOR", test.standardEnv) + g := NewGlobalImpl(&GlobalOptions{}) + require.True(t, g.NoColor(), "NoColor() should be true when env is set") + require.False(t, g.Color(), "Color() should be false when NoColor() is true via env") + require.NoError(t, g.ValidateConfig(), "ValidateConfig() should not error from env-only no-color") + }) + } +} + +// TestColorFlagOverridesNoColorEnv guards against ValidateConfig() firing when +// --color is explicitly passed but HELMFILE_NO_COLOR / NO_COLOR is set in the +// environment. The flag must win over the env var. +func TestColorFlagOverridesNoColorEnv(t *testing.T) { + tests := []struct { + name string + helmfileEnv string + standardEnv string + }{ + {name: "HELMFILE_NO_COLOR=true", helmfileEnv: "true"}, + {name: "NO_COLOR set", standardEnv: "1"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv(envvar.NoColor, test.helmfileEnv) + t.Setenv("NO_COLOR", test.standardEnv) + g := NewGlobalImpl(&GlobalOptions{Color: true}) + require.True(t, g.Color(), "Color() should be true when --color is set") + require.False(t, g.NoColor(), "NoColor() should be false when --color is set, even if env says otherwise") + require.NoError(t, g.ValidateConfig(), "ValidateConfig() should not error when --color overrides env no-color") + }) + } +} diff --git a/pkg/envvar/const.go b/pkg/envvar/const.go index d6f1575f..7a2e4542 100644 --- a/pkg/envvar/const.go +++ b/pkg/envvar/const.go @@ -11,6 +11,12 @@ const ( Environment = "HELMFILE_ENVIRONMENT" KubeContext = "HELMFILE_KUBE_CONTEXT" Namespace = "HELMFILE_NAMESPACE" + HelmBinary = "HELMFILE_HELM_BINARY" + KustomizeBinary = "HELMFILE_KUSTOMIZE_BINARY" + LogLevel = "HELMFILE_LOG_LEVEL" + Debug = "HELMFILE_DEBUG" + Quiet = "HELMFILE_QUIET" + NoColor = "HELMFILE_NO_COLOR" FilePath = "HELMFILE_FILE_PATH" TempDir = "HELMFILE_TEMPDIR" UpgradeNoticeDisabled = "HELMFILE_UPGRADE_NOTICE_DISABLED" From 31d424f502b7e3ab20efbde769933eb1fdf85fd8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 07:41:07 +0800 Subject: [PATCH 12/17] build(deps): bump github.com/gookit/color from 1.5.4 to 1.6.1 (#2608) Bumps [github.com/gookit/color](https://github.com/gookit/color) from 1.5.4 to 1.6.1. - [Release notes](https://github.com/gookit/color/releases) - [Commits](https://github.com/gookit/color/compare/v1.5.4...v1.6.1) --- updated-dependencies: - dependency-name: github.com/gookit/color dependency-version: 1.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ad1686fd..4bd43d0e 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/gofrs/flock v0.13.0 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 - github.com/gookit/color v1.5.4 + github.com/gookit/color v1.6.1 github.com/gosuri/uitable v0.0.4 github.com/hashicorp/go-cty-funcs v0.1.0 github.com/hashicorp/go-getter/v2 v2.2.3 diff --git a/go.sum b/go.sum index 9e4ac6eb..0465cc8e 100644 --- a/go.sum +++ b/go.sum @@ -469,8 +469,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= -github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= -github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= +github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= +github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= +github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= From 2c5eb919091f7c9050b06aa05357f13252079ccb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:32:31 +0800 Subject: [PATCH 13/17] build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.17 to 1.32.18 (#2610) build(deps): bump github.com/aws/aws-sdk-go-v2/config Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.17 to 1.32.18. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.17...config/v1.32.18) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/config dependency-version: 1.32.18 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 4bd43d0e..20092353 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( dario.cat/mergo v1.0.2 github.com/Masterminds/semver/v3 v3.5.0 github.com/Masterminds/sprig/v3 v3.3.0 - github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/config v1.32.18 github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-test/deep v1.1.1 @@ -166,7 +166,7 @@ require ( github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect @@ -181,7 +181,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect github.com/aws/smithy-go v1.25.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/go.sum b/go.sum index 0465cc8e..68744322 100644 --- a/go.sum +++ b/go.sum @@ -156,10 +156,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6t github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= +github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 h1:1i1SUOTLk0TbMh7+eJYxgv1r1f47BfR69LL6yaELoI0= @@ -190,8 +190,8 @@ github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5 h1:TY5Vh7uXQgJVuc6ahI6toLcRajG1 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5/go.mod h1:UkzShnbxHRIIL2cHi/7fBGLUAZIVTEADQjaA53bWWCE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= From dc336a54f6543f7c9cbae24083dd6eb88c97d095 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 18:10:00 +0800 Subject: [PATCH 14/17] build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.101.0 to 1.102.0 (#2612) build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.101.0 to 1.102.0. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.101.0...service/s3/v1.102.0) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/service/s3 dependency-version: 1.102.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 20092353..86b00f25 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/aws/aws-sdk-go-v2/config v1.32.18 - github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-test/deep v1.1.1 github.com/gofrs/flock v0.13.0 @@ -173,7 +173,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 // indirect diff --git a/go.sum b/go.sum index 68744322..785828bb 100644 --- a/go.sum +++ b/go.sum @@ -172,16 +172,16 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16 h1:tX68nPDCoX0s2ksM7CipWP0QFw2hGDWwUdxI6+eT9ZU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 h1:696UM+NwOrETBCLQJyCAGtVmmZmziBT59yMwgg6Fvrw= github.com/aws/aws-sdk-go-v2/service/kms v1.51.0/go.mod h1:GBO/aaEi47QldDVoqw2CsM2UZQDoqDiFIMJD/ztHPs0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0 h1:gfPQ6do5PZTCc5n/vZUHz/G8McrNrfERGSO+iHvVbCA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0/go.mod h1:wO6U9egJtCtsZEHG2AAcFf1kUWDRrH0Iif6K3bVmmdE= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 h1:XR42AXidhYs4HwH0I+yElLXVt7zb2hAyNHQJe6Blv7w= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6/go.mod h1:nOTsSVQlAsgwVRdtZYtECSnsInF8IUhrpnclCPat7Fs= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= From d0064ea37df3cd3cf61a25c4b77a7a2eaba9bd9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 09:29:48 +0800 Subject: [PATCH 15/17] build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.102.0 to 1.102.1 (#2613) build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.102.0 to 1.102.1. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.102.0...service/s3/v1.102.1) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/service/s3 dependency-version: 1.102.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 86b00f25..6a40b32e 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/aws/aws-sdk-go-v2/config v1.32.18 - github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-test/deep v1.1.1 github.com/gofrs/flock v0.13.0 @@ -164,18 +164,18 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.8 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 // indirect github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect diff --git a/go.sum b/go.sum index 785828bb..ab5b67ef 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,8 @@ github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9E github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774/go.mod h1:5wi5YYOpfuAKwL5XLFYopbgIl/v7NZxaJpa/4X6yFKE= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk= +github.com/aws/aws-sdk-go-v2 v1.41.8/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= @@ -164,24 +164,24 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8Tc github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 h1:1i1SUOTLk0TbMh7+eJYxgv1r1f47BfR69LL6yaELoI0= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2/go.mod h1:bo7DhmS/OyVeAJTC768nEk92YKWskqJ4gn0gB5e59qQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH3RdWKWqHULlTh7zHwb35Womf74= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16 h1:tX68nPDCoX0s2ksM7CipWP0QFw2hGDWwUdxI6+eT9ZU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17 h1:Zma31M1f9bbD/bsl6haTxupA0+z72L3l2ujKAH37zuI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17/go.mod h1:ZNHrGwBST3tZxBCTKbindx0BEdPN0Jnh7yJ7EVnktUM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24 h1:yPLVC8Lbsw92eepgdIZCChHRNQek5eAvAz5wS+UIpJE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24/go.mod h1:H2h39H1AivHYkozUIUYoVJGMUOvdJ4Lv9DLyUSMAjW8= github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 h1:696UM+NwOrETBCLQJyCAGtVmmZmziBT59yMwgg6Fvrw= github.com/aws/aws-sdk-go-v2/service/kms v1.51.0/go.mod h1:GBO/aaEi47QldDVoqw2CsM2UZQDoqDiFIMJD/ztHPs0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0 h1:gfPQ6do5PZTCc5n/vZUHz/G8McrNrfERGSO+iHvVbCA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0/go.mod h1:wO6U9egJtCtsZEHG2AAcFf1kUWDRrH0Iif6K3bVmmdE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1 h1:vttIo8BQwfnhimKRBZBBF3Y38SAIxif72B/M91m9hDk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1/go.mod h1:2qjInACJr84m/Tm4XXCcVNpejmbKy9kz7TEa6viQHSk= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 h1:XR42AXidhYs4HwH0I+yElLXVt7zb2hAyNHQJe6Blv7w= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6/go.mod h1:nOTsSVQlAsgwVRdtZYtECSnsInF8IUhrpnclCPat7Fs= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= From fbf4f31c028ca076ce00a7cc6eedd30eee3bea78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 10:44:13 +0800 Subject: [PATCH 16/17] build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.18 to 1.32.20 (#2614) build(deps): bump github.com/aws/aws-sdk-go-v2/config Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.18 to 1.32.20. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.18...config/v1.32.20) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/config dependency-version: 1.32.19 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 28 ++++++++++++++-------------- go.sum | 56 ++++++++++++++++++++++++++++---------------------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 6a40b32e..0f540211 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( dario.cat/mergo v1.0.2 github.com/Masterminds/semver/v3 v3.5.0 github.com/Masterminds/sprig/v3 v3.3.0 - github.com/aws/aws-sdk-go-v2/config v1.32.18 + github.com/aws/aws-sdk-go-v2/config v1.32.20 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-test/deep v1.1.1 @@ -164,26 +164,26 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.8 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 // indirect github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aws/smithy-go v1.25.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect + github.com/aws/smithy-go v1.26.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum index ab5b67ef..bc0c7dd7 100644 --- a/go.sum +++ b/go.sum @@ -152,30 +152,30 @@ github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9E github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774/go.mod h1:5wi5YYOpfuAKwL5XLFYopbgIl/v7NZxaJpa/4X6yFKE= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= -github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk= -github.com/aws/aws-sdk-go-v2 v1.41.8/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= -github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 h1:1i1SUOTLk0TbMh7+eJYxgv1r1f47BfR69LL6yaELoI0= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2/go.mod h1:bo7DhmS/OyVeAJTC768nEk92YKWskqJ4gn0gB5e59qQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH3RdWKWqHULlTh7zHwb35Womf74= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17 h1:Zma31M1f9bbD/bsl6haTxupA0+z72L3l2ujKAH37zuI= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17/go.mod h1:ZNHrGwBST3tZxBCTKbindx0BEdPN0Jnh7yJ7EVnktUM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24 h1:yPLVC8Lbsw92eepgdIZCChHRNQek5eAvAz5wS+UIpJE= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24/go.mod h1:H2h39H1AivHYkozUIUYoVJGMUOvdJ4Lv9DLyUSMAjW8= github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 h1:696UM+NwOrETBCLQJyCAGtVmmZmziBT59yMwgg6Fvrw= @@ -184,18 +184,18 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1 h1:vttIo8BQwfnhimKRBZBBF3Y38SAI github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1/go.mod h1:2qjInACJr84m/Tm4XXCcVNpejmbKy9kz7TEa6viQHSk= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 h1:XR42AXidhYs4HwH0I+yElLXVt7zb2hAyNHQJe6Blv7w= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6/go.mod h1:nOTsSVQlAsgwVRdtZYtECSnsInF8IUhrpnclCPat7Fs= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5 h1:TY5Vh7uXQgJVuc6ahI6toLcRajG1aYSDCP3a0xsPvmo= github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5/go.mod h1:UkzShnbxHRIIL2cHi/7fBGLUAZIVTEADQjaA53bWWCE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= From 7391453cbede4d9eb89716799ddc07839cc03af1 Mon Sep 17 00:00:00 2001 From: yxxhero <11087727+yxxhero@users.noreply.github.com> Date: Sun, 31 May 2026 09:57:35 +0800 Subject: [PATCH 17/17] fix: support array of maps in set/setTemplate values (#2615) Changed SetValue.Values type from []string to []any to allow passing maps (not just strings) in the values field of set/setTemplate. Previously, YAML like: setTemplate: - name: source.helm.parameters values: - name: demo - version: v2 would fail with 'cannot unmarshal !!map into string'. Map values are now serialized to JSON when generating --set flags. Fixes #1021 Signed-off-by: yxxhero --- pkg/state/release.go | 15 ++++++---- pkg/state/state.go | 49 +++++++++++++++++++++++++++---- pkg/state/state_exec_tmpl_test.go | 4 +-- pkg/state/state_test.go | 26 ++++++++++++++-- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/pkg/state/release.go b/pkg/state/release.go index c1b5c437..8f395215 100644 --- a/pkg/state/release.go +++ b/pkg/state/release.go @@ -193,13 +193,18 @@ func (r ReleaseSpec) ExecuteTemplateExpressions(renderer *tmpl.FileRenderer) (*R } result.SetValuesTemplate[i].File = s.String() } - for j, ts := range val.Values { + for j, tv := range val.Values { // values - s, err := renderer.RenderTemplateContentToBuffer([]byte(ts)) - if err != nil { - return nil, fmt.Errorf("failed executing template expressions in release \"%s\".set[%d].values[%d] = \"%s\": %v", r.Name, i, j, ts, err) + switch ts := tv.(type) { + case string: + s, err := renderer.RenderTemplateContentToBuffer([]byte(ts)) + if err != nil { + return nil, fmt.Errorf("failed executing template expressions in release \"%s\".set[%d].values[%d] = \"%s\": %v", r.Name, i, j, ts, err) + } + result.SetValuesTemplate[i].Values[j] = s.String() + default: + result.SetValuesTemplate[i].Values[j] = ts } - result.SetValuesTemplate[i].Values[j] = s.String() } } diff --git a/pkg/state/state.go b/pkg/state/state.go index b5e027ce..c4e7c5d2 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -576,10 +576,10 @@ type Release struct { // SetValue are the key values to set on a helm release type SetValue struct { - Name string `yaml:"name,omitempty"` - Value string `yaml:"value,omitempty"` - File string `yaml:"file,omitempty"` - Values []string `yaml:"values,omitempty"` + Name string `yaml:"name,omitempty"` + Value string `yaml:"value,omitempty"` + File string `yaml:"file,omitempty"` + Values []any `yaml:"values,omitempty"` } // AffectedReleases hold the list of released that where updated, deleted, or in error @@ -4570,7 +4570,7 @@ func (st *HelmState) setFlags(setValues []SetValue) ([]string, error) { } else if set.File != "" { flags = append(flags, "--set-file", fmt.Sprintf("%s=%s", escape(set.Name), st.storage().normalizeSetFilePath(set.File, runtime.GOOS))) } else if len(set.Values) > 0 { - renderedValues, err := renderValsSecrets(st.valsRuntime, set.Values...) + renderedValues, err := renderValsSecretsAny(st.valsRuntime, set.Values) if err != nil { return nil, err } @@ -4598,7 +4598,7 @@ func (st *HelmState) setStringFlags(setValues []SetValue) ([]string, error) { } flags = append(flags, "--set-string", fmt.Sprintf("%s=%s", escape(set.Name), escape(renderedValue[0]))) } else if len(set.Values) > 0 { - renderedValues, err := renderValsSecrets(st.valsRuntime, set.Values...) + renderedValues, err := renderValsSecretsAny(st.valsRuntime, set.Values) if err != nil { return nil, err } @@ -4635,6 +4635,43 @@ func renderValsSecrets(e vals.Evaluator, input ...string) ([]string, error) { return output, nil } +// renderValsSecretsAny renders 'ref+.*' secrets in a slice of any-typed values. +// Map values are serialized to JSON; string values are rendered via vals. +func renderValsSecretsAny(e vals.Evaluator, input []any) ([]string, error) { + output := make([]string, len(input)) + if len(input) == 0 { + return output, nil + } + + strInputs := make([]string, 0, len(input)) + strIndexMap := make([]int, 0, len(input)) + for i, v := range input { + switch tv := v.(type) { + case string: + strInputs = append(strInputs, tv) + strIndexMap = append(strIndexMap, i) + default: + jsonBytes, err := json.Marshal(tv) + if err != nil { + return nil, fmt.Errorf("failed to marshal set value at index %d: %w", i, err) + } + output[i] = string(jsonBytes) + } + } + + if len(strInputs) > 0 { + rendered, err := renderValsSecrets(e, strInputs...) + if err != nil { + return nil, err + } + for idx, renderedIdx := range strIndexMap { + output[renderedIdx] = rendered[idx] + } + } + + return output, nil +} + func hideChartCredentials(chartCredentials string) (string, error) { u, err := url.Parse(chartCredentials) if err != nil { diff --git a/pkg/state/state_exec_tmpl_test.go b/pkg/state/state_exec_tmpl_test.go index 92ec36df..01956a0f 100644 --- a/pkg/state/state_exec_tmpl_test.go +++ b/pkg/state/state_exec_tmpl_test.go @@ -96,7 +96,7 @@ func TestHelmState_executeTemplates(t *testing.T) { SetValuesTemplate: []SetValue{ {Name: "val1", Value: "{{ .Release.Name }}-val1"}, {Name: "val2", File: "{{ .Release.Name }}.yml"}, - {Name: "val3", Values: []string{"{{ .Release.Name }}-val2", "{{ .Release.Name }}-val3"}}, + {Name: "val3", Values: []any{"{{ .Release.Name }}-val2", "{{ .Release.Name }}-val3"}}, {Name: "val4", Value: "{{ .Release.Chart }}-{{ .Release.ChartVersion}}"}, }, }, @@ -108,7 +108,7 @@ func TestHelmState_executeTemplates(t *testing.T) { SetValues: []SetValue{ {Name: "val1", Value: "test-app-val1"}, {Name: "val2", File: "test-app.yml"}, - {Name: "val3", Values: []string{"test-app-val2", "test-app-val3"}}, + {Name: "val3", Values: []any{"test-app-val2", "test-app-val3"}}, {Name: "val4", Value: "test-charts/chart-1.5"}, }, }, diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index d188cd57..be883d32 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -2096,7 +2096,7 @@ func TestHelmState_SyncReleases(t *testing.T) { SetValues: []SetValue{ { Name: "foo.bar[0]", - Values: []string{ + Values: []any{ "A", "B", }, @@ -2107,6 +2107,26 @@ func TestHelmState_SyncReleases(t *testing.T) { helm: &exectest.Helm{}, wantReleases: []exectest.Release{{Name: "releaseName", Flags: []string{"--set", "foo.bar[0]={A,B}", "--reset-values"}}}, }, + { + name: "set array of map values", + releases: []ReleaseSpec{ + { + Name: "releaseName", + Chart: "foo", + SetValues: []SetValue{ + { + Name: "source.helm.parameters", + Values: []any{ + map[string]any{"name": "demo"}, + map[string]any{"version": "v2"}, + }, + }, + }, + }, + }, + helm: &exectest.Helm{}, + wantReleases: []exectest.Release{{Name: "releaseName", Flags: []string{"--set", "source.helm.parameters={\\{\"name\":\"demo\"\\},\\{\"version\":\"v2\"\\}}", "--reset-values"}}}, + }, { name: "post renderer helm 3", releases: []ReleaseSpec{ @@ -2709,7 +2729,7 @@ func TestHelmState_DiffReleases(t *testing.T) { SetValues: []SetValue{ { Name: "foo.bar[0]", - Values: []string{ + Values: []any{ "A", "B", }, @@ -5698,7 +5718,7 @@ func TestHelmState_setStringFlags(t *testing.T) { setStringValues: []SetValue{ { Name: "key", - Values: []string{"value1", "value2"}, + Values: []any{"value1", "value2"}, }, }, want: []string{"--set-string", "key={value1,value2}"},