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