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---
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package emitter
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
func TestRenderUpdatePlan_RollingBatches(t *testing.T) {
|
|
// count=4, max_parallel=2 → 2 batches of 2.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 4,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "rolling",
|
|
MaxParallel: 2,
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
if len(plan.Steps) != 2 {
|
|
t.Fatalf("got %d steps, want 2", len(plan.Steps))
|
|
}
|
|
for i, s := range plan.Steps {
|
|
if s.Action != "start" {
|
|
t.Errorf("step %d action = %q, want start", i, s.Action)
|
|
}
|
|
if !s.WaitForHealthy {
|
|
t.Errorf("step %d WaitForHealthy = false, want true", i)
|
|
}
|
|
if len(s.Allocs) != 2 {
|
|
t.Errorf("step %d allocs = %d, want 2", i, len(s.Allocs))
|
|
}
|
|
}
|
|
if plan.Steps[0].Allocs[0] != "web-1" || plan.Steps[0].Allocs[1] != "web-2" {
|
|
t.Errorf("step 0 allocs = %v, want [web-1 web-2]", plan.Steps[0].Allocs)
|
|
}
|
|
if plan.Steps[1].Allocs[0] != "web-3" || plan.Steps[1].Allocs[1] != "web-4" {
|
|
t.Errorf("step 1 allocs = %v, want [web-3 web-4]", plan.Steps[1].Allocs)
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_RollingUnevenBatches(t *testing.T) {
|
|
// count=5, max_parallel=2 → 3 batches: 2, 2, 1.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 5,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "rolling",
|
|
MaxParallel: 2,
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
if len(plan.Steps) != 3 {
|
|
t.Fatalf("got %d steps, want 3", len(plan.Steps))
|
|
}
|
|
if len(plan.Steps[2].Allocs) != 1 {
|
|
t.Errorf("step 2 allocs = %d, want 1 (remainder)", len(plan.Steps[2].Allocs))
|
|
}
|
|
if plan.Steps[2].Allocs[0] != "web-5" {
|
|
t.Errorf("step 2 allocs = %v, want [web-5]", plan.Steps[2].Allocs)
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_RollingMaxParallelUnset(t *testing.T) {
|
|
// max_parallel unset (0) → default 1 → 4 batches of 1.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 4,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "rolling",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
if len(plan.Steps) != 4 {
|
|
t.Fatalf("got %d steps, want 4 (one per alloc, batch=1)", len(plan.Steps))
|
|
}
|
|
for _, s := range plan.Steps {
|
|
if len(s.Allocs) != 1 {
|
|
t.Errorf("allocs = %d, want 1", len(s.Allocs))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_CanaryDefaultOne(t *testing.T) {
|
|
// canary unset → default 1 canary alloc, then 3 in batches of 2.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 4,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "canary",
|
|
MaxParallel: 2,
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
// Step 0: canary (1 alloc). Step 1: promote. Steps 2..: remaining.
|
|
if plan.Steps[0].Action != "start" || len(plan.Steps[0].Allocs) != 1 {
|
|
t.Errorf("step 0 = %+v, want canary start with 1 alloc", plan.Steps[0])
|
|
}
|
|
if plan.Steps[0].Allocs[0] != "web-1" {
|
|
t.Errorf("canary alloc = %q, want web-1", plan.Steps[0].Allocs[0])
|
|
}
|
|
if !plan.Steps[0].WaitForHealthy {
|
|
t.Error("canary step should wait for healthy")
|
|
}
|
|
if plan.Steps[1].Action != "promote" {
|
|
t.Errorf("step 1 action = %q, want promote", plan.Steps[1].Action)
|
|
}
|
|
// Remaining: web-2, web-3, web-4 in batches of 2 → [web-2,web-3], [web-4].
|
|
if len(plan.Steps) != 4 {
|
|
t.Fatalf("got %d steps, want 4 (canary + promote + 2 batches)", len(plan.Steps))
|
|
}
|
|
if len(plan.Steps[2].Allocs) != 2 || plan.Steps[2].Allocs[0] != "web-2" {
|
|
t.Errorf("step 2 = %v, want [web-2 web-3]", plan.Steps[2].Allocs)
|
|
}
|
|
if len(plan.Steps[3].Allocs) != 1 || plan.Steps[3].Allocs[0] != "web-4" {
|
|
t.Errorf("step 3 = %v, want [web-4]", plan.Steps[3].Allocs)
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_CanaryPercent(t *testing.T) {
|
|
// count=10, canary=20% → 2 canary allocs, then 8 in batches of 3.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 10,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "canary",
|
|
MaxParallel: 3,
|
|
Canary: "20%",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
if len(plan.Steps[0].Allocs) != 2 {
|
|
t.Errorf("canary step allocs = %d, want 2 (20%% of 10)", len(plan.Steps[0].Allocs))
|
|
}
|
|
// Remaining 8 in batches of 3 → ceil(8/3)=3 batches.
|
|
// Steps: canary, promote, batch(3), batch(3), batch(2) = 5 steps.
|
|
if len(plan.Steps) != 5 {
|
|
t.Fatalf("got %d steps, want 5", len(plan.Steps))
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_CanaryIntegerCount(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 4,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "canary",
|
|
Canary: "2",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
if len(plan.Steps[0].Allocs) != 2 {
|
|
t.Errorf("canary step allocs = %d, want 2", len(plan.Steps[0].Allocs))
|
|
}
|
|
if plan.Steps[0].Allocs[0] != "web-1" || plan.Steps[0].Allocs[1] != "web-2" {
|
|
t.Errorf("canary allocs = %v, want [web-1 web-2]", plan.Steps[0].Allocs)
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_CanaryFullCount(t *testing.T) {
|
|
// canary == count → no remaining allocs after canary.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 3,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "canary",
|
|
Canary: "3",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
// Steps: canary start (3), promote. No remaining batches.
|
|
if len(plan.Steps) != 2 {
|
|
t.Fatalf("got %d steps, want 2 (canary + promote, no remainder)", len(plan.Steps))
|
|
}
|
|
if plan.Steps[1].Action != "promote" {
|
|
t.Errorf("step 1 action = %q, want promote", plan.Steps[1].Action)
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_BlueGreen(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 4,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "blue-green",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
// Step 0: start all 4 in parallel. Step 1: cutover.
|
|
if len(plan.Steps) != 2 {
|
|
t.Fatalf("got %d steps, want 2", len(plan.Steps))
|
|
}
|
|
if plan.Steps[0].Action != "start" {
|
|
t.Errorf("step 0 action = %q, want start", plan.Steps[0].Action)
|
|
}
|
|
if len(plan.Steps[0].Allocs) != 4 {
|
|
t.Errorf("step 0 allocs = %d, want 4 (all new in parallel)", len(plan.Steps[0].Allocs))
|
|
}
|
|
if !plan.Steps[0].WaitForHealthy {
|
|
t.Error("blue-green start step should wait for healthy")
|
|
}
|
|
if plan.Steps[1].Action != "cutover" {
|
|
t.Errorf("step 1 action = %q, want cutover", plan.Steps[1].Action)
|
|
}
|
|
if plan.Steps[1].WaitForHealthy {
|
|
t.Error("cutover step should NOT wait for healthy (already healthy)")
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_BlueGreenAllocs(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "api",
|
|
Count: 3,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "blue-green",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
want := []string{"api-1", "api-2", "api-3"}
|
|
if len(plan.Steps[0].Allocs) != 3 {
|
|
t.Errorf("allocs = %v, want %v", plan.Steps[0].Allocs, want)
|
|
}
|
|
for i, a := range want {
|
|
if plan.Steps[0].Allocs[i] != a {
|
|
t.Errorf("alloc[%d] = %q, want %q", i, plan.Steps[0].Allocs[i], a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_NilSpec(t *testing.T) {
|
|
_, err := RenderUpdatePlan(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for nil spec")
|
|
}
|
|
if !strings.Contains(err.Error(), "spec is nil") {
|
|
t.Errorf("error = %q, want 'spec is nil'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_NilUpdate(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{Kind: "Service", Name: "web", Count: 1}
|
|
_, err := RenderUpdatePlan(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 TestRenderUpdatePlan_CountZero(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 0,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "rolling",
|
|
},
|
|
}
|
|
_, err := RenderUpdatePlan(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for count 0")
|
|
}
|
|
if !strings.Contains(err.Error(), "count must be") {
|
|
t.Errorf("error = %q, want 'count must be'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_UnknownStrategy(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 2,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "recreate",
|
|
},
|
|
}
|
|
_, err := RenderUpdatePlan(spec)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown strategy")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown strategy") {
|
|
t.Errorf("error = %q, want 'unknown strategy'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_AllStrategies(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: 3,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: strat,
|
|
MaxParallel: 1,
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan(%s): %v", strat, err)
|
|
}
|
|
if len(plan.Steps) == 0 {
|
|
t.Errorf("strategy %s produced 0 steps", strat)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_PromoteStepCarriesCanaryAllocs(t *testing.T) {
|
|
// The promote step lists the canary allocs so the executor knows
|
|
// which allocs are being promoted from canary to stable.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 4,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "canary",
|
|
Canary: "2",
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
promote := plan.Steps[1]
|
|
if promote.Action != "promote" {
|
|
t.Fatalf("step 1 action = %q, want promote", promote.Action)
|
|
}
|
|
if len(promote.Allocs) != 2 {
|
|
t.Errorf("promote allocs = %d, want 2 (the canary allocs)", len(promote.Allocs))
|
|
}
|
|
}
|
|
|
|
func TestRenderUpdatePlan_MaxParallelClampedToCount(t *testing.T) {
|
|
// max_parallel > count is clamped to count (defensive; validator
|
|
// rejects this but the emitter is defensive against direct
|
|
// callers).
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Count: 2,
|
|
Update: &jobspec.UpdateBlock{
|
|
Strategy: "rolling",
|
|
MaxParallel: 99,
|
|
},
|
|
}
|
|
plan, err := RenderUpdatePlan(spec)
|
|
if err != nil {
|
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
|
}
|
|
// Clamped to 2 → single batch of 2.
|
|
if len(plan.Steps) != 1 {
|
|
t.Errorf("got %d steps, want 1 (clamped)", len(plan.Steps))
|
|
}
|
|
if len(plan.Steps[0].Allocs) != 2 {
|
|
t.Errorf("step 0 allocs = %d, want 2", len(plan.Steps[0].Allocs))
|
|
}
|
|
}
|