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---
226 lines
9.0 KiB
Go
226 lines
9.0 KiB
Go
package emitter
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
// TraefikEmitter is the Layer-4 emitter for the Traefik dynamic-config
|
|
// file (REQ-077). It renders /etc/traefik/dynamic/orca-<spec.Name>.yaml
|
|
// — a single Traefik dynamic-config file describing the routers,
|
|
// services (servers = the R-007 socket paths), TLS config pointing at
|
|
// the step-ca root CA, and the service health check.
|
|
//
|
|
// Registered on the emitter.Registry under the service-kind keys:
|
|
//
|
|
// - service:process
|
|
// - service:podman
|
|
// - service:wasm
|
|
//
|
|
// RegisterTraefik wires all three; callers can also call Register
|
|
// directly with TraefikEmitter{} for a single runtime.
|
|
//
|
|
// Atomic reload (gate C-10): the Traefik dynamic-config file is written
|
|
// atomically via the SSH-push transport (sshpush.WriteFileIdempotent
|
|
// performs temp-file + fsync + rename, and WriteTraefikDynamic wraps
|
|
// it with an explicit tmp+mv so fsnotify sees a single rename event).
|
|
// Traefik watches the dynamic dir with fsnotify; the rename triggers a
|
|
// reload. On a malformed config Traefik logs an error and holds the
|
|
// last-good config (documented Traefik behavior; the C-10 test
|
|
// verifies the tmp+rename sequence so a half-written file is never
|
|
// observed by Traefik). Drain is rendered by setting the backend
|
|
// server's weight to 0 (or removing it) — see RenderDrain.
|
|
//
|
|
// The orca-v1- prefix is NOT applied to Traefik dynamic-config paths
|
|
// (the prefix is only for systemd unit names; the Traefik file is named
|
|
// orca-<spec.Name>.yaml and is the single source of truth for the
|
|
// service route — there is no dual-write window for Traefik configs).
|
|
type TraefikEmitter struct{}
|
|
|
|
// traefikDynamicDir is the canonical Traefik dynamic-config directory
|
|
// (R-006). The emitter writes one file per service at
|
|
// /etc/traefik/dynamic/orca-<spec.Name>.yaml.
|
|
const traefikDynamicDir = "/etc/traefik/dynamic"
|
|
|
|
// traefikRouterTLSCertResolver is the Traefik cert-resolver name that
|
|
// the orca step-ca integration configures on the Traefik static config
|
|
// (P10 / v0.10 wires the step-ca root into this resolver). The
|
|
// dynamic-config file references it by name.
|
|
const traefikRouterTLSCertResolver = "orca"
|
|
|
|
// defaultTrustDomain is the SPIFFE trust domain used in the rendered
|
|
// TLS stanza when the spec does not carry an explicit trust domain.
|
|
// The step-ca provisioner (P10) overrides this at render time via the
|
|
// node argument; for P02 the emitter renders the placeholder.
|
|
const defaultTrustDomain = "cluster.orca.local"
|
|
|
|
// Render renders the Traefik dynamic-config YAML for a Service
|
|
// workload. The output is a single File whose Path is
|
|
// /etc/traefik/dynamic/orca-<spec.Name>.yaml, Content is the rendered
|
|
// YAML, and Mode is 0644.
|
|
//
|
|
// Returns an error if the spec is nil, the name is empty, the spec has
|
|
// no ports (a Service with no ports has no backends to route to), or a
|
|
// service.bind value (when present) is not a valid IP address (R-007).
|
|
func (TraefikEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
|
if spec == nil {
|
|
return nil, errors.New("emitter/traefik: spec is nil")
|
|
}
|
|
if strings.TrimSpace(spec.Name) == "" {
|
|
return nil, errors.New("emitter/traefik: spec name is empty")
|
|
}
|
|
if len(spec.Ports) == 0 {
|
|
return nil, errors.New("emitter/traefik: service has no ports (no backends to route to)")
|
|
}
|
|
if spec.Service != nil {
|
|
if b := strings.TrimSpace(spec.Service.Bind); b != "" && net.ParseIP(b) == nil {
|
|
return nil, fmt.Errorf("emitter/traefik: service.bind %q is not a valid IP (R-007)", b)
|
|
}
|
|
}
|
|
content, err := renderTraefikYAML(spec, node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
|
|
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
|
}
|
|
|
|
// RenderDrain renders a Traefik dynamic-config that drains the service
|
|
// by setting every backend server's weight to 0 (I-B-005 drain). The
|
|
// path matches the live config so the atomic rename overwrites the
|
|
// routing config with the drained config (Traefik reloads and stops
|
|
// sending traffic). The caller writes the result via
|
|
// WriteTraefikDynamic for the C-10 atomicity protocol.
|
|
func (e TraefikEmitter) RenderDrain(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
|
if spec == nil {
|
|
return nil, errors.New("emitter/traefik: spec is nil")
|
|
}
|
|
if strings.TrimSpace(spec.Name) == "" {
|
|
return nil, errors.New("emitter/traefik: spec name is empty")
|
|
}
|
|
if len(spec.Ports) == 0 {
|
|
return nil, errors.New("emitter/traefik: service has no ports (no backends to drain)")
|
|
}
|
|
content, err := renderTraefikYAMLDrain(spec, node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
|
|
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
|
}
|
|
|
|
// RegisterTraefik registers the TraefikEmitter on the given Registry
|
|
// under the three service-kind runtime keys (service:process,
|
|
// service:podman, service:wasm). The emitter is the same instance for
|
|
// all three runtimes — the rendered Traefik config is runtime-agnostic
|
|
// (the backend server URL is the R-007 socket path, which the runtime
|
|
// layer binds regardless of process/wasm/podman).
|
|
func RegisterTraefik(reg *Registry) {
|
|
e := TraefikEmitter{}
|
|
reg.Register("service:process", e)
|
|
reg.Register("service:podman", e)
|
|
reg.Register("service:wasm", e)
|
|
}
|
|
|
|
// renderTraefikYAML renders the Traefik dynamic-config YAML for the
|
|
// given spec + node. The shape (verified by the Traefik docs) is:
|
|
//
|
|
// http:
|
|
// routers:
|
|
// orca-<name>:
|
|
// rule: PathPrefix("/<name>")
|
|
// service: orca-<name>
|
|
// tls:
|
|
// certResolver: orca
|
|
// domains:
|
|
// - main: "<trust-domain>"
|
|
// services:
|
|
// orca-<name>:
|
|
// loadBalancer:
|
|
// servers:
|
|
// - url: "unix:///run/orca/alloc-<allocID>/port-<portName>.sock"
|
|
// healthCheck:
|
|
// path: /healthz
|
|
// interval: <interval>
|
|
// timeout: <timeout>
|
|
//
|
|
// The alloc-id placeholder is "<allocID>" pending the P08 socket
|
|
// layer; Traefik will reject the URL until a real alloc-id is
|
|
// substituted. For P02 the emitter renders the placeholder so the
|
|
// C-10 atomicity protocol is testable end-to-end; the socket layer
|
|
// (P08) replaces the placeholder with the live alloc-id.
|
|
func renderTraefikYAML(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
|
|
return renderTraefikYAMLWeighted(spec, node, false)
|
|
}
|
|
|
|
// renderTraefikYAMLDrain renders the drained Traefik dynamic-config
|
|
// (every backend server has weight: 0). The shape mirrors the live
|
|
// config so the rename overwrites the live route with the drain.
|
|
func renderTraefikYAMLDrain(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
|
|
return renderTraefikYAMLWeighted(spec, node, true)
|
|
}
|
|
|
|
// renderTraefikYAMLWeighted renders the Traefik dynamic-config YAML.
|
|
// When drain is true, every server entry is emitted with `weight: 0`
|
|
// (I-B-005). When drain is false, no weight is emitted (Traefik
|
|
// defaults to 1 — equal weighting across servers).
|
|
func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain bool) (string, error) {
|
|
var b strings.Builder
|
|
routerName := "orca-" + spec.Name
|
|
serviceName := "orca-" + spec.Name
|
|
rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
|
|
trustDomain := defaultTrustDomain
|
|
|
|
b.WriteString("http:\n")
|
|
b.WriteString(" routers:\n")
|
|
b.WriteString(fmt.Sprintf(" %s:\n", routerName))
|
|
b.WriteString(fmt.Sprintf(" rule: %s\n", rule))
|
|
b.WriteString(fmt.Sprintf(" service: %s\n", serviceName))
|
|
b.WriteString(" tls:\n")
|
|
b.WriteString(fmt.Sprintf(" certResolver: %s\n", traefikRouterTLSCertResolver))
|
|
b.WriteString(" domains:\n")
|
|
b.WriteString(fmt.Sprintf(" - main: %q\n", trustDomain))
|
|
b.WriteString(" services:\n")
|
|
b.WriteString(fmt.Sprintf(" %s:\n", serviceName))
|
|
b.WriteString(" loadBalancer:\n")
|
|
b.WriteString(" servers:\n")
|
|
allocID := allocIDFor(node)
|
|
for _, p := range spec.Ports {
|
|
sock := fmt.Sprintf("unix:///run/orca/alloc-%s/port-%s.sock", allocID, p.Name)
|
|
b.WriteString(" - url: ")
|
|
b.WriteString(fmt.Sprintf("%q\n", sock))
|
|
if drain {
|
|
b.WriteString(" weight: 0\n")
|
|
}
|
|
}
|
|
if spec.Health != nil {
|
|
b.WriteString(" healthCheck:\n")
|
|
path := "/healthz"
|
|
b.WriteString(fmt.Sprintf(" path: %s\n", path))
|
|
if spec.Health.Interval != "" {
|
|
b.WriteString(fmt.Sprintf(" interval: %s\n", spec.Health.Interval))
|
|
}
|
|
if spec.Health.Timeout != "" {
|
|
b.WriteString(fmt.Sprintf(" timeout: %s\n", spec.Health.Timeout))
|
|
}
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// allocIDFor returns the alloc-id placeholder for the node. P08 will
|
|
// substitute the live alloc-id from the socket layer; for P02 we use a
|
|
// deterministic placeholder derived from the node hostname so the
|
|
// rendered config is stable across re-renders (the C-10 idempotency
|
|
// check depends on a stable hash). When the node is nil or has no
|
|
// hostname, the literal placeholder "<allocID>" is emitted.
|
|
func allocIDFor(node *Node) string {
|
|
if node == nil || strings.TrimSpace(node.Hostname) == "" {
|
|
return "<allocID>"
|
|
}
|
|
return node.Hostname
|
|
}
|