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 "%" 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 // ("%" 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 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 \"%%\")", 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 \"%%\")", 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 }