package emitter import ( "fmt" "strconv" "strings" "git.cloudinit.dev/coreci/orca/internal/jobspec" ) // UpdatePlan is the computed update sequence for a Service (P03). It is // a PLAN, not an execution — the transactional execution lands in // v0.10-P10. Each step describes a discrete action the executor takes: // start a set of allocs (Action="start"), wait for them to become // healthy (WaitForHealthy=true), or cutover from old to new // (Action="cutover" for blue-green). The Allocs field carries // placeholder alloc names of the form "-" where // index is 1-based (the scheduler assigns the real alloc-id at submit // time; P03 uses spec.Name as a placeholder per the socket layer // contract — see SocketEmitter). type UpdatePlan struct { Steps []UpdateStep } // UpdateStep is a single step in an UpdatePlan. Action is one of // "start", "wait", "cutover", "promote". Allocs is the list of // placeholder alloc names the step applies to. WaitForHealthy is true // when the executor must wait for the allocs in this step to pass // their health check before proceeding to the next step (driven by // min_healthy_time / healthy_deadline on the spec, which the executor // — not the plan — enforces). type UpdateStep struct { Action string Allocs []string WaitForHealthy bool } // maxParallelFor returns the effective max_parallel for the spec, // defaulting to 1 when unset (0) and clamping to count (the validator // already rejects out-of-range values; this is a defensive clamp for // direct callers that bypass the validator). func maxParallelFor(spec *jobspec.WorkloadSpec) int { if spec.Update == nil { return 1 } if spec.Update.MaxParallel < 1 { return 1 } if spec.Count > 0 && spec.Update.MaxParallel > spec.Count { return spec.Count } return spec.Update.MaxParallel } // allocName returns the placeholder alloc name for index i (1-based). // The real alloc-id is assigned by the scheduler at submit time; P03 // uses spec.Name as the placeholder per the socket-layer contract. func allocName(spec *jobspec.WorkloadSpec, i int) string { return fmt.Sprintf("%s-%d", spec.Name, i) } // allAllocs returns the placeholder alloc names for the full count of // the spec (1..count). func allAllocs(spec *jobspec.WorkloadSpec) []string { out := make([]string, 0, spec.Count) for i := 1; i <= spec.Count; i++ { out = append(out, allocName(spec, i)) } return out } // canaryCount returns the integer canary count for the spec. The // canary field accepts an integer count or a percentage ("%"). For // a percentage, the count is ceil(count * n / 100) with a minimum of 1 // when n > 0 (a 10% canary of a 3-replica service is 1 alloc, not 0). // When the canary field is empty, the default is 1 (a single canary // alloc — the smallest meaningful canary). func canaryCount(spec *jobspec.WorkloadSpec) int { if spec.Update == nil { return 1 } c := strings.TrimSpace(spec.Update.Canary) if c == "" { return 1 } if strings.HasSuffix(c, "%") { n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(c, "%"))) if err != nil || n <= 0 { return 1 } allocs := spec.Count * n / 100 if allocs < 1 { allocs = 1 } return allocs } n, err := strconv.Atoi(c) if err != nil || n < 1 { return 1 } if spec.Count > 0 && n > spec.Count { return spec.Count } return n } // RenderUpdatePlan computes the rolling/canary/blue-green update // sequence for a Service spec. Returns an *UpdatePlan describing the // steps; the actual transactional execution lands in v0.10-P10. // // The three strategies: // // - rolling: allocs are started in batches of max_parallel. Each // batch waits for healthy before the next batch starts. This is // the simplest strategy and the default for stateless services. // // - canary: a single canary alloc (or N per the canary field) is // started first and waits for healthy. After the canary is // healthy, the plan emits a "promote" step (manual or auto per // auto_promote); the remaining allocs are then started in // max_parallel batches. // // - blue-green: all new allocs are started in parallel (a single // "start" step with the full count). After they are healthy, a // "cutover" step swaps traffic from the old allocs to the new // ones. The old allocs are then stopped (the stop is implicit in // the cutover step for the plan; v0.10-P10 makes it explicit). // // Returns an error if the spec is nil, the update block is nil, or // the strategy is unknown (the validator should have caught these, // but RenderUpdatePlan is defensive — emitters are called from // render paths that may bypass the schema validator). func RenderUpdatePlan(spec *jobspec.WorkloadSpec) (*UpdatePlan, error) { if spec == nil { return nil, fmt.Errorf("emitter/update: spec is nil") } if spec.Update == nil { return nil, fmt.Errorf("emitter/update: update block is nil") } if spec.Count < 1 { return nil, fmt.Errorf("emitter/update: count must be ≥ 1, got %d", spec.Count) } switch spec.Update.Strategy { case "rolling": return renderRollingPlan(spec), nil case "canary": return renderCanaryPlan(spec), nil case "blue-green": return renderBlueGreenPlan(spec), nil default: return nil, fmt.Errorf("emitter/update: unknown strategy %q (want rolling, canary, or blue-green)", spec.Update.Strategy) } } // renderRollingPlan emits the rolling-update plan: allocs in batches // of max_parallel, each batch waiting for healthy before the next. func renderRollingPlan(spec *jobspec.WorkloadSpec) *UpdatePlan { plan := &UpdatePlan{} batch := maxParallelFor(spec) allocs := allAllocs(spec) for i := 0; i < len(allocs); i += batch { end := i + batch if end > len(allocs) { end = len(allocs) } plan.Steps = append(plan.Steps, UpdateStep{ Action: "start", Allocs: allocs[i:end], WaitForHealthy: true, }) } return plan } // renderCanaryPlan emits the canary-update plan: a canary batch first // (size per the canary field, default 1), a "promote" step, then the // remaining allocs in max_parallel batches. func renderCanaryPlan(spec *jobspec.WorkloadSpec) *UpdatePlan { plan := &UpdatePlan{} allocs := allAllocs(spec) canary := canaryCount(spec) if canary > len(allocs) { canary = len(allocs) } if canary < 1 { canary = 1 } // Step 1: start the canary alloc(s) and wait for healthy. plan.Steps = append(plan.Steps, UpdateStep{ Action: "start", Allocs: allocs[:canary], WaitForHealthy: true, }) // Step 2: promote (manual or auto per auto_promote). plan.Steps = append(plan.Steps, UpdateStep{ Action: "promote", Allocs: allocs[:canary], }) // Step 3+: remaining allocs in max_parallel batches. batch := maxParallelFor(spec) remaining := allocs[canary:] for i := 0; i < len(remaining); i += batch { end := i + batch if end > len(remaining) { end = len(remaining) } plan.Steps = append(plan.Steps, UpdateStep{ Action: "start", Allocs: remaining[i:end], WaitForHealthy: true, }) } return plan } // renderBlueGreenPlan emits the blue-green update plan: all new allocs // start in parallel, wait for healthy, then cutover (swap traffic). func renderBlueGreenPlan(spec *jobspec.WorkloadSpec) *UpdatePlan { plan := &UpdatePlan{} allocs := allAllocs(spec) // Step 1: start ALL new allocs in parallel (blue-green does not // batch — the new fleet stands up alongside the old). plan.Steps = append(plan.Steps, UpdateStep{ Action: "start", Allocs: allocs, WaitForHealthy: true, }) // Step 2: cutover — swap traffic from old to new. The old allocs // are stopped implicitly as part of the cutover (v0.10-P10 makes // the stop explicit in the transactional plane). plan.Steps = append(plan.Steps, UpdateStep{ Action: "cutover", Allocs: allocs, WaitForHealthy: false, }) return plan }