Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions pkg/lint2/helmchart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's multiple Config kinds with different api versions (e.g. KOTS's config, ec's Config, etc...). i guess the below parse continues on error so that might be fine with the current implementation you have but might cause issues later on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added hasAPIVersionKind function

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).
//
Expand Down Expand Up @@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.
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" {
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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
}
229 changes: 229 additions & 0 deletions pkg/lint2/helmchart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
charts:
- path: ./chart
manifests:
- ./manifests/*.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: v2
name: ec-app
version: 1.0.0
description: Test chart for EC Config validation scenario
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# default values
Loading
Loading