Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
192 changes: 162 additions & 30 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 All @@ -150,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 {
Expand All @@ -178,26 +257,79 @@ 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")
}

// 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
}
}
}

// 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")
// 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)
}

// 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.
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)
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 &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 manifests, nil
}
Loading
Loading