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---
94 lines
3.8 KiB
Go
94 lines
3.8 KiB
Go
package emitter
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// AtomicWriter is the SSH-push transport surface that
|
|
// WriteTraefikDynamic uses to write the Traefik dynamic-config file
|
|
// atomically. It is the subset of *sshpush.Transport that the
|
|
// atomicity protocol depends on. Tests substitute a mock to assert
|
|
// the tmp+rename sequence (gate C-10) without a real SSH server.
|
|
//
|
|
// *sshpush.Transport satisfies this interface (the compile-time
|
|
// assertion lives in internal/sshpush to avoid an import cycle — the
|
|
// sshpush package imports emitter for fan-out, so this package cannot
|
|
// import sshpush).
|
|
type AtomicWriter interface {
|
|
// WriteFileIdempotent writes content to peer:path atomically with
|
|
// mode, returning written=true if the file was actually written
|
|
// (content hash differed). Used by WriteTraefikDynamic to write
|
|
// the .tmp sibling.
|
|
WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error)
|
|
// Exec runs a command on peer and returns its combined output.
|
|
// Used by WriteTraefikDynamic to perform the atomic `mv -f
|
|
// path.tmp path`.
|
|
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
|
|
}
|
|
|
|
// WriteTraefikDynamic writes a Traefik dynamic-config file atomically
|
|
// (gate C-10: tmpfile + fsync + rename). The protocol is:
|
|
//
|
|
// 1. Write content to <path>.tmp via WriteFileIdempotent. The
|
|
// underlying sshpush transport writes the tmp file in the same
|
|
// directory as the target with mode-appended naming, fsyncs, and
|
|
// renames — but we add an extra hop here so the *Traefik* file is
|
|
// only ever observed at its final path after a single atomic
|
|
// rename event that Traefik's fsnotify watcher sees.
|
|
// 2. `mv -f <path>.tmp <path>` on the peer (atomic rename on POSIX).
|
|
// Traefik's fsnotify watcher picks up the rename → 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 — the only window where Traefik can read the
|
|
// file is after the rename, which is atomic on POSIX).
|
|
//
|
|
// The mode is 0644 (Traefik reads the dynamic dir as root; the lead
|
|
// applier chmods after the rename).
|
|
func WriteTraefikDynamic(ctx context.Context, t AtomicWriter, peer string, path string, content []byte) error {
|
|
if t == nil {
|
|
return fmt.Errorf("traefik: atomic writer is nil")
|
|
}
|
|
if path == "" {
|
|
return fmt.Errorf("traefik: path is empty")
|
|
}
|
|
tmpPath := path + ".tmp"
|
|
if _, err := t.WriteFileIdempotent(ctx, peer, tmpPath, content, 0o644); err != nil {
|
|
return fmt.Errorf("traefik: write tmp %s: %w", tmpPath, err)
|
|
}
|
|
// Atomic rename on POSIX. `mv -f` overwrites an existing target
|
|
// without prompting. The rename is atomic; Traefik's fsnotify
|
|
// watcher observes a single IN_MOVED_TO event.
|
|
renameCmd := fmt.Sprintf("mv -f %s %s", shellQuoteLocal(tmpPath), shellQuoteLocal(path))
|
|
if _, err := t.Exec(ctx, peer, renameCmd); err != nil {
|
|
return fmt.Errorf("traefik: rename %s -> %s: %w", tmpPath, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// shellQuoteLocal single-quotes a path for safe shell interpolation on
|
|
// the peer. It escapes embedded single-quotes via the standard '\”
|
|
// idiom (close the single-quoted string, escape the literal single
|
|
// quote, reopen the single-quoted string). This is a local
|
|
// re-implementation (the sshpush package has its own) so the emitter
|
|
// layer does not depend on the transport package's private helpers —
|
|
// the AtomicWriter interface keeps the boundary clean for testing.
|
|
func shellQuoteLocal(s string) string {
|
|
var b []byte
|
|
b = append(b, '\'')
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c == '\'' {
|
|
// close quote, escape the literal single-quote, reopen.
|
|
b = append(b, '\'', '\\', '\'', '\'')
|
|
continue
|
|
}
|
|
b = append(b, c)
|
|
}
|
|
b = append(b, '\'')
|
|
return string(b)
|
|
}
|