diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 5be385a12..a93a95f03 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -8,6 +8,7 @@ import ( "github.com/manifoldco/promptui" "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/lint2" "github.com/replicatedhq/replicated/pkg/tools" "github.com/spf13/cobra" ) @@ -328,36 +329,28 @@ func (r *runners) initConfig(cmd *cobra.Command, nonInteractive bool, skipDetect switch preflightChoice { case "Yes": - // Check if any preflights are v1beta3 (need values file) - needsValues := false - for _, preflightPath := range detected.Preflights { - apiVersion, err := tools.GetYAMLAPIVersion(preflightPath) - if err == nil && strings.Contains(apiVersion, "v1beta3") { - needsValues = true - break - } - } - - // If any preflight needs values, prompt once for the values file to use - var sharedValuesPath string - if needsValues { - valuesPath, err := r.promptForSharedValuesFile(detected.ValuesFiles) + // Prompt user to select chart for preflights (if charts are available) + var selectedChartName, selectedChartVersion string + if len(config.Charts) > 0 { + chartName, chartVersion, err := r.promptForChart(config.Charts) if err != nil { return err } - sharedValuesPath = valuesPath + selectedChartName = chartName + selectedChartVersion = chartVersion } - // Add all detected preflights with the shared values path if applicable + // Add all detected preflights with the selected chart for _, preflightPath := range detected.Preflights { // Convert to relative path with ./ prefix if !strings.HasPrefix(preflightPath, ".") { preflightPath = "./" + preflightPath } - preflight := tools.PreflightConfig{Path: preflightPath} - if sharedValuesPath != "" { - preflight.ValuesPath = sharedValuesPath + preflight := tools.PreflightConfig{ + Path: preflightPath, + ChartName: selectedChartName, + ChartVersion: selectedChartVersion, } config.Preflights = append(config.Preflights, preflight) @@ -427,26 +420,27 @@ func (r *runners) initConfig(cmd *cobra.Command, nonInteractive bool, skipDetect }) } - // For preflights, check if any are v1beta3 and auto-assign first values file if available - var autoValuesPath string - if len(detected.ValuesFiles) > 0 { - autoValuesPath = detected.ValuesFiles[0] - if !strings.HasPrefix(autoValuesPath, ".") { - autoValuesPath = "./" + autoValuesPath + // Auto-assign first detected chart to preflights (if available) + var autoChartName, autoChartVersion string + if len(config.Charts) > 0 { + // Get metadata from first chart + metadata, err := lint2.GetChartMetadata(config.Charts[0].Path) + if err == nil { + autoChartName = metadata.Name + autoChartVersion = metadata.Version } } + // Add detected preflights with auto-assigned chart (if available) for _, preflightPath := range detected.Preflights { if !strings.HasPrefix(preflightPath, ".") { preflightPath = "./" + preflightPath } - preflight := tools.PreflightConfig{Path: preflightPath} - - // Check if this is v1beta3 and assign values file - apiVersion, err := tools.GetYAMLAPIVersion(preflightPath) - if err == nil && strings.Contains(apiVersion, "v1beta3") && autoValuesPath != "" { - preflight.ValuesPath = autoValuesPath + preflight := tools.PreflightConfig{ + Path: preflightPath, + ChartName: autoChartName, // Auto-assigned from first chart + ChartVersion: autoChartVersion, // Auto-assigned from first chart } config.Preflights = append(config.Preflights, preflight) @@ -500,8 +494,8 @@ func (r *runners) initConfig(cmd *cobra.Command, nonInteractive bool, skipDetect if len(config.Preflights) > 0 { fmt.Fprintf(r.w, " Preflights: %d configured\n", len(config.Preflights)) for _, preflight := range config.Preflights { - if preflight.ValuesPath != "" { - fmt.Fprintf(r.w, " - %s (values: %s)\n", preflight.Path, preflight.ValuesPath) + if preflight.ChartName != "" && preflight.ChartVersion != "" { + fmt.Fprintf(r.w, " - %s (chart: %s:%s)\n", preflight.Path, preflight.ChartName, preflight.ChartVersion) } else { fmt.Fprintf(r.w, " - %s\n", preflight.Path) } @@ -608,57 +602,69 @@ func (r *runners) promptForChartPaths() ([]tools.ChartConfig, error) { return charts, nil } -func (r *runners) promptForSharedValuesFile(detectedValuesFiles []string) (string, error) { - // Build options list - options := []string{"None"} - for _, vf := range detectedValuesFiles { - if !strings.HasPrefix(vf, ".") { - vf = "./" + vf - } - options = append(options, vf) +// promptForChart prompts the user to select a chart for preflight configuration. +// Returns chart name and version to be stored in the config. +// Displays charts as "name:version (from path)" for clarity. +func (r *runners) promptForChart(charts []tools.ChartConfig) (string, string, error) { + if len(charts) == 0 { + return "", "", errors.New("no charts available to select from") } - options = append(options, "Custom path") - prompt := promptui.Select{ - Label: "Which values file should be used with the preflights?", - Items: options, + // Build chart selection list with metadata + type chartOption struct { + displayName string // For UI: "myapp:1.0.0 (from ./charts/myapp)" + name string // For config + version string // For config + path string // For context } - _, result, err := prompt.Run() - if err != nil { - if err == promptui.ErrInterrupt { - return "", errors.New("cancelled") + var options []chartOption + for _, chart := range charts { + // Get chart metadata to extract name and version + metadata, err := lint2.GetChartMetadata(chart.Path) + if err != nil { + // Skip charts we can't read + continue } - return "", errors.Wrap(err, "failed to read values file choice") + options = append(options, chartOption{ + displayName: fmt.Sprintf("%s:%s (from %s)", metadata.Name, metadata.Version, chart.Path), + name: metadata.Name, + version: metadata.Version, + path: chart.Path, + }) } - if result == "None" { - return "", nil + if len(options) == 0 { + return "", "", errors.New("no valid charts found (could not read Chart.yaml from any chart)") } - if result == "Custom path" { - pathPrompt := promptui.Prompt{ - Label: "Values file path", - Default: "", - } - path, err := pathPrompt.Run() - if err != nil { - if err == promptui.ErrInterrupt { - return "", errors.New("cancelled") - } - return "", errors.Wrap(err, "failed to read values path") + // Extract just display names for prompt + displayNames := make([]string, len(options)) + for i, opt := range options { + displayNames[i] = opt.displayName + } + + prompt := promptui.Select{ + Label: "Select chart for preflight checks", + Items: displayNames, + } + + idx, _, err := prompt.Run() + if err != nil { + if err == promptui.ErrInterrupt { + return "", "", errors.New("cancelled") } - return path, nil + return "", "", errors.Wrap(err, "failed to select chart") } - // Return the selected values file - return result, nil + selected := options[idx] + return selected.name, selected.version, nil } func (r *runners) promptForPreflightPathsWithCharts(charts []tools.ChartConfig, detectedValuesFiles []string) ([]tools.PreflightConfig, error) { var preflights []tools.PreflightConfig - var sharedValuesPath string - var checkedForValues bool + var sharedChartName, sharedChartVersion string + var checkedForChart bool for { pathPrompt := promptui.Prompt{ @@ -678,23 +684,20 @@ func (r *runners) promptForPreflightPathsWithCharts(charts []tools.ChartConfig, preflight := tools.PreflightConfig{Path: path} - // Check if this preflight is v1beta3 (needs values file) - apiVersion, err := tools.GetYAMLAPIVersion(path) - needsValues := err == nil && strings.Contains(apiVersion, "v1beta3") - - // If this preflight needs values and we haven't prompted yet, prompt now - if needsValues && !checkedForValues { - sharedValuesPath, err = r.promptForSharedValuesFile(detectedValuesFiles) + // If we haven't prompted for chart yet and charts are available, prompt now + if !checkedForChart && len(charts) > 0 { + chartName, chartVersion, err := r.promptForChart(charts) if err != nil { return nil, err } - checkedForValues = true + sharedChartName = chartName + sharedChartVersion = chartVersion + checkedForChart = true } - // Apply shared values path if needed - if needsValues && sharedValuesPath != "" { - preflight.ValuesPath = sharedValuesPath - } + // Apply shared chart selection + preflight.ChartName = sharedChartName + preflight.ChartVersion = sharedChartVersion preflights = append(preflights, preflight) diff --git a/docs/lint-format.md b/docs/lint-format.md index 7d365a57f..23195b596 100644 --- a/docs/lint-format.md +++ b/docs/lint-format.md @@ -49,7 +49,8 @@ Notes: preflights: [ { path: "./preflights/stuff", - valuesPath: "./chart/something", # directory to corresponding helm chart + chartName: "something", + chartVersion: "1.0.0", } ] releaseLabel: "" ## some sort of semver pattern? @@ -154,3 +155,41 @@ data: ```yaml # repl-lint-ignore-file ``` + +## Preflight Configuration + +Preflight specs require a chart reference for template rendering. Configure preflights by specifying the chart name and version to use: + +```yaml +charts: + - path: "./chart" + +preflights: + - path: "./preflight.yaml" + chartName: "my-chart" # Must match chart name in Chart.yaml + chartVersion: "1.0.0" # Must match chart version in Chart.yaml +``` + +**Requirements:** +- Both `chartName` and `chartVersion` are required for each preflight +- The chart name/version must match the values in the chart's `Chart.yaml` file +- The chart must be listed in the `charts` section of your `.replicated` config + +### Values File Location + +Preflight template rendering requires a chart's values file. The CLI automatically locates this file using these rules: + +1. **Checks for `values.yaml` in the chart root directory** (most common) +2. **Falls back to `values.yml`** if `values.yaml` doesn't exist +3. **Returns an error** if neither exists + +**Expected Chart Structure:** +``` +my-chart/ + ├── Chart.yaml # Required: defines chart name and version + ├── values.yaml # Required: default values for templates + ├── templates/ # Chart templates + └── ... +``` + +**Note:** Custom values file paths are not currently supported. Values files must be named `values.yaml` or `values.yml` and located in the chart root directory. diff --git a/examples/.replicated.yaml b/examples/.replicated.yaml index a9212ff26..02a4d9a19 100644 --- a/examples/.replicated.yaml +++ b/examples/.replicated.yaml @@ -12,7 +12,8 @@ charts: [ preflights: [ { path: "./preflights/**", - valuesPath: "./helm-chart/values.yaml", # path to values.yaml file + chartName: "helm-chart", + chartVersion: "1.0.0", } ] releaseLabel: "" ## some sort of semver pattern? diff --git a/pkg/lint2/config.go b/pkg/lint2/config.go index 5a3596a41..6e5002da1 100644 --- a/pkg/lint2/config.go +++ b/pkg/lint2/config.go @@ -2,6 +2,7 @@ package lint2 import ( "fmt" + "os" "path/filepath" "github.com/replicatedhq/replicated/pkg/tools" @@ -76,6 +77,113 @@ func GetChartsWithMetadataFromConfig(config *tools.Config) ([]ChartWithMetadata, return results, nil } +// ============================================================================== +// Typed Errors for Chart Lookup +// ============================================================================== + +// ChartNotFoundError is returned when a preflight references a chart that doesn't exist in the config. +type ChartNotFoundError struct { + RequestedChart string // "name:version" + AvailableCharts []string // List of available chart keys +} + +func (e *ChartNotFoundError) Error() string { + return fmt.Sprintf( + "chart %q not found in charts configuration\n"+ + "Available charts: %v\n\n"+ + "Ensure the chart is listed in the 'charts' section of your .replicated config:\n"+ + "charts:\n"+ + " - path: \"./path/to/chart\"", + e.RequestedChart, e.AvailableCharts, + ) +} + +// DuplicateChartError is returned when multiple charts have the same name:version. +type DuplicateChartError struct { + ChartKey string // "name:version" + FirstPath string + SecondPath string +} + +func (e *DuplicateChartError) Error() string { + return fmt.Sprintf( + "duplicate chart %q found in configuration\n"+ + " First: %s\n"+ + " Second: %s\n\n"+ + "Each chart name:version pair must be unique. Consider:\n"+ + "- Renaming one chart in Chart.yaml\n"+ + "- Changing the version in Chart.yaml\n"+ + "- Removing one chart from the configuration", + e.ChartKey, e.FirstPath, e.SecondPath, + ) +} + +// ============================================================================== +// Chart Lookup Utilities +// ============================================================================== + +// getChartKeys extracts chart keys from a lookup map. +// Helper function for error messages listing available charts. +func getChartKeys(lookup map[string]*ChartWithMetadata) []string { + keys := make([]string, 0, len(lookup)) + for k := range lookup { + keys = append(keys, k) + } + return keys +} + +// BuildChartLookup creates a name:version lookup map for charts. +// Returns error if duplicate chart name:version pairs are found. +// This utility is reusable by any code that needs to resolve charts by name:version. +func BuildChartLookup(config *tools.Config) (map[string]*ChartWithMetadata, error) { + if len(config.Charts) == 0 { + return nil, fmt.Errorf("no charts found in .replicated config") + } + + // Get all charts with metadata + charts, err := GetChartsWithMetadataFromConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to get charts from config: %w", err) + } + + // Build lookup map + chartLookup := make(map[string]*ChartWithMetadata) + for i := range charts { + key := fmt.Sprintf("%s:%s", charts[i].Name, charts[i].Version) + if existing, exists := chartLookup[key]; exists { + return nil, &DuplicateChartError{ + ChartKey: key, + FirstPath: existing.Path, + SecondPath: charts[i].Path, + } + } + chartLookup[key] = &charts[i] + } + + return chartLookup, nil +} + +// findValuesFile finds values.yaml or values.yml in a chart directory. +// Checks for values.yaml first (most common), then falls back to values.yml. +// Returns the path if found, or an error if neither exists. +// +// This follows the same pattern as Chart.yaml/Chart.yml detection in GetChartMetadata. +func findValuesFile(chartPath string) (string, error) { + // Check values.yaml first (most common) + valuesYaml := filepath.Join(chartPath, "values.yaml") + if _, err := os.Stat(valuesYaml); err == nil { + return valuesYaml, nil + } + + // Check values.yml as fallback + valuesYml := filepath.Join(chartPath, "values.yml") + if _, err := os.Stat(valuesYml); err == nil { + return valuesYml, nil + } + + return "", fmt.Errorf("no values.yaml or values.yml found in chart directory %s", chartPath) +} + // GetPreflightPathsFromConfig extracts and expands preflight spec paths from config func GetPreflightPathsFromConfig(config *tools.Config) ([]string, error) { if len(config.Preflights) == 0 { @@ -85,6 +193,20 @@ func GetPreflightPathsFromConfig(config *tools.Config) ([]string, error) { return expandPaths(config.Preflights, func(p tools.PreflightConfig) string { return p.Path }, DiscoverPreflightPaths, "preflight specs") } +// discoverPreflightSpecs discovers and validates preflight spec paths from a path pattern. +// Wraps DiscoverPreflightPaths with validation that at least one spec was found. +// This helper eliminates duplication in GetPreflightWithValuesFromConfig. +func discoverPreflightSpecs(path string) ([]string, error) { + specs, err := DiscoverPreflightPaths(path) + if err != nil { + return nil, fmt.Errorf("failed to discover preflights from %s: %w", path, err) + } + if len(specs) == 0 { + return nil, fmt.Errorf("no preflight specs found matching: %s", path) + } + return specs, nil +} + // PreflightWithValues contains preflight spec path and optional values file type PreflightWithValues struct { SpecPath string // Path to the preflight spec file @@ -93,58 +215,148 @@ type PreflightWithValues struct { ChartVersion string // Chart version from Chart.yaml (used to look up HelmChart manifest for builder values) } -// GetPreflightWithValuesFromConfig extracts preflight paths with associated chart/values information +// resolvePreflightWithChart resolves a single preflight spec with an explicit chart reference. +// Uses v1beta3 detection to determine strict (error on failures) vs lenient (continue with empty values) behavior. +// +// Parameters: +// - specPath: path to the preflight spec file +// - chartName: explicit chart name reference +// - chartVersion: explicit chart version reference +// - chartLookup: pre-built name:version lookup map (or nil if chartLookupErr is set) +// - chartLookupErr: error from BuildChartLookup (or nil if chartLookup is valid) +// +// Returns: +// - PreflightWithValues with resolved chart/values information +// - error if strict validation fails (v1beta3) or if critical errors occur +// +// Behavior: +// - v1beta3 specs: errors if chart lookup fails, chart not found, or values file missing (strict) +// - v1beta2 specs: continues with empty values if lookups fail (lenient) +func resolvePreflightWithChart( + specPath string, + chartName string, + chartVersion string, + chartLookup map[string]*ChartWithMetadata, + chartLookupErr error, +) (PreflightWithValues, error) { + // Check if this is v1beta3 (determines strict vs lenient behavior) + isV1Beta3, err := isPreflightV1Beta3(specPath) + if err != nil { + return PreflightWithValues{}, fmt.Errorf("failed to check preflight version for %s: %w", specPath, err) + } + + // Handle chart lookup failure + if chartLookupErr != nil { + if isV1Beta3 { + // v1beta3 requires charts to be configured + return PreflightWithValues{}, fmt.Errorf("v1beta3 preflight %s requires charts configuration: %w", specPath, chartLookupErr) + } + // v1beta2 can continue without charts (lenient) + return PreflightWithValues{ + SpecPath: specPath, + ValuesPath: "", + ChartName: "", + ChartVersion: "", + }, nil + } + + // Look up chart by name:version + key := fmt.Sprintf("%s:%s", chartName, chartVersion) + chart, found := chartLookup[key] + if !found { + if isV1Beta3 { + // v1beta3 requires the chart to exist (strict) + return PreflightWithValues{}, &ChartNotFoundError{ + RequestedChart: key, + AvailableCharts: getChartKeys(chartLookup), + } + } + // v1beta2 can continue without the specific chart (lenient) + return PreflightWithValues{ + SpecPath: specPath, + ValuesPath: "", + ChartName: chartName, + ChartVersion: chartVersion, + }, nil + } + + // Find values file + valuesPath, err := findValuesFile(chart.Path) + if err != nil { + if isV1Beta3 { + // v1beta3 requires values file to exist (strict) + return PreflightWithValues{}, fmt.Errorf("chart %q: %w\nEnsure the chart directory contains values.yaml or values.yml", key, err) + } + // v1beta2 can continue without values file (lenient) + return PreflightWithValues{ + SpecPath: specPath, + ValuesPath: "", + ChartName: chart.Name, + ChartVersion: chart.Version, + }, nil + } + + // Success: all lookups worked + return PreflightWithValues{ + SpecPath: specPath, + ValuesPath: valuesPath, + ChartName: chart.Name, + ChartVersion: chart.Version, + }, nil +} + +// GetPreflightWithValuesFromConfig extracts preflight paths with optional chart/values information. +// If chartName/chartVersion are provided, uses explicit chart references. +// If not provided, returns empty values and lets the linter decide requirements. func GetPreflightWithValuesFromConfig(config *tools.Config) ([]PreflightWithValues, error) { if len(config.Preflights) == 0 { return nil, fmt.Errorf("no preflights found in .replicated config") } + // Build chart lookup upfront (simpler than lazy init) + // Helper function handles chartLookupErr appropriately based on v1beta3 vs v1beta2 + chartLookup, chartLookupErr := BuildChartLookup(config) + var results []PreflightWithValues for _, preflightConfig := range config.Preflights { - // Discovery handles both explicit paths and glob patterns - specPaths, err := DiscoverPreflightPaths(preflightConfig.Path) + // Discover preflight spec paths (handles globs) + specPaths, err := discoverPreflightSpecs(preflightConfig.Path) if err != nil { - return nil, fmt.Errorf("failed to discover preflights from %s: %w", preflightConfig.Path, err) - } - if len(specPaths) == 0 { - return nil, fmt.Errorf("no preflight specs found matching: %s", preflightConfig.Path) + return nil, err } - // Create PreflightWithValues for each discovered spec - for _, specPath := range specPaths { - result := PreflightWithValues{ - SpecPath: specPath, - ValuesPath: preflightConfig.ValuesPath, // Optional - can be empty + // BRANCH 1: Chart reference provided (explicit reference pattern) + if preflightConfig.ChartName != "" { + // Validate chartVersion also provided + if preflightConfig.ChartVersion == "" { + return nil, fmt.Errorf("preflight %s: chartVersion required when chartName is specified", preflightConfig.Path) } - // Extract chart metadata if valuesPath is provided - // This is needed to look up the matching HelmChart manifest for builder values - if preflightConfig.ValuesPath != "" { - // Check if this is v1beta3 (requires Chart.yaml for builder values) - isV1Beta3, err := isPreflightV1Beta3(specPath) + // Process each discovered spec using helper function + for _, specPath := range specPaths { + result, err := resolvePreflightWithChart( + specPath, + preflightConfig.ChartName, + preflightConfig.ChartVersion, + chartLookup, + chartLookupErr, + ) if err != nil { - return nil, fmt.Errorf("failed to check preflight version for %s: %w", specPath, err) - } - - // Get the chart directory from the values path - // valuesPath is expected to be like "./helm-chart/values.yaml" - chartDir := filepath.Dir(preflightConfig.ValuesPath) - - chartMetadata, err := GetChartMetadata(chartDir) - if err != nil { - if isV1Beta3 { - // v1beta3 requires Chart.yaml to get chart name/version for HelmChart manifest lookup - return nil, fmt.Errorf("failed to read chart metadata for preflight %s: %w", specPath, err) - } - // v1beta2 doesn't need Chart.yaml - skip metadata extraction - } else { - result.ChartName = chartMetadata.Name - result.ChartVersion = chartMetadata.Version + return nil, err } + results = append(results, result) + } + } else { + // BRANCH 2: No chart reference provided (linter decides) + for _, specPath := range specPaths { + results = append(results, PreflightWithValues{ + SpecPath: specPath, + ValuesPath: "", + ChartName: "", + ChartVersion: "", + }) } - - results = append(results, result) } } diff --git a/pkg/lint2/config_test.go b/pkg/lint2/config_test.go index e0075814f..b99c097c9 100644 --- a/pkg/lint2/config_test.go +++ b/pkg/lint2/config_test.go @@ -1,6 +1,7 @@ package lint2 import ( + "errors" "os" "path/filepath" "testing" @@ -1151,9 +1152,9 @@ spec: } } -func TestGetPreflightWithValuesFromConfig_MissingChartYaml(t *testing.T) { - // Test that GetPreflightWithValuesFromConfig errors for v1beta3 when Chart.yaml is missing - // v1beta3 requires Chart.yaml to extract chart name/version for HelmChart manifest lookup +func TestGetPreflightWithValuesFromConfig_ChartNotFound(t *testing.T) { + // Test that GetPreflightWithValuesFromConfig errors when chart reference doesn't exist for v1beta3 + // For v1beta3, chart must be found. For v1beta2, it's lenient and continues with empty values. tmpDir := t.TempDir() // Create a v1beta3 preflight spec (v1beta3 requires Chart.yaml for builder values) @@ -1169,36 +1170,332 @@ spec: t.Fatal(err) } - // Create a values.yaml file WITHOUT adjacent Chart.yaml - valuesDir := filepath.Join(tmpDir, "chart") - if err := os.MkdirAll(valuesDir, 0755); err != nil { + // Create a chart + chartDir := filepath.Join(tmpDir, "available-chart") + if err := os.MkdirAll(chartDir, 0755); err != nil { t.Fatal(err) } - valuesPath := filepath.Join(valuesDir, "values.yaml") - valuesContent := `database: - enabled: true -` - if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + chartYaml := filepath.Join(chartDir, "Chart.yaml") + if err := os.WriteFile(chartYaml, []byte("name: available\nversion: 1.0.0\n"), 0644); err != nil { t.Fatal(err) } - // Config with valuesPath but no Chart.yaml + // Config references a chart that doesn't exist in Charts section config := &tools.Config{ + Charts: []tools.ChartConfig{ + {Path: chartDir}, + }, Preflights: []tools.PreflightConfig{ { - Path: preflightPath, - ValuesPath: valuesPath, + Path: preflightPath, + ChartName: "missing-chart", + ChartVersion: "1.0.0", }, }, } _, err := GetPreflightWithValuesFromConfig(config) if err == nil { - t.Fatal("GetPreflightWithValuesFromConfig() should error when Chart.yaml is missing, got nil") + t.Fatal("GetPreflightWithValuesFromConfig() should error when chart not found, got nil") + } + + // Should be a ChartNotFoundError + var notFoundErr *ChartNotFoundError + if !errors.As(err, ¬FoundErr) { + t.Errorf("Expected ChartNotFoundError, got %T: %v", err, err) + } + + // Error should mention the requested chart + if !contains(err.Error(), "missing-chart:1.0.0") { + t.Errorf("Error should mention requested chart 'missing-chart:1.0.0', got: %v", err) + } +} + +// ============================================================================== +// Tests for Phase 0 Utility Functions +// ============================================================================== + +func TestBuildChartLookup_Success(t *testing.T) { + tmpDir := t.TempDir() + + // Create two charts with different names/versions + chart1Dir := filepath.Join(tmpDir, "chart1") + if err := os.MkdirAll(chart1Dir, 0755); err != nil { + t.Fatal(err) + } + chart1Yaml := filepath.Join(chart1Dir, "Chart.yaml") + if err := os.WriteFile(chart1Yaml, []byte("name: app1\nversion: 1.0.0\n"), 0644); err != nil { + t.Fatal(err) + } + + chart2Dir := filepath.Join(tmpDir, "chart2") + if err := os.MkdirAll(chart2Dir, 0755); err != nil { + t.Fatal(err) + } + chart2Yaml := filepath.Join(chart2Dir, "Chart.yaml") + if err := os.WriteFile(chart2Yaml, []byte("name: app2\nversion: 2.0.0\n"), 0644); err != nil { + t.Fatal(err) + } + + config := &tools.Config{ + Charts: []tools.ChartConfig{ + {Path: chart1Dir}, + {Path: chart2Dir}, + }, + } + + lookup, err := BuildChartLookup(config) + if err != nil { + t.Fatalf("BuildChartLookup() unexpected error: %v", err) + } + + if len(lookup) != 2 { + t.Errorf("Expected 2 charts in lookup, got %d", len(lookup)) + } + + // Verify app1:1.0.0 is in lookup + chart1 := lookup["app1:1.0.0"] + if chart1 == nil { + t.Error("app1:1.0.0 not found in lookup") + } else { + if chart1.Path != chart1Dir { + t.Errorf("app1:1.0.0 path = %s, want %s", chart1.Path, chart1Dir) + } + if chart1.Name != "app1" { + t.Errorf("app1:1.0.0 name = %s, want app1", chart1.Name) + } + if chart1.Version != "1.0.0" { + t.Errorf("app1:1.0.0 version = %s, want 1.0.0", chart1.Version) + } + } + + // Verify app2:2.0.0 is in lookup + chart2 := lookup["app2:2.0.0"] + if chart2 == nil { + t.Error("app2:2.0.0 not found in lookup") + } else { + if chart2.Path != chart2Dir { + t.Errorf("app2:2.0.0 path = %s, want %s", chart2.Path, chart2Dir) + } + } +} + +func TestBuildChartLookup_DuplicateError(t *testing.T) { + tmpDir := t.TempDir() + + // Create two charts with SAME name:version + chart1Dir := filepath.Join(tmpDir, "chart1") + if err := os.MkdirAll(chart1Dir, 0755); err != nil { + t.Fatal(err) + } + chart1Yaml := filepath.Join(chart1Dir, "Chart.yaml") + if err := os.WriteFile(chart1Yaml, []byte("name: myapp\nversion: 1.0.0\n"), 0644); err != nil { + t.Fatal(err) + } + + chart2Dir := filepath.Join(tmpDir, "chart2") + if err := os.MkdirAll(chart2Dir, 0755); err != nil { + t.Fatal(err) + } + chart2Yaml := filepath.Join(chart2Dir, "Chart.yaml") + if err := os.WriteFile(chart2Yaml, []byte("name: myapp\nversion: 1.0.0\n"), 0644); err != nil { + t.Fatal(err) + } + + config := &tools.Config{ + Charts: []tools.ChartConfig{ + {Path: chart1Dir}, + {Path: chart2Dir}, + }, + } + + _, err := BuildChartLookup(config) + if err == nil { + t.Fatal("BuildChartLookup() should return error for duplicate charts, got nil") + } + + // Verify it's a DuplicateChartError + dupErr, ok := err.(*DuplicateChartError) + if !ok { + t.Fatalf("Expected *DuplicateChartError, got %T: %v", err, err) + } + + if dupErr.ChartKey != "myapp:1.0.0" { + t.Errorf("DuplicateChartError.ChartKey = %s, want myapp:1.0.0", dupErr.ChartKey) + } + if dupErr.FirstPath != chart1Dir { + t.Errorf("DuplicateChartError.FirstPath = %s, want %s", dupErr.FirstPath, chart1Dir) + } + if dupErr.SecondPath != chart2Dir { + t.Errorf("DuplicateChartError.SecondPath = %s, want %s", dupErr.SecondPath, chart2Dir) + } +} + +func TestBuildChartLookup_NoCharts(t *testing.T) { + config := &tools.Config{ + Charts: []tools.ChartConfig{}, + } + + _, err := BuildChartLookup(config) + if err == nil { + t.Fatal("BuildChartLookup() should error when no charts, got nil") + } + if !contains(err.Error(), "no charts found") { + t.Errorf("Error should mention 'no charts found', got: %v", err) + } +} + +func TestFindValuesFile_Yaml(t *testing.T) { + tmpDir := t.TempDir() + + // Create values.yaml + valuesPath := filepath.Join(tmpDir, "values.yaml") + if err := os.WriteFile(valuesPath, []byte("key: value\n"), 0644); err != nil { + t.Fatal(err) + } + + found, err := findValuesFile(tmpDir) + if err != nil { + t.Fatalf("findValuesFile() unexpected error: %v", err) + } + if found != valuesPath { + t.Errorf("findValuesFile() = %s, want %s", found, valuesPath) + } +} + +func TestFindValuesFile_Yml(t *testing.T) { + tmpDir := t.TempDir() + + // Create values.yml (not .yaml) + valuesPath := filepath.Join(tmpDir, "values.yml") + if err := os.WriteFile(valuesPath, []byte("key: value\n"), 0644); err != nil { + t.Fatal(err) + } + + found, err := findValuesFile(tmpDir) + if err != nil { + t.Fatalf("findValuesFile() unexpected error: %v", err) + } + if found != valuesPath { + t.Errorf("findValuesFile() = %s, want %s", found, valuesPath) + } +} + +func TestFindValuesFile_PreferYaml(t *testing.T) { + tmpDir := t.TempDir() + + // Create BOTH values.yaml and values.yml + valuesYamlPath := filepath.Join(tmpDir, "values.yaml") + if err := os.WriteFile(valuesYamlPath, []byte("yaml: true\n"), 0644); err != nil { + t.Fatal(err) + } + valuesYmlPath := filepath.Join(tmpDir, "values.yml") + if err := os.WriteFile(valuesYmlPath, []byte("yml: true\n"), 0644); err != nil { + t.Fatal(err) + } + + // Should prefer .yaml over .yml + found, err := findValuesFile(tmpDir) + if err != nil { + t.Fatalf("findValuesFile() unexpected error: %v", err) + } + if found != valuesYamlPath { + t.Errorf("findValuesFile() = %s, want %s (should prefer .yaml)", found, valuesYamlPath) + } +} + +func TestFindValuesFile_Missing(t *testing.T) { + tmpDir := t.TempDir() + + // No values file at all + _, err := findValuesFile(tmpDir) + if err == nil { + t.Fatal("findValuesFile() should error when no values file exists, got nil") + } + if !contains(err.Error(), "no values.yaml or values.yml") { + t.Errorf("Error should mention 'no values.yaml or values.yml', got: %v", err) + } + if !contains(err.Error(), tmpDir) { + t.Errorf("Error should mention the chart directory path, got: %v", err) + } +} + +func TestChartNotFoundError_Message(t *testing.T) { + err := &ChartNotFoundError{ + RequestedChart: "myapp:1.0.0", + AvailableCharts: []string{"otherapp:2.0.0", "thirdapp:3.0.0"}, + } + + msg := err.Error() + + // Verify error message contains key information + if !contains(msg, "myapp:1.0.0") { + t.Error("Error message should contain requested chart name") + } + if !contains(msg, "otherapp:2.0.0") { + t.Error("Error message should list available charts") + } + if !contains(msg, "thirdapp:3.0.0") { + t.Error("Error message should list all available charts") + } + if !contains(msg, "charts:") { + t.Error("Error message should include example config snippet") + } + if !contains(msg, "path:") { + t.Error("Error message should show example path in config") + } +} + +func TestDuplicateChartError_Message(t *testing.T) { + err := &DuplicateChartError{ + ChartKey: "myapp:1.0.0", + FirstPath: "/path/to/chart1", + SecondPath: "/path/to/chart2", } - // Error should mention failed to read Chart.yaml or Chart.yml - if !contains(err.Error(), "failed to read Chart.yaml or Chart.yml") { - t.Errorf("Error should mention failed to read Chart.yaml or Chart.yml, got: %v", err) + msg := err.Error() + + // Verify error message contains key information + if !contains(msg, "myapp:1.0.0") { + t.Error("Error message should contain chart key") + } + if !contains(msg, "/path/to/chart1") { + t.Error("Error message should contain first chart path") + } + if !contains(msg, "/path/to/chart2") { + t.Error("Error message should contain second chart path") + } + if !contains(msg, "duplicate") { + t.Error("Error message should mention 'duplicate'") + } + if !contains(msg, "Renaming") || !contains(msg, "version") { + t.Error("Error message should provide helpful suggestions") + } +} + +func TestGetChartKeys(t *testing.T) { + lookup := map[string]*ChartWithMetadata{ + "app1:1.0.0": {Path: "/chart1", Name: "app1", Version: "1.0.0"}, + "app2:2.0.0": {Path: "/chart2", Name: "app2", Version: "2.0.0"}, + "app3:3.0.0": {Path: "/chart3", Name: "app3", Version: "3.0.0"}, + } + + keys := getChartKeys(lookup) + + if len(keys) != 3 { + t.Errorf("getChartKeys() returned %d keys, want 3", len(keys)) + } + + // Verify all keys are present (order doesn't matter) + keyMap := make(map[string]bool) + for _, k := range keys { + keyMap[k] = true + } + + expectedKeys := []string{"app1:1.0.0", "app2:2.0.0", "app3:3.0.0"} + for _, expected := range expectedKeys { + if !keyMap[expected] { + t.Errorf("Expected key %s not found in result", expected) + } } } diff --git a/pkg/lint2/preflight.go b/pkg/lint2/preflight.go index 0d2fca7ef..7017de352 100644 --- a/pkg/lint2/preflight.go +++ b/pkg/lint2/preflight.go @@ -143,17 +143,28 @@ func LintPreflight( } // isPreflightV1Beta3 checks if a preflight spec is apiVersion v1beta3 -// Uses string matching to handle specs with Helm template syntax that aren't valid YAML yet +// Tries YAML parsing first for accurate detection, falls back to string matching for templated specs func isPreflightV1Beta3(specPath string) (bool, error) { data, err := os.ReadFile(specPath) if err != nil { return false, fmt.Errorf("failed to read spec file: %w", err) } - content := string(data) + // Strategy 1: Try YAML parsing for accurate detection + var doc struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(data, &doc); err == nil { + // Successfully parsed as YAML - use accurate field matching + isPreflightKind := doc.Kind == "Preflight" + hasV1Beta3 := strings.Contains(doc.APIVersion, "v1beta3") + return isPreflightKind && hasV1Beta3, nil + } - // Check for kind: Preflight and apiVersion containing v1beta3 - // Use simple string matching to handle templated specs that aren't valid YAML + // Strategy 2: Fall back to string matching for templated specs (with {{ }} syntax) + // This handles specs that contain Helm template expressions and aren't valid YAML yet + content := string(data) hasPreflightKind := strings.Contains(content, "kind: Preflight") || strings.Contains(content, "kind:Preflight") hasV1Beta3 := strings.Contains(content, "v1beta3") diff --git a/pkg/lint2/preflight_integration_test.go b/pkg/lint2/preflight_integration_test.go index 9637502db..c0cd40dd8 100644 --- a/pkg/lint2/preflight_integration_test.go +++ b/pkg/lint2/preflight_integration_test.go @@ -236,9 +236,9 @@ func TestLintPreflight_Integration(t *testing.T) { t.Fatal("Expected error for missing HelmChart manifest, got nil") } - // Error should mention missing HelmChart - if !contains(err.Error(), "no HelmChart manifest found") { - t.Errorf("Error should mention missing HelmChart, got: %v", err) + // Error should mention v1beta3 requires HelmChart manifests + if !contains(err.Error(), "v1beta3 preflight spec requires HelmChart manifests") { + t.Errorf("Error should mention v1beta3 requires HelmChart manifests, got: %v", err) } }) @@ -369,10 +369,14 @@ func TestLintPreflight_Integration(t *testing.T) { // Create a config structure that mimics a .replicated config file config := &tools.Config{ + Charts: []tools.ChartConfig{ + {Path: "testdata/preflights/templated-test/chart"}, + }, Preflights: []tools.PreflightConfig{ { - Path: "testdata/preflights/templated-test/preflight-templated.yaml", - ValuesPath: "testdata/preflights/templated-test/chart/values.yaml", + Path: "testdata/preflights/templated-test/preflight-templated.yaml", + ChartName: "test-app", + ChartVersion: "1.0.0", }, }, Manifests: []string{"testdata/preflights/templated-test/manifests/*.yaml"}, diff --git a/pkg/lint2/testdata/.replicated.example b/pkg/lint2/testdata/.replicated.example index d443dbde4..aa2e615c0 100644 --- a/pkg/lint2/testdata/.replicated.example +++ b/pkg/lint2/testdata/.replicated.example @@ -36,19 +36,22 @@ charts: - path: "./charts/my-chart" - path: "./charts/*" # Glob patterns are supported -# Preflight specs to lint (valuesPath is required for all preflights) +# Preflight specs to lint (chartName and chartVersion are required for all preflights) preflights: - # Single file with chart values + # Single file with chart reference - path: "./preflight.yaml" - valuesPath: "./chart/values.yaml" + chartName: "my-chart" + chartVersion: "1.0.0" # Glob pattern to match multiple files - path: "./preflights/*.yaml" - valuesPath: "./chart/values.yaml" + chartName: "my-chart" + chartVersion: "1.0.0" - # Absolute paths also work + # Absolute paths also work (chart references must match Chart.yaml) - path: "/absolute/path/to/preflight.yaml" - valuesPath: "/absolute/path/to/chart/values.yaml" + chartName: "my-chart" + chartVersion: "1.0.0" # Manifests to lint (optional, for future support) manifests: diff --git a/pkg/tools/config.go b/pkg/tools/config.go index 56ee338cd..0ded178b4 100644 --- a/pkg/tools/config.go +++ b/pkg/tools/config.go @@ -312,6 +312,14 @@ func (p *ConfigParser) validateConfig(config *Config) error { if preflight.Path == "" { return fmt.Errorf("preflight[%d]: path is required", i) } + + // chartName and chartVersion are optional, but must be provided together + if preflight.ChartName != "" && preflight.ChartVersion == "" { + return fmt.Errorf("preflight[%d]: chartVersion is required when chartName is specified", i) + } + if preflight.ChartVersion != "" && preflight.ChartName == "" { + return fmt.Errorf("preflight[%d]: chartName is required when chartVersion is specified", i) + } } // Validate manifest paths @@ -424,10 +432,7 @@ func (p *ConfigParser) resolvePaths(config *Config, configFilePath string) { if config.Preflights[i].Path != "" && !filepath.IsAbs(config.Preflights[i].Path) { config.Preflights[i].Path = filepath.Join(configDir, config.Preflights[i].Path) } - // Resolve valuesPath - if config.Preflights[i].ValuesPath != "" && !filepath.IsAbs(config.Preflights[i].ValuesPath) { - config.Preflights[i].ValuesPath = filepath.Join(configDir, config.Preflights[i].ValuesPath) - } + // Note: chartName and chartVersion are not paths - don't resolve them } // Resolve manifest paths (glob patterns) diff --git a/pkg/tools/config_test.go b/pkg/tools/config_test.go index 5e90b77d7..4fddbadce 100644 --- a/pkg/tools/config_test.go +++ b/pkg/tools/config_test.go @@ -493,8 +493,11 @@ charts: - path: ./charts/chart2 preflights: - path: ./preflights/check1 - valuesPath: ./charts/chart1 + chartName: "chart1" + chartVersion: "1.0.0" - path: ./preflights/check2 + chartName: "chart2" + chartVersion: "1.0.0" releaseLabel: "v{{.Version}}" manifests: - "replicated/**/*.yaml" @@ -628,9 +631,11 @@ repl-lint: configData := []byte(`preflights: - path: ./preflights/check1 - valuesPath: ./charts/chart1 + chartName: chart1 + chartVersion: "1.0.0" - path: preflights/check2 - valuesPath: ../parent-charts/chart2 + chartName: chart2 + chartVersion: "2.0.0" repl-lint: `) if err := os.WriteFile(configPath, configData, 0644); err != nil { @@ -646,14 +651,16 @@ repl-lint: t.Fatalf("expected 2 preflights, got %d", len(config.Preflights)) } - // Check first preflight + // Check first preflight - path is resolved, chartName/Version are not expectedPath := filepath.Join(tmpDir, "preflights/check1") if config.Preflights[0].Path != expectedPath { t.Errorf("preflights[0].Path = %q, want %q", config.Preflights[0].Path, expectedPath) } - expectedValuesPath := filepath.Join(tmpDir, "charts/chart1") - if config.Preflights[0].ValuesPath != expectedValuesPath { - t.Errorf("preflights[0].ValuesPath = %q, want %q", config.Preflights[0].ValuesPath, expectedValuesPath) + if config.Preflights[0].ChartName != "chart1" { + t.Errorf("preflights[0].ChartName = %q, want %q", config.Preflights[0].ChartName, "chart1") + } + if config.Preflights[0].ChartVersion != "1.0.0" { + t.Errorf("preflights[0].ChartVersion = %q, want %q", config.Preflights[0].ChartVersion, "1.0.0") } // Check second preflight @@ -661,9 +668,11 @@ repl-lint: if config.Preflights[1].Path != expectedPath2 { t.Errorf("preflights[1].Path = %q, want %q", config.Preflights[1].Path, expectedPath2) } - expectedValuesPath2 := filepath.Join(tmpDir, "../parent-charts/chart2") - if config.Preflights[1].ValuesPath != expectedValuesPath2 { - t.Errorf("preflights[1].ValuesPath = %q, want %q", config.Preflights[1].ValuesPath, expectedValuesPath2) + if config.Preflights[1].ChartName != "chart2" { + t.Errorf("preflights[1].ChartName = %q, want %q", config.Preflights[1].ChartName, "chart2") + } + if config.Preflights[1].ChartVersion != "2.0.0" { + t.Errorf("preflights[1].ChartVersion = %q, want %q", config.Preflights[1].ChartVersion, "2.0.0") } }) @@ -965,6 +974,50 @@ repl-lint: } }) + t.Run("missing preflight chartName rejected", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".replicated") + + configData := []byte(`preflights: + - path: "./preflight.yaml" + chartVersion: "1.0.0" +repl-lint: +`) + if err := os.WriteFile(configPath, configData, 0644); err != nil { + t.Fatalf("writing test config: %v", err) + } + + _, err := parser.ParseConfigFile(configPath) + if err == nil { + t.Error("ParseConfigFile() expected error for missing chartName, got nil") + } + if !strings.Contains(err.Error(), "preflight[0]: chartName is required") { + t.Errorf("Expected 'preflight[0]: chartName is required' error, got: %v", err) + } + }) + + t.Run("missing preflight chartVersion rejected", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".replicated") + + configData := []byte(`preflights: + - path: "./preflight.yaml" + chartName: "my-chart" +repl-lint: +`) + if err := os.WriteFile(configPath, configData, 0644); err != nil { + t.Fatalf("writing test config: %v", err) + } + + _, err := parser.ParseConfigFile(configPath) + if err == nil { + t.Error("ParseConfigFile() expected error for missing chartVersion, got nil") + } + if !strings.Contains(err.Error(), "preflight[0]: chartVersion is required") { + t.Errorf("Expected 'preflight[0]: chartVersion is required' error, got: %v", err) + } + }) + t.Run("empty manifest path rejected", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, ".replicated") @@ -1072,6 +1125,8 @@ repl-lint: rootConfigPath := filepath.Join(rootDir, ".replicated") rootConfigData := []byte(`preflights: - path: ./checks/preflight1 + chartName: "test-chart" + chartVersion: "1.0.0" repl-lint: `) if err := os.WriteFile(rootConfigPath, rootConfigData, 0644); err != nil { @@ -1081,6 +1136,8 @@ repl-lint: appConfigPath := filepath.Join(appDir, ".replicated") appConfigData := []byte(`preflights: - path: ../checks/preflight1 + chartName: "test-chart" + chartVersion: "1.0.0" repl-lint: `) if err := os.WriteFile(appConfigPath, appConfigData, 0644); err != nil { @@ -1213,6 +1270,8 @@ charts: configYAML: ` preflights: - path: "./preflights/{unclosed" + chartName: "test" + chartVersion: "1.0.0" `, wantErrMsg: "invalid glob pattern in preflights[0].path", }, @@ -1232,6 +1291,8 @@ charts: - path: "./charts/[bad" preflights: - path: "./preflights/{invalid" + chartName: "test" + chartVersion: "1.0.0" `, wantErrMsg: "invalid glob pattern in charts[1].path", }, @@ -1242,6 +1303,8 @@ charts: - path: "./charts/**" preflights: - path: "./preflights/{dev,prod}/*.yaml" + chartName: "test" + chartVersion: "1.0.0" manifests: - "./manifests/**/*.yaml" `, @@ -1385,3 +1448,24 @@ func TestFindAndParseConfigWithMinimalConfig(t *testing.T) { t.Errorf("Expected SupportBundle to default to 'latest', got '%s' (exists: %v)", v, ok) } } + +// TestValidateConfig_PreflightWithoutChart tests that preflights without chart references are valid +func TestValidateConfig_PreflightWithoutChart(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".replicated") + + // Config with preflight but NO chart reference (Branch 2: linter decides) + configData := []byte(`preflights: + - path: "./preflight.yaml" +repl-lint: +`) + if err := os.WriteFile(configPath, configData, 0644); err != nil { + t.Fatalf("writing test config: %v", err) + } + + parser := NewConfigParser() + _, err := parser.ParseConfigFile(configPath) + if err != nil { + t.Errorf("ParseConfigFile() unexpected error for preflight without chart reference: %v", err) + } +} diff --git a/pkg/tools/init.go b/pkg/tools/init.go index 490d0fb9e..7baeacfd2 100644 --- a/pkg/tools/init.go +++ b/pkg/tools/init.go @@ -191,9 +191,8 @@ func WriteConfigFile(config *Config, path string) error { for _, preflight := range config.Preflights { sb.WriteString(" {\n") sb.WriteString(fmt.Sprintf(" path: %q,\n", preflight.Path)) - if preflight.ValuesPath != "" { - sb.WriteString(fmt.Sprintf(" valuesPath: %q,\n", preflight.ValuesPath)) - } + sb.WriteString(fmt.Sprintf(" chartName: %q,\n", preflight.ChartName)) + sb.WriteString(fmt.Sprintf(" chartVersion: %q,\n", preflight.ChartVersion)) sb.WriteString(" },\n") } sb.WriteString("]\n") diff --git a/pkg/tools/types.go b/pkg/tools/types.go index 96f471b85..67c928493 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/types.go @@ -21,10 +21,11 @@ type ChartConfig struct { } // PreflightConfig represents a preflight entry in the config -// Both Path and ValuesPath are required for all preflight specs +// Path is required. ChartName and ChartVersion are optional but must be provided together. type PreflightConfig struct { - Path string `yaml:"path"` - ValuesPath string `yaml:"valuesPath"` // Required: path to chart values.yaml for template rendering + Path string `yaml:"path"` + ChartName string `yaml:"chartName,omitempty"` // Optional: explicit chart reference (must provide chartVersion if set) + ChartVersion string `yaml:"chartVersion,omitempty"` // Optional: explicit chart version (must provide chartName if set) } // ReplLintConfig is the lint configuration section @@ -57,9 +58,9 @@ func (c LinterConfig) IsEnabled() bool { // Default tool versions - kept for backward compatibility in tests // In production, "latest" is used to fetch the most recent stable version from GitHub const ( - DefaultHelmVersion = "3.14.4" // Deprecated: Use "latest" instead - DefaultPreflightVersion = "0.123.9" // Deprecated: Use "latest" instead - DefaultSupportBundleVersion = "0.123.9" // Deprecated: Use "latest" instead + DefaultHelmVersion = "3.14.4" // Deprecated: Use "latest" instead + DefaultPreflightVersion = "latest" + DefaultSupportBundleVersion = "latest" ) // Supported tool names