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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user