Files
orca/internal/spec/schema/update.go
T
Jon Chery ae6eb5a27b feat(P03,P04,P08): update stanza + lifecycle hooks + socket plumbing
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---
2026-08-05 17:55:11 +00:00

127 lines
4.6 KiB
Go

package schema
import (
"fmt"
"strconv"
"strings"
"time"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
)
// UpdateValidator validates the rolling/canary/blue-green update stanza
// (P03). The schema validator (ServiceValidator) already enforces that
// the strategy is one of rolling/canary/blue-green and that the update
// block is present for a Service. UpdateValidator adds the
// field-level validation:
//
// - max_parallel: integer in [1, count] (defaults to 1 when unset)
// - min_healthy_time: a valid time.Duration when set (time.ParseDuration)
// - healthy_deadline: a valid time.Duration when set (time.ParseDuration)
// - canary: an integer count in [0, count] OR a percentage string of
// the form "<n>%" where n is in [0, 100] (the parser already accepts
// both shapes; the validator accepts them too). Only meaningful
// for the canary strategy; ignored (but still validated for shape)
// for rolling/blue-green.
// - auto_promote: boolean (no validation beyond the parser's
// true/false parse; the field is always populated)
//
// The validator is pure (no I/O). Violations return a clear error
// listing every problem found, mirroring the per-field style of
// ServiceValidator.
type UpdateValidator struct{}
// Validate validates the UpdateBlock on the given spec. The spec must
// be non-nil and carry a Count (services have count ≥ 1 per
// ServiceValidator). When spec.Update is nil the validator returns an
// error (the update block is required for Service; this validator
// assumes the caller has already established the spec is a Service).
func (UpdateValidator) Validate(spec *jobspec.WorkloadSpec) error {
if spec == nil {
return fmt.Errorf("schema/Update: spec is nil")
}
if spec.Update == nil {
return fmt.Errorf("schema/Update: update block is nil")
}
var errs []string
u := spec.Update
switch u.Strategy {
case "rolling", "canary", "blue-green":
case "":
errs = append(errs, "update strategy required (one of rolling, canary, blue-green)")
default:
errs = append(errs, fmt.Sprintf("update strategy %q invalid (want one of rolling, canary, blue-green)", u.Strategy))
}
// max_parallel defaults to 1 when unset (0); validate the range
// only when the user has set it explicitly.
if u.MaxParallel != 0 {
if u.MaxParallel < 1 {
errs = append(errs, fmt.Sprintf("update.max_parallel must be ≥ 1, got %d", u.MaxParallel))
}
if spec.Count > 0 && u.MaxParallel > spec.Count {
errs = append(errs, fmt.Sprintf("update.max_parallel %d exceeds count %d (must be 1..count)", u.MaxParallel, spec.Count))
}
}
if u.MinHealthyTime != "" {
if _, err := time.ParseDuration(u.MinHealthyTime); err != nil {
errs = append(errs, fmt.Sprintf("update.min_healthy_time %q is not a valid duration: %v", u.MinHealthyTime, err))
}
}
if u.HealthyDeadline != "" {
if _, err := time.ParseDuration(u.HealthyDeadline); err != nil {
errs = append(errs, fmt.Sprintf("update.healthy_deadline %q is not a valid duration: %v", u.HealthyDeadline, err))
}
}
// canary accepts an integer count (0..count) or a percentage
// ("<n>%" with n in 0..100). The field is only meaningful for the
// canary strategy but we validate the shape regardless so a typo
// in a rolling/blue-green stanza still surfaces.
if u.Canary != "" {
if err := validateCanary(u.Canary, spec.Count); err != nil {
errs = append(errs, err.Error())
}
}
// auto_promote is a bool; no extra validation beyond the parser.
return composeErrors("schema/Update", errs)
}
// validateCanary validates the canary field shape: either an integer
// count (0..count) or a percentage string "<n>%" (n in 0..100). count
// is the spec.Count; when count is 0 (e.g. a DaemonSet or unset), the
// integer-count upper bound is not enforced (only the percentage
// bound is enforced, since percentage does not depend on count).
func validateCanary(canary string, count int) error {
c := strings.TrimSpace(canary)
if c == "" {
return nil
}
if strings.HasSuffix(c, "%") {
nStr := strings.TrimSuffix(c, "%")
n, err := strconv.Atoi(strings.TrimSpace(nStr))
if err != nil {
return fmt.Errorf("update.canary %q is not a valid percentage (want \"<n>%%\")", canary)
}
if n < 0 || n > 100 {
return fmt.Errorf("update.canary percentage %d out of range (want 0..100)", n)
}
return nil
}
n, err := strconv.Atoi(c)
if err != nil {
return fmt.Errorf("update.canary %q is not a valid count or percentage (want integer or \"<n>%%\")", canary)
}
if n < 0 {
return fmt.Errorf("update.canary count %d must be ≥ 0", n)
}
if count > 0 && n > count {
return fmt.Errorf("update.canary count %d exceeds count %d (must be 0..count)", n, count)
}
return nil
}