Files
orca/internal/cli/job_lint_test.go
T
Jon Chery cf3d98eb2b feat(P03): wire scheduler into job run + fix jobspec parser (REQ-151, REQ-152)
R-022: orca job run now deploys to remote nodes via scheduler -> emitter
-> SSH-push. Local exec fallback only when no remote nodes registered.

jobspec parser (REQ-152):
- schedule: and timeout: now parsed (were silently dropped)
- DaemonSet Count no longer defaults to 1 (was breaking DaemonSet)
- restart: policy translated to systemd Restart=/StartLimitBurst
- job lint: advisory warnings for cron/health/update/affinity (honest)

scheduler wiring (REQ-151, C-44):
- new internal/cli/job_dispatch.go: dispatchDecision + deployRemote
- scheduler.Schedule evaluates constraints/capacity/affinity
- --target overrides scheduler (manual pinning)
- local fallback only when len(ready non-localhost nodes)==0
- C-44: SSH-push failure returns error (no silent local fallback)
- systemd-analyze verify on rendered unit before deploy

Tests: 22 new test functions covering scheduler, parser, C-44, local
fallback, target override, systemd-analyze skip, restart directives.

---ci---
project: orca
phase: 3
milestone: v0.13
status: complete
requirements:
  covered: [151, 152]
---/ci---
2026-08-07 19:59:31 +00:00

