60b0357eb6
P0c — Kind-specific schema validators + Layer 4 emitter interface.
Schemas (internal/spec/schema/schema.go, REQ-074):
- Validator interface with JobValidator, ServiceValidator, DaemonSetValidator.
JobValidator: count=1, no service block, optional schedule/timeout.
ServiceValidator: ports required, count>=1, restart+update+runtime required.
DaemonSetValidator: schedule mode required, no ports (D-175), no count.
ValidatorFor(kind) dispatcher. 96.2% coverage.
Emitter interface (internal/emitter/emitter.go, REQ-074, I-B-002):
- File{Path,Content,Mode}, Emitter interface { Render(spec,node) []File },
Registry keyed by kind:runtime, Register + Render lookup. 100% coverage.
Systemd stub (internal/emitter/systemd.go):
- SystemdEmitter for process runtime. Renders minimal [Service] unit at
/etc/systemd/system/orca-v1-alloc-<name>.service (orca-v1- prefix per
dual-write window REQ-090 — no overlap with v0.8 daemon's orca-<job>).
Flock test fix: TestFlock_concurrentBlocks rewritten to use non-blocking
tryFlockEx (LOCK_NB) instead of a leaked blocking goroutine. Eliminates
the temp-dir cleanup race.
20 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.
---ci---
project: orca
phase: P0c
milestone: v0.9
status: execute
---/ci---
157 lines
4.9 KiB
Go
157 lines
4.9 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)
|
|
}
|
|
}
|