// Package cli: job_lint.go implements `orca job lint` (P11, REQ-084, // v0.11 milestone). It validates a jobspec (.md/.yaml/.yml/.hcl) with: // // - schema validation (kind, blocks, frontmatter fields) via // internal/spec/schema // - CEL constraint syntax check (basic -- balanced parens/quotes, // presence of operators; no CEL engine dependency in this phase) // - body preservation (R-015): warn when body is empty for .md specs // - migration: flag deprecated .hcl specs with a warning suggesting // conversion to .md (REQ-090) // - best-practice: warn on missing health checks for Services, // missing restart policies, etc. // // Output: one finding per line (category | severity | line | message). // --explain prints the rationale for each finding. --format text (the // default) or json. Exit 0 = no errors (warnings OK); 1 = errors found. package cli import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/jobspec" "git.cloudinit.dev/coreci/orca/internal/spec/schema" ) var ( jobLintExplain bool jobLintFormat string ) type lintSeverity string const ( severityError lintSeverity = "error" severityWarning lintSeverity = "warning" severityInfo lintSeverity = "info" ) type lintCategory string const ( catSchema lintCategory = "schema" catCEL lintCategory = "cel" catBody lintCategory = "body" catMigration lintCategory = "migration" catBestPractice lintCategory = "best-practice" ) type lintFinding struct { Category lintCategory `json:"category"` Severity lintSeverity `json:"severity"` Line int `json:"line"` Message string `json:"message"` } var rationale = map[lintCategory]string{ catSchema: "Schema violations prevent the spec from being parsed or rendered; fix these first.", catCEL: "CEL constraints gate placement; a syntactically invalid expression is rejected by the scheduler (REQ-083).", catBody: "R-015 requires the markdown body to be preserved byte-exact; an empty body is allowed but loses operator documentation.", catMigration: "HCL specs are legacy (REQ-090); convert to Markdown+frontmatter before v1.0 to keep schema validation working.", catBestPractice: "Best-practice warnings do not block apply, but address them to keep the fleet observable and restartable.", } type lintExitError struct { findings []lintFinding } func (e *lintExitError) Error() string { return fmt.Sprintf("lint: %d error(s) found", countErrors(e.findings)) } func countErrors(findings []lintFinding) int { n := 0 for _, f := range findings { if f.Severity == severityError { n++ } } return n } var jobLintCmd = &cobra.Command{ Use: "lint ", Short: "Lint a jobspec (schema, CEL, body, migration, best-practice)", Long: `Validate a jobspec (.md/.yaml/.yml/.hcl) without applying it. Checks (REQ-084): - schema: kind (Job/Service/DaemonSet), required blocks, frontmatter - CEL: constraint expressions are syntactically valid - body: markdown body present and non-empty (R-015) - migration: flag deprecated .hcl specs (suggest .md) - best-practice: warn on missing health/restart/update for the kind Flags: --explain print the rationale for each finding --format text|json output format (default text) Exit codes: 0 = no errors (warnings OK), 1 = errors found.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { findings, err := runJobLint(args[0]) if err != nil { var le *lintExitError if errors.As(err, &le) { if jobLintFormat == "json" { _ = printLintJSON(findings) } else { printLintText(findings, jobLintExplain) } return err } return err } if jobLintFormat == "json" { return printLintJSON(findings) } printLintText(findings, jobLintExplain) return nil }, } func runJobLint(path string) ([]lintFinding, error) { var findings []lintFinding data, err := os.ReadFile(path) if err != nil { findings = append(findings, lintFinding{ Category: catSchema, Severity: severityError, Line: 0, Message: fmt.Sprintf("cannot read spec file: %v", err), }) return findings, &lintExitError{findings} } ext := strings.ToLower(filepath.Ext(path)) if ext == ".hcl" { findings = append(findings, lintFinding{ Category: catMigration, Severity: severityWarning, Line: 0, Message: fmt.Sprintf("%s is a legacy HCL spec; convert to Markdown (.md) before v1.0 (REQ-090)", filepath.Base(path)), }) } spec, perr := jobspec.Dispatch(data, filepath.Base(path)) if perr != nil { findings = append(findings, lintFinding{ Category: catSchema, Severity: severityError, Line: 0, Message: fmt.Sprintf("parse: %v", perr), }) return findings, &lintExitError{findings} } findings = append(findings, lintSchema(spec)...) findings = append(findings, lintCEL(spec)...) findings = append(findings, lintBody(spec, ext)...) findings = append(findings, lintBestPractice(spec)...) sortLint(findings) if countErrors(findings) > 0 { return findings, &lintExitError{findings} } return findings, nil } func lintSchema(spec *jobspec.WorkloadSpec) []lintFinding { if spec == nil { return nil } v, err := schema.ValidatorFor(spec.Kind) if err != nil { return []lintFinding{{ Category: catSchema, Severity: severityError, Line: 0, Message: err.Error(), }} } verr := v.Validate(spec) if verr == nil { return nil } msg := verr.Error() parts := strings.Split(msg, "; ") var out []lintFinding for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } out = append(out, lintFinding{ Category: catSchema, Severity: severityError, Line: 0, Message: trimValidatorPrefix(p), }) } return out } func trimValidatorPrefix(s string) string { if strings.HasPrefix(s, "schema/") { if i := strings.Index(s, ": "); i >= 0 { return strings.TrimSpace(s[i+2:]) } } return s } func lintCEL(spec *jobspec.WorkloadSpec) []lintFinding { if spec == nil { return nil } var out []lintFinding for i, c := range spec.Constraints { if msg := basicCELCheck(c); msg != "" { out = append(out, lintFinding{ Category: catCEL, Severity: severityError, Line: 0, Message: fmt.Sprintf("constraints[%d]: %s", i, msg), }) } } for i, a := range spec.Affinity { if msg := basicCELCheck(a.Target); msg != "" { out = append(out, lintFinding{ Category: catCEL, Severity: severityError, Line: 0, Message: fmt.Sprintf("affinity[%d].target: %s", i, msg), }) } } return out } func basicCELCheck(expr string) string { expr = strings.TrimSpace(expr) if expr == "" { return "empty CEL expression" } parens := 0 inSingle := false inDouble := false for i := 0; i < len(expr); i++ { c := expr[i] switch c { case '\'': if !inDouble { inSingle = !inSingle } case '"': if !inSingle { inDouble = !inDouble } case '(': if !inSingle && !inDouble { parens++ } case ')': if !inSingle && !inDouble { parens-- if parens < 0 { return "unbalanced parentheses: ')' before '('" } } } } if inSingle || inDouble { return "unbalanced quotes" } if parens != 0 { return fmt.Sprintf("unbalanced parentheses: %d unclosed '('", parens) } return "" } func lintBody(spec *jobspec.WorkloadSpec, ext string) []lintFinding { if spec == nil { return nil } if ext != ".md" { return nil } if strings.TrimSpace(spec.Body) == "" { return []lintFinding{{ Category: catBody, Severity: severityWarning, Line: 0, Message: "markdown body is empty (R-015: body preserved byte-exact; add operator documentation)", }} } return nil } func lintBestPractice(spec *jobspec.WorkloadSpec) []lintFinding { if spec == nil { return nil } var out []lintFinding switch spec.Kind { case "Service": if spec.Health == nil { out = append(out, lintFinding{ Category: catBestPractice, Severity: severityWarning, Line: 0, Message: "Service without a health block: Traefik routing depends on health checks (R-012)", }) } if spec.Restart == nil { out = append(out, lintFinding{ Category: catBestPractice, Severity: severityWarning, Line: 0, Message: "Service without a restart policy: defaults to 'service' but an explicit policy is recommended", }) } if spec.Update == nil { out = append(out, lintFinding{ Category: catBestPractice, Severity: severityWarning, Line: 0, Message: "Service without an update block: rolling/canary strategy should be explicit", }) } case "DaemonSet": if spec.Restart == nil { out = append(out, lintFinding{ Category: catBestPractice, Severity: severityWarning, Line: 0, Message: "DaemonSet without a restart policy: a long-running daemon should declare its restart mode", }) } case "Job": if spec.Restart == nil { out = append(out, lintFinding{ Category: catBestPractice, Severity: severityInfo, Line: 0, Message: "Job without a restart policy: defaults to 'never' (one-shot); set explicitly if retry is desired", }) } } return out } func sortLint(f []lintFinding) { sort.SliceStable(f, func(i, j int) bool { si := severityRank(f[i].Severity) sj := severityRank(f[j].Severity) if si != sj { return si < sj } if f[i].Category != f[j].Category { return string(f[i].Category) < string(f[j].Category) } return f[i].Line < f[j].Line }) } func severityRank(s lintSeverity) int { switch s { case severityError: return 0 case severityWarning: return 1 case severityInfo: return 2 } return 3 } func printLintText(findings []lintFinding, explain bool) { w := rootCmd.OutOrStdout() errs := countErrors(findings) warns := 0 infos := 0 for _, f := range findings { switch f.Severity { case severityWarning: warns++ case severityInfo: infos++ } line := fmt.Sprintf("%-14s %-8s %s", f.Category, f.Severity, f.Message) if f.Line > 0 { line = fmt.Sprintf("%-14s %-8s line %d: %s", f.Category, f.Severity, f.Line, f.Message) } fmt.Fprintln(w, line) if explain { fmt.Fprintf(w, " -> %s\n", rationale[f.Category]) } } fmt.Fprintf(w, "\n%d error(s), %d warning(s), %d info\n", errs, warns, infos) } func printLintJSON(findings []lintFinding) error { out := make([]lintFinding, len(findings)) copy(out, findings) w := rootCmd.OutOrStdout() enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(out) } func init() { jobLintCmd.Flags().BoolVar(&jobLintExplain, "explain", false, "print the rationale for each finding") jobLintCmd.Flags().StringVar(&jobLintFormat, "format", "text", "output format: text or json") jobCmd.AddCommand(jobLintCmd) }