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