436641782c
P02 — Traefik dynamic config generation + atomic reload protocol. Parser (internal/jobspec/markdown.go): - Extended WorkloadSpec with Health, Constraints, Affinity, Lifecycle fields. Parsed restart/update/service/health/lifecycle/affinity/ constraints blocks. HealthBlock, AffinityRule, LifecycleBlock types. Schema (internal/spec/schema/schema.go): - ServiceValidator: restart.mode enum (service/on-failure/never), update.strategy enum (rolling/canary/blue-green), health required, service.bind IP validation (R-007 loopback opt-in). 98.5% coverage. Traefik emitter (internal/emitter/traefik.go, REQ-077): - TraefikEmitter renders /etc/traefik/dynamic/orca-<name>.yaml with http.routers, http.services (servers = R-007 socket paths), TLS (certResolver=orca, trust domain), healthCheck. RenderDrain sets weight:0 per backend. RegisterTraefik wires process/podman/wasm. Atomic reload (internal/emitter/traefik_atomic.go, gate C-10): - WriteTraefikDynamic: write to path.tmp via WriteFileIdempotent, then mv -f path.tmp path (atomic POSIX rename, Traefik fsnotify observes IN_MOVED_TO). Traefik holds-last-good on malformed config. C-10 PASS. 22 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. Coverage: emitter 96.5%, jobspec 88.8%, schema 98.5%, sshpush 93.0%. ---ci--- project: orca phase: P02 milestone: v0.9 status: execute ---/ci---
210 lines
7.4 KiB
Go
210 lines
7.4 KiB
Go
// Package schema provides kind-specific validators for the unified
|
|
// *jobspec.WorkloadSpec introduced in P0b (REQ-064). Each workload kind
|
|
// (Job, Service, DaemonSet per R-012) has different required fields;
|
|
// this package exposes a Validator interface and a ValidatorFor
|
|
// dispatcher so the emitter layer (REQ-074) and the lint engine
|
|
// (REQ-084) can reject invalid specs before rendering.
|
|
//
|
|
// The validators operate purely on the *WorkloadSpec shape; they do no
|
|
// I/O. Required-field violations return a structured error listing
|
|
// every problem found (missing required fields, invalid combinations).
|
|
package schema
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
// Validator validates a *jobspec.WorkloadSpec against a kind-specific
|
|
// schema. Implementations are pure (no I/O) and return a clear error
|
|
// listing every violation found.
|
|
type Validator interface {
|
|
Validate(spec *jobspec.WorkloadSpec) error
|
|
}
|
|
|
|
// JobValidator validates the Job workload kind (R-012).
|
|
//
|
|
// Rules:
|
|
// - no service block required (Job has no Traefik route by default D-175)
|
|
// - restart optional (defaults to never/on-failure when omitted)
|
|
// - schedule optional (cron string)
|
|
// - timeout optional
|
|
// - ports optional
|
|
// - count must be 1 (or unset → 1); count > 1 is an error for Job
|
|
// (use a Service for replicas)
|
|
// - no Traefik route (a ServiceBlock is rejected)
|
|
type JobValidator struct{}
|
|
|
|
// ServiceValidator validates the Service workload kind (R-012).
|
|
//
|
|
// Rules:
|
|
// - ports required (at least one)
|
|
// - count ≥ 1
|
|
// - restart required (mode must be service)
|
|
// - update required (strategy must be rolling/canary/blue-green)
|
|
// - runtime required
|
|
// - health block required (Traefik routing depends on health checks)
|
|
// - service block, if present, must have a valid bind (127.0.0.1
|
|
// opt-in per R-007; default is socket — empty bind is OK)
|
|
// - service block implied (Traefik route YES)
|
|
type ServiceValidator struct{}
|
|
|
|
// DaemonSetValidator validates the DaemonSet workload kind (R-012).
|
|
//
|
|
// Rules:
|
|
// - schedule block with mode (every-node/matching/mandatory) required
|
|
// - no ports (no Traefik route by default D-175)
|
|
// - no count (implicit = nodes matching condition)
|
|
// - restart required
|
|
type DaemonSetValidator struct{}
|
|
|
|
// ValidatorFor returns the Validator for the given workload kind, or an
|
|
// error for an unknown kind. kind must be one of Job, Service,
|
|
// DaemonSet (R-012).
|
|
func ValidatorFor(kind string) (Validator, error) {
|
|
switch kind {
|
|
case "Job":
|
|
return JobValidator{}, nil
|
|
case "Service":
|
|
return ServiceValidator{}, nil
|
|
case "DaemonSet":
|
|
return DaemonSetValidator{}, nil
|
|
default:
|
|
return nil, fmt.Errorf("schema: unknown kind %q (want one of Job, Service, DaemonSet)", kind)
|
|
}
|
|
}
|
|
|
|
// Validate validates a Job spec. See JobValidator for the rules.
|
|
func (JobValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
|
if spec == nil {
|
|
return errors.New("schema/Job: spec is nil")
|
|
}
|
|
var errs []string
|
|
if strings.TrimSpace(spec.Name) == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if spec.Count != 0 && spec.Count != 1 {
|
|
errs = append(errs, fmt.Sprintf("count must be 1 (or unset) for Job, got %d (use Service for replicas)", spec.Count))
|
|
}
|
|
if spec.Service != nil {
|
|
errs = append(errs, "service block (Traefik route) is not allowed for Job (D-175)")
|
|
}
|
|
return composeErrors("schema/Job", errs)
|
|
}
|
|
|
|
// Validate validates a Service spec. See ServiceValidator for the rules.
|
|
func (ServiceValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
|
if spec == nil {
|
|
return errors.New("schema/Service: spec is nil")
|
|
}
|
|
var errs []string
|
|
if strings.TrimSpace(spec.Name) == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if len(spec.Ports) == 0 {
|
|
errs = append(errs, "ports required (at least one)")
|
|
}
|
|
if spec.Count < 1 {
|
|
errs = append(errs, fmt.Sprintf("count must be ≥ 1 for Service, got %d", spec.Count))
|
|
}
|
|
if spec.Restart == nil {
|
|
errs = append(errs, "restart block required for Service")
|
|
} else {
|
|
switch spec.Restart.Mode {
|
|
case "service", "on-failure", "never":
|
|
// Valid per R-012 (default for Service is "service",
|
|
// but the validator accepts the full enum; the
|
|
// Service-specific "must be service" rule is enforced
|
|
// below for the default case where mode is empty).
|
|
case "":
|
|
errs = append(errs, "restart mode required for Service (one of service, on-failure, never; default is service)")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("restart mode %q invalid (want one of service, on-failure, never)", spec.Restart.Mode))
|
|
}
|
|
}
|
|
if spec.Update == nil {
|
|
errs = append(errs, "update block required for Service")
|
|
} else {
|
|
switch spec.Update.Strategy {
|
|
case "rolling", "canary", "blue-green":
|
|
case "":
|
|
errs = append(errs, "update strategy required for Service (one of rolling, canary, blue-green)")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("update strategy %q invalid (want one of rolling, canary, blue-green)", spec.Update.Strategy))
|
|
}
|
|
}
|
|
if spec.Runtime == nil {
|
|
errs = append(errs, "runtime block required for Service")
|
|
}
|
|
if spec.Health == nil {
|
|
errs = append(errs, "health block required for Service (Traefik routing requires health checks)")
|
|
}
|
|
if spec.Service != nil {
|
|
if err := validateServiceBind(spec.Service.Bind); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
}
|
|
return composeErrors("schema/Service", errs)
|
|
}
|
|
|
|
// validateServiceBind validates the service.bind field (R-007). Empty
|
|
// is OK (default = socket). When set, it must be a valid IPv4/IPv6
|
|
// address (the only opt-in to bind on a non-loopback address); the
|
|
// loopback 127.0.0.1 is the documented opt-in. Anything that is not
|
|
// parseable as an IP address is rejected.
|
|
func validateServiceBind(bind string) error {
|
|
if strings.TrimSpace(bind) == "" {
|
|
return nil
|
|
}
|
|
if net.ParseIP(bind) == nil {
|
|
return fmt.Errorf("service.bind %q is not a valid IP address (R-007: 127.0.0.1 opt-in; default is socket)", bind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate validates a DaemonSet spec. See DaemonSetValidator for the rules.
|
|
func (DaemonSetValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
|
if spec == nil {
|
|
return errors.New("schema/DaemonSet: spec is nil")
|
|
}
|
|
var errs []string
|
|
if strings.TrimSpace(spec.Name) == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if spec.Schedule == nil {
|
|
errs = append(errs, "schedule block required for DaemonSet")
|
|
} else {
|
|
switch spec.Schedule.Mode {
|
|
case "every-node", "matching", "mandatory":
|
|
case "":
|
|
errs = append(errs, "schedule mode required for DaemonSet (one of every-node, matching, mandatory)")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("schedule mode %q invalid (want one of every-node, matching, mandatory)", spec.Schedule.Mode))
|
|
}
|
|
}
|
|
if len(spec.Ports) > 0 {
|
|
errs = append(errs, "ports not allowed for DaemonSet (no Traefik route by default D-175)")
|
|
}
|
|
if spec.Count != 0 {
|
|
errs = append(errs, fmt.Sprintf("count not allowed for DaemonSet (implicit = nodes matching condition), got %d", spec.Count))
|
|
}
|
|
if spec.Restart == nil {
|
|
errs = append(errs, "restart block required for DaemonSet")
|
|
}
|
|
return composeErrors("schema/DaemonSet", errs)
|
|
}
|
|
|
|
// composeErrors joins the per-field errors into a single error prefixed
|
|
// by the validator name. Returns nil when there are no errors so the
|
|
// caller can return the result directly.
|
|
func composeErrors(name string, errs []string) error {
|
|
if len(errs) == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s: %s", name, strings.Join(errs, "; "))
|
|
}
|