Files
orca/internal/cli/workload_test.go
T
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

142 lines
3.6 KiB
Go

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)
}
}