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---
354 lines
10 KiB
Go
354 lines
10 KiB
Go
package emitter
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
func TestTraefikEmitter_RenderBasic(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Health: &jobspec.HealthBlock{CheckType: "http", Interval: "5s", Timeout: "1s"},
|
|
}
|
|
node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
|
|
files, err := TraefikEmitter{}.Render(spec, node)
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("got %d files, want 1", len(files))
|
|
}
|
|
f := files[0]
|
|
wantPath := "/etc/traefik/dynamic/orca-web.yaml"
|
|
if f.Path != wantPath {
|
|
t.Errorf("Path = %q, want %q", f.Path, wantPath)
|
|
}
|
|
if f.Mode != "0644" {
|
|
t.Errorf("Mode = %q, want 0644", f.Mode)
|
|
}
|
|
c := f.Content
|
|
if !strings.Contains(c, "http:") {
|
|
t.Errorf("content missing 'http:'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "routers:") {
|
|
t.Errorf("content missing 'routers:'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "orca-web:") {
|
|
t.Errorf("content missing 'orca-web:' router/service key\n%s", c)
|
|
}
|
|
if !strings.Contains(c, `rule: PathPrefix("/web")`) {
|
|
t.Errorf("content missing PathPrefix rule\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "services:") {
|
|
t.Errorf("content missing 'services:'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "loadBalancer:") {
|
|
t.Errorf("content missing 'loadBalancer:'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "unix:///run/orca/alloc-node-1/port-http.sock") {
|
|
t.Errorf("content missing socket server URL\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "certResolver: orca") {
|
|
t.Errorf("content missing 'certResolver: orca'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "domains:") {
|
|
t.Errorf("content missing TLS domains\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "healthCheck:") {
|
|
t.Errorf("content missing 'healthCheck:'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "interval: 5s") {
|
|
t.Errorf("content missing 'interval: 5s'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "timeout: 1s") {
|
|
t.Errorf("content missing 'timeout: 1s'\n%s", c)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_RenderMultiplePorts(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "api",
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Ports: []jobspec.PortSpec{
|
|
{Name: "http", Port: 8080},
|
|
{Name: "grpc", Port: 9090},
|
|
},
|
|
}
|
|
node := &Node{Hostname: "n1"}
|
|
files, err := TraefikEmitter{}.Render(spec, node)
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
c := files[0].Content
|
|
if !strings.Contains(c, "port-http.sock") {
|
|
t.Errorf("missing http socket: %s", c)
|
|
}
|
|
if !strings.Contains(c, "port-grpc.sock") {
|
|
t.Errorf("missing grpc socket: %s", c)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_RenderDrain(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
node := &Node{Hostname: "n1"}
|
|
files, err := TraefikEmitter{}.RenderDrain(spec, node)
|
|
if err != nil {
|
|
t.Fatalf("RenderDrain: %v", err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("got %d files, want 1", len(files))
|
|
}
|
|
c := files[0].Content
|
|
if !strings.Contains(c, "weight: 0") {
|
|
t.Errorf("drain config missing 'weight: 0'\n%s", c)
|
|
}
|
|
if !strings.Contains(c, "unix:///run/orca/alloc-n1/port-http.sock") {
|
|
t.Errorf("drain config missing socket URL\n%s", c)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_RenderLiveHasNoWeightZero(t *testing.T) {
|
|
// Sanity: the live (non-drain) render must NOT emit `weight: 0`.
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
node := &Node{Hostname: "n1"}
|
|
files, err := TraefikEmitter{}.Render(spec, node)
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if strings.Contains(files[0].Content, "weight: 0") {
|
|
t.Errorf("live config should not contain 'weight: 0'\n%s", files[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_RenderNoHealthOmitsHealthCheck(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
node := &Node{Hostname: "n1"}
|
|
files, err := TraefikEmitter{}.Render(spec, node)
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if strings.Contains(files[0].Content, "healthCheck:") {
|
|
t.Errorf("config without Health should omit 'healthCheck:'\n%s", files[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_NilSpec(t *testing.T) {
|
|
_, err := TraefikEmitter{}.Render(nil, &Node{})
|
|
if err == nil {
|
|
t.Fatal("expected error for nil spec")
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_EmptyName(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: " ",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty name")
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_NoPorts(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
}
|
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing ports")
|
|
}
|
|
if !strings.Contains(err.Error(), "no ports") {
|
|
t.Errorf("error = %q, want 'no ports'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_NoPortsDrain(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
}
|
|
_, err := TraefikEmitter{}.RenderDrain(spec, &Node{})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing ports on drain")
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_InvalidBind(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
|
}
|
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid service.bind")
|
|
}
|
|
if !strings.Contains(err.Error(), "valid IP") {
|
|
t.Errorf("error = %q, want 'valid IP'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_ValidBindLoopback(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
|
}
|
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
|
if err != nil {
|
|
t.Fatalf("127.0.0.1 should be accepted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_NilNodeAllocPlaceholder(t *testing.T) {
|
|
// With a nil node, the alloc-id placeholder is the literal
|
|
// "<allocID>" sentinel so the rendered config is still valid YAML
|
|
// (the P08 socket layer substitutes the real alloc-id).
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
files, err := TraefikEmitter{}.Render(spec, nil)
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if !strings.Contains(files[0].Content, "alloc-<allocID>") {
|
|
t.Errorf("nil node should render alloc-<allocID> placeholder\n%s", files[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_EmptyHostnameAllocPlaceholder(t *testing.T) {
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
files, err := TraefikEmitter{}.Render(spec, &Node{Hostname: " "})
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if !strings.Contains(files[0].Content, "alloc-<allocID>") {
|
|
t.Errorf("empty hostname should render alloc-<allocID> placeholder\n%s", files[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_PathNotOrcaV1Prefixed(t *testing.T) {
|
|
// REQ-090: the orca-v1- prefix is only for systemd units; Traefik
|
|
// dynamic-config paths are named orca-<spec.Name>.yaml (single
|
|
// source of truth — no dual-write window for Traefik configs).
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
files, err := TraefikEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if strings.Contains(files[0].Path, "orca-v1-") {
|
|
t.Errorf("Path %q should NOT contain the orca-v1- prefix (systemd-only)", files[0].Path)
|
|
}
|
|
if !strings.HasPrefix(files[0].Path, "/etc/traefik/dynamic/orca-") {
|
|
t.Errorf("Path %q should start with /etc/traefik/dynamic/orca-", files[0].Path)
|
|
}
|
|
if !strings.HasSuffix(files[0].Path, ".yaml") {
|
|
t.Errorf("Path %q should end with .yaml", files[0].Path)
|
|
}
|
|
}
|
|
|
|
func TestTraefikEmitter_RenderYAMLHasRoutersServicesTLS(t *testing.T) {
|
|
// Aggregate structural assertion: the rendered YAML has the four
|
|
// top-level Traefik concepts (routers, services, tls, servers).
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
|
}
|
|
files, err := TraefikEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
c := files[0].Content
|
|
for _, want := range []string{"routers:", "services:", "tls:", "servers:", "url:"} {
|
|
if !strings.Contains(c, want) {
|
|
t.Errorf("rendered YAML missing %q\n%s", want, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegisterTraefik_AllServiceRuntimes(t *testing.T) {
|
|
r := NewRegistry()
|
|
RegisterTraefik(r)
|
|
spec := func(runtime string) *jobspec.WorkloadSpec {
|
|
return &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: runtime},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
}
|
|
for _, runtime := range []string{"process", "podman", "wasm"} {
|
|
t.Run(runtime, func(t *testing.T) {
|
|
files, err := r.Render(spec(runtime), &Node{Hostname: "n1"})
|
|
if err != nil {
|
|
t.Fatalf("Render(service:%s): %v", runtime, err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("got %d files, want 1", len(files))
|
|
}
|
|
if !strings.Contains(files[0].Path, "/etc/traefik/dynamic/orca-web.yaml") {
|
|
t.Errorf("Path = %q", files[0].Path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegisterTraefik_OverwritesExisting(t *testing.T) {
|
|
// RegisterTraefik should overwrite any prior registration (the
|
|
// Registry documents last-wins).
|
|
r := NewRegistry()
|
|
r.Register("service:process", mockEmitter{files: []File{{Path: "/old"}}})
|
|
RegisterTraefik(r)
|
|
spec := &jobspec.WorkloadSpec{
|
|
Kind: "Service",
|
|
Name: "web",
|
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
|
}
|
|
files, err := r.Render(spec, &Node{Hostname: "n1"})
|
|
if err != nil {
|
|
t.Fatalf("Render: %v", err)
|
|
}
|
|
if files[0].Path == "/old" {
|
|
t.Errorf("RegisterTraefik did not overwrite the prior registration")
|
|
}
|
|
}
|
|
|
|
// Compile-time assertion that TraefikEmitter implements Emitter.
|
|
var _ Emitter = TraefikEmitter{}
|