Files
orca/internal/spec/schema/update_test.go
T
Jon Chery ae6eb5a27b feat(P03,P04,P08): update stanza + lifecycle hooks + socket plumbing
P03 — Update stanza (rolling/canary/blue-green):
- internal/spec/schema/update.go: UpdateValidator (strategy enum, max_parallel
  1..count, duration parsing, canary int/% forms, auto_promote). 98.2% cov.
- internal/emitter/update.go: RenderUpdatePlan computes the step sequence
  (rolling batches, canary 1+promote+rest, blue-green all+cutover). Pure plan,
  no execution (v0.10-P10 is transactional). 73.7-100% cov.

P04 — Lifecycle hooks (systemd ExecStop semantics):
- Extended internal/emitter/systemd.go: post_start -> ExecStartPost=,
  pre_stop -> ExecStop=. Order: ExecStart -> ExecStartPost -> ExecStop ->
  socket lines. 8 lifecycle tests. 100% cov on systemd.go.

P08 — Socket plumbing (R-007):
- internal/emitter/socket.go: SocketEmitter renders RuntimeDirectory=orca/
  alloc-<id> per port (mode 0750, orca:orca). ExecStartPre TCP-bind marker
  when service.bind=127.0.0.1. SocketPath(allocID,portName) helper. 100% cov.
- Alloc-id is spec.Name placeholder; real id assigned by scheduler at submit.

22 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.

---ci---
project: orca
phase: P03/P04/P08
milestone: v0.9
status: execute
---/ci---
2026-08-05 17:55:11 +00:00

465 lines
11 KiB
Go

