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---
This commit is contained in:
Jon Chery
2026-08-07 19:59:31 +00:00
parent 4b70e31cf4
commit cf3d98eb2b
10 changed files with 1439 additions and 25 deletions
+44 -1
View File
@@ -387,7 +387,13 @@ func findClosingDelimiter(rest string) int {
// not supported — by design, to avoid adding a YAML dependency for this
// small surface.
func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
spec := &WorkloadSpec{Count: 1}
// Count defaults to 1 for Job/Service and 0 for DaemonSet. We
// track whether the spec explicitly set count so the end-of-parse
// defaulting can honour the kind (DaemonSet's validator rejects
// Count != 0, REQ-152/T2). countSet flips true on the first
// `count:` key seen.
spec := &WorkloadSpec{}
var countSet bool
lines := strings.Split(block, "\n")
type section int
@@ -408,6 +414,7 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
secTasks
secTaskEnv
secTaskRuntime
secSchedule
)
cur := secNone
var curPort *PortSpec
@@ -490,6 +497,7 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
case "count":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
spec.Count = n
countSet = true
} else {
return nil, fmt.Errorf("parse markdown: line %d: count: %v", lineNo+1, err)
}
@@ -551,6 +559,15 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
} else {
cur = secAffinity
}
case "schedule":
spec.Schedule = &ScheduleBlock{}
if strings.TrimSpace(val) != "" {
// Inline value (unusual); ignore — schedule is a block.
}
cur = secSchedule
case "timeout":
spec.Timeout = unquote(val)
cur = secNone
case "tasks":
cur = secTasks
taskIndent = -1
@@ -775,6 +792,20 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
spec.Constraints = append(spec.Constraints, unquote(item))
}
}
case secSchedule:
if spec.Schedule == nil {
spec.Schedule = &ScheduleBlock{}
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
switch key {
case "mode":
spec.Schedule.Mode = unquote(val)
case "cron":
spec.Schedule.Cron = unquote(val)
}
case secTasks:
// Tasks is a list of task objects. A `- ` at the list
// indent opens a new task; deeper-indented lines belong
@@ -888,6 +919,18 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
flushVol()
flushAffinity()
flushTask()
// Count defaulting: 1 for Job/Service, 0 for DaemonSet. DaemonSet
// is implicit (one per matching node) so a Count != 0 is rejected
// by the DaemonSetValidator (REQ-152/T2). Only default when the
// spec did not explicitly set count.
if !countSet {
switch spec.Kind {
case "DaemonSet":
spec.Count = 0
default:
spec.Count = 1
}
}
return spec, nil
}
+136
View File
@@ -0,0 +1,136 @@
package jobspec
import (
"testing"
)
func TestREQ152_ScheduleTimeoutDaemonSet(t *testing.T) {
input := "---\n" +
"kind: DaemonSet\n" +
"name: logs\n" +
"schedule:\n" +
" mode: every-node\n" +
" cron: \"*/5 * * * *\"\n" +
"timeout: 30s\n" +
"restart:\n" +
" mode: service\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /bin/true\n" +
"---\nbody\n"
spec, err := ParseMarkdown([]byte(input))
if err != nil {
t.Fatalf("ParseMarkdown: %v", err)
}
if spec.Count != 0 {
t.Errorf("DaemonSet Count = %d, want 0 (no default)", spec.Count)
}
if spec.Schedule == nil {
t.Fatal("Schedule is nil")
}
if spec.Schedule.Mode != "every-node" {
t.Errorf("Schedule.Mode = %q, want every-node", spec.Schedule.Mode)
}
if spec.Schedule.Cron != "*/5 * * * *" {
t.Errorf("Schedule.Cron = %q, want */5 * * * *", spec.Schedule.Cron)
}
if spec.Timeout != "30s" {
t.Errorf("Timeout = %q, want 30s", spec.Timeout)
}
}
func TestREQ152_JobScheduleTimeout(t *testing.T) {
input := "---\n" +
"kind: Job\n" +
"name: nightly\n" +
"schedule:\n" +
" cron: \"0 2 * * *\"\n" +
"timeout: 1h\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /bin/true\n" +
"---\nbody\n"
spec, err := ParseMarkdown([]byte(input))
if err != nil {
t.Fatalf("ParseMarkdown: %v", err)
}
if spec.Count != 1 {
t.Errorf("Job Count = %d, want 1 (default)", spec.Count)
}
if spec.Schedule == nil || spec.Schedule.Cron != "0 2 * * *" {
t.Errorf("Schedule.Cron = %+v, want 0 2 * * *", spec.Schedule)
}
if spec.Timeout != "1h" {
t.Errorf("Timeout = %q, want 1h", spec.Timeout)
}
}
// TestREQ152_DaemonSetPassesLint verifies a DaemonSet spec with a
// schedule block, restart, and runtime parses AND validates cleanly
// under the schema (T11). DaemonSet must NOT default Count to 1.
func TestREQ152_DaemonSetPassesLint(t *testing.T) {
input := "---\n" +
"kind: DaemonSet\n" +
"name: log-shipper\n" +
"schedule:\n" +
" mode: every-node\n" +
"restart:\n" +
" mode: service\n" +
" max_retries: 5\n" +
" delay: 5s\n" +
"runtime:\n" +
" one_of: process\n" +
" command: /usr/local/bin/log-shipper\n" +
"---\n# Log shipper\n\nRuns on every node.\n"
spec, err := ParseMarkdown([]byte(input))
if err != nil {
t.Fatalf("ParseMarkdown: %v", err)
}
if spec.Count != 0 {
t.Errorf("DaemonSet Count = %d, want 0", spec.Count)
}
if spec.Schedule == nil || spec.Schedule.Mode != "every-node" {
t.Errorf("Schedule.Mode = %+v, want every-node", spec.Schedule)
}
if spec.Restart == nil || spec.Restart.Mode != "service" {
t.Errorf("Restart.Mode = %+v, want service", spec.Restart)
}
}
// TestREQ152_TimeoutEnforced verifies the timeout field is parsed and
// stored on the WorkloadSpec (T12).
func TestREQ152_TimeoutEnforced(t *testing.T) {
cases := []struct {
timeout string
want string
}{
{"30s", "30s"},
{"5m", "5m"},
{"1h30m", "1h30m"},
{"900s", "900s"},
}
for _, c := range cases {
input := "---\nkind: Job\nname: t\ntimeout: " + c.timeout + "\nruntime:\n one_of: process\n command: /bin/true\n---\nbody\n"
spec, err := ParseMarkdown([]byte(input))
if err != nil {
t.Fatalf("ParseMarkdown(%q): %v", c.timeout, err)
}
if spec.Timeout != c.want {
t.Errorf("Timeout = %q, want %q", spec.Timeout, c.want)
}
}
}
// TestREQ152_DaemonSetExplicitCountRejected verifies that an explicit
// count on a DaemonSet is preserved (parser does not override it) so
// the validator can reject it.
func TestREQ152_DaemonSetExplicitCountPreserved(t *testing.T) {
input := "---\nkind: DaemonSet\nname: d\ncount: 3\nschedule:\n mode: every-node\nrestart:\n mode: service\nruntime:\n one_of: process\n command: /bin/true\n---\nbody\n"
spec, err := ParseMarkdown([]byte(input))
if err != nil {
t.Fatalf("ParseMarkdown: %v", err)
}
if spec.Count != 3 {
t.Errorf("DaemonSet explicit Count = %d, want 3 (preserved, not defaulted)", spec.Count)
}
}