Files
orca/internal/emitter/traefik_atomic_test.go
T
Jon Chery 436641782c feat(P02): Service block + Traefik emitter + atomic reload (REQ-077, gate C-10)
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---
2026-08-05 17:48:04 +00:00

191 lines
6.0 KiB
Go

package emitter
import (
"context"
"errors"
"os"
"strings"
"testing"
)
// mockAtomicWriter is a test-only AtomicWriter that records calls so
// the C-10 atomicity protocol (tmp + rename) can be asserted.
type mockAtomicWriter struct {
written []writeCall
execed []execCall
writeErr error
writeWrote bool
execErr error
}
type writeCall struct {
peer string
path string
mode os.FileMode
bytes []byte
}
type execCall struct {
peer string
cmd string
}
func (m *mockAtomicWriter) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
m.written = append(m.written, writeCall{peer: peer, path: path, mode: mode, bytes: append([]byte(nil), content...)})
if m.writeErr != nil {
return false, m.writeErr
}
return m.writeWrote, nil
}
func (m *mockAtomicWriter) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
m.execed = append(m.execed, execCall{peer: peer, cmd: cmd})
if m.execErr != nil {
return nil, m.execErr
}
return []byte("ok"), nil
}
func TestWriteTraefikDynamic_TmpThenRename(t *testing.T) {
// Gate C-10: the Traefik dynamic-config write must be a tmp +
// rename sequence so Traefik's fsnotify watcher never observes a
// half-written file.
mock := &mockAtomicWriter{writeWrote: true}
path := "/etc/traefik/dynamic/orca-web.yaml"
peer := "node-1:22"
content := []byte("http:\n routers: {}\n")
if err := WriteTraefikDynamic(context.Background(), mock, peer, path, content); err != nil {
t.Fatalf("WriteTraefikDynamic: %v", err)
}
if len(mock.written) != 1 {
t.Fatalf("WriteFileIdempotent calls = %d, want 1", len(mock.written))
}
w := mock.written[0]
if w.peer != peer {
t.Errorf("write peer = %q, want %q", w.peer, peer)
}
// The tmp path is the target path + ".tmp".
if w.path != path+".tmp" {
t.Errorf("write path = %q, want %q (.tmp suffix is the C-10 atomicity protocol)", w.path, path+".tmp")
}
if string(w.bytes) != string(content) {
t.Errorf("write content = %q, want %q", string(w.bytes), string(content))
}
if w.mode != 0o644 {
t.Errorf("write mode = %o, want 0644", w.mode)
}
if len(mock.execed) != 1 {
t.Fatalf("Exec calls = %d, want 1 (the rename)", len(mock.execed))
}
e := mock.execed[0]
if e.peer != peer {
t.Errorf("exec peer = %q, want %q", e.peer, peer)
}
// The rename command must `mv -f` the .tmp file to the final path.
if !strings.Contains(e.cmd, "mv -f") {
t.Errorf("exec cmd = %q, want it to contain 'mv -f' (atomic rename)", e.cmd)
}
if !strings.Contains(e.cmd, path+".tmp") {
t.Errorf("exec cmd = %q, want it to contain the .tmp path as source", e.cmd)
}
if !strings.Contains(e.cmd, path) {
t.Errorf("exec cmd = %q, want it to contain the final path as destination", e.cmd)
}
// Sanity: the source must come before the destination in the
// mv command.
srcIdx := strings.Index(e.cmd, path+".tmp")
dstIdx := strings.Index(e.cmd, "'"+path+"'")
if srcIdx < 0 || dstIdx < 0 || srcIdx > dstIdx {
t.Errorf("exec cmd %q: source .tmp must come before destination %s", e.cmd, path)
}
}
func TestWriteTraefikDynamic_WriteTmpError(t *testing.T) {
mock := &mockAtomicWriter{writeErr: errors.New("disk full")}
err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
if err == nil {
t.Fatal("expected error from WriteFileIdempotent, got nil")
}
if !strings.Contains(err.Error(), "write tmp") {
t.Errorf("error = %q, want 'write tmp'", err.Error())
}
if !strings.Contains(err.Error(), "disk full") {
t.Errorf("error = %q, want underlying 'disk full'", err.Error())
}
if len(mock.execed) != 0 {
t.Errorf("on tmp write failure, no rename should happen; execed = %v", mock.execed)
}
}
func TestWriteTraefikDynamic_RenameError(t *testing.T) {
mock := &mockAtomicWriter{writeWrote: true, execErr: errors.New("permission denied")}
err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
if err == nil {
t.Fatal("expected error from rename, got nil")
}
if !strings.Contains(err.Error(), "rename") {
t.Errorf("error = %q, want 'rename'", err.Error())
}
if !strings.Contains(err.Error(), "permission denied") {
t.Errorf("error = %q, want underlying 'permission denied'", err.Error())
}
}
func TestWriteTraefikDynamic_NilWriter(t *testing.T) {
err := WriteTraefikDynamic(context.Background(), nil, "p", "/x", []byte("x"))
if err == nil {
t.Fatal("expected error for nil writer")
}
if !strings.Contains(err.Error(), "nil") {
t.Errorf("error = %q, want 'nil'", err.Error())
}
}
func TestWriteTraefikDynamic_EmptyPath(t *testing.T) {
mock := &mockAtomicWriter{writeWrote: true}
err := WriteTraefikDynamic(context.Background(), mock, "p", "", []byte("x"))
if err == nil {
t.Fatal("expected error for empty path")
}
if !strings.Contains(err.Error(), "path is empty") {
t.Errorf("error = %q, want 'path is empty'", err.Error())
}
}
func TestWriteTraefikDynamic_SkipWhenContentMatches(t *testing.T) {
// When the .tmp file already matches (writeWrote=false), the
// protocol still proceeds with the rename — the idempotency
// check is per-file, not per-protocol. The rename still happens
// so the final path reflects the (unchanged) content.
mock := &mockAtomicWriter{writeWrote: false}
err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
if err != nil {
t.Fatalf("WriteTraefikDynamic: %v", err)
}
if len(mock.execed) != 1 {
t.Errorf("rename should still happen on idempotent skip; execed = %v", mock.execed)
}
}
func TestShellQuoteLocal(t *testing.T) {
cases := []struct {
in, want string
}{
{"/etc/traefik/dynamic/orca-web.yaml", "'/etc/traefik/dynamic/orca-web.yaml'"},
{"", "''"},
{"/path with space/x", "'/path with space/x'"},
{"a'b", "'a'\\''b'"},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got := shellQuoteLocal(tc.in)
if got != tc.want {
t.Errorf("shellQuoteLocal(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}