020aa01623
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---
425 lines
11 KiB
Go
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)
|
|
}
|