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---
191 lines
6.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|