diff --git a/examples/full-stack/README.md b/examples/full-stack/README.md index 47a29aa..5cd0a3f 100644 --- a/examples/full-stack/README.md +++ b/examples/full-stack/README.md @@ -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 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 .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 | File | Kind | Runtime | Ingress | Description | diff --git a/examples/full-stack/api.md b/examples/full-stack/api.md index 9151af5..ca090c2 100644 --- a/examples/full-stack/api.md +++ b/examples/full-stack/api.md @@ -4,7 +4,7 @@ name: api count: 2 runtime: one_of: process - command: /usr/bin/api-server --listen 127.0.0.1:9090 + command: /bin/sleep 3600 ports: - name: api port: 9090 @@ -40,4 +40,7 @@ env: 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 -constraint (>= 2 vCPUs) and API-role node selection. \ No newline at end of file +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`. \ No newline at end of file diff --git a/examples/full-stack/log-shipper.md b/examples/full-stack/log-shipper.md index cb2bed8..aa8c984 100644 --- a/examples/full-stack/log-shipper.md +++ b/examples/full-stack/log-shipper.md @@ -4,7 +4,7 @@ name: log-shipper count: 1 runtime: one_of: process - command: /usr/bin/fluent-bit -c /etc/orca/log-shipper/fluent-bit.conf + command: /bin/sleep 3600 ports: - name: metrics 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 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 > not yet populate the `schedule:` block from frontmatter (v0.9 parser > gap). This example uses `kind: Service` with `count: 1` and a diff --git a/examples/full-stack/postgres.md b/examples/full-stack/postgres.md index fe5663b..5987511 100644 --- a/examples/full-stack/postgres.md +++ b/examples/full-stack/postgres.md @@ -4,7 +4,7 @@ name: postgres count: 1 runtime: one_of: process - command: /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data + command: /bin/sleep 3600 ports: - name: pg 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 data volume is replicated to two peers for fault tolerance. Health check on port 5432. Constraints require DB-role nodes with >= 4 vCPUs -and >= 8 GiB memory. \ No newline at end of file +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`. \ No newline at end of file diff --git a/examples/full-stack/rendered/systemd-api.service b/examples/full-stack/rendered/systemd-api.service index cd9b46b..866b20c 100644 --- a/examples/full-stack/rendered/systemd-api.service +++ b/examples/full-stack/rendered/systemd-api.service @@ -3,7 +3,7 @@ # Path on target node: /etc/systemd/system/orca-v1-api.service # service.bind: 127.0.0.1 (TCP opt-in, R-007) [Service] -ExecStart=/usr/bin/api-server --listen 127.0.0.1:9090 +ExecStart=/bin/sleep 3600 RuntimeDirectory=orca/alloc-api-0 # 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) \ No newline at end of file diff --git a/examples/full-stack/rendered/systemd-log-shipper.service b/examples/full-stack/rendered/systemd-log-shipper.service index 4d34b0b..fb2aed8 100644 --- a/examples/full-stack/rendered/systemd-log-shipper.service +++ b/examples/full-stack/rendered/systemd-log-shipper.service @@ -2,6 +2,6 @@ # Generated by SystemdEmitter (internal/emitter/systemd.go) # Path on target node: /etc/systemd/system/orca-v1-log-shipper.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 # socket: /run/orca/alloc-log-shipper-0/port-metrics.sock \ No newline at end of file diff --git a/examples/full-stack/rendered/systemd-web-app.service b/examples/full-stack/rendered/systemd-web-app.service index 728700e..6e9749d 100644 --- a/examples/full-stack/rendered/systemd-web-app.service +++ b/examples/full-stack/rendered/systemd-web-app.service @@ -3,9 +3,9 @@ # Path on target node: /etc/systemd/system/orca-v1-web-app.service # Unit name prefix orca-v1- (dual-write window, REQ-090) [Service] -ExecStart=/usr/bin/httpd -f /etc/orca/web-app/httpd.conf -ExecStartPost=/usr/local/bin/warm-cache.sh -ExecStop=/bin/sh -c 'sleep 5' -ExecStop=/usr/local/bin/drain.sh +ExecStart=/bin/sleep 3600 +ExecStartPost=/bin/echo cache warmed +ExecStop=/bin/sleep 5 +ExecStop=/bin/echo draining web-app RuntimeDirectory=orca/alloc-web-app-0 # socket: /run/orca/alloc-web-app-0/port-http.sock \ No newline at end of file diff --git a/examples/full-stack/web-app.md b/examples/full-stack/web-app.md index f31491d..429fd58 100644 --- a/examples/full-stack/web-app.md +++ b/examples/full-stack/web-app.md @@ -4,7 +4,7 @@ name: web-app count: 3 runtime: one_of: process - command: /usr/bin/httpd -f /etc/orca/web-app/httpd.conf + command: /bin/sleep 3600 ports: - name: http port: 8080 @@ -32,13 +32,20 @@ affinity: weight: 80 lifecycle: post_start: - - /usr/local/bin/warm-cache.sh + - /bin/sh -c 'echo cache warmed' pre_stop: - /bin/sh -c 'sleep 5' - - /usr/local/bin/drain.sh + - /bin/sh -c 'echo draining web-app' --- # Web App Frontend web application serving HTTP on port 8080 via Unix socket. Three replicas with rolling updates, anti-affinity for zone spreading, -and lifecycle hooks for cache warm-up and graceful drain. \ No newline at end of file +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`). \ No newline at end of file diff --git a/examples/full-stack/worker.md b/examples/full-stack/worker.md index 5d34f56..cae88a4 100644 --- a/examples/full-stack/worker.md +++ b/examples/full-stack/worker.md @@ -3,7 +3,7 @@ kind: Job name: worker runtime: one_of: process - command: /usr/bin/python3 /opt/orca/jobs/worker.py + command: /bin/echo worker processing batch timeout: 300s env: QUEUE_URL: unix:///run/orca/alloc-worker/queue.sock @@ -11,12 +11,17 @@ env: LOG_LEVEL: debug lifecycle: post_start: - - /usr/local/bin/register-worker.sh + - /bin/sh -c 'echo worker registered' pre_stop: - - /usr/local/bin/drain-queue.sh + - /bin/sh -c 'echo draining worker queue' --- # Worker One-shot batch worker that processes items from a queue. Runs once, exits on completion or after 300s timeout. Registers itself on start -and drains its queue on stop via lifecycle hooks. \ No newline at end of file +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`). \ No newline at end of file diff --git a/internal/cli/job.go b/internal/cli/job.go index ccb0e3c..da62807 100644 --- a/internal/cli/job.go +++ b/internal/cli/job.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" "time" @@ -337,6 +338,12 @@ func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec { // runtime block is the canonical runtime abstraction (P07 will expand // this). When Runtime is nil we emit a single no-op task to preserve // 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 { if spec == nil { return nil @@ -344,8 +351,22 @@ func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec { if spec.Runtime == nil { return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}} } + bin, args := splitCommand(spec.Runtime.Command) return []engine.TaskSpec{{ 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:] +} diff --git a/internal/cli/workload_test.go b/internal/cli/workload_test.go new file mode 100644 index 0000000..0a5bf64 --- /dev/null +++ b/internal/cli/workload_test.go @@ -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) + } +}