382 lines
9.7 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeMDSpec(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "spec.md")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
return p
}
func writeHCLSpec(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "spec.hcl")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
return p
}
const validJobMD = "---\n" +
"kind: Job\n" +
"name: my-job\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /bin/true\n" +
"---\n" +
"# My Job\n\nRuns /bin/true.\n"
const validServiceMD = "---\n" +
"kind: Service\n" +
"name: web\n" +
"count: 3\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /bin/http\n" +
"ports:\n" +
" - name: http\n" +
" port: 8080\n" +
"restart:\n" +
" mode: service\n" +
"update:\n" +
" strategy: rolling\n" +
" max_surge: 1\n" +
"health:\n" +
" check_type: http\n" +
" interval: 5s\n" +
"---\n" +
"# Web service\n\nServes HTTP.\n"
const invalidKindMD = "---\n" +
"kind: CronJob\n" +
"name: bad\n" +
"---\nbody\n"
const serviceMissingHealthMD = "---\n" +
"kind: Service\n" +
"name: web\n" +
"count: 2\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /bin/http\n" +
"ports:\n" +
" - name: http\n" +
" port: 8080\n" +
"restart:\n" +
" mode: service\n" +
"update:\n" +
" strategy: rolling\n" +
"---\n" +
"# web\n\nbody\n"
const serviceMissingPortsMD = "---\n" +
"kind: Service\n" +
"name: web\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /bin/http\n" +
"restart:\n" +
" mode: service\n" +
"update:\n" +
" strategy: rolling\n" +
"health:\n" +
" check_type: http\n" +
"---\nbody\n"
const emptyBodyMD = "---\n" +
"kind: Job\n" +
"name: emptybody\n" +
"runtime:\n" +
" command: /bin/true\n" +
"---\n"
const badCELMD = "---\n" +
"kind: Job\n" +
"name: badcel\n" +
"runtime:\n" +
" command: /bin/true\n" +
"constraints:\n" +
" - 'node.role == \"web\"'\n" +
" - 'region == (\"us\"'\n" +
"---\nbody\n"
func TestJobLintCmdRegistered(t *testing.T) {
for _, c := range jobCmd.Commands() {
if c.Name() == "lint" {
return
}
}
t.Fatal("job lint command not registered on jobCmd")
}
func TestJobLintValidSpec(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, validJobMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job lint valid: %v", err)
}
out := buf.String()
if !strings.Contains(out, "0 error(s)") {
t.Errorf("expected 0 errors, got: %s", out)
}
}
func TestJobLintValidServiceSpec(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, validServiceMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job lint valid service: %v", err)
}
out := buf.String()
if !strings.Contains(out, "0 error(s)") {
t.Errorf("expected 0 errors, got: %s", out)
}
}
func TestJobLintInvalidKind(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, invalidKindMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for invalid kind, got nil")
}
}
func TestJobLintInvalidKindReportsError(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, invalidKindMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
_ = rootCmd.Execute()
out := buf.String()
if !strings.Contains(strings.ToLower(out), "cronjob") && !strings.Contains(strings.ToLower(out), "kind") {
t.Errorf("expected error about kind, got: %s", out)
}
}
func TestJobLintMissingRequiredField(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, serviceMissingPortsMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for missing ports, got nil")
}
out := buf.String()
if !strings.Contains(strings.ToLower(out), "port") {
t.Errorf("expected error mentioning ports, got: %s", out)
}
}
func TestJobLintDeprecatedHCLWarning(t *testing.T) {
resetRootFlags(t)
spec := writeHCLSpec(t, trueJobSpec)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job lint hcl: %v", err)
}
out := buf.String()
if !strings.Contains(out, "migration") && !strings.Contains(strings.ToLower(out), "legacy") && !strings.Contains(strings.ToLower(out), "hcl") {
t.Errorf("expected migration/legacy warning, got: %s", out)
}
}
func TestJobLintExplain(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, serviceMissingHealthMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec, "--explain"})
_ = rootCmd.Execute()
out := buf.String()
if !strings.Contains(out, "->") {
t.Errorf("expected rationale lines with '->', got: %s", out)
}
}
func TestJobLintFormatJSON(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, serviceMissingHealthMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec, "--format", "json"})
_ = rootCmd.Execute()
out := buf.String()
var findings []lintFinding
if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &findings); err != nil {
t.Fatalf("unmarshal json findings: %v\n%s", err, out)
}
if len(findings) == 0 {
t.Fatalf("expected at least one finding")
}
foundHealth := false
for _, f := range findings {
if strings.Contains(strings.ToLower(f.Message), "health") {
foundHealth = true
}
}
if !foundHealth {
t.Errorf("expected a health-related finding, got: %+v", findings)
}
}
func TestJobLintMissingHealthCheckWarning(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, serviceMissingHealthMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
_ = rootCmd.Execute()
out := buf.String()
if !strings.Contains(strings.ToLower(out), "health") {
t.Errorf("expected health-related warning, got: %s", out)
}
}
func TestJobLintEmptyBodyWarning(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, emptyBodyMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job lint empty body (warnings only): %v", err)
}
out := buf.String()
if !strings.Contains(strings.ToLower(out), "body") {
t.Errorf("expected body warning, got: %s", out)
}
}
func TestJobLintBadCEL(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, badCELMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for unbalanced CEL, got nil")
}
out := buf.String()
if !strings.Contains(strings.ToLower(out), "cel") && !strings.Contains(strings.ToLower(out), "parenthes") {
t.Errorf("expected CEL/parentheses error, got: %s", out)
}
}
func TestJobLintMissingFile(t *testing.T) {
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", "/nonexistent/spec.md"})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestJobLintDaemonSetValid(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, "---\n"+
"kind: DaemonSet\n"+
"name: log-shipper\n"+
"schedule:\n"+
" mode: every-node\n"+
"restart:\n"+
" mode: service\n"+
"runtime:\n"+
" one_of: process\n"+
" command: /usr/local/bin/log-shipper\n"+
"---\n# Log shipper\n\nRuns on every node.\n")
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job lint daemonset: %v\n%s", err, buf.String())
}
out := buf.String()
if !strings.Contains(out, "0 error(s)") {
t.Errorf("expected 0 errors for valid DaemonSet, got: %s", out)
}
}
func TestJobLintAdvisoryScheduleCron(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, "---\n"+
"kind: Job\n"+
"name: nightly\n"+
"schedule:\n"+
" cron: \"0 2 * * *\"\n"+
"runtime:\n"+
" one_of: process\n"+
" command: /bin/true\n"+
"---\n# Nightly\n\nBackup.\n")
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("job lint: %v\n%s", err, buf.String())
}
out := buf.String()
if !strings.Contains(out, "schedule.cron' is not enforced") {
t.Errorf("expected advisory warning for schedule.cron, got: %s", out)
}
if !strings.Contains(out, "0 error(s)") {
t.Errorf("expected 0 errors, got: %s", out)
}
}
func TestJobLintAdvisoryHealthUpdateAffinity(t *testing.T) {
resetRootFlags(t)
spec := writeMDSpec(t, validServiceMD)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"job", "lint", spec})
_ = rootCmd.Execute()
out := buf.String()
// validServiceMD has health + update blocks; both are advisory.
if !strings.Contains(out, "field 'health' is not enforced") {
t.Errorf("expected advisory warning for health, got: %s", out)
}
if !strings.Contains(out, "field 'update' is not enforced") {
t.Errorf("expected advisory warning for update, got: %s", out)
}
}