cf3d98eb2b
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---
382 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|