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)