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---
238 lines
7.8 KiB
Go
238 lines
7.8 KiB
Go
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 "<spec.Name>-<index>" 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 ("<n>%"). 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
|
|
}
|