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 .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 .tmp ` 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) }