From b8b0f2c56fbc3d892bbefa0b3fbca480f8be4162 Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Wed, 6 May 2026 13:24:11 -0700 Subject: [PATCH 1/6] feat(lint): discover helm charts from EC Config manifests Add support for discovering HelmChart declarations from embeddedcluster.replicated.com/v1beta1 Config manifest extensions.helmCharts. This allows validation to properly match EC Config helm charts against local chart archives, preventing false-positive warnings for charts declared in EC Config manifests. --- pkg/lint2/helmchart.go | 136 +++++++++++ pkg/lint2/helmchart_test.go | 229 ++++++++++++++++++ .../.replicated | 4 + .../chart/Chart.yaml | 4 + .../chart/values.yaml | 1 + .../manifests/ec.yaml | 11 + .../.replicated | 4 + .../chart/Chart.yaml | 4 + .../chart/values.yaml | 1 + .../manifests/ec.yaml | 11 + .../manifests/helmchart.yaml | 9 + pkg/lint2/validation_integration_test.go | 102 ++++++++ 12 files changed, 516 insertions(+) create mode 100644 pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/.replicated create mode 100644 pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/Chart.yaml create mode 100644 pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/values.yaml create mode 100644 pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/manifests/ec.yaml create mode 100644 pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/.replicated create mode 100644 pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/Chart.yaml create mode 100644 pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/values.yaml create mode 100644 pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/ec.yaml create mode 100644 pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/helmchart.yaml diff --git a/pkg/lint2/helmchart.go b/pkg/lint2/helmchart.go index 70a606d8d..33b0dfd8b 100644 --- a/pkg/lint2/helmchart.go +++ b/pkg/lint2/helmchart.go @@ -121,6 +121,22 @@ func DiscoverHelmChartManifests(manifestGlobs []string) (map[string]*HelmChartMa } } + // Discover EC Config helm charts and merge + ecHelmCharts, err := DiscoverECConfigHelmCharts(manifestGlobs) + if err != nil { + return nil, err + } + for key, manifest := range ecHelmCharts { + if existing, found := helmCharts[key]; found { + return nil, &DuplicateHelmChartError{ + ChartKey: key, + FirstFile: existing.FilePath, + SecondFile: manifest.FilePath, + } + } + helmCharts[key] = manifest + } + // Return empty map if no HelmCharts found - validation layer will check if charts need HelmCharts // Discovery is lenient - validation happens later in the flow if len(helmCharts) == 0 { @@ -136,6 +152,71 @@ func isHelmChartManifest(path string) (bool, error) { return hasKind(path, "HelmChart") } +// DiscoverECConfigHelmCharts scans manifest glob patterns and extracts helm chart +// declarations from embeddedcluster.replicated.com/v1beta1 Config manifests. +// It returns a map keyed by "name:chartVersion" for efficient lookup during validation. +// +// Silently skips: +// - Files that can't be read +// - Files that aren't valid YAML +// - Files that don't contain an EC Config with extensions.helmCharts +// - Hidden directories (.git, .github, etc.) +func DiscoverECConfigHelmCharts(manifestGlobs []string) (map[string]*HelmChartManifest, error) { + if len(manifestGlobs) == 0 { + return make(map[string]*HelmChartManifest), nil + } + + helmCharts := make(map[string]*HelmChartManifest) + seenFiles := make(map[string]bool) + + for _, pattern := range manifestGlobs { + matches, err := GlobFiles(pattern) + if err != nil { + return nil, fmt.Errorf("failed to expand manifest pattern %s: %w", pattern, err) + } + + for _, path := range matches { + if isHiddenPath(path) { + continue + } + if seenFiles[path] { + continue + } + seenFiles[path] = true + + // Quick kind check before parsing + isConfig, err := hasKind(path, "Config") + if err != nil || !isConfig { + continue + } + + // Parse EC Config helm charts + manifests, err := parseECConfigHelmCharts(path) + if err != nil { + continue + } + + for _, manifest := range manifests { + 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 + } + } + } + + if len(helmCharts) == 0 { + return make(map[string]*HelmChartManifest), nil + } + + return helmCharts, nil +} + // parseHelmChartManifest parses a HelmChart manifest and extracts the fields needed for preflight rendering. // Accepts any apiVersion (validation happens in the linter). // @@ -201,3 +282,58 @@ func parseHelmChartManifest(path string) (*HelmChartManifest, error) { FilePath: path, }, nil } + +// parseECConfigHelmCharts reads a YAML file and extracts helm chart declarations from +// any embeddedcluster.replicated.com/v1beta1 Config documents. +// It returns a slice of HelmChartManifest (without BuilderValues, as EC Config doesn't have them). +func parseECConfigHelmCharts(path string) ([]*HelmChartManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var ecConfig struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Spec struct { + Extensions struct { + HelmCharts []struct { + Chart struct { + Name string `yaml:"name"` + ChartVersion string `yaml:"chartVersion"` + } `yaml:"chart"` + } `yaml:"helmCharts"` + } `yaml:"extensions"` + } `yaml:"spec"` + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var manifests []*HelmChartManifest + + for { + err := decoder.Decode(&ecConfig) + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + if ecConfig.Kind != "Config" || ecConfig.APIVersion != "embeddedcluster.replicated.com/v1beta1" { + continue + } + + for _, hc := range ecConfig.Spec.Extensions.HelmCharts { + if hc.Chart.Name == "" || hc.Chart.ChartVersion == "" { + continue + } + manifests = append(manifests, &HelmChartManifest{ + Name: hc.Chart.Name, + ChartVersion: hc.Chart.ChartVersion, + FilePath: path, + }) + } + } + + return manifests, nil +} diff --git a/pkg/lint2/helmchart_test.go b/pkg/lint2/helmchart_test.go index 447526dd2..5ac258ec4 100644 --- a/pkg/lint2/helmchart_test.go +++ b/pkg/lint2/helmchart_test.go @@ -754,3 +754,232 @@ spec: } }) } + +func TestDiscoverECConfigHelmCharts(t *testing.T) { + t.Run("empty manifests list returns empty map", func(t *testing.T) { + manifests, err := DiscoverECConfigHelmCharts([]string{}) + if err != nil { + t.Fatalf("unexpected error for empty manifests list: %v", err) + } + if manifests == nil { + t.Fatal("expected non-nil map, got nil") + } + if len(manifests) != 0 { + t.Errorf("expected empty map, got %d manifests", len(manifests)) + } + }) + + t.Run("single valid EC Config helm chart", func(t *testing.T) { + tmpDir := t.TempDir() + ecFile := filepath.Join(tmpDir, "ec.yaml") + content := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: my-app + chartVersion: 1.2.3 +` + if err := os.WriteFile(ecFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverECConfigHelmCharts([]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 != ecFile { + t.Errorf("expected filePath %q, got %q", ecFile, manifest.FilePath) + } + }) + + t.Run("multiple EC Config helm charts", func(t *testing.T) { + tmpDir := t.TempDir() + ecFile := filepath.Join(tmpDir, "ec.yaml") + content := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: app-one + chartVersion: 1.0.0 + - chart: + name: app-two + chartVersion: 2.0.0 +` + if err := os.WriteFile(ecFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverECConfigHelmCharts([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(manifests) != 2 { + t.Fatalf("expected 2 manifests, got %d", len(manifests)) + } + + if _, found := manifests["app-one:1.0.0"]; !found { + t.Error("expected app-one:1.0.0 not found") + } + if _, found := manifests["app-two:2.0.0"]; !found { + t.Error("expected app-two:2.0.0 not found") + } + }) + + t.Run("duplicate EC Config helm charts returns error", func(t *testing.T) { + tmpDir := t.TempDir() + + ecFile1 := filepath.Join(tmpDir, "ec1.yaml") + content1 := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec1 +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: my-app + chartVersion: 1.0.0 +` + if err := os.WriteFile(ecFile1, []byte(content1), 0644); err != nil { + t.Fatal(err) + } + + ecFile2 := filepath.Join(tmpDir, "ec2.yaml") + content2 := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec2 +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: my-app + chartVersion: 1.0.0 +` + if err := os.WriteFile(ecFile2, []byte(content2), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + _, err := DiscoverECConfigHelmCharts([]string{pattern}) + if err == nil { + t.Fatal("expected error for duplicate EC Config helm chart, got nil") + } + + dupErr, ok := err.(*DuplicateHelmChartError) + if !ok { + t.Fatalf("expected DuplicateHelmChartError, got %T", err) + } + if dupErr.ChartKey != "my-app:1.0.0" { + t.Errorf("expected ChartKey 'my-app:1.0.0', got %q", dupErr.ChartKey) + } + }) + + t.Run("non-EC Config files skipped", func(t *testing.T) { + tmpDir := t.TempDir() + + kotsConfig := filepath.Join(tmpDir, "config.yaml") + kotsContent := `apiVersion: kots.io/v1beta1 +kind: Config +metadata: + name: config +spec: + groups: [] +` + if err := os.WriteFile(kotsConfig, []byte(kotsContent), 0644); err != nil { + t.Fatal(err) + } + + pattern := filepath.Join(tmpDir, "*.yaml") + manifests, err := DiscoverECConfigHelmCharts([]string{pattern}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(manifests) != 0 { + t.Errorf("expected 0 manifests, got %d", len(manifests)) + } + }) + + t.Run("merged with kots HelmCharts in DiscoverHelmChartManifests", func(t *testing.T) { + tmpDir := t.TempDir() + + helmChartFile := filepath.Join(tmpDir, "helmchart.yaml") + helmContent := `apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: kots-chart +spec: + chart: + name: kots-app + chartVersion: 1.0.0 +` + if err := os.WriteFile(helmChartFile, []byte(helmContent), 0644); err != nil { + t.Fatal(err) + } + + ecFile := filepath.Join(tmpDir, "ec.yaml") + ecContent := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: ec-app + chartVersion: 2.0.0 +` + if err := os.WriteFile(ecFile, []byte(ecContent), 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 (1 kots + 1 EC), got %d", len(manifests)) + } + + if _, found := manifests["kots-app:1.0.0"]; !found { + t.Error("expected kots-app:1.0.0 not found") + } + if _, found := manifests["ec-app:2.0.0"]; !found { + t.Error("expected ec-app:2.0.0 not found") + } + }) +} diff --git a/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/.replicated b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/.replicated new file mode 100644 index 000000000..41bcca854 --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/.replicated @@ -0,0 +1,4 @@ +charts: + - path: ./chart +manifests: + - ./manifests/*.yaml diff --git a/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/Chart.yaml b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/Chart.yaml new file mode 100644 index 000000000..3dafca10c --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: ec-app +version: 1.0.0 +description: Test chart for EC Config validation scenario diff --git a/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/values.yaml b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/values.yaml new file mode 100644 index 000000000..d6c93d3e8 --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/chart/values.yaml @@ -0,0 +1 @@ +# default values diff --git a/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/manifests/ec.yaml b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/manifests/ec.yaml new file mode 100644 index 000000000..ff59aba4a --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-7-ec-config-helmcharts/manifests/ec.yaml @@ -0,0 +1,11 @@ +apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: ec-app + chartVersion: 1.0.0 diff --git a/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/.replicated b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/.replicated new file mode 100644 index 000000000..41bcca854 --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/.replicated @@ -0,0 +1,4 @@ +charts: + - path: ./chart +manifests: + - ./manifests/*.yaml diff --git a/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/Chart.yaml b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/Chart.yaml new file mode 100644 index 000000000..986fbf040 --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: other-app +version: 1.0.0 +description: Test chart with matching kots HelmChart manifest diff --git a/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/values.yaml b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/values.yaml new file mode 100644 index 000000000..d6c93d3e8 --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/chart/values.yaml @@ -0,0 +1 @@ +# default values diff --git a/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/ec.yaml b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/ec.yaml new file mode 100644 index 000000000..fa691b67d --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/ec.yaml @@ -0,0 +1,11 @@ +apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: missing-chart + chartVersion: 1.0.0 diff --git a/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/helmchart.yaml b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/helmchart.yaml new file mode 100644 index 000000000..b1b039190 --- /dev/null +++ b/pkg/lint2/testdata/validation/scenario-8-ec-config-missing-archive/manifests/helmchart.yaml @@ -0,0 +1,9 @@ +apiVersion: kots.io/v1beta1 +kind: HelmChart +metadata: + name: other-app +spec: + chart: + name: other-app + chartVersion: 1.0.0 + builder: {} diff --git a/pkg/lint2/validation_integration_test.go b/pkg/lint2/validation_integration_test.go index 6c59d9485..2a9b5f732 100644 --- a/pkg/lint2/validation_integration_test.go +++ b/pkg/lint2/validation_integration_test.go @@ -374,3 +374,105 @@ func TestLintValidation_AutoDiscovery(t *testing.T) { t.Log("Auto-discovery successfully found and validated chart with HelmChart manifest") } + +// TestLintValidation_ECConfigHelmCharts tests validation with EC Config helmCharts that match a chart. +func TestLintValidation_ECConfigHelmCharts(t *testing.T) { + testDir := filepath.Join("testdata", "validation", "scenario-7-ec-config-helmcharts") + + originalWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(originalWd) + + if err := os.Chdir(testDir); err != nil { + t.Fatalf("failed to change to test directory: %v", err) + } + + parser := tools.NewConfigParser() + config, err := parser.FindAndParseConfig(".") + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + charts, err := GetChartsWithMetadataFromConfig(config) + if err != nil { + t.Fatalf("GetChartsWithMetadataFromConfig failed: %v", err) + } + + if len(charts) != 1 { + t.Fatalf("expected 1 chart, got %d", len(charts)) + } + + helmCharts, err := DiscoverHelmChartManifests(config.Manifests) + if err != nil { + t.Fatalf("DiscoverHelmChartManifests failed: %v", err) + } + + if len(helmCharts) != 1 { + t.Fatalf("expected 1 HelmChart manifest discovered from EC Config, got %d", len(helmCharts)) + } + + result, err := ValidateChartToHelmChartMapping(charts, helmCharts) + if err != nil { + t.Fatalf("validation failed: %v", err) + } + + if len(result.Warnings) != 0 { + t.Errorf("expected no warnings, got %d: %v", len(result.Warnings), result.Warnings) + } +} + + // TestLintValidation_ECConfigMissingArchive tests warning when EC Config helmCharts has no matching chart. +func TestLintValidation_ECConfigMissingArchive(t *testing.T) { + testDir := filepath.Join("testdata", "validation", "scenario-8-ec-config-missing-archive") + + originalWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(originalWd) + + if err := os.Chdir(testDir); err != nil { + t.Fatalf("failed to change to test directory: %v", err) + } + + parser := tools.NewConfigParser() + config, err := parser.FindAndParseConfig(".") + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + charts, err := GetChartsWithMetadataFromConfig(config) + if err != nil { + t.Fatalf("GetChartsWithMetadataFromConfig failed: %v", err) + } + + if len(charts) != 1 { + t.Fatalf("expected 1 chart, got %d", len(charts)) + } + + helmCharts, err := DiscoverHelmChartManifests(config.Manifests) + if err != nil { + t.Fatalf("DiscoverHelmChartManifests failed: %v", err) + } + + if len(helmCharts) != 2 { + t.Fatalf("expected 2 manifests (1 kots + 1 EC), got %d", len(helmCharts)) + } + + result, err := ValidateChartToHelmChartMapping(charts, helmCharts) + if err != nil { + t.Fatalf("validation failed: %v", err) + } + + if len(result.Warnings) != 1 { + t.Errorf("expected 1 warning for orphaned EC Config manifest, got %d: %v", len(result.Warnings), result.Warnings) + } + + // Verify the warning mentions the orphaned EC Config chart + warning := result.Warnings[0] + if !strings.Contains(warning, "missing-chart") { + t.Errorf("warning should mention orphaned chart 'missing-chart': %s", warning) + } +} From c47709899f19d8bcec4fbf2d5ceb8ffbb6297f6d Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Wed, 6 May 2026 13:25:42 -0700 Subject: [PATCH 2/6] f --- pkg/lint2/validation_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/lint2/validation_integration_test.go b/pkg/lint2/validation_integration_test.go index 2a9b5f732..315a8a0d1 100644 --- a/pkg/lint2/validation_integration_test.go +++ b/pkg/lint2/validation_integration_test.go @@ -423,7 +423,7 @@ func TestLintValidation_ECConfigHelmCharts(t *testing.T) { } } - // TestLintValidation_ECConfigMissingArchive tests warning when EC Config helmCharts has no matching chart. +// TestLintValidation_ECConfigMissingArchive tests warning when EC Config helmCharts has no matching chart. func TestLintValidation_ECConfigMissingArchive(t *testing.T) { testDir := filepath.Join("testdata", "validation", "scenario-8-ec-config-missing-archive") From 2f7950abcdb1d60935bbffad744242e1d4af54f5 Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Wed, 6 May 2026 13:52:36 -0700 Subject: [PATCH 3/6] fix(lint): reset decoder target per document to avoid stale yaml values Move struct declarations inside the yaml.v3 decode loop in both parseHelmChartManifest and parseECConfigHelmCharts. yaml.v3's decoder modifies the target in place and does not clear fields absent in the current document, causing phantom entries or false duplicates in multi-document YAML files. Also removes unreachable return after the infinite for loop in parseHelmChartManifest. --- pkg/lint2/helmchart.go | 94 ++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/pkg/lint2/helmchart.go b/pkg/lint2/helmchart.go index 33b0dfd8b..ad5605c78 100644 --- a/pkg/lint2/helmchart.go +++ b/pkg/lint2/helmchart.go @@ -231,25 +231,23 @@ func parseHelmChartManifest(path string) (*HelmChartManifest, error) { 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 { + 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"` + } + err := decoder.Decode(&helmChart) if err != nil { if err == io.EOF { @@ -259,28 +257,26 @@ func parseHelmChartManifest(path string) (*HelmChartManifest, error) { } 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") - } + // 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. + // 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 + 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 + } + } } // parseECConfigHelmCharts reads a YAML file and extracts helm chart declarations from @@ -292,25 +288,25 @@ func parseECConfigHelmCharts(path string) ([]*HelmChartManifest, error) { return nil, fmt.Errorf("failed to read file: %w", err) } - var ecConfig struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Spec struct { - Extensions struct { - HelmCharts []struct { - Chart struct { - Name string `yaml:"name"` - ChartVersion string `yaml:"chartVersion"` - } `yaml:"chart"` - } `yaml:"helmCharts"` - } `yaml:"extensions"` - } `yaml:"spec"` - } - decoder := yaml.NewDecoder(bytes.NewReader(data)) var manifests []*HelmChartManifest for { + var ecConfig struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Spec struct { + Extensions struct { + HelmCharts []struct { + Chart struct { + Name string `yaml:"name"` + ChartVersion string `yaml:"chartVersion"` + } `yaml:"chart"` + } `yaml:"helmCharts"` + } `yaml:"extensions"` + } `yaml:"spec"` + } + err := decoder.Decode(&ecConfig) if err != nil { if err == io.EOF { From 9d77b3f5641b427ead7b58c0973fa548a2fa9625 Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Wed, 6 May 2026 14:08:38 -0700 Subject: [PATCH 4/6] refactor(lint): use ecConfigAPIVersion constant and fix test errorf - parseECConfigHelmCharts: use ecConfigAPIVersion constant from embedded_cluster.go instead of hardcoded string to prevent silent discovery failures if the API version changes. - TestLintValidation_ECConfigMissingArchive: use t.Fatalf instead of t.Errorf for the warnings length check to prevent index-out-of-bounds panic when result.Warnings is empty. --- pkg/lint2/helmchart.go | 2 +- pkg/lint2/validation_integration_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/lint2/helmchart.go b/pkg/lint2/helmchart.go index ad5605c78..1194bcdb4 100644 --- a/pkg/lint2/helmchart.go +++ b/pkg/lint2/helmchart.go @@ -315,7 +315,7 @@ func parseECConfigHelmCharts(path string) ([]*HelmChartManifest, error) { return nil, fmt.Errorf("failed to parse YAML: %w", err) } - if ecConfig.Kind != "Config" || ecConfig.APIVersion != "embeddedcluster.replicated.com/v1beta1" { + if ecConfig.Kind != "Config" || ecConfig.APIVersion != ecConfigAPIVersion { continue } diff --git a/pkg/lint2/validation_integration_test.go b/pkg/lint2/validation_integration_test.go index 315a8a0d1..ee531c414 100644 --- a/pkg/lint2/validation_integration_test.go +++ b/pkg/lint2/validation_integration_test.go @@ -467,7 +467,7 @@ func TestLintValidation_ECConfigMissingArchive(t *testing.T) { } if len(result.Warnings) != 1 { - t.Errorf("expected 1 warning for orphaned EC Config manifest, got %d: %v", len(result.Warnings), result.Warnings) + t.Fatalf("expected 1 warning for orphaned EC Config manifest, got %d: %v", len(result.Warnings), result.Warnings) } // Verify the warning mentions the orphaned EC Config chart From e6ed04faf5db6d245f2d6ac8fa72e21a33f356d5 Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Fri, 8 May 2026 09:43:11 -0700 Subject: [PATCH 5/6] refactor(lint): add hasAPIVersionKind helper for precise EC Config discovery Replace the overly-broad hasKind(path, "Config") quick check in DiscoverECConfigHelmCharts with hasAPIVersionKind(path, apiVersion, kind), which verifies both apiVersion and kind. This avoids false-positive matches against KOTS Config (kots.io/v1beta1) or any other Config kind when scanning manifest files for EC Config helmCharts declarations. --- pkg/lint2/discovery.go | 38 ++++++++++++++++++++++++++++++++++++++ pkg/lint2/helmchart.go | 6 +++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/pkg/lint2/discovery.go b/pkg/lint2/discovery.go index 639bc1a7e..ad34e30b8 100644 --- a/pkg/lint2/discovery.go +++ b/pkg/lint2/discovery.go @@ -174,6 +174,44 @@ func hasKind(path string, kind string) (bool, error) { return false, nil } +// hasAPIVersionKind checks if a YAML file contains a specific apiVersion and kind. +// Handles multi-document YAML files properly using yaml.NewDecoder. +// For files with syntax errors, falls back to simple regex matching to detect both apiVersion and kind. +func hasAPIVersionKind(path string, apiVersion string, kind string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + + for { + var doc struct { + Kind string `yaml:"kind"` + APIVersion string `yaml:"apiVersion"` + } + + err := decoder.Decode(&doc) + if err != nil { + if err == io.EOF { + break + } + // Parse error - fall back to regex matching + kindPattern := fmt.Sprintf(`(?m)^kind:\s+%s\s*$`, regexp.QuoteMeta(kind)) + apiPattern := fmt.Sprintf(`(?m)^apiVersion:\s+%s\s*$`, regexp.QuoteMeta(apiVersion)) + kindMatched, _ := regexp.Match(kindPattern, data) + apiMatched, _ := regexp.Match(apiPattern, data) + return kindMatched && apiMatched, nil + } + + if doc.Kind == kind && doc.APIVersion == apiVersion { + return true, nil + } + } + + return false, nil +} + // discoverPreflightPaths discovers Preflight spec files from a glob pattern. // This is a thin wrapper around discoverYAMLsByKind for backward compatibility. // diff --git a/pkg/lint2/helmchart.go b/pkg/lint2/helmchart.go index 1194bcdb4..01410fce1 100644 --- a/pkg/lint2/helmchart.go +++ b/pkg/lint2/helmchart.go @@ -184,9 +184,9 @@ func DiscoverECConfigHelmCharts(manifestGlobs []string) (map[string]*HelmChartMa } seenFiles[path] = true - // Quick kind check before parsing - isConfig, err := hasKind(path, "Config") - if err != nil || !isConfig { + // Quick apiVersion+kind check before parsing to avoid matching KOTS Config + isECConfig, err := hasAPIVersionKind(path, ecConfigAPIVersion, "Config") + if err != nil || !isECConfig { continue } From 43ab34cf2f51c1779af973e2106d12f6458bad65 Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Fri, 8 May 2026 09:45:08 -0700 Subject: [PATCH 6/6] test(lint): add unit tests for hasAPIVersionKind Add comprehensive tests covering: - EC Config match (correct apiVersion + kind) - KOTS Config mismatch (same kind, different apiVersion) - Multi-document YAML with EC Config present - Multi-document YAML without EC Config - Empty file returns false - Valid EC Config parsed by yaml decoder (not regex fallback) --- pkg/lint2/discovery_test.go | 140 ++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/pkg/lint2/discovery_test.go b/pkg/lint2/discovery_test.go index 25acf7098..303bda5ff 100644 --- a/pkg/lint2/discovery_test.go +++ b/pkg/lint2/discovery_test.go @@ -2676,3 +2676,143 @@ func TestDiscoverSupportBundlePaths_ExplicitBypass(t *testing.T) { t.Errorf("Expected bundle in dist/, got: %s", bundles[0]) } } + +// Phase 9 Tests: hasAPIVersionKind + +func TestHasAPIVersionKind_ECConfigMatch(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "ec.yaml") + content := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got, err := hasAPIVersionKind(path, "embeddedcluster.replicated.com/v1beta1", "Config") + if err != nil { + t.Fatalf("hasAPIVersionKind() error = %v", err) + } + if !got { + t.Errorf("hasAPIVersionKind() = false, want true for EC Config") + } +} + +func TestHasAPIVersionKind_KOTSConfigMismatch(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.yaml") + content := `apiVersion: kots.io/v1beta1 +kind: Config +metadata: + name: config +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got, err := hasAPIVersionKind(path, "embeddedcluster.replicated.com/v1beta1", "Config") + if err != nil { + t.Fatalf("hasAPIVersionKind() error = %v", err) + } + if got { + t.Errorf("hasAPIVersionKind() = true, want false for KOTS Config") + } +} + +func TestHasAPIVersionKind_MultiDocumentWithECConfig(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "multi.yaml") + content := `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +--- +apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got, err := hasAPIVersionKind(path, "embeddedcluster.replicated.com/v1beta1", "Config") + if err != nil { + t.Fatalf("hasAPIVersionKind() error = %v", err) + } + if !got { + t.Errorf("hasAPIVersionKind() = false, want true for multi-doc with EC Config") + } +} + +func TestHasAPIVersionKind_MultiDocumentWithoutECConfig(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "multi.yaml") + content := `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +--- +apiVersion: kots.io/v1beta1 +kind: Config +metadata: + name: config +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got, err := hasAPIVersionKind(path, "embeddedcluster.replicated.com/v1beta1", "Config") + if err != nil { + t.Fatalf("hasAPIVersionKind() error = %v", err) + } + if got { + t.Errorf("hasAPIVersionKind() = true, want false for multi-doc without EC Config") + } +} + +func TestHasAPIVersionKind_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty.yaml") + if err := os.WriteFile(path, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + got, err := hasAPIVersionKind(path, "embeddedcluster.replicated.com/v1beta1", "Config") + if err != nil { + t.Fatalf("hasAPIVersionKind() error = %v", err) + } + if got { + t.Errorf("hasAPIVersionKind() = true, want false for empty file") + } +} + +func TestHasAPIVersionKind_InvalidYAMLRegexFallback(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "invalid.yaml") + content := `apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Config +metadata: + name: ec +spec: + version: 3.0.0 + extensions: + helmCharts: + - chart: + name: my-app + chartVersion: 1.2.3 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got, err := hasAPIVersionKind(path, "embeddedcluster.replicated.com/v1beta1", "Config") + if err != nil { + t.Fatalf("hasAPIVersionKind() error = %v", err) + } + if !got { + t.Errorf("hasAPIVersionKind() = false, want true for valid EC Config") + } +}