package state import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "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" ) func TestRewriteChartDependencies(t *testing.T) { tests := []struct { name string chartYaml string expectModified bool expectError bool validate func(t *testing.T, modifiedChartYaml string) }{ { name: "no Chart.yaml exists", chartYaml: "", expectModified: false, expectError: false, }, { name: "no dependencies", chartYaml: `apiVersion: v2 name: test-chart version: 1.0.0 `, expectModified: false, expectError: false, }, { name: "absolute file:// dependency - not modified", chartYaml: `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file:///absolute/path/to/chart version: 1.0.0 `, expectModified: false, expectError: false, validate: func(t *testing.T, modifiedChartYaml string) { if !strings.Contains(modifiedChartYaml, "file:///absolute/path/to/chart") { t.Errorf("absolute path should not be modified") } }, }, { name: "relative file:// dependency - should be modified", chartYaml: `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file://../relative-chart version: 1.0.0 `, expectModified: true, expectError: false, validate: func(t *testing.T, modifiedChartYaml string) { if strings.Contains(modifiedChartYaml, "file://../relative-chart") { t.Errorf("relative path should have been converted to absolute") } if !strings.Contains(modifiedChartYaml, "file://") { t.Errorf("should still have file:// prefix") } }, }, { name: "mixed dependencies - only relative file:// modified", chartYaml: `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: https://charts.example.com version: 1.0.0 - name: dep2 repository: file://../relative-chart version: 2.0.0 - name: dep3 repository: file:///absolute/chart version: 3.0.0 - name: dep4 repository: oci://registry.example.com/charts/mychart version: 4.0.0 `, expectModified: true, expectError: false, validate: func(t *testing.T, modifiedChartYaml string) { if !strings.Contains(modifiedChartYaml, "https://charts.example.com") { t.Errorf("https repository should not be modified") } if strings.Contains(modifiedChartYaml, "file://../relative-chart") { t.Errorf("relative file:// path should have been converted") } if !strings.Contains(modifiedChartYaml, "file:///absolute/chart") { t.Errorf("absolute file:// path should not be modified") } if !strings.Contains(modifiedChartYaml, "oci://registry.example.com") { t.Errorf("oci repository should not be modified") } }, }, { name: "multiple relative dependencies", chartYaml: `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file://../chart1 version: 1.0.0 - name: dep2 repository: file://./chart2 version: 2.0.0 - name: dep3 repository: file://../../../chart3 version: 3.0.0 `, expectModified: true, expectError: false, validate: func(t *testing.T, modifiedChartYaml string) { if strings.Contains(modifiedChartYaml, "file://../chart1") || strings.Contains(modifiedChartYaml, "file://./chart2") || strings.Contains(modifiedChartYaml, "file://../../../chart3") { t.Errorf("all relative paths should have been converted") } }, }, { name: "extra fields", chartYaml: `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: https://charts.example.com version: 1.0.0 condition: dep.install import-values: - child: persistence parent: global.persistence extra-field2: bbb extra-field: aaa `, expectModified: false, expectError: false, validate: func(t *testing.T, modifiedChartYaml string) { requiredFields := []string{ "condition: dep.install", "import-values:", "child: persistence", "parent: global.persistence", "extra-field2: bbb", "extra-field: aaa", } for _, field := range requiredFields { if !strings.Contains(modifiedChartYaml, field) { t.Errorf("field %q should be preserved", field) } } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir, err := os.MkdirTemp("", "helmfile-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) if tt.chartYaml != "" { chartYamlPath := filepath.Join(tempDir, "Chart.yaml") if err := os.WriteFile(chartYamlPath, []byte(tt.chartYaml), 0644); err != nil { t.Fatalf("failed to write Chart.yaml: %v", err) } } logger := zap.NewNop().Sugar() st := &HelmState{ logger: logger, fs: filesystem.DefaultFileSystem(), } rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) if tt.expectError { if err == nil { t.Errorf("expected error but got none") } return } if tt.expectModified { if rewrittenPath == tempDir { t.Errorf("expected rewrittenPath != tempDir when modifications are needed, got same path") } } else { if rewrittenPath != tempDir { t.Errorf("expected rewrittenPath == tempDir when no modifications are needed, got %q", rewrittenPath) } } if err != nil { t.Fatalf("unexpected error: %v", err) } defer cleanup() if tt.chartYaml == "" { return } modifiedChartBytes, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml")) if err != nil { t.Fatalf("failed to read Chart.yaml: %v", err) } modifiedChartYaml := string(modifiedChartBytes) if tt.validate != nil { tt.validate(t, modifiedChartYaml) } }) } } func TestRewriteChartDependencies_OriginalNotModified(t *testing.T) { tempDir, err := os.MkdirTemp("", "helmfile-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) originalChart := `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file://../relative-chart version: 1.0.0 ` chartYamlPath := filepath.Join(tempDir, "Chart.yaml") if err := os.WriteFile(chartYamlPath, []byte(originalChart), 0644); err != nil { t.Fatalf("failed to write Chart.yaml: %v", err) } logger := zap.NewNop().Sugar() st := &HelmState{ logger: logger, fs: filesystem.DefaultFileSystem(), } rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { if rewrittenPath == tempDir { return } cleanup() if _, statErr := os.Stat(rewrittenPath); !os.IsNotExist(statErr) { t.Errorf("expected rewritten chart path %q to be removed after cleanup", rewrittenPath) } }) if rewrittenPath == tempDir { t.Errorf("expected a different path when modifications are needed, got same path") } modifiedContent, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml")) if err != nil { t.Fatalf("failed to read modified Chart.yaml: %v", err) } if string(modifiedContent) == originalChart { t.Errorf("Chart.yaml in the copy should have been modified") } originalContent, err := os.ReadFile(chartYamlPath) if err != nil { t.Fatalf("failed to read original Chart.yaml: %v", err) } if string(originalContent) != originalChart { t.Errorf("original Chart.yaml should not have been modified\nexpected:\n%s\ngot:\n%s", originalChart, string(originalContent)) } } func TestRewriteChartDependencies_PreservesOtherFields(t *testing.T) { tempDir, err := os.MkdirTemp("", "helmfile-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) chartYaml := `apiVersion: v2 name: test-chart version: 1.0.0 description: A test chart keywords: - test - example maintainers: - name: Test User email: test@example.com dependencies: - name: dep1 repository: file://../relative-chart version: 1.0.0 condition: test ` chartYamlPath := filepath.Join(tempDir, "Chart.yaml") if err := os.WriteFile(chartYamlPath, []byte(chartYaml), 0644); err != nil { t.Fatalf("failed to write Chart.yaml: %v", err) } logger := zap.NewNop().Sugar() st := &HelmState{ logger: logger, fs: filesystem.DefaultFileSystem(), } rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { if rewrittenPath == tempDir { return } cleanup() if _, statErr := os.Stat(rewrittenPath); !os.IsNotExist(statErr) { t.Errorf("expected rewritten chart path %q to be removed after cleanup", rewrittenPath) } }) modifiedContent, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml")) if err != nil { t.Fatalf("failed to read modified Chart.yaml: %v", err) } content := string(modifiedContent) requiredFields := []string{ "apiVersion: v2", "name: test-chart", "version: 1.0.0", "description: A test chart", "keywords:", "maintainers:", "condition:", } for _, field := range requiredFields { if !strings.Contains(content, field) { t.Errorf("field %q should be preserved", field) } } if strings.Contains(content, "file://../relative-chart") { t.Errorf("relative path should have been converted to absolute") } originalContent, err := os.ReadFile(chartYamlPath) if err != nil { t.Fatalf("failed to read original Chart.yaml: %v", err) } if string(originalContent) != chartYaml { t.Errorf("original Chart.yaml should not have been modified") } } func TestRewriteChartDependencies_ErrorHandling(t *testing.T) { tests := []struct { name string setupFunc func(tempDir string) error expectError bool }{ { name: "invalid yaml in Chart.yaml", setupFunc: func(tempDir string) error { invalidYaml := `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file://../chart version: 1.0.0 invalid yaml here! ` return os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(invalidYaml), 0644) }, expectError: true, }, { name: "unreadable Chart.yaml", setupFunc: func(tempDir string) error { chartYamlPath := filepath.Join(tempDir, "Chart.yaml") if err := os.WriteFile(chartYamlPath, []byte("test"), 0000); err != nil { return err } return nil }, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir, err := os.MkdirTemp("", "helmfile-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) if err := tt.setupFunc(tempDir); err != nil { t.Fatalf("setup failed: %v", err) } logger := zap.NewNop().Sugar() st := &HelmState{ logger: logger, fs: filesystem.DefaultFileSystem(), } _, _, err = st.rewriteChartDependencies(tempDir) if tt.expectError && err == nil { t.Errorf("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } }) } } func TestRewriteChartDependencies_WindowsStylePath(t *testing.T) { tempDir, err := os.MkdirTemp("", "helmfile-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) chartYaml := `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file://./subdir/chart version: 1.0.0 ` chartYamlPath := filepath.Join(tempDir, "Chart.yaml") if err := os.WriteFile(chartYamlPath, []byte(chartYaml), 0644); err != nil { t.Fatalf("failed to write Chart.yaml: %v", err) } logger := zap.NewNop().Sugar() st := &HelmState{ logger: logger, fs: filesystem.DefaultFileSystem(), } rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) if err != nil { t.Fatalf("unexpected error: %v", err) } t.Cleanup(func() { if rewrittenPath == tempDir { return } cleanup() if _, statErr := os.Stat(rewrittenPath); !os.IsNotExist(statErr) { t.Errorf("expected rewritten chart path %q to be removed after cleanup", rewrittenPath) } }) data, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml")) if err != nil { t.Fatalf("failed to read Chart.yaml: %v", err) } content := string(data) if strings.Contains(content, "file://./subdir/chart") { t.Errorf("relative path with ./ should have been converted") } } func TestRewriteChartDependencies_RaceCondition(t *testing.T) { tempDir, err := os.MkdirTemp("", "helmfile-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) chartYaml := `apiVersion: v2 name: test-chart version: 1.0.0 dependencies: - name: dep1 repository: file://../relative-chart version: 1.0.0 ` chartYamlPath := filepath.Join(tempDir, "Chart.yaml") if err := os.WriteFile(chartYamlPath, []byte(chartYaml), 0644); err != nil { t.Fatalf("failed to write Chart.yaml: %v", err) } numGoroutines := 10 var wg sync.WaitGroup var readyWg sync.WaitGroup errCh := make(chan error, numGoroutines) ready := make(chan struct{}) for i := 0; i < numGoroutines; i++ { wg.Add(1) readyWg.Add(1) go func() { defer wg.Done() readyWg.Done() <-ready logger := zap.NewNop().Sugar() st := &HelmState{ logger: logger, fs: filesystem.DefaultFileSystem(), } rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir) if err != nil { errCh <- err return } defer cleanup() data, readErr := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml")) if readErr != nil { errCh <- readErr return } type ChartDependency struct { Name string `yaml:"name"` Repository string `yaml:"repository"` Version string `yaml:"version"` } type ChartMeta struct { APIVersion string `yaml:"apiVersion"` Name string `yaml:"name"` Version string `yaml:"version"` Dependencies []ChartDependency `yaml:"dependencies,omitempty"` } var meta ChartMeta if unmarshalErr := yaml.Unmarshal(data, &meta); unmarshalErr != nil { errCh <- unmarshalErr return } if meta.Name != "test-chart" { errCh <- fmt.Errorf("expected chart name 'test-chart', got %q", meta.Name) return } }() } readyWg.Wait() close(ready) wg.Wait() close(errCh) for err := range errCh { t.Errorf("goroutine error: %v", err) } data, err := os.ReadFile(chartYamlPath) if err != nil { t.Fatalf("failed to read original Chart.yaml: %v", err) } type ChartDependency struct { Name string `yaml:"name"` Repository string `yaml:"repository"` Version string `yaml:"version"` } type ChartMeta struct { APIVersion string `yaml:"apiVersion"` Name string `yaml:"name"` Version string `yaml:"version"` Dependencies []ChartDependency `yaml:"dependencies,omitempty"` } var chartMeta ChartMeta if err := yaml.Unmarshal(data, &chartMeta); err != nil { t.Fatalf("original Chart.yaml is not valid YAML: %v", err) } if chartMeta.Name != "test-chart" { t.Errorf("expected original chart name 'test-chart', got %q", chartMeta.Name) } if len(chartMeta.Dependencies) == 0 { t.Fatalf("expected original Chart.yaml to contain at least one dependency, got %d", len(chartMeta.Dependencies)) } const wantDependencyName = "dep1" if chartMeta.Dependencies[0].Name != wantDependencyName { t.Errorf("expected first dependency name %q, got %q", wantDependencyName, chartMeta.Dependencies[0].Name) } const wantRepository = "file://../relative-chart" if chartMeta.Dependencies[0].Repository != wantRepository { 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) } }