Files
orca/internal/spec/schema/schema.go
T
Jon Chery 60b0357eb6 feat(P0c): Job/Service/DaemonSet schemas + emitter interface + systemd stub (REQ-074)
P0c — Kind-specific schema validators + Layer 4 emitter interface.

Schemas (internal/spec/schema/schema.go, REQ-074):
- Validator interface with JobValidator, ServiceValidator, DaemonSetValidator.
  JobValidator: count=1, no service block, optional schedule/timeout.
  ServiceValidator: ports required, count>=1, restart+update+runtime required.
  DaemonSetValidator: schedule mode required, no ports (D-175), no count.
  ValidatorFor(kind) dispatcher. 96.2% coverage.

Emitter interface (internal/emitter/emitter.go, REQ-074, I-B-002):
- File{Path,Content,Mode}, Emitter interface { Render(spec,node) []File },
  Registry keyed by kind:runtime, Register + Render lookup. 100% coverage.

Systemd stub (internal/emitter/systemd.go):
- SystemdEmitter for process runtime. Renders minimal [Service] unit at
  /etc/systemd/system/orca-v1-alloc-<name>.service (orca-v1- prefix per
  dual-write window REQ-090 — no overlap with v0.8 daemon's orca-<job>).

Flock test fix: TestFlock_concurrentBlocks rewritten to use non-blocking
tryFlockEx (LOCK_NB) instead of a leaked blocking goroutine. Eliminates
the temp-dir cleanup race.

20 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.

---ci---
project: orca
phase: P0c
milestone: v0.9
status: execute
---/ci---
2026-08-05 17:17:02 +00:00

165 lines
5.6 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"
"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
// - runtime required
// - 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 if spec.Restart.Mode != "service" {
errs = append(errs, fmt.Sprintf("restart mode must be %q for Service, got %q", "service", spec.Restart.Mode))
}
if spec.Update == nil {
errs = append(errs, "update block required for Service")
}
if spec.Runtime == nil {
errs = append(errs, "runtime block required for Service")
}
return composeErrors("schema/Service", errs)
}
// 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, "; "))
}