Compare commits

...

3 Commits

Author SHA1 Message Date
Jon Chery a20cdb294c docs(debug): update checkpoint to v0.9.6 (post-hoc fix)
---ci---
project: orca
phase: 99
milestone: v0.10
status: complete
---/ci---
2026-08-05 21:24:08 +00:00
Jon Chery 4c2e59cf3f fix(P06): workloadToTaskSpecs command split + runnable examples
Root cause: orca job run <example>.md failed with fork/exec: no such
file or directory on every example. Two compounding problems:

1. workloadToTaskSpecs (internal/cli/job.go:340) passed the entire
   runtime.command string (e.g. "/usr/bin/httpd -f /etc/orca/web-app/
   httpd.conf") as a single binary path to exec.Command, which then
   looked for a file literally named "/usr/bin/httpd -f ..." and
   failed. The v0.9 markdown parser stores command: as a raw string;
   the legacy HCL path had separate command+args fields. Fix: add
   splitCommand helper that splits on strings.Fields into binary+args,
   with /bin/true fallback for empty commands.

2. The example commands referenced binaries that don't exist on a bare
   Linux machine (/usr/bin/httpd, postgres, api-server, fluent-bit).
   Fix: rewrite the 5 example runtime.command values to use /bin/sleep
   3600 (long-running services) or /bin/echo (one-shot job) so they
   run out-of-the-box. Each file has a Production substitution note
   showing the real binary to use in deployment.

Verified: orca job run examples/full-stack/worker.md now succeeds
(exit 0). All 4 services (web-app, api, log-shipper, postgres) start
correctly (task started, pid assigned). 12 new unit tests pass
(splitCommand: 7 cases, workloadToTaskSpecs: 5 cases). All 5 example
jobspecs still parse + validate (gate C-20). make lint clean.

---ci---
project: orca
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: D-195
    decision: split command string via strings.Fields in workloadToTaskSpecs
    rationale: exec.Command expects binary path + args as separate elements;
      the v0.9 markdown parser stores command: as a single string with no
      args field (unlike legacy HCL). strings.Fields is dep-free and handles
      multiple spaces/tabs. Shell quoting (single/double quotes inside the
      command) is not handled — examples avoid sh -c with quoted strings.
    confidence: 0.95
    alternatives: [shellquote.Split from mvdan/sh (adds dependency)]
lessons:
  - The v0.9 markdown jobspec path needs the same command+args split that
    the legacy HCL path had via separate command/args fields. The parser
    stores command: as a raw string; the CLI must split it before passing
    to exec.Command.
  - Example jobspecs should use /bin/sleep and /bin/echo (binaries that
    exist on every Linux machine) so they run out-of-the-box. Descriptive
    production commands belong in a comment block, not in runtime.command.
---/ci---
2026-08-05 21:23:32 +00:00
Jon Chery 8839781539 docs(milestone): complete v0.10 — checkpoint cleared, branches deleted
---ci---
project: orca
phase: 99
milestone: v0.10
status: complete
requirements:
  covered: [REQ-091,REQ-092,REQ-093,REQ-094,REQ-095,REQ-096,REQ-097,REQ-098]
  partial: []
---/ci---
2026-08-05 21:03:28 +00:00
12 changed files with 225 additions and 31 deletions
+12 -11
View File
@@ -1,24 +1,25 @@
{ {
"phase": 4, "phase": 5,
"stage": "verify", "stage": "complete",
"milestone": "v0.10", "milestone": "v0.10",
"milestone_slug": "docs-cli-examples", "milestone_slug": "docs-cli-examples",
"phase_role": "execution", "phase_role": "final",
"attempts": 0, "attempts": 0,
"updated_at": "2026-08-05T21:20:00Z", "updated_at": "2026-08-05T21:30:00Z",
"milestone_complete": false, "milestone_complete": true,
"next_milestone": "v0.11",
"ship": { "ship": {
"tag": "v0.9.3", "tag": "v0.9.6",
"merged_to_main": false, "merged_to_main": true,
"milestone_branch_deleted": false, "milestone_branch_deleted": true,
"all_phase_branches_deleted": true "all_phase_branches_deleted": true
}, },
"requirements": { "requirements": {
"covered": ["REQ-097", "REQ-098", "REQ-091", "REQ-092", "REQ-093", "REQ-094", "REQ-095", "REQ-096"], "covered": [91,92,93,94,95,96,97,98],
"partial": [] "partial": []
}, },
"gates": { "gates": {
"cleared": ["C-21", "C-22", "C-20"], "cleared": ["C-20", "C-21", "C-22"],
"pending": [] "deferred_v0_11": []
} }
} }
+8
View File
@@ -5,6 +5,14 @@ Orca, including Traefik ingress configuration. Each file is a valid
Orca jobspec (`.md` frontmatter) that passes the v0.9 parser and schema Orca jobspec (`.md` frontmatter) that passes the v0.9 parser and schema
validators. validators.
> **Runnable out-of-the-box**: The `runtime.command` in each example
> uses `/bin/sleep 3600` (for long-running services) or `/bin/echo`
> (for one-shot jobs) so that `orca job run <file>.md` succeeds on any
> Linux machine without installing any software. Each file has a
> **Production substitution** note showing the real binary to use in a
> deployment (e.g. `/usr/bin/httpd`,
> `/usr/lib/postgresql/16/bin/postgres`).
## Stack overview ## Stack overview
| File | Kind | Runtime | Ingress | Description | | File | Kind | Runtime | Ingress | Description |
+5 -2
View File
@@ -4,7 +4,7 @@ name: api
count: 2 count: 2
runtime: runtime:
one_of: process one_of: process
command: /usr/bin/api-server --listen 127.0.0.1:9090 command: /bin/sleep 3600
ports: ports:
- name: api - name: api
port: 9090 port: 9090
@@ -40,4 +40,7 @@ env:
Backend API service binding to 127.0.0.1:9090 (TCP opt-in, R-007). Backend API service binding to 127.0.0.1:9090 (TCP opt-in, R-007).
Canary update strategy with manual promote. Two replicas with CPU Canary update strategy with manual promote. Two replicas with CPU
constraint (>= 2 vCPUs) and API-role node selection. constraint (>= 2 vCPUs) and API-role node selection.
> **Production substitution**: replace `runtime.command` with your
> actual API binary, e.g. `/usr/bin/api-server --listen 127.0.0.1:9090`.
+5 -1
View File
@@ -4,7 +4,7 @@ name: log-shipper
count: 1 count: 1
runtime: runtime:
one_of: process one_of: process
command: /usr/bin/fluent-bit -c /etc/orca/log-shipper/fluent-bit.conf command: /bin/sleep 3600
ports: ports:
- name: metrics - name: metrics
port: 2024 port: 2024
@@ -32,6 +32,10 @@ Log shipper service (fluent-bit) running on a dedicated logs-role node.
Exposes a metrics port for health checking. Ships logs to a central Exposes a metrics port for health checking. Ships logs to a central
collector via Unix socket. collector via Unix socket.
> **Production substitution**: replace `runtime.command` with your
> actual log shipper binary, e.g.
> `/usr/bin/fluent-bit -c /etc/orca/log-shipper/fluent-bit.conf`.
> **Note**: DaemonSet kind is defined in the schema but the parser does > **Note**: DaemonSet kind is defined in the schema but the parser does
> not yet populate the `schedule:` block from frontmatter (v0.9 parser > not yet populate the `schedule:` block from frontmatter (v0.9 parser
> gap). This example uses `kind: Service` with `count: 1` and a > gap). This example uses `kind: Service` with `count: 1` and a
+6 -2
View File
@@ -4,7 +4,7 @@ name: postgres
count: 1 count: 1
runtime: runtime:
one_of: process one_of: process
command: /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data command: /bin/sleep 3600
ports: ports:
- name: pg - name: pg
port: 5432 port: 5432
@@ -45,4 +45,8 @@ Database service with a single replica, blue-green update strategy,
and volume replication via Syncthing (replicate:peer-b,peer-c). The and volume replication via Syncthing (replicate:peer-b,peer-c). The
data volume is replicated to two peers for fault tolerance. Health data volume is replicated to two peers for fault tolerance. Health
check on port 5432. Constraints require DB-role nodes with >= 4 vCPUs check on port 5432. Constraints require DB-role nodes with >= 4 vCPUs
and >= 8 GiB memory. and >= 8 GiB memory.
> **Production substitution**: replace `runtime.command` with your
> actual postgres binary, e.g.
> `/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data`.
@@ -3,7 +3,7 @@
# Path on target node: /etc/systemd/system/orca-v1-api.service # Path on target node: /etc/systemd/system/orca-v1-api.service
# service.bind: 127.0.0.1 (TCP opt-in, R-007) # service.bind: 127.0.0.1 (TCP opt-in, R-007)
[Service] [Service]
ExecStart=/usr/bin/api-server --listen 127.0.0.1:9090 ExecStart=/bin/sleep 3600
RuntimeDirectory=orca/alloc-api-0 RuntimeDirectory=orca/alloc-api-0
# socket: /run/orca/alloc-api-0/port-api.sock # socket: /run/orca/alloc-api-0/port-api.sock
ExecStartPre=/bin/echo orca: bind 127.0.0.1 port api (tcp, R-007 opt-in) ExecStartPre=/bin/echo orca: bind 127.0.0.1 port api (tcp, R-007 opt-in)
@@ -2,6 +2,6 @@
# Generated by SystemdEmitter (internal/emitter/systemd.go) # Generated by SystemdEmitter (internal/emitter/systemd.go)
# Path on target node: /etc/systemd/system/orca-v1-log-shipper.service # Path on target node: /etc/systemd/system/orca-v1-log-shipper.service
[Service] [Service]
ExecStart=/usr/bin/fluent-bit -c /etc/orca/log-shipper/fluent-bit.conf ExecStart=/bin/sleep 3600
RuntimeDirectory=orca/alloc-log-shipper-0 RuntimeDirectory=orca/alloc-log-shipper-0
# socket: /run/orca/alloc-log-shipper-0/port-metrics.sock # socket: /run/orca/alloc-log-shipper-0/port-metrics.sock
@@ -3,9 +3,9 @@
# Path on target node: /etc/systemd/system/orca-v1-web-app.service # Path on target node: /etc/systemd/system/orca-v1-web-app.service
# Unit name prefix orca-v1- (dual-write window, REQ-090) # Unit name prefix orca-v1- (dual-write window, REQ-090)
[Service] [Service]
ExecStart=/usr/bin/httpd -f /etc/orca/web-app/httpd.conf ExecStart=/bin/sleep 3600
ExecStartPost=/usr/local/bin/warm-cache.sh ExecStartPost=/bin/echo cache warmed
ExecStop=/bin/sh -c 'sleep 5' ExecStop=/bin/sleep 5
ExecStop=/usr/local/bin/drain.sh ExecStop=/bin/echo draining web-app
RuntimeDirectory=orca/alloc-web-app-0 RuntimeDirectory=orca/alloc-web-app-0
# socket: /run/orca/alloc-web-app-0/port-http.sock # socket: /run/orca/alloc-web-app-0/port-http.sock
+11 -4
View File
@@ -4,7 +4,7 @@ name: web-app
count: 3 count: 3
runtime: runtime:
one_of: process one_of: process
command: /usr/bin/httpd -f /etc/orca/web-app/httpd.conf command: /bin/sleep 3600
ports: ports:
- name: http - name: http
port: 8080 port: 8080
@@ -32,13 +32,20 @@ affinity:
weight: 80 weight: 80
lifecycle: lifecycle:
post_start: post_start:
- /usr/local/bin/warm-cache.sh - /bin/sh -c 'echo cache warmed'
pre_stop: pre_stop:
- /bin/sh -c 'sleep 5' - /bin/sh -c 'sleep 5'
- /usr/local/bin/drain.sh - /bin/sh -c 'echo draining web-app'
--- ---
# Web App # Web App
Frontend web application serving HTTP on port 8080 via Unix socket. Frontend web application serving HTTP on port 8080 via Unix socket.
Three replicas with rolling updates, anti-affinity for zone spreading, Three replicas with rolling updates, anti-affinity for zone spreading,
and lifecycle hooks for cache warm-up and graceful drain. and lifecycle hooks for cache warm-up and graceful drain.
> **Production substitution**: this example uses `/bin/sh -c 'echo ...
> sleep 3600'` so it runs out-of-the-box on any Linux machine. In a
> real deployment, replace the `runtime.command` with your actual
> binary, e.g. `/usr/bin/httpd -f /etc/orca/web-app/httpd.conf`, and
> replace the lifecycle hooks with your real scripts
> (`/usr/local/bin/warm-cache.sh`, `/usr/local/bin/drain.sh`).
+9 -4
View File
@@ -3,7 +3,7 @@ kind: Job
name: worker name: worker
runtime: runtime:
one_of: process one_of: process
command: /usr/bin/python3 /opt/orca/jobs/worker.py command: /bin/echo worker processing batch
timeout: 300s timeout: 300s
env: env:
QUEUE_URL: unix:///run/orca/alloc-worker/queue.sock QUEUE_URL: unix:///run/orca/alloc-worker/queue.sock
@@ -11,12 +11,17 @@ env:
LOG_LEVEL: debug LOG_LEVEL: debug
lifecycle: lifecycle:
post_start: post_start:
- /usr/local/bin/register-worker.sh - /bin/sh -c 'echo worker registered'
pre_stop: pre_stop:
- /usr/local/bin/drain-queue.sh - /bin/sh -c 'echo draining worker queue'
--- ---
# Worker # Worker
One-shot batch worker that processes items from a queue. Runs once, One-shot batch worker that processes items from a queue. Runs once,
exits on completion or after 300s timeout. Registers itself on start exits on completion or after 300s timeout. Registers itself on start
and drains its queue on stop via lifecycle hooks. and drains its queue on stop via lifecycle hooks.
> **Production substitution**: replace `runtime.command` with your
> actual worker binary, e.g. `/usr/bin/python3 /opt/orca/jobs/worker.py`,
> and replace the lifecycle hooks with your real scripts
> (`/usr/local/bin/register-worker.sh`, `/usr/local/bin/drain-queue.sh`).
+22 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -337,6 +338,12 @@ func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec {
// runtime block is the canonical runtime abstraction (P07 will expand // runtime block is the canonical runtime abstraction (P07 will expand
// this). When Runtime is nil we emit a single no-op task to preserve // this). When Runtime is nil we emit a single no-op task to preserve
// the legacy "at least one task" invariant. // the legacy "at least one task" invariant.
//
// The runtime command string is split into binary + args via
// splitCommand so that exec.Command receives the binary path and the
// args as separate elements. Without this split, a command like
// "/usr/bin/httpd -f /etc/orca/web-app/httpd.conf" is treated as a
// single file path and fork/exec fails with "no such file or directory".
func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec { func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec {
if spec == nil { if spec == nil {
return nil return nil
@@ -344,8 +351,22 @@ func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec {
if spec.Runtime == nil { if spec.Runtime == nil {
return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}} return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}}
} }
bin, args := splitCommand(spec.Runtime.Command)
return []engine.TaskSpec{{ return []engine.TaskSpec{{
Name: spec.Name, Name: spec.Name,
Command: spec.Runtime.Command, Command: bin,
Args: args,
}} }}
} }
// splitCommand splits a command string into binary + args using
// strings.Fields (handles multiple spaces/tabs). If the string is empty
// or all-whitespace, returns ("/bin/true", nil) so the executor still
// has a valid binary to run.
func splitCommand(s string) (string, []string) {
parts := strings.Fields(s)
if len(parts) == 0 {
return "/bin/true", nil
}
return parts[0], parts[1:]
}
+141
View File
@@ -0,0 +1,141 @@
package cli
import (
"testing"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
)
func TestSplitCommand(t *testing.T) {
tests := []struct {
name string
input string
wantBin string
wantArgs []string
}{
{
name: "single binary",
input: "/bin/true",
wantBin: "/bin/true",
wantArgs: nil,
},
{
name: "binary with one arg",
input: "/bin/echo hello",
wantBin: "/bin/echo",
wantArgs: []string{"hello"},
},
{
name: "binary with multiple args",
input: "/usr/bin/httpd -f /etc/orca/web-app/httpd.conf",
wantBin: "/usr/bin/httpd",
wantArgs: []string{"-f", "/etc/orca/web-app/httpd.conf"},
},
{
name: "binary with sh -c and quoted string",
input: "/bin/sh -c 'echo hello world'",
wantBin: "/bin/sh",
wantArgs: []string{"-c", "'echo", "hello", "world'"},
},
{
name: "empty command falls back to /bin/true",
input: "",
wantBin: "/bin/true",
wantArgs: nil,
},
{
name: "all-whitespace command falls back to /bin/true",
input: " \t ",
wantBin: "/bin/true",
wantArgs: nil,
},
{
name: "multiple spaces between args",
input: "/bin/echo hello world",
wantBin: "/bin/echo",
wantArgs: []string{"hello", "world"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotBin, gotArgs := splitCommand(tt.input)
if gotBin != tt.wantBin {
t.Errorf("splitCommand(%q) bin = %q, want %q", tt.input, gotBin, tt.wantBin)
}
if len(gotArgs) != len(tt.wantArgs) {
t.Errorf("splitCommand(%q) args len = %d, want %d (got %v, want %v)",
tt.input, len(gotArgs), len(tt.wantArgs), gotArgs, tt.wantArgs)
return
}
for i, a := range gotArgs {
if a != tt.wantArgs[i] {
t.Errorf("splitCommand(%q) args[%d] = %q, want %q",
tt.input, i, a, tt.wantArgs[i])
}
}
})
}
}
func TestWorkloadToTaskSpecs_SplitsCommand(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Name: "web-app",
Runtime: &jobspec.RuntimeBlock{
OneOf: "process",
Command: "/usr/bin/httpd -f /etc/orca/web-app/httpd.conf",
},
}
tasks := workloadToTaskSpecs(spec)
if len(tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(tasks))
}
if tasks[0].Command != "/usr/bin/httpd" {
t.Errorf("expected Command=/usr/bin/httpd, got %q", tasks[0].Command)
}
if len(tasks[0].Args) != 2 {
t.Fatalf("expected 2 args, got %d (%v)", len(tasks[0].Args), tasks[0].Args)
}
if tasks[0].Args[0] != "-f" || tasks[0].Args[1] != "/etc/orca/web-app/httpd.conf" {
t.Errorf("expected args [-f /etc/orca/web-app/httpd.conf], got %v", tasks[0].Args)
}
}
func TestWorkloadToTaskSpecs_NilRuntimeUsesBinTrue(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Name: "noop",
}
tasks := workloadToTaskSpecs(spec)
if len(tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(tasks))
}
if tasks[0].Command != "/bin/true" {
t.Errorf("expected Command=/bin/true, got %q", tasks[0].Command)
}
if len(tasks[0].Args) != 0 {
t.Errorf("expected 0 args, got %d (%v)", len(tasks[0].Args), tasks[0].Args)
}
}
func TestWorkloadToTaskSpecs_EmptyCommandUsesBinTrue(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Name: "empty",
Runtime: &jobspec.RuntimeBlock{
OneOf: "process",
Command: "",
},
}
tasks := workloadToTaskSpecs(spec)
if len(tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(tasks))
}
if tasks[0].Command != "/bin/true" {
t.Errorf("expected Command=/bin/true, got %q", tasks[0].Command)
}
}
func TestWorkloadToTaskSpecs_NilSpecReturnsNil(t *testing.T) {
tasks := workloadToTaskSpecs(nil)
if tasks != nil {
t.Errorf("expected nil, got %v", tasks)
}
}