From a5f4afb488a0e5f13f84c83ec3f2f1723f0ed892 Mon Sep 17 00:00:00 2001 From: Noah Campbell Date: Tue, 7 Oct 2025 16:19:01 -0700 Subject: [PATCH] added lint subcommand --- cmd/preflight/cli/lint.go | 93 +++ cmd/preflight/cli/root.go | 1 + cmd/troubleshoot/cli/root.go | 1 + .../helm-builtins-v1beta3.yaml | 33 + .../invalid-yaml-v1beta3.yaml | 8 + .../missing-apiversion-v1beta3.yaml | 11 + .../missing-metadata-v1beta3.yaml | 10 + .../no-analyzers-v1beta3.yaml | 7 + .../simple-no-template-v1beta3.yaml | 18 + .../support-bundle-no-collectors-v1beta3.yaml | 12 + .../support-bundle-valid-v1beta3.yaml | 15 + .../test-error-messages/valid-v1beta3.yaml | 15 + .../wrong-apiversion-v1beta3.yaml | 12 + pkg/lint/lint.go | 668 ++++++++++++++++++ 14 files changed, 904 insertions(+) create mode 100644 cmd/preflight/cli/lint.go create mode 100644 examples/test-error-messages/helm-builtins-v1beta3.yaml create mode 100644 examples/test-error-messages/invalid-yaml-v1beta3.yaml create mode 100644 examples/test-error-messages/missing-apiversion-v1beta3.yaml create mode 100644 examples/test-error-messages/missing-metadata-v1beta3.yaml create mode 100644 examples/test-error-messages/no-analyzers-v1beta3.yaml create mode 100644 examples/test-error-messages/simple-no-template-v1beta3.yaml create mode 100644 examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml create mode 100644 examples/test-error-messages/support-bundle-valid-v1beta3.yaml create mode 100644 examples/test-error-messages/valid-v1beta3.yaml create mode 100644 examples/test-error-messages/wrong-apiversion-v1beta3.yaml create mode 100644 pkg/lint/lint.go diff --git a/cmd/preflight/cli/lint.go b/cmd/preflight/cli/lint.go new file mode 100644 index 00000000..946ab2ef --- /dev/null +++ b/cmd/preflight/cli/lint.go @@ -0,0 +1,93 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/pkg/constants" + "github.com/replicatedhq/troubleshoot/pkg/lint" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func LintCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "lint [spec-files...]", + Args: cobra.MinimumNArgs(1), + Short: "Lint v1beta3 preflight specs for syntax and structural errors", + Long: `Lint v1beta3 preflight specs for syntax and structural errors. + +This command validates v1beta3 preflight specs and checks for: +- YAML syntax errors +- Missing required fields (apiVersion, kind, metadata, spec) +- Invalid template syntax ({{ .Values.* }}) +- Missing analyzers or collectors +- Common structural issues +- Missing docStrings (warning) + +The linter only validates v1beta3 specs. For v1beta2 specs, use the 'convert' command first. + +Examples: + # Lint a single spec file + preflight lint my-preflight.yaml + + # Lint multiple spec files + preflight lint spec1.yaml spec2.yaml spec3.yaml + + # Lint with automatic fixes + preflight lint --fix my-preflight.yaml + + # Lint and output as JSON for CI/CD integration + preflight lint --format json my-preflight.yaml + +Exit codes: + 0 - No errors found + 2 - Validation errors found`, + PreRun: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) + }, + RunE: func(cmd *cobra.Command, args []string) error { + v := viper.GetViper() + + opts := lint.LintOptions{ + FilePaths: args, + Fix: v.GetBool("fix"), + Format: v.GetString("format"), + } + + return runLint(opts) + }, + } + + cmd.Flags().Bool("fix", false, "Automatically fix issues where possible") + cmd.Flags().String("format", "text", "Output format: text or json") + + return cmd +} + +func runLint(opts lint.LintOptions) error { + // Validate file paths exist + for _, filePath := range opts.FilePaths { + if _, err := os.Stat(filePath); err != nil { + return errors.Wrapf(err, "file not found: %s", filePath) + } + } + + // Run linting + results, err := lint.LintFiles(opts) + if err != nil { + return errors.Wrap(err, "failed to lint files") + } + + // Format and print results + output := lint.FormatResults(results, opts.Format) + fmt.Print(output) + + // Return appropriate exit code + if lint.HasErrors(results) { + os.Exit(constants.EXIT_CODE_SPEC_ISSUES) + } + + return nil +} diff --git a/cmd/preflight/cli/root.go b/cmd/preflight/cli/root.go index bf97d3f4..6c6a509d 100644 --- a/cmd/preflight/cli/root.go +++ b/cmd/preflight/cli/root.go @@ -89,6 +89,7 @@ that a cluster meets the requirements to run an application.`, cmd.AddCommand(TemplateCmd()) cmd.AddCommand(DocsCmd()) cmd.AddCommand(ConvertCmd()) + cmd.AddCommand(LintCmd()) preflight.AddFlags(cmd.PersistentFlags()) diff --git a/cmd/troubleshoot/cli/root.go b/cmd/troubleshoot/cli/root.go index 1208bc2f..ee8265a4 100644 --- a/cmd/troubleshoot/cli/root.go +++ b/cmd/troubleshoot/cli/root.go @@ -109,6 +109,7 @@ If no arguments are provided, specs are automatically loaded from the cluster by cmd.AddCommand(Schedule()) cmd.AddCommand(UploadCmd()) cmd.AddCommand(util.VersionCmd()) + cmd.AddCommand(LintCmd()) cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use") cmd.Flags().Bool("redact", true, "enable/disable default redactions") diff --git a/examples/test-error-messages/helm-builtins-v1beta3.yaml b/examples/test-error-messages/helm-builtins-v1beta3.yaml new file mode 100644 index 00000000..68d40e57 --- /dev/null +++ b/examples/test-error-messages/helm-builtins-v1beta3.yaml @@ -0,0 +1,33 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: Preflight +metadata: + name: helm-builtins-example + labels: + release: {{ .Release.Name }} +spec: + analyzers: + - docString: | + Title: Example using Helm builtin objects + Requirement: Demonstrates .Values, .Release, .Chart, etc. + + Supported Helm builtin objects: + - .Values.* - User-provided values + - .Release.Name - Release name (default: "preflight") + - .Release.Namespace - Release namespace (default: "default") + - .Release.IsInstall - Whether this is an install (true) + - .Release.IsUpgrade - Whether this is an upgrade (false) + - .Release.Revision - Release revision (1) + - .Chart.Name - Chart name + - .Chart.Version - Chart version + - .Capabilities.KubeVersion - Kubernetes version capabilities + clusterVersion: + checkName: Kubernetes version check in {{ .Release.Namespace }} + outcomes: + - fail: + when: '< {{ .Values.minVersion | default "1.19.0" }}' + message: | + Release {{ .Release.Name }} requires Kubernetes {{ .Values.minVersion | default "1.19.0" }} or later. + Chart: {{ .Chart.Name }} + - pass: + when: '>= {{ .Values.minVersion | default "1.19.0" }}' + message: Kubernetes version is supported for release {{ .Release.Name }} diff --git a/examples/test-error-messages/invalid-yaml-v1beta3.yaml b/examples/test-error-messages/invalid-yaml-v1beta3.yaml new file mode 100644 index 00000000..5453c38c --- /dev/null +++ b/examples/test-error-messages/invalid-yaml-v1beta3.yaml @@ -0,0 +1,8 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: Preflight +metadata + name: invalid-yaml +spec: + analyzers: + - clusterVersion: + checkName: Kubernetes version diff --git a/examples/test-error-messages/missing-apiversion-v1beta3.yaml b/examples/test-error-messages/missing-apiversion-v1beta3.yaml new file mode 100644 index 00000000..7f168f8b --- /dev/null +++ b/examples/test-error-messages/missing-apiversion-v1beta3.yaml @@ -0,0 +1,11 @@ +kind: Preflight +metadata: + name: missing-apiversion +spec: + analyzers: + - clusterVersion: + checkName: Kubernetes version + outcomes: + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/examples/test-error-messages/missing-metadata-v1beta3.yaml b/examples/test-error-messages/missing-metadata-v1beta3.yaml new file mode 100644 index 00000000..4ce03bee --- /dev/null +++ b/examples/test-error-messages/missing-metadata-v1beta3.yaml @@ -0,0 +1,10 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: Preflight +spec: + analyzers: + - clusterVersion: + checkName: Kubernetes version + outcomes: + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/examples/test-error-messages/no-analyzers-v1beta3.yaml b/examples/test-error-messages/no-analyzers-v1beta3.yaml new file mode 100644 index 00000000..6be07fd0 --- /dev/null +++ b/examples/test-error-messages/no-analyzers-v1beta3.yaml @@ -0,0 +1,7 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: Preflight +metadata: + name: no-analyzers +spec: + collectors: + - clusterInfo: {} diff --git a/examples/test-error-messages/simple-no-template-v1beta3.yaml b/examples/test-error-messages/simple-no-template-v1beta3.yaml new file mode 100644 index 00000000..a8f7f4c0 --- /dev/null +++ b/examples/test-error-messages/simple-no-template-v1beta3.yaml @@ -0,0 +1,18 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: Preflight +metadata: + name: simple-no-template +spec: + analyzers: + - docString: | + Title: Kubernetes Version Check + Requirement: Kubernetes 1.19.0 or later + clusterVersion: + checkName: Kubernetes version + outcomes: + - fail: + when: '< 1.19.0' + message: Kubernetes version must be at least 1.19.0 + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml b/examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml new file mode 100644 index 00000000..51d94e99 --- /dev/null +++ b/examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml @@ -0,0 +1,12 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: SupportBundle +metadata: + name: no-collectors +spec: + analyzers: + - clusterVersion: + checkName: Kubernetes version + outcomes: + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/examples/test-error-messages/support-bundle-valid-v1beta3.yaml b/examples/test-error-messages/support-bundle-valid-v1beta3.yaml new file mode 100644 index 00000000..9cccf5ab --- /dev/null +++ b/examples/test-error-messages/support-bundle-valid-v1beta3.yaml @@ -0,0 +1,15 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: SupportBundle +metadata: + name: valid-support-bundle +spec: + collectors: + - clusterInfo: {} + - clusterResources: {} + analyzers: + - clusterVersion: + checkName: Kubernetes version + outcomes: + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/examples/test-error-messages/valid-v1beta3.yaml b/examples/test-error-messages/valid-v1beta3.yaml new file mode 100644 index 00000000..5eda464c --- /dev/null +++ b/examples/test-error-messages/valid-v1beta3.yaml @@ -0,0 +1,15 @@ +apiVersion: troubleshoot.sh/v1beta3 +kind: Preflight +metadata: + name: valid-preflight +spec: + analyzers: + - docString: | + Title: Test Analyzer + Requirement: Test requirement + clusterVersion: + checkName: Kubernetes version + outcomes: + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/examples/test-error-messages/wrong-apiversion-v1beta3.yaml b/examples/test-error-messages/wrong-apiversion-v1beta3.yaml new file mode 100644 index 00000000..ffc86cbd --- /dev/null +++ b/examples/test-error-messages/wrong-apiversion-v1beta3.yaml @@ -0,0 +1,12 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: Preflight +metadata: + name: wrong-version +spec: + analyzers: + - clusterVersion: + checkName: Kubernetes version + outcomes: + - pass: + when: '>= 1.19.0' + message: Kubernetes version is supported diff --git a/pkg/lint/lint.go b/pkg/lint/lint.go new file mode 100644 index 00000000..09d09685 --- /dev/null +++ b/pkg/lint/lint.go @@ -0,0 +1,668 @@ +package lint + +import ( + "fmt" + "io/ioutil" + "regexp" + "strings" + + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/pkg/constants" + "sigs.k8s.io/yaml" +) + +type LintResult struct { + FilePath string + Errors []LintError + Warnings []LintWarning +} + +type LintError struct { + Line int + Column int + Message string + Field string +} + +type LintWarning struct { + Line int + Column int + Message string + Field string +} + +type LintOptions struct { + FilePaths []string + Fix bool + Format string // "text" or "json" +} + +// LintFiles validates v1beta3 troubleshoot specs for syntax and structural errors +func LintFiles(opts LintOptions) ([]LintResult, error) { + results := []LintResult{} + + for _, filePath := range opts.FilePaths { + result, err := lintFile(filePath, opts.Fix) + if err != nil { + return nil, err + } + results = append(results, result) + } + + return results, nil +} + +func lintFile(filePath string, fix bool) (LintResult, error) { + result := LintResult{ + FilePath: filePath, + Errors: []LintError{}, + Warnings: []LintWarning{}, + } + + // Read file + content, err := ioutil.ReadFile(filePath) + if err != nil { + return result, errors.Wrapf(err, "failed to read file %s", filePath) + } + + // Check for v1beta3 apiVersion + if !strings.Contains(string(content), constants.Troubleshootv1beta3Kind) { + result.Errors = append(result.Errors, LintError{ + Line: 1, + Message: fmt.Sprintf("File must contain apiVersion: %s", constants.Troubleshootv1beta3Kind), + Field: "apiVersion", + }) + return result, nil + } + + // Check if file contains template expressions + hasTemplates := strings.Contains(string(content), "{{") && strings.Contains(string(content), "}}") + + // Validate YAML syntax (but be lenient with templated files) + var parsed map[string]interface{} + if err := yaml.Unmarshal(content, &parsed); err != nil { + // If the file has templates, YAML parsing may fail - that's expected + // We'll still try to validate what we can + if !hasTemplates { + result.Errors = append(result.Errors, LintError{ + Line: extractLineFromError(err), + Message: fmt.Sprintf("YAML syntax error: %s", err.Error()), + }) + return result, nil + } + // For templated files, we can't parse YAML strictly, so just check template syntax + result.Errors = append(result.Errors, checkTemplateSyntax(string(content))...) + return result, nil + } + + // Check required fields + result.Errors = append(result.Errors, checkRequiredFields(parsed, string(content))...) + + // Check template syntax + result.Errors = append(result.Errors, checkTemplateSyntax(string(content))...) + + // Check for kind-specific requirements + if kind, ok := parsed["kind"].(string); ok { + switch kind { + case "Preflight": + result.Errors = append(result.Errors, checkPreflightSpec(parsed, string(content))...) + case "SupportBundle": + result.Errors = append(result.Errors, checkSupportBundleSpec(parsed, string(content))...) + } + } + + // Check for common issues + result.Warnings = append(result.Warnings, checkCommonIssues(parsed, string(content))...) + + // Apply fixes if requested + if fix && (len(result.Errors) > 0 || len(result.Warnings) > 0) { + fixed, err := applyFixes(filePath, string(content), result) + if err != nil { + return result, err + } + if fixed { + // Re-lint to verify fixes + return lintFile(filePath, false) + } + } + + return result, nil +} + +func checkRequiredFields(parsed map[string]interface{}, content string) []LintError { + errors := []LintError{} + + // Check apiVersion + if apiVersion, ok := parsed["apiVersion"].(string); !ok || apiVersion == "" { + errors = append(errors, LintError{ + Line: findLineNumber(content, "apiVersion"), + Field: "apiVersion", + Message: "Missing or empty 'apiVersion' field", + }) + } + + // Check kind + if kind, ok := parsed["kind"].(string); !ok || kind == "" { + errors = append(errors, LintError{ + Line: findLineNumber(content, "kind"), + Field: "kind", + Message: "Missing or empty 'kind' field", + }) + } else if kind != "Preflight" && kind != "SupportBundle" { + errors = append(errors, LintError{ + Line: findLineNumber(content, "kind"), + Field: "kind", + Message: fmt.Sprintf("Invalid kind '%s'. Must be 'Preflight' or 'SupportBundle'", kind), + }) + } + + // Check metadata + if _, ok := parsed["metadata"]; !ok { + errors = append(errors, LintError{ + Line: findLineNumber(content, "metadata"), + Field: "metadata", + Message: "Missing 'metadata' section", + }) + } else if metadata, ok := parsed["metadata"].(map[string]interface{}); ok { + if name, ok := metadata["name"].(string); !ok || name == "" { + errors = append(errors, LintError{ + Line: findLineNumber(content, "name"), + Field: "metadata.name", + Message: "Missing or empty 'metadata.name' field", + }) + } + } + + // Check spec + if _, ok := parsed["spec"]; !ok { + errors = append(errors, LintError{ + Line: findLineNumber(content, "spec"), + Field: "spec", + Message: "Missing 'spec' section", + }) + } + + return errors +} + +func checkTemplateSyntax(content string) []LintError { + errors := []LintError{} + lines := strings.Split(content, "\n") + + // Check for unmatched braces + for i, line := range lines { + // Count opening and closing braces + opening := strings.Count(line, "{{") + closing := strings.Count(line, "}}") + + if opening != closing { + errors = append(errors, LintError{ + Line: i + 1, + Message: fmt.Sprintf("Unmatched template braces: %d opening, %d closing", opening, closing), + }) + } + + // Check for common template syntax issues + // Look for templates that might be missing the leading dot + if strings.Contains(line, "{{") && strings.Contains(line, "}}") { + // Extract template expressions + templateExpr := extractTemplateBetweenBraces(line) + for _, expr := range templateExpr { + trimmed := strings.TrimSpace(expr) + + // Skip empty expressions + if trimmed == "" { + continue + } + + // Skip control structures (if, else, end, range, with, etc.) + if isControlStructure(trimmed) { + continue + } + + // Skip comments: {{/* ... */}} + if strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*/") { + continue + } + + // Skip template variables (start with $) + if strings.HasPrefix(trimmed, "$") { + continue + } + + // Skip expressions that start with a dot (valid references) + if strings.HasPrefix(trimmed, ".") { + continue + } + + // Skip string literals + if strings.HasPrefix(trimmed, "\"") || strings.HasPrefix(trimmed, "'") { + continue + } + + // Skip numeric literals + if regexp.MustCompile(`^[0-9]+$`).MatchString(trimmed) { + continue + } + + // Skip function calls (contain parentheses or pipes) + if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "|") { + continue + } + + // Skip known Helm functions/keywords + helmFunctions := []string{"toYaml", "toJson", "include", "required", "default", "quote", "nindent", "indent", "upper", "lower", "trim"} + isFunction := false + for _, fn := range helmFunctions { + if strings.HasPrefix(trimmed, fn+" ") || trimmed == fn { + isFunction = true + break + } + } + if isFunction { + continue + } + + // If we got here, it might be missing a leading dot + errors = append(errors, LintError{ + Line: i + 1, + Message: fmt.Sprintf("Template expression may be missing leading dot: {{ %s }}", expr), + }) + } + } + } + + return errors +} + +func checkPreflightSpec(parsed map[string]interface{}, content string) []LintError { + errors := []LintError{} + + spec, ok := parsed["spec"].(map[string]interface{}) + if !ok { + return errors + } + + // Check for analyzers + analyzers, hasAnalyzers := spec["analyzers"] + if !hasAnalyzers { + errors = append(errors, LintError{ + Line: findLineNumber(content, "spec:"), + Field: "spec.analyzers", + Message: "Preflight spec must contain 'analyzers'", + }) + } else if analyzersList, ok := analyzers.([]interface{}); ok { + if len(analyzersList) == 0 { + errors = append(errors, LintError{ + Line: findLineNumber(content, "analyzers"), + Field: "spec.analyzers", + Message: "Preflight spec must have at least one analyzer", + }) + } + } + + return errors +} + +func checkSupportBundleSpec(parsed map[string]interface{}, content string) []LintError { + errors := []LintError{} + + spec, ok := parsed["spec"].(map[string]interface{}) + if !ok { + return errors + } + + // Check for collectors + collectors, hasCollectors := spec["collectors"] + _, hasHostCollectors := spec["hostCollectors"] + + if !hasCollectors && !hasHostCollectors { + errors = append(errors, LintError{ + Line: findLineNumber(content, "spec:"), + Field: "spec.collectors", + Message: "SupportBundle spec must contain 'collectors' or 'hostCollectors'", + }) + } else { + // Check if collectors list is empty + if hasCollectors { + if collectorsList, ok := collectors.([]interface{}); ok && len(collectorsList) == 0 { + errors = append(errors, LintError{ + Line: findLineNumber(content, "collectors"), + Field: "spec.collectors", + Message: "Collectors list is empty", + }) + } + } + } + + return errors +} + +func checkCommonIssues(parsed map[string]interface{}, content string) []LintWarning { + warnings := []LintWarning{} + + // Check for missing docStrings in analyzers + spec, ok := parsed["spec"].(map[string]interface{}) + if !ok { + return warnings + } + + if analyzers, ok := spec["analyzers"].([]interface{}); ok { + for i, analyzer := range analyzers { + if analyzerMap, ok := analyzer.(map[string]interface{}); ok { + if _, hasDocString := analyzerMap["docString"]; !hasDocString { + warnings = append(warnings, LintWarning{ + Line: findAnalyzerLine(content, i), + Field: fmt.Sprintf("spec.analyzers[%d].docString", i), + Message: "Analyzer missing docString (recommended for v1beta3)", + }) + } + } + } + } + + return warnings +} + +func applyFixes(filePath, content string, result LintResult) (bool, error) { + fixed := false + newContent := content + lines := strings.Split(newContent, "\n") + + // Sort errors by line number (descending) to avoid line number shifts when editing + errorsByLine := make(map[int][]LintError) + for _, err := range result.Errors { + if err.Line > 0 { + errorsByLine[err.Line] = append(errorsByLine[err.Line], err) + } + } + + // Process errors line by line + for lineNum, errs := range errorsByLine { + if lineNum > len(lines) { + continue + } + + line := lines[lineNum-1] + originalLine := line + + for _, err := range errs { + // Fix 1: Add missing colon + if strings.Contains(err.Message, "could not find expected ':'") { + if !strings.Contains(line, ":") { + trimmed := strings.TrimSpace(line) + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + line = indent + trimmed + ":" + fixed = true + } + } + + // Fix 2: Add missing leading dot in template expressions + if strings.Contains(err.Message, "Template expression may be missing leading dot:") { + // Extract the expression from the error message + re := regexp.MustCompile(`Template expression may be missing leading dot: \{\{ (.+?) \}\}`) + matches := re.FindStringSubmatch(err.Message) + if len(matches) > 1 { + badExpr := matches[1] + // Add the leading dot + fixedExpr := "." + badExpr + // Replace in the line + line = strings.Replace(line, "{{ "+badExpr+" }}", "{{ "+fixedExpr+" }}", 1) + line = strings.Replace(line, "{{"+badExpr+"}}", "{{"+fixedExpr+"}}", 1) + line = strings.Replace(line, "{{- "+badExpr+" }}", "{{- "+fixedExpr+" }}", 1) + line = strings.Replace(line, "{{- "+badExpr+" -}}", "{{- "+fixedExpr+" -}}", 1) + fixed = true + } + } + + // Fix 3: Fix wrong apiVersion + if strings.Contains(err.Message, "File must contain apiVersion:") && err.Field == "apiVersion" { + if strings.Contains(line, "apiVersion:") && !strings.Contains(line, constants.Troubleshootv1beta3Kind) { + // Replace existing apiVersion with correct one + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + line = indent + "apiVersion: " + constants.Troubleshootv1beta3Kind + fixed = true + } + } + } + + // Update the line if it changed + if line != originalLine { + lines[lineNum-1] = line + } + } + + // Fix 4: Add missing required top-level fields + for _, err := range result.Errors { + if err.Field == "apiVersion" && strings.Contains(err.Message, "Missing or empty 'apiVersion'") { + // Add apiVersion at the beginning + lines = append([]string{"apiVersion: " + constants.Troubleshootv1beta3Kind}, lines...) + fixed = true + } else if err.Field == "kind" && strings.Contains(err.Message, "Missing or empty 'kind'") { + // Try to determine if it should be Preflight or SupportBundle based on filename + kind := "Preflight" + if strings.Contains(strings.ToLower(filePath), "bundle") { + kind = "SupportBundle" + } + // Add kind after apiVersion + insertIndex := 0 + for i, line := range lines { + if strings.Contains(line, "apiVersion:") { + insertIndex = i + 1 + break + } + } + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:insertIndex]...) + newLines = append(newLines, "kind: "+kind) + newLines = append(newLines, lines[insertIndex:]...) + lines = newLines + fixed = true + } else if err.Field == "metadata" && strings.Contains(err.Message, "Missing 'metadata'") { + // Add metadata section after kind + insertIndex := 0 + for i, line := range lines { + if strings.Contains(line, "kind:") { + insertIndex = i + 1 + break + } + } + newLines := make([]string, 0, len(lines)+2) + newLines = append(newLines, lines[:insertIndex]...) + newLines = append(newLines, "metadata:") + newLines = append(newLines, " name: my-spec") + newLines = append(newLines, lines[insertIndex:]...) + lines = newLines + fixed = true + } + } + + // Write fixed content back to file if changes were made + if fixed { + newContent = strings.Join(lines, "\n") + if err := ioutil.WriteFile(filePath, []byte(newContent), 0644); err != nil { + return false, errors.Wrapf(err, "failed to write fixed content to %s", filePath) + } + } + + return fixed, nil +} + +func findLineNumber(content, search string) int { + lines := strings.Split(content, "\n") + for i, line := range lines { + if strings.Contains(line, search) { + return i + 1 + } + } + return 0 +} + +func findAnalyzerLine(content string, index int) int { + lines := strings.Split(content, "\n") + analyzerCount := 0 + inAnalyzers := false + + for i, line := range lines { + if strings.Contains(line, "analyzers:") { + inAnalyzers = true + continue + } + if inAnalyzers && strings.HasPrefix(strings.TrimSpace(line), "- ") { + if analyzerCount == index { + return i + 1 + } + analyzerCount++ + } + } + return 0 +} + +func extractLineFromError(err error) int { + // Try to extract line number from YAML error message + re := regexp.MustCompile(`line (\d+)`) + matches := re.FindStringSubmatch(err.Error()) + if len(matches) > 1 { + var line int + fmt.Sscanf(matches[1], "%d", &line) + return line + } + return 0 +} + +// extractTemplateBetweenBraces extracts template expressions from a line +func extractTemplateBetweenBraces(line string) []string { + var expressions []string + // Match {{ ... }} with optional whitespace trimming (-), including comments {{/* */}} + re := regexp.MustCompile(`\{\{-?\s*(.+?)\s*-?\}\}`) + matches := re.FindAllStringSubmatch(line, -1) + for _, match := range matches { + if len(match) > 1 { + // Clean up the expression + expr := match[1] + // Remove */ at the end if it's part of a comment + expr = strings.TrimSuffix(strings.TrimSpace(expr), "*/") + expressions = append(expressions, expr) + } + } + return expressions +} + +// isControlStructure checks if a template expression is a control structure +func isControlStructure(expr string) bool { + trimmed := strings.TrimSpace(expr) + controlKeywords := []string{"if", "else", "end", "range", "with", "define", "template", "block", "include"} + for _, keyword := range controlKeywords { + if strings.HasPrefix(trimmed, keyword+" ") || trimmed == keyword { + return true + } + } + return false +} + +// FormatResults formats lint results for output +func FormatResults(results []LintResult, format string) string { + if format == "json" { + return formatJSON(results) + } + return formatText(results) +} + +func formatText(results []LintResult) string { + var output strings.Builder + totalErrors := 0 + totalWarnings := 0 + + for _, result := range results { + if len(result.Errors) == 0 && len(result.Warnings) == 0 { + output.WriteString(fmt.Sprintf("✓ %s: No issues found\n", result.FilePath)) + continue + } + + output.WriteString(fmt.Sprintf("\n%s:\n", result.FilePath)) + + for _, err := range result.Errors { + output.WriteString(fmt.Sprintf(" ✗ Error (line %d): %s\n", err.Line, err.Message)) + if err.Field != "" { + output.WriteString(fmt.Sprintf(" Field: %s\n", err.Field)) + } + totalErrors++ + } + + for _, warn := range result.Warnings { + output.WriteString(fmt.Sprintf(" ⚠ Warning (line %d): %s\n", warn.Line, warn.Message)) + if warn.Field != "" { + output.WriteString(fmt.Sprintf(" Field: %s\n", warn.Field)) + } + totalWarnings++ + } + } + + output.WriteString(fmt.Sprintf("\nSummary: %d error(s), %d warning(s) across %d file(s)\n", totalErrors, totalWarnings, len(results))) + + return output.String() +} + +func formatJSON(results []LintResult) string { + // Simple JSON formatting without importing encoding/json + var output strings.Builder + output.WriteString("{\n") + output.WriteString(" \"results\": [\n") + + for i, result := range results { + output.WriteString(" {\n") + output.WriteString(fmt.Sprintf(" \"filePath\": %q,\n", result.FilePath)) + output.WriteString(" \"errors\": [\n") + + for j, err := range result.Errors { + output.WriteString(" {\n") + output.WriteString(fmt.Sprintf(" \"line\": %d,\n", err.Line)) + output.WriteString(fmt.Sprintf(" \"column\": %d,\n", err.Column)) + output.WriteString(fmt.Sprintf(" \"message\": %q,\n", err.Message)) + output.WriteString(fmt.Sprintf(" \"field\": %q\n", err.Field)) + output.WriteString(" }") + if j < len(result.Errors)-1 { + output.WriteString(",") + } + output.WriteString("\n") + } + + output.WriteString(" ],\n") + output.WriteString(" \"warnings\": [\n") + + for j, warn := range result.Warnings { + output.WriteString(" {\n") + output.WriteString(fmt.Sprintf(" \"line\": %d,\n", warn.Line)) + output.WriteString(fmt.Sprintf(" \"column\": %d,\n", warn.Column)) + output.WriteString(fmt.Sprintf(" \"message\": %q,\n", warn.Message)) + output.WriteString(fmt.Sprintf(" \"field\": %q\n", warn.Field)) + output.WriteString(" }") + if j < len(result.Warnings)-1 { + output.WriteString(",") + } + output.WriteString("\n") + } + + output.WriteString(" ]\n") + output.WriteString(" }") + if i < len(results)-1 { + output.WriteString(",") + } + output.WriteString("\n") + } + + output.WriteString(" ]\n") + output.WriteString("}\n") + + return output.String() +} + +// HasErrors returns true if any of the results contain errors +func HasErrors(results []LintResult) bool { + for _, result := range results { + if len(result.Errors) > 0 { + return true + } + } + return false +}