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---
872 lines
27 KiB
Go
872 lines
27 KiB
Go
package schema
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
func TestJobValidator_ValidMinimal(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "backup", Count: 1}
|
|
v := JobValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJobValidator_ValidWithSchedule(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Job",
|
|
Name: "backup",
|
|
Count: 1,
|
|
Schedule: &jobspec.ScheduleBlock{Cron: "0 2 * * *"},
|
|
Timeout: "1h",
|
|
}
|
|
v := JobValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJobValidator_ValidUnsetCount(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "one-shot"}
|
|
v := JobValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("unset count should default-accept, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJobValidator_MissingName(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Count: 1}
|
|
err := JobValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing name, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "name is required") {
|
|
t.Errorf("error = %q, want 'name is required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestJobValidator_CountGreaterThanOne(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "batch", Count: 3}
|
|
err := JobValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for count > 1, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "count must be 1") {
|
|
t.Errorf("error = %q, want 'count must be 1'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestJobValidator_ServiceBlockRejected(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Job",
|
|
Name: "x",
|
|
Count: 1,
|
|
Service: &jobspec.ServiceBlock{Host: "x.example"},
|
|
}
|
|
err := JobValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for service block on Job, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "service block") {
|
|
t.Errorf("error = %q, want 'service block'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestJobValidator_NilSpec(t *testing.T) {
|
|
v := JobValidator{}
|
|
if err := v.Validate(nil); err == nil {
|
|
t.Fatal("expected error for nil spec")
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_ValidFull(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 3,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/http"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling", MaxSurge: 1},
|
|
Health: &jobspec.HealthBlock{CheckType: "http", Interval: "5s"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_MissingPorts(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 2,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing ports, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "ports required") {
|
|
t.Errorf("error = %q, want 'ports required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_CountZero(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 0,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for count 0, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "count must be") {
|
|
t.Errorf("error = %q, want 'count must be'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_MissingRestart(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing restart, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "restart block required") {
|
|
t.Errorf("error = %q, want 'restart block required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_WrongRestartMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid restart mode, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "restart mode") {
|
|
t.Errorf("error = %q, want 'restart mode'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_AcceptedRestartModes(t *testing.T) {
|
|
// R-012: restart.mode accepts service / on-failure / never for
|
|
// Service; the default per R-012 is "service" but the validator
|
|
// accepts the full enum (a Service that wants on-failure is
|
|
// unusual but not invalid — only "always" and unknown modes are
|
|
// rejected).
|
|
for _, mode := range []string{"service", "on-failure", "never"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: mode},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Errorf("mode %q should be accepted, got: %v", mode, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_MissingUpdate(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing update, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "update block required") {
|
|
t.Errorf("error = %q, want 'update block required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_MissingRuntime(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing runtime, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "runtime block required") {
|
|
t.Errorf("error = %q, want 'runtime block required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_NilSpec(t *testing.T) {
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(nil); err == nil {
|
|
t.Fatal("expected error for nil spec")
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_MissingHealth(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing health block, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "health block required") {
|
|
t.Errorf("error = %q, want 'health block required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_InvalidRestartMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid restart mode, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "restart mode") || !strings.Contains(err.Error(), "invalid") {
|
|
t.Errorf("error = %q, want 'restart mode ... invalid'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_EmptyRestartMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: ""},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty restart mode, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "restart mode required") {
|
|
t.Errorf("error = %q, want 'restart mode required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_InvalidUpdateStrategy(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "recreate"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid update strategy, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "update strategy") || !strings.Contains(err.Error(), "invalid") {
|
|
t.Errorf("error = %q, want 'update strategy ... invalid'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_EmptyUpdateStrategy(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: ""},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty update strategy, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "update strategy required") {
|
|
t.Errorf("error = %q, want 'update strategy required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_AcceptedUpdateStrategies(t *testing.T) {
|
|
for _, strat := range []string{"rolling", "canary", "blue-green"} {
|
|
t.Run(strat, func(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: strat},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Errorf("strategy %q should be accepted, got: %v", strat, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_InvalidServiceBind(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid service.bind, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "service.bind") || !strings.Contains(err.Error(), "valid IP") {
|
|
t.Errorf("error = %q, want 'service.bind ... valid IP'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_ValidServiceBindLoopback(t *testing.T) {
|
|
// R-007: 127.0.0.1 is the documented opt-in for a non-socket bind.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
|
}
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("127.0.0.1 should be accepted, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_ValidServiceBindIPv6(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Service: &jobspec.ServiceBlock{Bind: "::1"},
|
|
}
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("::1 should be accepted, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_EmptyServiceBindOK(t *testing.T) {
|
|
// R-007: empty bind = default = socket; valid.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Service: &jobspec.ServiceBlock{Bind: ""},
|
|
}
|
|
v := ServiceValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("empty bind should default to socket (valid), got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceValidator_MultipleErrors(t *testing.T) {
|
|
// Multiple violations should all surface in the composed error.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "",
|
|
Count: 0,
|
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "recreate"},
|
|
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
for _, want := range []string{
|
|
"name is required",
|
|
"ports required",
|
|
"count must be",
|
|
"restart mode",
|
|
"update strategy",
|
|
"runtime block required",
|
|
"health block required",
|
|
"service.bind",
|
|
} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q missing %q", err.Error(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_Valid(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "log-shipper",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
v := DaemonSetValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_ValidMatchingMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "matching"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
v := DaemonSetValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("matching mode should be accepted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_ValidMandatoryMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "mandatory"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
v := DaemonSetValidator{}
|
|
if err := v.Validate(spec); err != nil {
|
|
t.Fatalf("mandatory mode should be accepted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_MissingScheduleMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: ""},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
err := DaemonSetValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing schedule mode, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "schedule mode required") {
|
|
t.Errorf("error = %q, want 'schedule mode required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_MissingScheduleBlock(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
err := DaemonSetValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing schedule block, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "schedule block required") {
|
|
t.Errorf("error = %q, want 'schedule block required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_InvalidScheduleMode(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "always"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
err := DaemonSetValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid schedule mode, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "schedule mode") {
|
|
t.Errorf("error = %q, want 'schedule mode'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_HasPorts(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 80}},
|
|
}
|
|
err := DaemonSetValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for ports on DaemonSet, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "ports not allowed") {
|
|
t.Errorf("error = %q, want 'ports not allowed'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_HasCount(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Count: 3,
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
}
|
|
err := DaemonSetValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for count on DaemonSet, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "count not allowed") {
|
|
t.Errorf("error = %q, want 'count not allowed'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_MissingRestart(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "x",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
|
}
|
|
err := DaemonSetValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing restart on DaemonSet, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "restart block required") {
|
|
t.Errorf("error = %q, want 'restart block required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDaemonSetValidator_NilSpec(t *testing.T) {
|
|
v := DaemonSetValidator{}
|
|
if err := v.Validate(nil); err == nil {
|
|
t.Fatal("expected error for nil spec")
|
|
}
|
|
}
|
|
|
|
func TestValidatorFor_EachKind(t *testing.T) {
|
|
cases := []struct {
|
|
kind string
|
|
want string
|
|
}{
|
|
{"Job", "schema.JobValidator"},
|
|
{"Service", "schema.ServiceValidator"},
|
|
{"DaemonSet", "schema.DaemonSetValidator"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.kind, func(t *testing.T) {
|
|
v, err := ValidatorFor(tc.kind)
|
|
if err != nil {
|
|
t.Fatalf("ValidatorFor(%q): %v", tc.kind, err)
|
|
}
|
|
got := fmtType(v)
|
|
if got != tc.want {
|
|
t.Errorf("ValidatorFor(%q) type = %q, want %q", tc.kind, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidatorFor_UnknownKind(t *testing.T) {
|
|
_, err := ValidatorFor("CronJob")
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown kind, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown kind") {
|
|
t.Errorf("error = %q, want 'unknown kind'", err.Error())
|
|
}
|
|
}
|
|
|
|
// fmtType returns a readable type name for a validator. Uses fmt.Sprintf
|
|
// with %T rather than reflection to keep the test surface minimal.
|
|
func fmtType(v Validator) string {
|
|
switch v.(type) {
|
|
case JobValidator:
|
|
return "schema.JobValidator"
|
|
case ServiceValidator:
|
|
return "schema.ServiceValidator"
|
|
case DaemonSetValidator:
|
|
return "schema.DaemonSetValidator"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// Ensure composeErrors returns nil for empty input (covers the
|
|
// short-circuit branch that the validators rely on).
|
|
func TestComposeErrors_Empty(t *testing.T) {
|
|
if err := composeErrors("schema/X", nil); err != nil {
|
|
t.Errorf("composeErrors(nil) = %v, want nil", err)
|
|
}
|
|
if err := composeErrors("schema/X", []string{}); err != nil {
|
|
t.Errorf("composeErrors([]) = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// Ensure the error type returned by composeErrors is a non-nil error
|
|
// when violations are present (guards against accidental nil-return).
|
|
func TestComposeErrors_NonEmpty(t *testing.T) {
|
|
err := composeErrors("schema/X", []string{"a", "b"})
|
|
if err == nil {
|
|
t.Fatal("expected non-nil error")
|
|
}
|
|
if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") {
|
|
t.Errorf("error = %q, want both 'a' and 'b'", err.Error())
|
|
}
|
|
}
|
|
|
|
// Compile-time assertion that the validators implement the interface.
|
|
var (
|
|
_ Validator = JobValidator{}
|
|
_ Validator = ServiceValidator{}
|
|
_ Validator = DaemonSetValidator{}
|
|
)
|
|
|
|
func TestTaskGroup_Valid(t *testing.T) {
|
|
// P06: a valid task group — two tasks, each with a unique name
|
|
// and a resolvable command (own command). The top-level runtime
|
|
// is optional when each task carries its own.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "app", Command: "/usr/bin/httpd"},
|
|
{Name: "sidecar", Command: "/bin/wasm-runner sidecar.wasm"},
|
|
},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
if err := (ServiceValidator{}).Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_ValidInheritsTopLevelRuntime(t *testing.T) {
|
|
// P06: tasks without their own runtime inherit the top-level
|
|
// runtime command. The validator accepts this as long as the
|
|
// resolved command is non-empty.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/default"},
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "app"},
|
|
{Name: "sidecar"},
|
|
},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
if err := (ServiceValidator{}).Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_ValidTaskRuntimeCommand(t *testing.T) {
|
|
// P06: a task whose command is provided via the task's own
|
|
// runtime.command (no top-level runtime) is valid.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 1,
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "app", Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/bin/httpd"}},
|
|
},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
if err := (ServiceValidator{}).Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_MissingTaskName(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Command: "/usr/bin/httpd"},
|
|
{Name: "sidecar", Command: "/bin/wasm-runner"},
|
|
},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing task name, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "name is required") {
|
|
t.Errorf("error = %q, want 'name is required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_DuplicateTaskNames(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "app", Command: "/usr/bin/httpd"},
|
|
{Name: "app", Command: "/bin/other"},
|
|
},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for duplicate task names, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "duplicate task name") {
|
|
t.Errorf("error = %q, want 'duplicate task name'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_MissingCommand(t *testing.T) {
|
|
// P06: a task with no resolvable command (no task.Command, no
|
|
// task.Runtime, no top-level Runtime) is rejected.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "app"},
|
|
},
|
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
err := ServiceValidator{}.Validate(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing task command, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "command is required") {
|
|
t.Errorf("error = %q, want 'command is required'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_JobAcceptsTaskGroup(t *testing.T) {
|
|
// P06: task groups apply to all kinds, not just Service. Job
|
|
// accepts a task group with unique names + resolvable commands.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Job",
|
|
Name: "batch",
|
|
Count: 1,
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "step1", Command: "/bin/extract"},
|
|
{Name: "step2", Command: "/bin/transform"},
|
|
},
|
|
}
|
|
if err := (JobValidator{}).Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_DaemonSetAcceptsTaskGroup(t *testing.T) {
|
|
// P06: DaemonSet accepts a task group.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "DaemonSet",
|
|
Name: "log-shipper",
|
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
|
Tasks: []jobspec.TaskGroupTask{
|
|
{Name: "collector", Command: "/bin/collect"},
|
|
{Name: "forwarder", Command: "/bin/forward"},
|
|
},
|
|
}
|
|
if err := (DaemonSetValidator{}).Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskGroup_NoTasksBackwardCompat(t *testing.T) {
|
|
// Backward compat: a spec with no Tasks is validated by the
|
|
// existing kind-specific rules (no task-group check fires).
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Job",
|
|
Name: "backup",
|
|
Count: 1,
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
|
}
|
|
if err := (JobValidator{}).Validate(spec); err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
}
|