Files
orca/internal/cli/job_lint.go
T
Jon Chery 020aa01623 feat(P11,P12): orca job lint (REQ-084) + orca job verify (dry-run txn)
P11: orca job lint <spec.md> — schema/CEL/body/migration/best-practice
checks; --explain, --format json; exit 0/1 by errors found.
P12: orca job verify <spec.md> — dry-run txn (render + stage + verify
without apply); reports planned allocs/files/units; no side effects;
--namespace, --json.

---ci---
project: orca
phase: 11
milestone: v0.11
status: execute
---/ci---
2026-08-07 07:29:44 +00:00

425 lines
11 KiB
Go

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