diff --git a/pkg/lint2/helmchart.go b/pkg/lint2/helmchart.go new file mode 100644 index 000000000..e10f9ba44 --- /dev/null +++ b/pkg/lint2/helmchart.go @@ -0,0 +1,228 @@ +package lint2 + +import ( + "bytes" + "fmt" + "io" + "os" + "regexp" + + "gopkg.in/yaml.v3" +) + +// HelmChartManifest represents a parsed KOTS HelmChart custom resource. +// It contains the fields needed to match charts with their builder values +// for preflight template rendering. +type HelmChartManifest struct { + Name string // spec.chart.name - must match Chart.yaml name + ChartVersion string // spec.chart.chartVersion - must match Chart.yaml version + BuilderValues map[string]interface{} // spec.builder - values for air gap bundle rendering (can be nil/empty) + FilePath string // Source file path for error reporting +} + +// DuplicateHelmChartError is returned when multiple HelmChart manifests +// are found with the same name:chartVersion combination. +type DuplicateHelmChartError struct { + ChartKey string // "name:chartVersion" + FirstFile string + SecondFile string +} + +func (e *DuplicateHelmChartError) Error() string { + return fmt.Sprintf( + "duplicate HelmChart manifest found for chart %q\n"+ + " First: %s\n"+ + " Second: %s\n"+ + "Each chart name:version pair must be unique", + e.ChartKey, e.FirstFile, e.SecondFile, + ) +} + +// DiscoverHelmChartManifests scans manifest glob patterns and extracts HelmChart custom resources. +// It returns a map keyed by "name:chartVersion" for efficient lookup during preflight rendering. +// +// Accepts HelmChart resources with any apiVersion (validation happens in the linter). +// +// Returns an error if: +// - manifestGlobs is empty (required to find builder values for templated preflights) +// - Duplicate name:chartVersion pairs are found (ambiguous builder values) +// - Glob expansion fails +// +// Silently skips: +// - Files that can't be read +// - Files that aren't valid YAML +// - Files that don't contain kind: HelmChart +// - Hidden directories (.git, .github, etc.) +func DiscoverHelmChartManifests(manifestGlobs []string) (map[string]*HelmChartManifest, error) { + if len(manifestGlobs) == 0 { + // Error instead of returning empty map (unlike DiscoverSupportBundlesFromManifests) + // because HelmChart discovery is only called when preflights have templated values, + // so manifests are required to find builder values + return nil, fmt.Errorf("no manifests configured - cannot discover HelmChart resources (required for templated preflights)") + } + + helmCharts := make(map[string]*HelmChartManifest) + seenFiles := make(map[string]bool) // Global deduplication across all patterns + + for _, pattern := range manifestGlobs { + // Expand glob pattern to find YAML files + matches, err := GlobFiles(pattern) + if err != nil { + return nil, fmt.Errorf("failed to expand manifest pattern %s: %w", pattern, err) + } + + for _, path := range matches { + // Skip hidden paths (.git, .github, etc.) + if isHiddenPath(path) { + continue + } + + // Skip if already processed (patterns can overlap) + if seenFiles[path] { + continue + } + seenFiles[path] = true + + // Check if this file contains a HelmChart resource + isHelmChart, err := isHelmChartManifest(path) + if err != nil { + // Skip files we can't read or parse + continue + } + if !isHelmChart { + // Not a HelmChart - skip silently (allows mixed manifest directories) + continue + } + + // Parse the HelmChart manifest + manifest, err := parseHelmChartManifest(path) + if err != nil { + // Skip malformed HelmCharts (missing required fields, etc.) + continue + } + + // Check for duplicates + key := fmt.Sprintf("%s:%s", manifest.Name, manifest.ChartVersion) + if existing, found := helmCharts[key]; found { + return nil, &DuplicateHelmChartError{ + ChartKey: key, + FirstFile: existing.FilePath, + SecondFile: manifest.FilePath, + } + } + + helmCharts[key] = manifest + } + } + + return helmCharts, nil +} + +// isHelmChartManifest checks if a YAML file contains a HelmChart kind. +// Handles multi-document YAML files properly using yaml.NewDecoder. +// Falls back to regex matching if the file has parse errors. +func isHelmChartManifest(path string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + + // Use yaml.Decoder for proper multi-document YAML parsing + decoder := yaml.NewDecoder(bytes.NewReader(data)) + + // Iterate through all documents in the file + for { + var kindDoc struct { + Kind string `yaml:"kind"` + } + + err := decoder.Decode(&kindDoc) + if err != nil { + if err == io.EOF { + // Reached end of file - no more documents + break + } + // Parse error - file is malformed + // Fall back to regex matching to detect if this looks like a HelmChart + // This allows invalid YAML files to still be discovered (consistent with preflight/support bundle discovery) + matched, _ := regexp.Match(`(?m)^kind:\s+HelmChart\s*$`, data) + if matched { + return true, nil + } + return false, nil + } + + // Check if this document is a HelmChart + if kindDoc.Kind == "HelmChart" { + return true, nil + } + } + + return false, nil +} + +// parseHelmChartManifest parses a HelmChart manifest and extracts the fields needed for preflight rendering. +// Accepts any apiVersion (validation happens in the linter). +// +// Returns an error if required fields are missing: +// - spec.chart.name +// - spec.chart.chartVersion +// +// The spec.builder field is optional (can be nil or empty). +func parseHelmChartManifest(path string) (*HelmChartManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Parse the full HelmChart structure + // Support both v1beta1 and v1beta2 - they have the same structure for fields we need + var helmChart struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Spec struct { + Chart struct { + Name string `yaml:"name"` + ChartVersion string `yaml:"chartVersion"` + } `yaml:"chart"` + Builder map[string]interface{} `yaml:"builder"` + } `yaml:"spec"` + } + + // Use yaml.NewDecoder to handle multi-document files + decoder := yaml.NewDecoder(bytes.NewReader(data)) + + // Find the first HelmChart document + for { + err := decoder.Decode(&helmChart) + if err != nil { + if err == io.EOF { + return nil, fmt.Errorf("no HelmChart document found in file") + } + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + if helmChart.Kind == "HelmChart" { + break + } + } + + // Validate required fields + if helmChart.Spec.Chart.Name == "" { + return nil, fmt.Errorf("spec.chart.name is required but not found") + } + if helmChart.Spec.Chart.ChartVersion == "" { + return nil, fmt.Errorf("spec.chart.chartVersion is required but not found") + } + + // Note: We don't validate apiVersion here - discovery is permissive. + // The preflight linter will validate apiVersion when it processes the HelmChart. + // This allows future apiVersions to work without code changes. + + return &HelmChartManifest{ + Name: helmChart.Spec.Chart.Name, + ChartVersion: helmChart.Spec.Chart.ChartVersion, + BuilderValues: helmChart.Spec.Builder, // Can be nil or empty - that's valid + FilePath: path, + }, nil +} diff --git a/pkg/lint2/helmchart_test.go b/pkg/lint2/helmchart_test.go new file mode 100644 index 000000000..16825ad1b --- /dev/null +++ b/pkg/lint2/helmchart_test.go @@ -0,0 +1,749 @@ +package lint2 + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiscoverHelmChartManifests(t *testing.T) { + t.Run("empty manifests list returns error", func(t *testing.T) { + _, err := DiscoverHelmChartManifests([]string{}) + if err == nil { + t.Fatal("expected error for empty manifests list, got nil") + } + if err.Error() != "no manifests configured - cannot discover HelmChart resources (required for templated preflights)" { + t.Errorf("unexpected error message: %v", err) + } + }) + + t.Run("single valid HelmChart with builder values", func(t *testing.T) { + tmpDir := t.TempDir() + helmChartFile := filepath.Join(tmpDir, "helmchart.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: my-app-chart +spec: + chart: + name: my-app + chartVersion: 1.2.3 + builder: + postgresql: + enabled: true + redis: + enabled: true +` + if err := os.WriteFile(helmChartFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 1 { + t.Fatalf("expected 1 manifest, got %d", len(manifests)) + } + + key := "my-app:1.2.3" + manifest, found := manifests[key] + if !found { + t.Fatalf("expected manifest with key %q not found", key) + } + + if manifest.Name != "my-app" { + t.Errorf("expected name 'my-app', got %q", manifest.Name) + } + if manifest.ChartVersion != "1.2.3" { + t.Errorf("expected chartVersion '1.2.3', got %q", manifest.ChartVersion) + } + if manifest.FilePath != helmChartFile { + t.Errorf("expected filePath %q, got %q", helmChartFile, manifest.FilePath) + } + + if manifest.BuilderValues == nil { + t.Fatal("expected builder values, got nil") + } + postgresql, ok := manifest.BuilderValues["postgresql"].(map[string]interface{}) + if !ok { + t.Fatal("expected postgresql in builder values") + } + if postgresql["enabled"] != true { + t.Errorf("expected postgresql.enabled=true, got %v", postgresql["enabled"]) + } + }) + + t.Run("multiple unique HelmCharts", func(t *testing.T) { + tmpDir := t.TempDir() + + // First chart + helmChart1 := filepath.Join(tmpDir, "chart1.yaml") + content1 := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: chart1 +spec: + chart: + name: app-one + chartVersion: 1.0.0 + builder: + enabled: true +` + if err := os.WriteFile(helmChart1, []byte(content1), 0644); err != nil { + t.Fatal(err) + } + + // Second chart + helmChart2 := filepath.Join(tmpDir, "chart2.yaml") + content2 := `apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: chart2 +spec: + chart: + name: app-two + chartVersion: 2.0.0 + builder: + features: + analytics: true +` + if err := os.WriteFile(helmChart2, []byte(content2), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 2 { + t.Fatalf("expected 2 manifests, got %d", len(manifests)) + } + + // Check first chart + manifest1, found := manifests["app-one:1.0.0"] + if !found { + t.Fatal("expected manifest 'app-one:1.0.0' not found") + } + if manifest1.Name != "app-one" { + t.Errorf("expected name 'app-one', got %q", manifest1.Name) + } + + // Check second chart + manifest2, found := manifests["app-two:2.0.0"] + if !found { + t.Fatal("expected manifest 'app-two:2.0.0' not found") + } + if manifest2.Name != "app-two" { + t.Errorf("expected name 'app-two', got %q", manifest2.Name) + } + }) + + t.Run("duplicate HelmChart returns error with both paths", func(t *testing.T) { + tmpDir := t.TempDir() + + // First chart + helmChart1 := filepath.Join(tmpDir, "helmchart-dev.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: dev-chart +spec: + chart: + name: my-app + chartVersion: 1.2.3 + builder: + env: dev +` + if err := os.WriteFile(helmChart1, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // Duplicate chart (same name:version) + helmChart2 := filepath.Join(tmpDir, "helmchart-prod.yaml") + content2 := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: prod-chart +spec: + chart: + name: my-app + chartVersion: 1.2.3 + builder: + env: prod +` + if err := os.WriteFile(helmChart2, []byte(content2), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + _, err := DiscoverHelmChartManifests([]string{pattern}) + + if err == nil { + t.Fatal("expected error for duplicate HelmChart, got nil") + } + + dupErr, ok := err.(*DuplicateHelmChartError) + if !ok { + t.Fatalf("expected DuplicateHelmChartError, got %T: %v", err, err) + } + + if dupErr.ChartKey != "my-app:1.2.3" { + t.Errorf("expected ChartKey 'my-app:1.2.3', got %q", dupErr.ChartKey) + } + + // Check that both file paths are in the error + errMsg := dupErr.Error() + if errMsg == "" { + t.Error("error message is empty") + } + // Error should mention both files (order may vary depending on filesystem) + hasDevFile := filepath.Base(dupErr.FirstFile) == "helmchart-dev.yaml" || + filepath.Base(dupErr.SecondFile) == "helmchart-dev.yaml" + hasProdFile := filepath.Base(dupErr.FirstFile) == "helmchart-prod.yaml" || + filepath.Base(dupErr.SecondFile) == "helmchart-prod.yaml" + + if !hasDevFile || !hasProdFile { + t.Errorf("error should reference both files, got: %v", errMsg) + } + }) + + t.Run("empty builder section is valid", func(t *testing.T) { + tmpDir := t.TempDir() + helmChartFile := filepath.Join(tmpDir, "helmchart.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: my-chart +spec: + chart: + name: my-app + chartVersion: 1.0.0 + builder: {} +` + if err := os.WriteFile(helmChartFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 1 { + t.Fatalf("expected 1 manifest, got %d", len(manifests)) + } + + manifest := manifests["my-app:1.0.0"] + if manifest.BuilderValues == nil || len(manifest.BuilderValues) != 0 { + t.Errorf("expected empty builder values map, got %v", manifest.BuilderValues) + } + }) + + t.Run("missing builder section is valid", func(t *testing.T) { + tmpDir := t.TempDir() + helmChartFile := filepath.Join(tmpDir, "helmchart.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: my-chart +spec: + chart: + name: my-app + chartVersion: 1.0.0 +` + if err := os.WriteFile(helmChartFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 1 { + t.Fatalf("expected 1 manifest, got %d", len(manifests)) + } + + manifest := manifests["my-app:1.0.0"] + // Builder values can be nil or empty map when not specified - both are valid + if manifest.BuilderValues != nil && len(manifest.BuilderValues) != 0 { + t.Errorf("expected empty/nil builder values, got %v", manifest.BuilderValues) + } + }) + + t.Run("missing required fields skipped", func(t *testing.T) { + tmpDir := t.TempDir() + + // Missing name + helmChart1 := filepath.Join(tmpDir, "missing-name.yaml") + content1 := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: test +spec: + chart: + chartVersion: 1.0.0 + builder: {} +` + if err := os.WriteFile(helmChart1, []byte(content1), 0644); err != nil { + t.Fatal(err) + } + + // Missing chartVersion + helmChart2 := filepath.Join(tmpDir, "missing-version.yaml") + content2 := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: test +spec: + chart: + name: my-app + builder: {} +` + if err := os.WriteFile(helmChart2, []byte(content2), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 0 { + t.Fatalf("expected 0 manifests (invalid files skipped), got %d", len(manifests)) + } + }) + + t.Run("invalid YAML skipped gracefully", func(t *testing.T) { + tmpDir := t.TempDir() + invalidFile := filepath.Join(tmpDir, "invalid.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: [invalid yaml here +spec: + chart: +` + if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error (should skip invalid YAML): %v", err) + } + + if len(manifests) != 0 { + t.Fatalf("expected 0 manifests (invalid YAML skipped), got %d", len(manifests)) + } + }) + + t.Run("multi-document YAML with HelmChart", func(t *testing.T) { + tmpDir := t.TempDir() + multiDocFile := filepath.Join(tmpDir, "multi.yaml") + content := `apiVersion: v1 +kind: ConfigMap +metadata: + name: config +data: + foo: bar +--- +apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: my-chart +spec: + chart: + name: my-app + chartVersion: 1.0.0 + builder: + enabled: true +--- +apiVersion: v1 +kind: Service +metadata: + name: svc +` + if err := os.WriteFile(multiDocFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 1 { + t.Fatalf("expected 1 manifest from multi-doc YAML, got %d", len(manifests)) + } + + manifest := manifests["my-app:1.0.0"] + if manifest == nil { + t.Fatal("expected manifest 'my-app:1.0.0' not found") + } + }) + + t.Run("non-HelmChart files skipped", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create a mix of files + configMap := filepath.Join(tmpDir, "configmap.yaml") + cm := `apiVersion: v1 +kind: ConfigMap +metadata: + name: config +` + if err := os.WriteFile(configMap, []byte(cm), 0644); err != nil { + t.Fatal(err) + } + + deployment := filepath.Join(tmpDir, "deployment.yaml") + dep := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +` + if err := os.WriteFile(deployment, []byte(dep), 0644); err != nil { + t.Fatal(err) + } + + helmChart := filepath.Join(tmpDir, "helmchart.yaml") + hc := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: test +spec: + chart: + name: my-app + chartVersion: 1.0.0 +` + if err := os.WriteFile(helmChart, []byte(hc), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 1 { + t.Fatalf("expected 1 HelmChart (others skipped), got %d", len(manifests)) + } + + if _, found := manifests["my-app:1.0.0"]; !found { + t.Fatal("expected manifest 'my-app:1.0.0' not found") + } + }) + + t.Run("glob pattern expansion", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create nested structure + devDir := filepath.Join(tmpDir, "dev") + prodDir := filepath.Join(tmpDir, "prod") + if err := os.MkdirAll(devDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(prodDir, 0755); err != nil { + t.Fatal(err) + } + + // Dev chart + devChart := filepath.Join(devDir, "helmchart.yaml") + devContent := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: dev-chart +spec: + chart: + name: app + chartVersion: 1.0.0-dev + builder: + env: dev +` + if err := os.WriteFile(devChart, []byte(devContent), 0644); err != nil { + t.Fatal(err) + } + + // Prod chart + prodChart := filepath.Join(prodDir, "helmchart.yaml") + prodContent := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: prod-chart +spec: + chart: + name: app + chartVersion: 1.0.0-prod + builder: + env: prod +` + if err := os.WriteFile(prodChart, []byte(prodContent), 0644); err != nil { + t.Fatal(err) + } + + // Use recursive glob pattern + pattern := filepath.Join(tmpDir, "**", "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 2 { + t.Fatalf("expected 2 manifests from recursive glob, got %d", len(manifests)) + } + + if _, found := manifests["app:1.0.0-dev"]; !found { + t.Error("expected dev manifest not found") + } + if _, found := manifests["app:1.0.0-prod"]; !found { + t.Error("expected prod manifest not found") + } + }) + + t.Run("hidden directories skipped", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create .git directory with HelmChart + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + gitChart := filepath.Join(gitDir, "helmchart.yaml") + gitContent := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: git-chart +spec: + chart: + name: should-be-ignored + chartVersion: 1.0.0 +` + if err := os.WriteFile(gitChart, []byte(gitContent), 0644); err != nil { + t.Fatal(err) + } + + // Create normal chart + normalChart := filepath.Join(tmpDir, "helmchart.yaml") + normalContent := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: normal-chart +spec: + chart: + name: app + chartVersion: 1.0.0 +` + if err := os.WriteFile(normalChart, []byte(normalContent), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "**", "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 1 { + t.Fatalf("expected 1 manifest (hidden dir skipped), got %d", len(manifests)) + } + + if _, found := manifests["should-be-ignored:1.0.0"]; found { + t.Error("chart from .git directory should be ignored") + } + if _, found := manifests["app:1.0.0"]; !found { + t.Error("normal chart should be found") + } + }) + + t.Run("both v1beta1 and v1beta2 supported", func(t *testing.T) { + tmpDir := t.TempDir() + + // v1beta1 + v1Chart := filepath.Join(tmpDir, "v1.yaml") + v1Content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: v1-chart +spec: + chart: + name: app-v1 + chartVersion: 1.0.0 + releaseName: old-style + builder: + version: v1 +` + if err := os.WriteFile(v1Chart, []byte(v1Content), 0644); err != nil { + t.Fatal(err) + } + + // v1beta2 + v2Chart := filepath.Join(tmpDir, "v2.yaml") + v2Content := `apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: v2-chart +spec: + chart: + name: app-v2 + chartVersion: 2.0.0 + releaseName: new-style + builder: + version: v2 +` + if err := os.WriteFile(v2Chart, []byte(v2Content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 2 { + t.Fatalf("expected 2 manifests (v1 and v2), got %d", len(manifests)) + } + + v1Manifest, found := manifests["app-v1:1.0.0"] + if !found { + t.Fatal("v1beta1 chart not found") + } + if v1Manifest.BuilderValues["version"] != "v1" { + t.Errorf("expected v1 builder values, got %v", v1Manifest.BuilderValues) + } + + v2Manifest, found := manifests["app-v2:2.0.0"] + if !found { + t.Fatal("v1beta2 chart not found") + } + if v2Manifest.BuilderValues["version"] != "v2" { + t.Errorf("expected v2 builder values, got %v", v2Manifest.BuilderValues) + } + }) + + t.Run("future apiVersion accepted", func(t *testing.T) { + tmpDir := t.TempDir() + helmChartFile := filepath.Join(tmpDir, "v3.yaml") + content := `apiVersion: kots.io/v1beta3 +kind: HelmChart +metadata: + name: future-chart +spec: + chart: + name: my-app + chartVersion: 2.0.0 + builder: + future: true +` + if err := os.WriteFile(helmChartFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Discovery should accept any apiVersion - validation happens in linter + if len(manifests) != 1 { + t.Fatalf("expected 1 manifest (future apiVersion accepted), got %d", len(manifests)) + } + + manifest := manifests["my-app:2.0.0"] + if manifest == nil { + t.Fatal("expected future apiVersion to be discovered") + } + if manifest.BuilderValues["future"] != true { + t.Errorf("expected future=true in builder values, got %v", manifest.BuilderValues["future"]) + } + }) + + t.Run("complex nested builder values", func(t *testing.T) { + tmpDir := t.TempDir() + helmChartFile := filepath.Join(tmpDir, "complex.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: complex-chart +spec: + chart: + name: my-app + chartVersion: 1.0.0 + builder: + postgresql: + enabled: true + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + redis: + enabled: true + cluster: + nodes: 3 + features: + - analytics + - logging + - monitoring +` + if err := os.WriteFile(helmChartFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverHelmChartManifests([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + manifest := manifests["my-app:1.0.0"] + if manifest == nil { + t.Fatal("manifest not found") + } + + // Verify nested structure is preserved + postgresql, ok := manifest.BuilderValues["postgresql"].(map[string]interface{}) + if !ok { + t.Fatal("postgresql not found in builder values") + } + + resources, ok := postgresql["resources"].(map[string]interface{}) + if !ok { + t.Fatal("resources not found in postgresql") + } + + requests, ok := resources["requests"].(map[string]interface{}) + if !ok { + t.Fatal("requests not found in resources") + } + + if requests["memory"] != "256Mi" { + t.Errorf("expected memory=256Mi, got %v", requests["memory"]) + } + + // Verify arrays are preserved + features, ok := manifest.BuilderValues["features"].([]interface{}) + if !ok { + t.Fatal("features not found or not an array") + } + if len(features) != 3 { + t.Errorf("expected 3 features, got %d", len(features)) + } + }) +}