// 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) // - task group (spec.Tasks) optional; when present, each task must // have a unique name and a resolvable command (P06). 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 (unless a task group is present; each task // can carry its own runtime — P06) // - 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) // - task group (spec.Tasks) optional; when present, each task must // have a unique name and a resolvable command (P06). 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 // - task group (spec.Tasks) optional; when present, each task must // have a unique name and a resolvable command (P06). 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)") } errs = append(errs, validateTaskGroup(spec)...) 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 && len(spec.Tasks) == 0 { errs = append(errs, "runtime block required for Service (or a task group with per-task runtimes)") } 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()) } } errs = append(errs, validateTaskGroup(spec)...) 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") } errs = append(errs, validateTaskGroup(spec)...) 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, "; ")) } // validateTaskGroup validates the task-group list shared by all kinds // (P06, PRD §9.1). When the spec carries a task group (spec.Tasks // non-empty), each task must have a unique name and a resolvable // command (the task's own Command, the task's runtime command, or the // top-level runtime command as the per-group default). The top-level // runtime is optional when tasks is present (each task can carry its // own runtime). Returns nil when the spec has no task group. func validateTaskGroup(spec *jobspec.WorkloadSpec) []string { if len(spec.Tasks) == 0 { return nil } var errs []string seen := make(map[string]bool, len(spec.Tasks)) for i, task := range spec.Tasks { if strings.TrimSpace(task.Name) == "" { errs = append(errs, fmt.Sprintf("tasks[%d]: name is required", i)) } else if seen[task.Name] { errs = append(errs, fmt.Sprintf("tasks[%d]: duplicate task name %q (names must be unique within the group)", i, task.Name)) } else { seen[task.Name] = true } cmd := task.Command if strings.TrimSpace(cmd) == "" && task.Runtime != nil { cmd = task.Runtime.Command } if strings.TrimSpace(cmd) == "" && spec.Runtime != nil { cmd = spec.Runtime.Command } if strings.TrimSpace(cmd) == "" { errs = append(errs, fmt.Sprintf("tasks[%d]: command is required (set tasks[].command, tasks[].runtime.command, or top-level runtime.command)", i)) } } return errs }