ae6eb5a27b
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---
465 lines
11 KiB
Go
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{}
|