package schema
import (
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
)
func TestUpdateValidator_ValidRolling(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
MaxParallel: 2,
MinHealthyTime: "30s",
HealthyDeadline: "5m",
},
}
v := UpdateValidator{}
if err := v.Validate(spec); err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestUpdateValidator_ValidCanary(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
MaxParallel: 2,
MinHealthyTime: "30s",
HealthyDeadline: "5m",
Canary: "10%",
AutoPromote: true,
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestUpdateValidator_ValidBlueGreen(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 3,
Update: &jobspec.UpdateBlock{
Strategy: "blue-green",
MinHealthyTime: "1m",
HealthyDeadline: "10m",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestUpdateValidator_ValidCanaryIntegerCount(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "1",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("integer canary count 1 should be valid, got %v", err)
}
}
func TestUpdateValidator_ValidCanaryZeroPercent(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "0%",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("0%% canary should be valid, got %v", err)
}
}
func TestUpdateValidator_ValidCanaryHundredPercent(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "100%",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("100%% canary should be valid, got %v", err)
}
}
func TestUpdateValidator_EmptyDurationsOK(t *testing.T) {
// Empty min_healthy_time / healthy_deadline should be accepted
// (they are optional; defaults are applied by the executor).
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("empty durations should be valid, got %v", err)
}
}
func TestUpdateValidator_NilSpec(t *testing.T) {
if err := (UpdateValidator{}).Validate(nil); err == nil {
t.Fatal("expected error for nil spec")
}
}
func TestUpdateValidator_NilUpdateBlock(t *testing.T) {
spec := &jobspec.WorkloadSpec{Kind: "Service", Name: "web", Count: 2}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for nil update block")
}
if !strings.Contains(err.Error(), "update block is nil") {
t.Errorf("error = %q, want 'update block is nil'", err.Error())
}
}
func TestUpdateValidator_InvalidStrategy(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "recreate",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for invalid strategy")
}
if !strings.Contains(err.Error(), "strategy") || !strings.Contains(err.Error(), "invalid") {
t.Errorf("error = %q, want 'strategy ... invalid'", err.Error())
}
}
func TestUpdateValidator_EmptyStrategy(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for empty strategy")
}
if !strings.Contains(err.Error(), "strategy required") {
t.Errorf("error = %q, want 'strategy required'", err.Error())
}
}
func TestUpdateValidator_MaxParallelZero(t *testing.T) {
// max_parallel=0 means "unset" → default 1; accepted.
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
MaxParallel: 0,
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("max_parallel=0 (unset) should be valid, got %v", err)
}
}
func TestUpdateValidator_MaxParallelNegative(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
MaxParallel: -1,
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for negative max_parallel")
}
if !strings.Contains(err.Error(), "max_parallel") {
t.Errorf("error = %q, want 'max_parallel'", err.Error())
}
}
func TestUpdateValidator_MaxParallelExceedsCount(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
MaxParallel: 5,
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for max_parallel > count")
}
if !strings.Contains(err.Error(), "exceeds count") {
t.Errorf("error = %q, want 'exceeds count'", err.Error())
}
}
func TestUpdateValidator_MaxParallelEqualsCount(t *testing.T) {
// max_parallel == count is the upper bound; valid.
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 3,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
MaxParallel: 3,
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("max_parallel==count should be valid, got %v", err)
}
}
func TestUpdateValidator_InvalidMinHealthyTime(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
MinHealthyTime: "not-a-duration",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for invalid min_healthy_time")
}
if !strings.Contains(err.Error(), "min_healthy_time") {
t.Errorf("error = %q, want 'min_healthy_time'", err.Error())
}
}
func TestUpdateValidator_InvalidHealthyDeadline(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "rolling",
HealthyDeadline: "nope",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for invalid healthy_deadline")
}
if !strings.Contains(err.Error(), "healthy_deadline") {
t.Errorf("error = %q, want 'healthy_deadline'", err.Error())
}
}
func TestUpdateValidator_CanaryNegative(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "-1",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for negative canary count")
}
if !strings.Contains(err.Error(), "canary") {
t.Errorf("error = %q, want 'canary'", err.Error())
}
}
func TestUpdateValidator_CanaryExceedsCount(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "5",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for canary > count")
}
if !strings.Contains(err.Error(), "exceeds count") {
t.Errorf("error = %q, want 'exceeds count'", err.Error())
}
}
func TestUpdateValidator_CanaryPercentOver100(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "150%",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for canary > 100%")
}
if !strings.Contains(err.Error(), "out of range") {
t.Errorf("error = %q, want 'out of range'", err.Error())
}
}
func TestUpdateValidator_CanaryPercentNegative(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "-10%",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for negative canary percent")
}
if !strings.Contains(err.Error(), "out of range") {
t.Errorf("error = %q, want 'out of range'", err.Error())
}
}
func TestUpdateValidator_CanaryNotANumber(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "abc",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for non-numeric canary")
}
if !strings.Contains(err.Error(), "not a valid") {
t.Errorf("error = %q, want 'not a valid'", err.Error())
}
}
func TestUpdateValidator_CanaryPercentNotANumber(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "xx%",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error for non-numeric canary percent")
}
if !strings.Contains(err.Error(), "not a valid percentage") {
t.Errorf("error = %q, want 'not a valid percentage'", err.Error())
}
}
func TestUpdateValidator_CanaryCountZeroOK(t *testing.T) {
// canary=0 is the lower bound; valid.
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 4,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "0",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("canary=0 should be valid, got %v", err)
}
}
func TestUpdateValidator_CanaryPercentWithoutCountOK(t *testing.T) {
// A percentage canary does not depend on count; valid even when
// count is 0 (e.g. DaemonSet-shaped spec bypassing the Service
// validator — defensive).
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 0,
Update: &jobspec.UpdateBlock{
Strategy: "canary",
Canary: "25%",
},
}
if err := (UpdateValidator{}).Validate(spec); err != nil {
t.Fatalf("percentage canary with count=0 should be valid, got %v", err)
}
}
func TestUpdateValidator_MultipleErrors(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Kind: "Service",
Name: "web",
Count: 2,
Update: &jobspec.UpdateBlock{
Strategy: "recreate",
MaxParallel: 99,
MinHealthyTime: "nope",
HealthyDeadline: "also-nope",
Canary: "200%",
},
}
err := (UpdateValidator{}).Validate(spec)
if err == nil {
t.Fatal("expected error, got nil")
}
for _, want := range []string{
"strategy",
"max_parallel",
"min_healthy_time",
"healthy_deadline",
"canary",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q missing %q", err.Error(), want)
}
}
}
// Compile-time assertion that UpdateValidator implements Validator.
var _ Validator = UpdateValidator{}