c3819dde12
P06 — Task groups (PRD §9.1: multiple systemd units per alloc). Parser (internal/jobspec/markdown.go): - TaskGroupTask type (Name, Runtime, Env, Command). Tasks []TaskGroupTask on WorkloadSpec. Parses tasks: frontmatter block (array of task objects). Tasks without their own runtime inherit the top-level Runtime as default. Backward compat: no tasks -> single-process (existing runtime block). Systemd emitter (internal/emitter/systemd.go): - Task group renders one systemd unit per task (orca-v1-alloc-<id>-<task> .service) plus a grouping target unit (orca-v1-alloc-<id>.target). Each per-task unit carries PartOf=<target> and WantedBy=multi-user.target. Single-process case unchanged (backward compat). Schema (internal/spec/schema/schema.go): - TaskGroup validation: unique task names, resolvable command (own or inherited). JobValidator/ServiceValidator/DaemonSetValidator all accept task groups. Tests: 9 task-group tests in schema_test.go, lifecycle + target-unit tests in systemd_test.go, parser tests in markdown_test.go. 22 packages pass. Fix: 3 Service task-group test fixtures missing Count:1 (ServiceValidator requires count>=1; a task-group Service still has >=1 replica). ---ci--- project: orca phase: P06 milestone: v0.9 status: execute ---/ci---
344 lines
11 KiB
Go
344 lines
11 KiB
Go
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-<job>.service`
|
|
// and the v0.9 SSH-push path writes `orca-v1-<spec.Name>.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-<alloc-id>-<task-name>.service`, carries
|
|
// `PartOf=orca-v1-alloc-<alloc-id>.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 ""
|
|
}
|