package emitter import ( "strings" "testing" "git.cloudinit.dev/coreci/orca/internal/jobspec" ) func TestSystemdEmitter_RenderJob(t *testing.T) { spec := &jobspec.WorkloadSpec{ Kind: "Job", Name: "backup", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/bin/rsync -a /src /dst"}, } node := &Node{Hostname: "node-1", Runtime: []string{"process"}} files, err := SystemdEmitter{}.Render(spec, node) if err != nil { t.Fatalf("Render: %v", err) } if len(files) != 1 { t.Fatalf("got %d files, want 1", len(files)) } f := files[0] wantPath := "/etc/systemd/system/orca-v1-backup.service" if f.Path != wantPath { t.Errorf("Path = %q, want %q", f.Path, wantPath) } wantContent := "[Service]\nExecStart=/usr/bin/rsync -a /src /dst\n" if f.Content != wantContent { t.Errorf("Content = %q, want %q", f.Content, wantContent) } if f.Mode != "0644" { t.Errorf("Mode = %q, want 0644", f.Mode) } } func TestSystemdEmitter_RenderService(t *testing.T) { // The full service emitter (Traefik route + health checks) lands in // P02; here we only prove the systemd side renders for a Service // kind with a process runtime. spec := &jobspec.WorkloadSpec{ Kind: "Service", Name: "web", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"}, } node := &Node{Hostname: "node-1", Runtime: []string{"process"}} files, err := SystemdEmitter{}.Render(spec, node) if err != nil { t.Fatalf("Render: %v", err) } if len(files) != 1 { t.Fatalf("got %d files, want 1", len(files)) } if files[0].Path != "/etc/systemd/system/orca-v1-web.service" { t.Errorf("Path = %q, want /etc/systemd/system/orca-v1-web.service", files[0].Path) } if !strings.Contains(files[0].Content, "ExecStart=/usr/local/bin/httpd -f") { t.Errorf("Content = %q, want it to contain the ExecStart line", files[0].Content) } } func TestSystemdEmitter_EmptyCommandError(t *testing.T) { spec := &jobspec.WorkloadSpec{ Kind: "Job", Name: "x", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: ""}, } _, err := SystemdEmitter{}.Render(spec, &Node{}) if err == nil { t.Fatal("expected error for empty command, got nil") } if !strings.Contains(err.Error(), "command is empty") { t.Errorf("error = %q, want 'command is empty'", err.Error()) } } func TestSystemdEmitter_WhitespaceCommandError(t *testing.T) { spec := &jobspec.WorkloadSpec{ Kind: "Job", Name: "x", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: " "}, } _, err := SystemdEmitter{}.Render(spec, &Node{}) if err == nil { t.Fatal("expected error for whitespace-only command, got nil") } if !strings.Contains(err.Error(), "command is empty") { t.Errorf("error = %q, want 'command is empty'", err.Error()) } } func TestSystemdEmitter_NilSpec(t *testing.T) { v := SystemdEmitter{} if _, err := v.Render(nil, &Node{}); err == nil { t.Fatal("expected error for nil spec") } } func TestSystemdEmitter_EmptyName(t *testing.T) { spec := &jobspec.WorkloadSpec{ Kind: "Job", Name: " ", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"}, } _, err := SystemdEmitter{}.Render(spec, &Node{}) if err == nil { t.Fatal("expected error for empty name") } if !strings.Contains(err.Error(), "name is empty") { t.Errorf("error = %q, want 'name is empty'", err.Error()) } } func TestSystemdEmitter_NilRuntime(t *testing.T) { spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x"} _, err := SystemdEmitter{}.Render(spec, &Node{}) if err == nil { t.Fatal("expected error for nil runtime") } if !strings.Contains(err.Error(), "runtime block is nil") { t.Errorf("error = %q, want 'runtime block is nil'", err.Error()) } } func TestSystemdEmitter_UnitNamePrefix(t *testing.T) { // The orca-v1- prefix is load-bearing for the dual-write window // (REQ-090, I-C-006): the v0.8 daemon writes `orca-.service` // and the v0.9 SSH-push path writes `orca-v1-.service`, // so the two never collide. This test guards against accidental // removal of the prefix. spec := &jobspec.WorkloadSpec{ Kind: "Job", Name: "dual-write-safety", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"}, } files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"}) if err != nil { t.Fatalf("Render: %v", err) } if !strings.HasPrefix(files[0].Path, "/etc/systemd/system/orca-v1-") { t.Errorf("Path = %q, want it to start with /etc/systemd/system/orca-v1- (REQ-090)", files[0].Path) } if !strings.HasSuffix(files[0].Path, ".service") { t.Errorf("Path = %q, want it to end with .service", files[0].Path) } // Explicitly assert the full expected unit name to lock the contract. want := "/etc/systemd/system/orca-v1-dual-write-safety.service" if files[0].Path != want { t.Errorf("Path = %q, want %q", files[0].Path, want) } // Sanity: the prefix is exactly "orca-v1-", not "orca-v0" or "orca". if unitNamePrefix != "orca-v1-" { t.Errorf("unitNamePrefix = %q, want orca-v1-", unitNamePrefix) } } func TestSystemdEmitter_TaskGroupTwoTasks(t *testing.T) { // P06: a task group with two tasks renders one unit per task plus // a grouping target unit. Each per-task unit is // `orca-v1-alloc--.service`, carries // `PartOf=orca-v1-alloc-.target`, and is // `WantedBy=multi-user.target`. The target unit lists every // per-task unit under Wants=. spec := &jobspec.WorkloadSpec{ Kind: "Service", Name: "web", Tasks: []jobspec.TaskGroupTask{ { Name: "app", Command: "/usr/bin/httpd -f", Runtime: &jobspec.RuntimeBlock{OneOf: "process"}, }, { Name: "sidecar", Command: "/bin/wasm-runner sidecar.wasm", Runtime: &jobspec.RuntimeBlock{OneOf: "wasm"}, }, }, } files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n1"}) if err != nil { t.Fatalf("Render: %v", err) } // 2 per-task units + 1 target unit. if len(files) != 3 { t.Fatalf("got %d files, want 3 (2 per-task units + 1 target)", len(files)) } wantApp := "/etc/systemd/system/orca-v1-alloc-web-app.service" wantSide := "/etc/systemd/system/orca-v1-alloc-web-sidecar.service" wantTarget := "/etc/systemd/system/orca-v1-alloc-web.target" paths := make(map[string]*File, len(files)) for i := range files { paths[files[i].Path] = &files[i] } if _, ok := paths[wantApp]; !ok { t.Errorf("missing per-task unit %q; got paths %v", wantApp, filePaths(files)) } if _, ok := paths[wantSide]; !ok { t.Errorf("missing per-task unit %q; got paths %v", wantSide, filePaths(files)) } if _, ok := paths[wantTarget]; !ok { t.Errorf("missing target unit %q; got paths %v", wantTarget, filePaths(files)) } if _, ok := paths[wantTarget]; !ok { t.Errorf("missing target unit %q; got paths %v", wantTarget, filePaths(files)) } // Verify PartOf relations and ExecStart on per-task units. app := paths[wantApp] if !strings.Contains(app.Content, "PartOf=orca-v1-alloc-web.target") { t.Errorf("app unit missing PartOf=orca-v1-alloc-web.target\n%s", app.Content) } if !strings.Contains(app.Content, "ExecStart=/usr/bin/httpd -f") { t.Errorf("app unit missing ExecStart=/usr/bin/httpd -f\n%s", app.Content) } if !strings.Contains(app.Content, "WantedBy=multi-user.target") { t.Errorf("app unit missing WantedBy=multi-user.target\n%s", app.Content) } side := paths[wantSide] if !strings.Contains(side.Content, "PartOf=orca-v1-alloc-web.target") { t.Errorf("sidecar unit missing PartOf=orca-v1-alloc-web.target\n%s", side.Content) } if !strings.Contains(side.Content, "ExecStart=/bin/wasm-runner sidecar.wasm") { t.Errorf("sidecar unit missing ExecStart\n%s", side.Content) } // Verify the target unit Wants= both per-task units. target := paths[wantTarget] if !strings.Contains(target.Content, "Wants=orca-v1-alloc-web-app.service") { t.Errorf("target missing Wants=...app.service\n%s", target.Content) } if !strings.Contains(target.Content, "Wants=orca-v1-alloc-web-sidecar.service") { t.Errorf("target missing Wants=...sidecar.service\n%s", target.Content) } } func TestSystemdEmitter_TaskGroupInheritsTopLevelRuntime(t *testing.T) { // P06: a task that omits its own runtime inherits the top-level // spec.Runtime as the per-group default. The per-task unit's // ExecStart must come from the top-level runtime command when // the task has no own command and no own runtime. spec := &jobspec.WorkloadSpec{ Kind: "Service", Name: "web", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/default"}, Tasks: []jobspec.TaskGroupTask{ {Name: "app"}, {Name: "sidecar", Command: "/bin/override"}, }, } files, err := SystemdEmitter{}.Render(spec, &Node{}) if err != nil { t.Fatalf("Render: %v", err) } if len(files) != 3 { t.Fatalf("got %d files, want 3", len(files)) } appContent := findUnitContent(files, "/etc/systemd/system/orca-v1-alloc-web-app.service") if appContent == "" { t.Fatalf("missing app unit; paths %v", filePaths(files)) } if !strings.Contains(appContent, "ExecStart=/bin/default") { t.Errorf("app unit should inherit top-level command /bin/default\n%s", appContent) } sideContent := findUnitContent(files, "/etc/systemd/system/orca-v1-alloc-web-sidecar.service") if sideContent == "" { t.Fatalf("missing sidecar unit; paths %v", filePaths(files)) } if !strings.Contains(sideContent, "ExecStart=/bin/override") { t.Errorf("sidecar unit should use its own command /bin/override\n%s", sideContent) } } func TestSystemdEmitter_TaskGroupNoCommandError(t *testing.T) { // P06: a task with no resolvable command (no task.Command, no // task.Runtime, no top-level Runtime) is an error. spec := &jobspec.WorkloadSpec{ Kind: "Service", Name: "web", Tasks: []jobspec.TaskGroupTask{{Name: "app"}}, } _, err := SystemdEmitter{}.Render(spec, &Node{}) if err == nil { t.Fatal("expected error for task with no runtime, got nil") } if !strings.Contains(err.Error(), "no runtime") { t.Errorf("error = %q, want 'no runtime'", err.Error()) } } func TestSystemdEmitter_TaskGroupEmptyCommandError(t *testing.T) { // P06: a task whose resolved runtime command is empty/whitespace // is an error (mirrors the single-process rule). spec := &jobspec.WorkloadSpec{ Kind: "Service", Name: "web", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: " "}, Tasks: []jobspec.TaskGroupTask{{Name: "app"}}, } _, err := SystemdEmitter{}.Render(spec, &Node{}) if err == nil { t.Fatal("expected error for empty command, got nil") } if !strings.Contains(err.Error(), "command is empty") { t.Errorf("error = %q, want 'command is empty'", err.Error()) } } func TestSystemdEmitter_NoTasksBackwardCompat(t *testing.T) { // Backward compat: a spec with no Tasks renders exactly one unit // (the historical single-process shape). spec := &jobspec.WorkloadSpec{ Kind: "Job", Name: "backup", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"}, } files, err := SystemdEmitter{}.Render(spec, &Node{}) if err != nil { t.Fatalf("Render: %v", err) } if len(files) != 1 { t.Fatalf("got %d files, want 1 (backward compat)", len(files)) } if files[0].Path != "/etc/systemd/system/orca-v1-backup.service" { t.Errorf("Path = %q, want /etc/systemd/system/orca-v1-backup.service", files[0].Path) } } func filePaths(files []File) []string { out := make([]string, len(files)) for i, f := range files { out[i] = f.Path } return out } func findUnitContent(files []File, path string) string { for _, f := range files { if f.Path == path { return f.Content } } return "" }