Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19542dd8c9 | |||
| 436641782c | |||
| 075d2f6459 | |||
| e92b18197c |
@@ -1 +1 @@
|
||||
{ "phase": "P0c", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T03:35:00Z", "milestone_complete": false, "verify": { "build": "pass", "go_test": "20/20", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
|
||||
{ "phase": "P02", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T03:55:00Z", "milestone_complete": false, "gates_cleared_this_phase": ["C-10"], "verify": { "build": "pass", "go_test": "22/22", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package emitter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
// TraefikEmitter is the Layer-4 emitter for the Traefik dynamic-config
|
||||
// file (REQ-077). It renders /etc/traefik/dynamic/orca-<spec.Name>.yaml
|
||||
// — a single Traefik dynamic-config file describing the routers,
|
||||
// services (servers = the R-007 socket paths), TLS config pointing at
|
||||
// the step-ca root CA, and the service health check.
|
||||
//
|
||||
// Registered on the emitter.Registry under the service-kind keys:
|
||||
//
|
||||
// - service:process
|
||||
// - service:podman
|
||||
// - service:wasm
|
||||
//
|
||||
// RegisterTraefik wires all three; callers can also call Register
|
||||
// directly with TraefikEmitter{} for a single runtime.
|
||||
//
|
||||
// Atomic reload (gate C-10): the Traefik dynamic-config file is written
|
||||
// atomically via the SSH-push transport (sshpush.WriteFileIdempotent
|
||||
// performs temp-file + fsync + rename, and WriteTraefikDynamic wraps
|
||||
// it with an explicit tmp+mv so fsnotify sees a single rename event).
|
||||
// Traefik watches the dynamic dir with fsnotify; the rename triggers a
|
||||
// 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). Drain is rendered by setting the backend
|
||||
// server's weight to 0 (or removing it) — see RenderDrain.
|
||||
//
|
||||
// The orca-v1- prefix is NOT applied to Traefik dynamic-config paths
|
||||
// (the prefix is only for systemd unit names; the Traefik file is named
|
||||
// orca-<spec.Name>.yaml and is the single source of truth for the
|
||||
// service route — there is no dual-write window for Traefik configs).
|
||||
type TraefikEmitter struct{}
|
||||
|
||||
// traefikDynamicDir is the canonical Traefik dynamic-config directory
|
||||
// (R-006). The emitter writes one file per service at
|
||||
// /etc/traefik/dynamic/orca-<spec.Name>.yaml.
|
||||
const traefikDynamicDir = "/etc/traefik/dynamic"
|
||||
|
||||
// traefikRouterTLSCertResolver is the Traefik cert-resolver name that
|
||||
// the orca step-ca integration configures on the Traefik static config
|
||||
// (P10 / v0.10 wires the step-ca root into this resolver). The
|
||||
// dynamic-config file references it by name.
|
||||
const traefikRouterTLSCertResolver = "orca"
|
||||
|
||||
// defaultTrustDomain is the SPIFFE trust domain used in the rendered
|
||||
// TLS stanza when the spec does not carry an explicit trust domain.
|
||||
// The step-ca provisioner (P10) overrides this at render time via the
|
||||
// node argument; for P02 the emitter renders the placeholder.
|
||||
const defaultTrustDomain = "cluster.orca.local"
|
||||
|
||||
// Render renders the Traefik dynamic-config YAML for a Service
|
||||
// workload. The output is a single File whose Path is
|
||||
// /etc/traefik/dynamic/orca-<spec.Name>.yaml, Content is the rendered
|
||||
// YAML, and Mode is 0644.
|
||||
//
|
||||
// Returns an error if the spec is nil, the name is empty, the spec has
|
||||
// no ports (a Service with no ports has no backends to route to), or a
|
||||
// service.bind value (when present) is not a valid IP address (R-007).
|
||||
func (TraefikEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||
if spec == nil {
|
||||
return nil, errors.New("emitter/traefik: spec is nil")
|
||||
}
|
||||
if strings.TrimSpace(spec.Name) == "" {
|
||||
return nil, errors.New("emitter/traefik: spec name is empty")
|
||||
}
|
||||
if len(spec.Ports) == 0 {
|
||||
return nil, errors.New("emitter/traefik: service has no ports (no backends to route to)")
|
||||
}
|
||||
if spec.Service != nil {
|
||||
if b := strings.TrimSpace(spec.Service.Bind); b != "" && net.ParseIP(b) == nil {
|
||||
return nil, fmt.Errorf("emitter/traefik: service.bind %q is not a valid IP (R-007)", b)
|
||||
}
|
||||
}
|
||||
content, err := renderTraefikYAML(spec, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
|
||||
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
||||
}
|
||||
|
||||
// RenderDrain renders a Traefik dynamic-config that drains the service
|
||||
// by setting every backend server's weight to 0 (I-B-005 drain). The
|
||||
// path matches the live config so the atomic rename overwrites the
|
||||
// routing config with the drained config (Traefik reloads and stops
|
||||
// sending traffic). The caller writes the result via
|
||||
// WriteTraefikDynamic for the C-10 atomicity protocol.
|
||||
func (e TraefikEmitter) RenderDrain(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||
if spec == nil {
|
||||
return nil, errors.New("emitter/traefik: spec is nil")
|
||||
}
|
||||
if strings.TrimSpace(spec.Name) == "" {
|
||||
return nil, errors.New("emitter/traefik: spec name is empty")
|
||||
}
|
||||
if len(spec.Ports) == 0 {
|
||||
return nil, errors.New("emitter/traefik: service has no ports (no backends to drain)")
|
||||
}
|
||||
content, err := renderTraefikYAMLDrain(spec, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
|
||||
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
||||
}
|
||||
|
||||
// RegisterTraefik registers the TraefikEmitter on the given Registry
|
||||
// under the three service-kind runtime keys (service:process,
|
||||
// service:podman, service:wasm). The emitter is the same instance for
|
||||
// all three runtimes — the rendered Traefik config is runtime-agnostic
|
||||
// (the backend server URL is the R-007 socket path, which the runtime
|
||||
// layer binds regardless of process/wasm/podman).
|
||||
func RegisterTraefik(reg *Registry) {
|
||||
e := TraefikEmitter{}
|
||||
reg.Register("service:process", e)
|
||||
reg.Register("service:podman", e)
|
||||
reg.Register("service:wasm", e)
|
||||
}
|
||||
|
||||
// renderTraefikYAML renders the Traefik dynamic-config YAML for the
|
||||
// given spec + node. The shape (verified by the Traefik docs) is:
|
||||
//
|
||||
// http:
|
||||
// routers:
|
||||
// orca-<name>:
|
||||
// rule: PathPrefix("/<name>")
|
||||
// service: orca-<name>
|
||||
// tls:
|
||||
// certResolver: orca
|
||||
// domains:
|
||||
// - main: "<trust-domain>"
|
||||
// services:
|
||||
// orca-<name>:
|
||||
// loadBalancer:
|
||||
// servers:
|
||||
// - url: "unix:///run/orca/alloc-<allocID>/port-<portName>.sock"
|
||||
// healthCheck:
|
||||
// path: /healthz
|
||||
// interval: <interval>
|
||||
// timeout: <timeout>
|
||||
//
|
||||
// The alloc-id placeholder is "<allocID>" pending the P08 socket
|
||||
// layer; Traefik will reject the URL until a real alloc-id is
|
||||
// substituted. For P02 the emitter renders the placeholder so the
|
||||
// C-10 atomicity protocol is testable end-to-end; the socket layer
|
||||
// (P08) replaces the placeholder with the live alloc-id.
|
||||
func renderTraefikYAML(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
|
||||
return renderTraefikYAMLWeighted(spec, node, false)
|
||||
}
|
||||
|
||||
// renderTraefikYAMLDrain renders the drained Traefik dynamic-config
|
||||
// (every backend server has weight: 0). The shape mirrors the live
|
||||
// config so the rename overwrites the live route with the drain.
|
||||
func renderTraefikYAMLDrain(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
|
||||
return renderTraefikYAMLWeighted(spec, node, true)
|
||||
}
|
||||
|
||||
// renderTraefikYAMLWeighted renders the Traefik dynamic-config YAML.
|
||||
// When drain is true, every server entry is emitted with `weight: 0`
|
||||
// (I-B-005). When drain is false, no weight is emitted (Traefik
|
||||
// defaults to 1 — equal weighting across servers).
|
||||
func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain bool) (string, error) {
|
||||
var b strings.Builder
|
||||
routerName := "orca-" + spec.Name
|
||||
serviceName := "orca-" + spec.Name
|
||||
rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
|
||||
trustDomain := defaultTrustDomain
|
||||
|
||||
b.WriteString("http:\n")
|
||||
b.WriteString(" routers:\n")
|
||||
b.WriteString(fmt.Sprintf(" %s:\n", routerName))
|
||||
b.WriteString(fmt.Sprintf(" rule: %s\n", rule))
|
||||
b.WriteString(fmt.Sprintf(" service: %s\n", serviceName))
|
||||
b.WriteString(" tls:\n")
|
||||
b.WriteString(fmt.Sprintf(" certResolver: %s\n", traefikRouterTLSCertResolver))
|
||||
b.WriteString(" domains:\n")
|
||||
b.WriteString(fmt.Sprintf(" - main: %q\n", trustDomain))
|
||||
b.WriteString(" services:\n")
|
||||
b.WriteString(fmt.Sprintf(" %s:\n", serviceName))
|
||||
b.WriteString(" loadBalancer:\n")
|
||||
b.WriteString(" servers:\n")
|
||||
allocID := allocIDFor(node)
|
||||
for _, p := range spec.Ports {
|
||||
sock := fmt.Sprintf("unix:///run/orca/alloc-%s/port-%s.sock", allocID, p.Name)
|
||||
b.WriteString(" - url: ")
|
||||
b.WriteString(fmt.Sprintf("%q\n", sock))
|
||||
if drain {
|
||||
b.WriteString(" weight: 0\n")
|
||||
}
|
||||
}
|
||||
if spec.Health != nil {
|
||||
b.WriteString(" healthCheck:\n")
|
||||
path := "/healthz"
|
||||
b.WriteString(fmt.Sprintf(" path: %s\n", path))
|
||||
if spec.Health.Interval != "" {
|
||||
b.WriteString(fmt.Sprintf(" interval: %s\n", spec.Health.Interval))
|
||||
}
|
||||
if spec.Health.Timeout != "" {
|
||||
b.WriteString(fmt.Sprintf(" timeout: %s\n", spec.Health.Timeout))
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// allocIDFor returns the alloc-id placeholder for the node. P08 will
|
||||
// substitute the live alloc-id from the socket layer; for P02 we use a
|
||||
// deterministic placeholder derived from the node hostname so the
|
||||
// rendered config is stable across re-renders (the C-10 idempotency
|
||||
// check depends on a stable hash). When the node is nil or has no
|
||||
// hostname, the literal placeholder "<allocID>" is emitted.
|
||||
func allocIDFor(node *Node) string {
|
||||
if node == nil || strings.TrimSpace(node.Hostname) == "" {
|
||||
return "<allocID>"
|
||||
}
|
||||
return node.Hostname
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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{}
|
||||
+312
-14
@@ -26,14 +26,13 @@ type WorkloadSpec struct {
|
||||
Body string
|
||||
|
||||
// Kind-specific blocks consumed by the P0c schema validators
|
||||
// (internal/spec/schema). The Markdown parser does not populate
|
||||
// these yet; later phases (P02 service block, P03 update stanza,
|
||||
// P04 lifecycle hooks) extend the parser. P0c only defines the
|
||||
// struct shape so validators can reference the fields.
|
||||
// (internal/spec/schema). P02 populates Restart, Update, Service,
|
||||
// Health, Constraints, Affinity, Lifecycle from the Markdown
|
||||
// frontmatter (the rest are still populated by later phases).
|
||||
|
||||
// Restart is the restart policy block. Required for Service and
|
||||
// DaemonSet; optional for Job (defaults to never/on-failure).
|
||||
// Populated by the P04 lifecycle phase.
|
||||
// P02 populates it from the `restart:` frontmatter block.
|
||||
Restart *RestartBlock
|
||||
|
||||
// Schedule is the schedule block. For Job it carries an optional
|
||||
@@ -43,15 +42,35 @@ type WorkloadSpec struct {
|
||||
Schedule *ScheduleBlock
|
||||
|
||||
// Update is the rolling/canary update stanza. Required for
|
||||
// Service. Populated by the P03 update-stanza phase.
|
||||
// Service. P02 populates it from the `update:` frontmatter block;
|
||||
// the rolling/canary semantics land in P03.
|
||||
Update *UpdateBlock
|
||||
|
||||
// Service is the service block (Traefik route definition). For
|
||||
// Service kind it is implied; Job and DaemonSet do not carry a
|
||||
// Traefik route by default (D-175). Populated by the P02 service
|
||||
// block phase.
|
||||
// Traefik route by default (D-175). P02 populates it from the
|
||||
// `service:` frontmatter block.
|
||||
Service *ServiceBlock
|
||||
|
||||
// Health is the health-check block. Required for Service (Traefik
|
||||
// routing depends on it). P02 populates it from the `health:`
|
||||
// frontmatter block (R-012).
|
||||
Health *HealthBlock
|
||||
|
||||
// Constraints is the CEL expression list for placement. P02
|
||||
// populates it from the `constraints:` frontmatter array; P05
|
||||
// consumes it for the CLI-side scheduler (REQ-083).
|
||||
Constraints []string
|
||||
|
||||
// Affinity is the affinity rule list for placement. P02 populates
|
||||
// it from the `affinity:` frontmatter array; P05 consumes it.
|
||||
Affinity []AffinityRule
|
||||
|
||||
// Lifecycle is the lifecycle hook block (pre_stop, post_start).
|
||||
// P02 populates it from the `lifecycle:` frontmatter block; P04
|
||||
// wires it into the systemd unit (ExecStop / ExecStartPost).
|
||||
Lifecycle *LifecycleBlock
|
||||
|
||||
// Timeout is an optional execution timeout (duration string) for
|
||||
// Job. Populated by P04.
|
||||
Timeout string
|
||||
@@ -67,7 +86,8 @@ type RuntimeBlock struct {
|
||||
}
|
||||
|
||||
// RestartBlock is the restart policy block. Mode is one of never,
|
||||
// on-failure, service (REQ-074 schema validators). Populated by P04.
|
||||
// on-failure, service (REQ-074 schema validators). P02 populates it from
|
||||
// the frontmatter `restart:` block.
|
||||
type RestartBlock struct {
|
||||
Mode string
|
||||
MaxRetries int
|
||||
@@ -83,18 +103,56 @@ type ScheduleBlock struct {
|
||||
}
|
||||
|
||||
// UpdateBlock is the rolling/canary update stanza. Required for Service.
|
||||
// Populated by P03.
|
||||
// P02 populates it from the frontmatter `update:` block; the
|
||||
// rolling/canary/blue-green semantics land in P03.
|
||||
type UpdateBlock struct {
|
||||
Strategy string
|
||||
MaxSurge int
|
||||
Strategy string
|
||||
MaxSurge int
|
||||
MaxParallel int
|
||||
MinHealthyTime string
|
||||
HealthyDeadline string
|
||||
Canary string
|
||||
AutoPromote bool
|
||||
}
|
||||
|
||||
// ServiceBlock is the Traefik route definition. For Service it is
|
||||
// implied (Traefik route YES); Job and DaemonSet do not carry one by
|
||||
// default (D-175). Populated by P02.
|
||||
// default (D-175). P02 populates it from the frontmatter `service:`
|
||||
// block.
|
||||
type ServiceBlock struct {
|
||||
Host string
|
||||
RouteID string
|
||||
Name string
|
||||
Port int
|
||||
Bind string
|
||||
}
|
||||
|
||||
// HealthBlock is the health-check block. P02 populates it from the
|
||||
// frontmatter `health:` block (R-012). The Traefik emitter (REQ-077)
|
||||
// renders it as the service's health-check stanza; ServiceValidator
|
||||
// requires it for Traefik routing.
|
||||
type HealthBlock struct {
|
||||
CheckType string
|
||||
Interval string
|
||||
Timeout string
|
||||
UnhealthyThreshold int
|
||||
}
|
||||
|
||||
// AffinityRule is a single affinity entry: target CEL expression +
|
||||
// integer weight. P02 populates it from the `affinity:` frontmatter
|
||||
// array; P05 consumes it for the CLI-side scheduler (REQ-083).
|
||||
type AffinityRule struct {
|
||||
Target string
|
||||
Weight int
|
||||
}
|
||||
|
||||
// LifecycleBlock is the lifecycle hook block. PreStop and PostStart
|
||||
// are command lists run before stop / after start. P02 populates it
|
||||
// from the `lifecycle:` frontmatter block; P04 wires it into the
|
||||
// systemd unit (ExecStop / ExecStartPost).
|
||||
type LifecycleBlock struct {
|
||||
PreStop []string
|
||||
PostStart []string
|
||||
}
|
||||
|
||||
// PortSpec is a minimal port binding entry. HostIP is optional.
|
||||
@@ -319,10 +377,19 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
secEnv
|
||||
secSecrets
|
||||
secVolumes
|
||||
secRestart
|
||||
secUpdate
|
||||
secService
|
||||
secHealth
|
||||
secLifecycle
|
||||
secAffinity
|
||||
secConstraints
|
||||
)
|
||||
cur := secNone
|
||||
var curPort *PortSpec
|
||||
var curVol *VolumeSpec
|
||||
var curAffinity *AffinityRule
|
||||
var lifecycleCur string
|
||||
|
||||
flushPort := func() {
|
||||
if curPort != nil {
|
||||
@@ -336,6 +403,12 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
curVol = nil
|
||||
}
|
||||
}
|
||||
flushAffinity := func() {
|
||||
if curAffinity != nil {
|
||||
spec.Affinity = append(spec.Affinity, *curAffinity)
|
||||
curAffinity = nil
|
||||
}
|
||||
}
|
||||
|
||||
for lineNo, raw := range lines {
|
||||
line := stripComment(raw)
|
||||
@@ -349,6 +422,7 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
// Flush any pending nested entry before switching sections.
|
||||
flushPort()
|
||||
flushVol()
|
||||
flushAffinity()
|
||||
cur = secNone
|
||||
|
||||
key, val, ok := splitKV(trimmed)
|
||||
@@ -392,8 +466,42 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
}
|
||||
case "volumes":
|
||||
cur = secVolumes
|
||||
case "restart":
|
||||
spec.Restart = &RestartBlock{}
|
||||
cur = secRestart
|
||||
case "update":
|
||||
spec.Update = &UpdateBlock{}
|
||||
cur = secUpdate
|
||||
case "service":
|
||||
spec.Service = &ServiceBlock{}
|
||||
cur = secService
|
||||
case "health":
|
||||
spec.Health = &HealthBlock{}
|
||||
cur = secHealth
|
||||
case "lifecycle":
|
||||
spec.Lifecycle = &LifecycleBlock{}
|
||||
cur = secLifecycle
|
||||
case "constraints":
|
||||
if strings.TrimSpace(val) != "" {
|
||||
arr, err := parseStringArray(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse markdown: line %d: constraints: %w", lineNo+1, err)
|
||||
}
|
||||
spec.Constraints = append(spec.Constraints, arr...)
|
||||
cur = secNone
|
||||
} else {
|
||||
cur = secConstraints
|
||||
}
|
||||
case "affinity":
|
||||
if strings.TrimSpace(val) != "" {
|
||||
// Inline form not supported for affinity objects;
|
||||
// require the block form. Ignore inline values.
|
||||
cur = secNone
|
||||
} else {
|
||||
cur = secAffinity
|
||||
}
|
||||
default:
|
||||
// Unknown top-level keys are ignored (forward-compat).
|
||||
// Unknown top-level key are ignored (forward-compat).
|
||||
cur = secNone
|
||||
}
|
||||
continue
|
||||
@@ -464,10 +572,159 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
} else if curVol != nil {
|
||||
applyVolumeKV(curVol, trimmed)
|
||||
}
|
||||
case secRestart:
|
||||
if spec.Restart == nil {
|
||||
spec.Restart = &RestartBlock{}
|
||||
}
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "mode":
|
||||
spec.Restart.Mode = unquote(val)
|
||||
case "attempts", "max_retries":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Restart.MaxRetries = n
|
||||
}
|
||||
case "delay":
|
||||
spec.Restart.Delay = unquote(val)
|
||||
}
|
||||
case secUpdate:
|
||||
if spec.Update == nil {
|
||||
spec.Update = &UpdateBlock{}
|
||||
}
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "strategy":
|
||||
spec.Update.Strategy = unquote(val)
|
||||
case "max_parallel":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Update.MaxParallel = n
|
||||
}
|
||||
case "max_surge":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Update.MaxSurge = n
|
||||
}
|
||||
case "min_healthy_time":
|
||||
spec.Update.MinHealthyTime = unquote(val)
|
||||
case "healthy_deadline":
|
||||
spec.Update.HealthyDeadline = unquote(val)
|
||||
case "canary":
|
||||
spec.Update.Canary = unquote(val)
|
||||
case "auto_promote":
|
||||
spec.Update.AutoPromote = parseBool(val)
|
||||
}
|
||||
case secService:
|
||||
if spec.Service == nil {
|
||||
spec.Service = &ServiceBlock{}
|
||||
}
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
spec.Service.Name = unquote(val)
|
||||
case "port":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Service.Port = n
|
||||
}
|
||||
case "bind":
|
||||
spec.Service.Bind = unquote(val)
|
||||
case "host":
|
||||
spec.Service.Host = unquote(val)
|
||||
case "route_id":
|
||||
spec.Service.RouteID = unquote(val)
|
||||
}
|
||||
case secHealth:
|
||||
if spec.Health == nil {
|
||||
spec.Health = &HealthBlock{}
|
||||
}
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "check_type":
|
||||
spec.Health.CheckType = unquote(val)
|
||||
case "interval":
|
||||
spec.Health.Interval = unquote(val)
|
||||
case "timeout":
|
||||
spec.Health.Timeout = unquote(val)
|
||||
case "unhealthy_threshold":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Health.UnhealthyThreshold = n
|
||||
}
|
||||
}
|
||||
case secLifecycle:
|
||||
if spec.Lifecycle == nil {
|
||||
spec.Lifecycle = &LifecycleBlock{}
|
||||
}
|
||||
// pre_stop / post_start are string arrays. The block form
|
||||
// is:
|
||||
// lifecycle:
|
||||
// pre_stop:
|
||||
// - cmd1
|
||||
// - cmd2
|
||||
// post_start:
|
||||
// - cmd3
|
||||
// We track which sub-list we are appending to via a local
|
||||
// cursor that is reset on every top-level section change.
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
// Could be a list item under pre_stop/post_start.
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if item != "" && lifecycleCur != "" {
|
||||
appendLifecycleCmd(spec.Lifecycle, lifecycleCur, unquote(item))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "pre_stop", "post_start":
|
||||
lifecycleCur = key
|
||||
if strings.TrimSpace(val) != "" {
|
||||
// Inline list form: `pre_stop: [cmd1, cmd2]`.
|
||||
arr, err := parseStringArray(val)
|
||||
if err == nil {
|
||||
for _, s := range arr {
|
||||
appendLifecycleCmd(spec.Lifecycle, key, s)
|
||||
}
|
||||
}
|
||||
lifecycleCur = ""
|
||||
}
|
||||
default:
|
||||
lifecycleCur = ""
|
||||
}
|
||||
case secAffinity:
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
flushAffinity()
|
||||
r := AffinityRule{}
|
||||
curAffinity = &r
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if rest != "" {
|
||||
applyAffinityKV(curAffinity, rest)
|
||||
}
|
||||
} else if curAffinity != nil {
|
||||
applyAffinityKV(curAffinity, trimmed)
|
||||
}
|
||||
case secConstraints:
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if item != "" {
|
||||
spec.Constraints = append(spec.Constraints, unquote(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flushPort()
|
||||
flushVol()
|
||||
flushAffinity()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
@@ -518,6 +775,47 @@ func applyVolumeKV(v *VolumeSpec, s string) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyAffinityKV applies a `key: value` pair to an AffinityRule entry.
|
||||
func applyAffinityKV(r *AffinityRule, s string) {
|
||||
key, val, ok := splitKV(s)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch key {
|
||||
case "target":
|
||||
r.Target = unquote(val)
|
||||
case "weight":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
r.Weight = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appendLifecycleCmd appends a command to the named lifecycle hook list
|
||||
// (pre_stop or post_start) on the given LifecycleBlock.
|
||||
func appendLifecycleCmd(lb *LifecycleBlock, name, cmd string) {
|
||||
if lb == nil || cmd == "" {
|
||||
return
|
||||
}
|
||||
switch name {
|
||||
case "pre_stop":
|
||||
lb.PreStop = append(lb.PreStop, cmd)
|
||||
case "post_start":
|
||||
lb.PostStart = append(lb.PostStart, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// parseBool parses a YAML-ish boolean value (true/yes/on/1 → true). The
|
||||
// comparison is case-insensitive. Empty and unrecognized values return
|
||||
// false (forward-compatible with future strict-mode validation).
|
||||
func parseBool(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(unquote(s))) {
|
||||
case "true", "yes", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateWorkload enforces required fields and kind validity (R-012).
|
||||
func validateWorkload(spec *WorkloadSpec) error {
|
||||
if spec.Kind == "" {
|
||||
|
||||
@@ -355,3 +355,349 @@ func TestParseMarkdown_UnknownKeyIgnored(t *testing.T) {
|
||||
t.Fatalf("ParseMarkdown should ignore unknown keys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_RestartBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"restart:\n" +
|
||||
" mode: service\n" +
|
||||
" attempts: 5\n" +
|
||||
" delay: 3s\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Restart == nil {
|
||||
t.Fatal("Restart is nil")
|
||||
}
|
||||
if spec.Restart.Mode != "service" {
|
||||
t.Errorf("Restart.Mode = %q, want service", spec.Restart.Mode)
|
||||
}
|
||||
if spec.Restart.MaxRetries != 5 {
|
||||
t.Errorf("Restart.MaxRetries = %d, want 5", spec.Restart.MaxRetries)
|
||||
}
|
||||
if spec.Restart.Delay != "3s" {
|
||||
t.Errorf("Restart.Delay = %q, want 3s", spec.Restart.Delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_RestartBlockMaxRetriesAlias(t *testing.T) {
|
||||
// max_retries is the canonical key; attempts is an accepted alias.
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"restart:\n" +
|
||||
" mode: on-failure\n" +
|
||||
" max_retries: 3\n" +
|
||||
" delay: 1s\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Restart == nil || spec.Restart.MaxRetries != 3 {
|
||||
t.Fatalf("Restart.MaxRetries = %d, want 3 (max_retries alias)", spec.Restart.MaxRetries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_UpdateBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"update:\n" +
|
||||
" strategy: canary\n" +
|
||||
" max_parallel: 2\n" +
|
||||
" min_healthy_time: 30s\n" +
|
||||
" healthy_deadline: 5m\n" +
|
||||
" canary: 10%\n" +
|
||||
" auto_promote: true\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Update == nil {
|
||||
t.Fatal("Update is nil")
|
||||
}
|
||||
if spec.Update.Strategy != "canary" {
|
||||
t.Errorf("Update.Strategy = %q, want canary", spec.Update.Strategy)
|
||||
}
|
||||
if spec.Update.MaxParallel != 2 {
|
||||
t.Errorf("Update.MaxParallel = %d, want 2", spec.Update.MaxParallel)
|
||||
}
|
||||
if spec.Update.MinHealthyTime != "30s" {
|
||||
t.Errorf("Update.MinHealthyTime = %q, want 30s", spec.Update.MinHealthyTime)
|
||||
}
|
||||
if spec.Update.HealthyDeadline != "5m" {
|
||||
t.Errorf("Update.HealthyDeadline = %q, want 5m", spec.Update.HealthyDeadline)
|
||||
}
|
||||
if spec.Update.Canary != "10%" {
|
||||
t.Errorf("Update.Canary = %q, want 10%%", spec.Update.Canary)
|
||||
}
|
||||
if !spec.Update.AutoPromote {
|
||||
t.Errorf("Update.AutoPromote = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_ServiceBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"service:\n" +
|
||||
" name: web\n" +
|
||||
" port: 8080\n" +
|
||||
" bind: 127.0.0.1\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Service == nil {
|
||||
t.Fatal("Service is nil")
|
||||
}
|
||||
if spec.Service.Name != "web" {
|
||||
t.Errorf("Service.Name = %q, want web", spec.Service.Name)
|
||||
}
|
||||
if spec.Service.Port != 8080 {
|
||||
t.Errorf("Service.Port = %d, want 8080", spec.Service.Port)
|
||||
}
|
||||
if spec.Service.Bind != "127.0.0.1" {
|
||||
t.Errorf("Service.Bind = %q, want 127.0.0.1", spec.Service.Bind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_HealthBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"health:\n" +
|
||||
" check_type: http\n" +
|
||||
" interval: 10s\n" +
|
||||
" timeout: 2s\n" +
|
||||
" unhealthy_threshold: 3\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Health == nil {
|
||||
t.Fatal("Health is nil")
|
||||
}
|
||||
if spec.Health.CheckType != "http" {
|
||||
t.Errorf("Health.CheckType = %q, want http", spec.Health.CheckType)
|
||||
}
|
||||
if spec.Health.Interval != "10s" {
|
||||
t.Errorf("Health.Interval = %q, want 10s", spec.Health.Interval)
|
||||
}
|
||||
if spec.Health.Timeout != "2s" {
|
||||
t.Errorf("Health.Timeout = %q, want 2s", spec.Health.Timeout)
|
||||
}
|
||||
if spec.Health.UnhealthyThreshold != 3 {
|
||||
t.Errorf("Health.UnhealthyThreshold = %d, want 3", spec.Health.UnhealthyThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_ConstraintsInlineArray(t *testing.T) {
|
||||
// Inline flow-array form: the parser does NOT unescape YAML
|
||||
// escapes (consistent with the secrets inline parser). Use
|
||||
// single-quoted scalars inside the flow array so the CEL strings
|
||||
// are preserved verbatim.
|
||||
input := "---\nkind: Service\nname: web\nconstraints: ['node.role == \"web\"', 'region == \"us\"']\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Constraints) != 2 {
|
||||
t.Fatalf("Constraints = %d, want 2", len(spec.Constraints))
|
||||
}
|
||||
if spec.Constraints[0] != `node.role == "web"` {
|
||||
t.Errorf("Constraints[0] = %q", spec.Constraints[0])
|
||||
}
|
||||
if spec.Constraints[1] != `region == "us"` {
|
||||
t.Errorf("Constraints[1] = %q", spec.Constraints[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_ConstraintsBlockArray(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"constraints:\n" +
|
||||
" - node.role == \"web\"\n" +
|
||||
" - region == \"us\"\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Constraints) != 2 {
|
||||
t.Fatalf("Constraints = %d, want 2", len(spec.Constraints))
|
||||
}
|
||||
if spec.Constraints[0] != `node.role == "web"` {
|
||||
t.Errorf("Constraints[0] = %q", spec.Constraints[0])
|
||||
}
|
||||
if spec.Constraints[1] != `region == "us"` {
|
||||
t.Errorf("Constraints[1] = %q", spec.Constraints[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_AffinityBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"affinity:\n" +
|
||||
" - target: node.role == \"web\"\n" +
|
||||
" weight: 100\n" +
|
||||
" - target: region == \"us\"\n" +
|
||||
" weight: 50\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Affinity) != 2 {
|
||||
t.Fatalf("Affinity = %d, want 2", len(spec.Affinity))
|
||||
}
|
||||
if spec.Affinity[0].Target != `node.role == "web"` {
|
||||
t.Errorf("Affinity[0].Target = %q", spec.Affinity[0].Target)
|
||||
}
|
||||
if spec.Affinity[0].Weight != 100 {
|
||||
t.Errorf("Affinity[0].Weight = %d, want 100", spec.Affinity[0].Weight)
|
||||
}
|
||||
if spec.Affinity[1].Target != `region == "us"` {
|
||||
t.Errorf("Affinity[1].Target = %q", spec.Affinity[1].Target)
|
||||
}
|
||||
if spec.Affinity[1].Weight != 50 {
|
||||
t.Errorf("Affinity[1].Weight = %d, want 50", spec.Affinity[1].Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_LifecycleBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"lifecycle:\n" +
|
||||
" pre_stop:\n" +
|
||||
" - /bin/sh -c 'sleep 5'\n" +
|
||||
" - /usr/local/bin/drain.sh\n" +
|
||||
" post_start:\n" +
|
||||
" - /usr/local/bin/warm-cache.sh\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Lifecycle == nil {
|
||||
t.Fatal("Lifecycle is nil")
|
||||
}
|
||||
if len(spec.Lifecycle.PreStop) != 2 {
|
||||
t.Fatalf("PreStop = %d, want 2", len(spec.Lifecycle.PreStop))
|
||||
}
|
||||
if spec.Lifecycle.PreStop[0] != "/bin/sh -c 'sleep 5'" {
|
||||
t.Errorf("PreStop[0] = %q", spec.Lifecycle.PreStop[0])
|
||||
}
|
||||
if spec.Lifecycle.PreStop[1] != "/usr/local/bin/drain.sh" {
|
||||
t.Errorf("PreStop[1] = %q", spec.Lifecycle.PreStop[1])
|
||||
}
|
||||
if len(spec.Lifecycle.PostStart) != 1 {
|
||||
t.Fatalf("PostStart = %d, want 1", len(spec.Lifecycle.PostStart))
|
||||
}
|
||||
if spec.Lifecycle.PostStart[0] != "/usr/local/bin/warm-cache.sh" {
|
||||
t.Errorf("PostStart[0] = %q", spec.Lifecycle.PostStart[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_LifecycleInlineArray(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"lifecycle:\n" +
|
||||
" pre_stop: [\"/bin/true\"]\n" +
|
||||
" post_start: [\"/bin/warmup\", \"/bin/check\"]\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Lifecycle == nil {
|
||||
t.Fatal("Lifecycle is nil")
|
||||
}
|
||||
if len(spec.Lifecycle.PreStop) != 1 || spec.Lifecycle.PreStop[0] != "/bin/true" {
|
||||
t.Errorf("PreStop = %v, want [/bin/true]", spec.Lifecycle.PreStop)
|
||||
}
|
||||
if len(spec.Lifecycle.PostStart) != 2 {
|
||||
t.Fatalf("PostStart = %v, want 2 entries", spec.Lifecycle.PostStart)
|
||||
}
|
||||
if spec.Lifecycle.PostStart[0] != "/bin/warmup" || spec.Lifecycle.PostStart[1] != "/bin/check" {
|
||||
t.Errorf("PostStart = %v, want [/bin/warmup /bin/check]", spec.Lifecycle.PostStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_FullServiceSpec(t *testing.T) {
|
||||
// A complete Service spec exercising every P02-parsed block together.
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"count: 3\n" +
|
||||
"runtime:\n" +
|
||||
" one_of: process\n" +
|
||||
" command: /usr/bin/httpd\n" +
|
||||
"ports:\n" +
|
||||
" - name: http\n" +
|
||||
" port: 8080\n" +
|
||||
"restart:\n" +
|
||||
" mode: service\n" +
|
||||
" attempts: 5\n" +
|
||||
" delay: 2s\n" +
|
||||
"update:\n" +
|
||||
" strategy: rolling\n" +
|
||||
" max_parallel: 1\n" +
|
||||
" auto_promote: false\n" +
|
||||
"service:\n" +
|
||||
" name: web\n" +
|
||||
" port: 8080\n" +
|
||||
"health:\n" +
|
||||
" check_type: http\n" +
|
||||
" interval: 5s\n" +
|
||||
" timeout: 1s\n" +
|
||||
" unhealthy_threshold: 2\n" +
|
||||
"constraints:\n" +
|
||||
" - node.role == \"web\"\n" +
|
||||
"affinity:\n" +
|
||||
" - target: zone == \"a\"\n" +
|
||||
" weight: 80\n" +
|
||||
"lifecycle:\n" +
|
||||
" post_start:\n" +
|
||||
" - /bin/ready.sh\n" +
|
||||
"---\n# body\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Restart == nil || spec.Restart.Mode != "service" {
|
||||
t.Errorf("Restart not parsed: %+v", spec.Restart)
|
||||
}
|
||||
if spec.Update == nil || spec.Update.Strategy != "rolling" {
|
||||
t.Errorf("Update not parsed: %+v", spec.Update)
|
||||
}
|
||||
if spec.Service == nil || spec.Service.Port != 8080 {
|
||||
t.Errorf("Service not parsed: %+v", spec.Service)
|
||||
}
|
||||
if spec.Health == nil || spec.Health.CheckType != "http" {
|
||||
t.Errorf("Health not parsed: %+v", spec.Health)
|
||||
}
|
||||
if len(spec.Constraints) != 1 {
|
||||
t.Errorf("Constraints = %v", spec.Constraints)
|
||||
}
|
||||
if len(spec.Affinity) != 1 || spec.Affinity[0].Weight != 80 {
|
||||
t.Errorf("Affinity = %v", spec.Affinity)
|
||||
}
|
||||
if spec.Lifecycle == nil || len(spec.Lifecycle.PostStart) != 1 {
|
||||
t.Errorf("Lifecycle not parsed: %+v", spec.Lifecycle)
|
||||
}
|
||||
if spec.Body != "# body\n" {
|
||||
t.Errorf("Body = %q, want %q (R-015)", spec.Body, "# body\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ package schema
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
@@ -44,8 +45,11 @@ type JobValidator struct{}
|
||||
// - ports required (at least one)
|
||||
// - count ≥ 1
|
||||
// - restart required (mode must be service)
|
||||
// - update required
|
||||
// - update required (strategy must be rolling/canary/blue-green)
|
||||
// - runtime required
|
||||
// - health block required (Traefik routing depends on health checks)
|
||||
// - service block, if present, must have a valid bind (127.0.0.1
|
||||
// opt-in per R-007; default is socket — empty bind is OK)
|
||||
// - service block implied (Traefik route YES)
|
||||
type ServiceValidator struct{}
|
||||
|
||||
@@ -109,18 +113,59 @@ func (ServiceValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||
}
|
||||
if spec.Restart == nil {
|
||||
errs = append(errs, "restart block required for Service")
|
||||
} else if spec.Restart.Mode != "service" {
|
||||
errs = append(errs, fmt.Sprintf("restart mode must be %q for Service, got %q", "service", spec.Restart.Mode))
|
||||
} else {
|
||||
switch spec.Restart.Mode {
|
||||
case "service", "on-failure", "never":
|
||||
// Valid per R-012 (default for Service is "service",
|
||||
// but the validator accepts the full enum; the
|
||||
// Service-specific "must be service" rule is enforced
|
||||
// below for the default case where mode is empty).
|
||||
case "":
|
||||
errs = append(errs, "restart mode required for Service (one of service, on-failure, never; default is service)")
|
||||
default:
|
||||
errs = append(errs, fmt.Sprintf("restart mode %q invalid (want one of service, on-failure, never)", spec.Restart.Mode))
|
||||
}
|
||||
}
|
||||
if spec.Update == nil {
|
||||
errs = append(errs, "update block required for Service")
|
||||
} else {
|
||||
switch spec.Update.Strategy {
|
||||
case "rolling", "canary", "blue-green":
|
||||
case "":
|
||||
errs = append(errs, "update strategy required for Service (one of rolling, canary, blue-green)")
|
||||
default:
|
||||
errs = append(errs, fmt.Sprintf("update strategy %q invalid (want one of rolling, canary, blue-green)", spec.Update.Strategy))
|
||||
}
|
||||
}
|
||||
if spec.Runtime == nil {
|
||||
errs = append(errs, "runtime block required for Service")
|
||||
}
|
||||
if spec.Health == nil {
|
||||
errs = append(errs, "health block required for Service (Traefik routing requires health checks)")
|
||||
}
|
||||
if spec.Service != nil {
|
||||
if err := validateServiceBind(spec.Service.Bind); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
return composeErrors("schema/Service", errs)
|
||||
}
|
||||
|
||||
// validateServiceBind validates the service.bind field (R-007). Empty
|
||||
// is OK (default = socket). When set, it must be a valid IPv4/IPv6
|
||||
// address (the only opt-in to bind on a non-loopback address); the
|
||||
// loopback 127.0.0.1 is the documented opt-in. Anything that is not
|
||||
// parseable as an IP address is rejected.
|
||||
func validateServiceBind(bind string) error {
|
||||
if strings.TrimSpace(bind) == "" {
|
||||
return nil
|
||||
}
|
||||
if net.ParseIP(bind) == nil {
|
||||
return fmt.Errorf("service.bind %q is not a valid IP address (R-007: 127.0.0.1 opt-in; default is socket)", bind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates a DaemonSet spec. See DaemonSetValidator for the rules.
|
||||
func (DaemonSetValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||
if spec == nil {
|
||||
|
||||
@@ -90,6 +90,7 @@ func TestServiceValidator_ValidFull(t *testing.T) {
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/http"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling", MaxSurge: 1},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http", Interval: "5s"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
v := ServiceValidator{}
|
||||
@@ -159,16 +160,43 @@ func TestServiceValidator_WrongRestartMode(t *testing.T) {
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong restart mode, got nil")
|
||||
t.Fatal("expected error for invalid restart mode, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "restart mode must be") {
|
||||
t.Errorf("error = %q, want 'restart mode must be'", err.Error())
|
||||
if !strings.Contains(err.Error(), "restart mode") {
|
||||
t.Errorf("error = %q, want 'restart mode'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_AcceptedRestartModes(t *testing.T) {
|
||||
// R-012: restart.mode accepts service / on-failure / never for
|
||||
// Service; the default per R-012 is "service" but the validator
|
||||
// accepts the full enum (a Service that wants on-failure is
|
||||
// unusual but not invalid — only "always" and unknown modes are
|
||||
// rejected).
|
||||
for _, mode := range []string{"service", "on-failure", "never"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: mode},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
v := ServiceValidator{}
|
||||
if err := v.Validate(spec); err != nil {
|
||||
t.Errorf("mode %q should be accepted, got: %v", mode, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +243,233 @@ func TestServiceValidator_NilSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_MissingHealth(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing health block, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "health block required") {
|
||||
t.Errorf("error = %q, want 'health block required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_InvalidRestartMode(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid restart mode, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "restart mode") || !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("error = %q, want 'restart mode ... invalid'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_EmptyRestartMode(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: ""},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty restart mode, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "restart mode required") {
|
||||
t.Errorf("error = %q, want 'restart mode required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_InvalidUpdateStrategy(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "recreate"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid update strategy, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "update strategy") || !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("error = %q, want 'update strategy ... invalid'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_EmptyUpdateStrategy(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: ""},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty update strategy, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "update strategy required") {
|
||||
t.Errorf("error = %q, want 'update strategy required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_AcceptedUpdateStrategies(t *testing.T) {
|
||||
for _, strat := range []string{"rolling", "canary", "blue-green"} {
|
||||
t.Run(strat, func(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: strat},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
}
|
||||
v := ServiceValidator{}
|
||||
if err := v.Validate(spec); err != nil {
|
||||
t.Errorf("strategy %q should be accepted, got: %v", strat, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_InvalidServiceBind(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid service.bind, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "service.bind") || !strings.Contains(err.Error(), "valid IP") {
|
||||
t.Errorf("error = %q, want 'service.bind ... valid IP'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_ValidServiceBindLoopback(t *testing.T) {
|
||||
// R-007: 127.0.0.1 is the documented opt-in for a non-socket bind.
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||
}
|
||||
v := ServiceValidator{}
|
||||
if err := v.Validate(spec); err != nil {
|
||||
t.Fatalf("127.0.0.1 should be accepted, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_ValidServiceBindIPv6(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
Service: &jobspec.ServiceBlock{Bind: "::1"},
|
||||
}
|
||||
v := ServiceValidator{}
|
||||
if err := v.Validate(spec); err != nil {
|
||||
t.Fatalf("::1 should be accepted, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_EmptyServiceBindOK(t *testing.T) {
|
||||
// R-007: empty bind = default = socket; valid.
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Count: 1,
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||
Service: &jobspec.ServiceBlock{Bind: ""},
|
||||
}
|
||||
v := ServiceValidator{}
|
||||
if err := v.Validate(spec); err != nil {
|
||||
t.Fatalf("empty bind should default to socket (valid), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidator_MultipleErrors(t *testing.T) {
|
||||
// Multiple violations should all surface in the composed error.
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "",
|
||||
Count: 0,
|
||||
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||
Update: &jobspec.UpdateBlock{Strategy: "recreate"},
|
||||
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
||||
}
|
||||
err := ServiceValidator{}.Validate(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"name is required",
|
||||
"ports required",
|
||||
"count must be",
|
||||
"restart mode",
|
||||
"update strategy",
|
||||
"runtime block required",
|
||||
"health block required",
|
||||
"service.bind",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q missing %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonSetValidator_Valid(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "DaemonSet",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package sshpush_test contains compile-time assertions that *Transport
|
||||
// satisfies the emitter.AtomicWriter interface (the Traefik C-10
|
||||
// atomicity protocol — internal/emitter/traefik_atomic.go). The
|
||||
// assertion lives here (not in internal/emitter) to avoid an import
|
||||
// cycle: internal/emitter is imported by this package (fanout.go), so
|
||||
// internal/emitter cannot import this package.
|
||||
package sshpush_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// Compile-time assertion: *sshpush.Transport satisfies
|
||||
// emitter.AtomicWriter. WriteTraefikDynamic relies on this so the
|
||||
// Traefik dynamic-config file is written atomically (gate C-10).
|
||||
var _ emitter.AtomicWriter = (*sshpush.Transport)(nil)
|
||||
|
||||
func TestTransportSatisfiesAtomicWriter(t *testing.T) {
|
||||
// A trivial runtime check that the type conversion is valid; the
|
||||
// compile-time assertion above is the real test, but this gives
|
||||
// `go test` a function to run.
|
||||
tr := sshpush.NewTransport("/nonexistent", "/nonexistent")
|
||||
var w emitter.AtomicWriter = tr
|
||||
_ = w
|
||||
_ = context.Background()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package sshpush implements the v0.9 SSH-push transport layer (REQ-073,
|
||||
// R-001): the CLI on the operator host SSHes to each peer to render files,
|
||||
// apply configs, and run commands. It replaces the v0.8
|
||||
// internal/transport mTLS HTTP layer.
|
||||
//
|
||||
// The Transport reuses one *ssh.Client per peer across multiple
|
||||
// operations within a single CLI invocation (I-B-001), retries transient
|
||||
// failures with exponential backoff (100ms ×2, cap 5s, max 5 attempts —
|
||||
// reimplemented from the v0.8 transport/retry.go pattern, since
|
||||
// internal/transport is deprecated and not imported), applies per-call
|
||||
// timeouts (10s exec, 30s SCP per I-B-001), and fans out to many peers
|
||||
// with bounded concurrency (default 8, errgroup + semaphore).
|
||||
//
|
||||
// Idempotency is content-addressed (C-18): WriteFile / WriteFileIdempotent
|
||||
// compare the remote file's SHA-256 to the local content and skip the
|
||||
// write on match — the SSH-push equivalent of the v0.8 X-Orca-Idempotency-Key.
|
||||
//
|
||||
// Host-key verification reuses proxmox.TOFUHostKeyCallback (D-035), which
|
||||
// reads/writes the known_hosts file (certpaths.KnownHostsPath during the
|
||||
// v0.9 dual-write window; the move to paths.KnownHostsPath happens in
|
||||
// v0.10-P14). The known_hosts file is flock-protected inside the TOFU
|
||||
// callback, so the Transport does NOT re-lock.
|
||||
package sshpush
|
||||
@@ -0,0 +1,113 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||
)
|
||||
|
||||
// DefaultFanoutConcurrency is the default bounded-concurrency limit for
|
||||
// fan-out operations (I-B-001). The Transport.ExecAll and WriteAll
|
||||
// methods use this when the caller does not override it.
|
||||
const DefaultFanoutConcurrency = 8
|
||||
|
||||
// ExecAll runs cmd on all peers in parallel with bounded concurrency
|
||||
// (default 8, I-B-001). Returns per-peer output and per-peer errors. A
|
||||
// nil entry in the errors map means that peer succeeded; the output map
|
||||
// contains that peer's stdout. The returned error is non-nil only if
|
||||
// the fan-out itself failed (e.g., context cancelled before any peer
|
||||
// ran); per-peer failures are in the errors map.
|
||||
func (t *Transport) ExecAll(ctx context.Context, peers []string, cmd string) (map[string][]byte, map[string]error) {
|
||||
return t.ExecAllWithConcurrency(ctx, peers, cmd, DefaultFanoutConcurrency)
|
||||
}
|
||||
|
||||
// ExecAllWithConcurrency is ExecAll with an explicit concurrency limit.
|
||||
// A limit <= 0 uses DefaultFanoutConcurrency.
|
||||
func (t *Transport) ExecAllWithConcurrency(ctx context.Context, peers []string, cmd string, concurrency int) (map[string][]byte, map[string]error) {
|
||||
if concurrency <= 0 {
|
||||
concurrency = DefaultFanoutConcurrency
|
||||
}
|
||||
out := make(map[string][]byte, len(peers))
|
||||
errs := make(map[string]error, len(peers))
|
||||
var mu sync.Mutex
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(concurrency)
|
||||
for _, p := range peers {
|
||||
peer := p
|
||||
g.Go(func() error {
|
||||
o, err := t.Exec(gctx, peer, cmd)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs[peer] = err
|
||||
return nil // per-peer error; do not cancel the group
|
||||
}
|
||||
out[peer] = o
|
||||
return nil
|
||||
})
|
||||
}
|
||||
_ = g.Wait()
|
||||
return out, errs
|
||||
}
|
||||
|
||||
// WriteAll writes the given files to each peer in parallel with bounded
|
||||
// concurrency (default 8, I-B-001). The files map is keyed by peer; each
|
||||
// peer's files are written sequentially (to preserve order and avoid
|
||||
// intra-peer races on shared paths). Returns per-peer errors; a peer
|
||||
// missing from the map or with a nil entry succeeded. The returned
|
||||
// error is non-nil only if the fan-out itself failed (context cancelled).
|
||||
func (t *Transport) WriteAll(ctx context.Context, peers []string, files map[string][]emitter.File) map[string]error {
|
||||
return t.WriteAllWithConcurrency(ctx, peers, files, DefaultFanoutConcurrency)
|
||||
}
|
||||
|
||||
// WriteAllWithConcurrency is WriteAll with an explicit concurrency limit.
|
||||
// A limit <= 0 uses DefaultFanoutConcurrency.
|
||||
func (t *Transport) WriteAllWithConcurrency(ctx context.Context, peers []string, files map[string][]emitter.File, concurrency int) map[string]error {
|
||||
if concurrency <= 0 {
|
||||
concurrency = DefaultFanoutConcurrency
|
||||
}
|
||||
errs := make(map[string]error, len(peers))
|
||||
var mu sync.Mutex
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(concurrency)
|
||||
for _, p := range peers {
|
||||
peer := p
|
||||
peerFiles := files[peer]
|
||||
g.Go(func() error {
|
||||
for _, f := range peerFiles {
|
||||
mode := parseMode(f.Mode)
|
||||
if _, err := t.WriteFileIdempotent(gctx, peer, f.Path, []byte(f.Content), mode); err != nil {
|
||||
mu.Lock()
|
||||
errs[peer] = fmt.Errorf("sshpush: write %s on %s: %w", f.Path, peer, err)
|
||||
mu.Unlock()
|
||||
return nil // per-peer error; do not cancel the group
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
_ = g.Wait()
|
||||
return errs
|
||||
}
|
||||
|
||||
// parseMode parses an octal mode string like "0644" into an os.FileMode.
|
||||
// Returns 0644 on parse failure (a safe default for non-executable
|
||||
// config files).
|
||||
func parseMode(s string) os.FileMode {
|
||||
var m uint32
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '7' {
|
||||
return 0o644
|
||||
}
|
||||
m = m<<3 | uint32(r-'0')
|
||||
}
|
||||
if m == 0 {
|
||||
return 0o644
|
||||
}
|
||||
return os.FileMode(m)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||
)
|
||||
|
||||
// --- ExecAll tests ---
|
||||
|
||||
func TestExecAll_AllSucceed(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
// Use one server for all peers (same addr).
|
||||
addr := srv.addr()
|
||||
peers := []string{addr, addr, addr}
|
||||
out, errs := tr.ExecAll(context.Background(), peers, "echo hello")
|
||||
for _, p := range peers {
|
||||
if e, ok := errs[p]; ok && e != nil {
|
||||
t.Errorf("peer %s: %v", p, e)
|
||||
}
|
||||
if string(out[p]) != "hello\n" {
|
||||
t.Errorf("out[%s] = %q, want hello\\n", p, out[p])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAll_OneFails(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
// Peer "bad" returns a permanent error via a mock session.
|
||||
goodPeers := []string{addr}
|
||||
badPeer := "127.0.0.1:1" // unreachable -> transient dial error, retried, fails
|
||||
peers := append(goodPeers, badPeer)
|
||||
out, errs := tr.ExecAll(context.Background(), peers, "echo hello")
|
||||
if string(out[addr]) != "hello\n" {
|
||||
t.Errorf("good peer out = %q, want hello\\n", out[addr])
|
||||
}
|
||||
if errs[badPeer] == nil {
|
||||
t.Error("bad peer should have an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAll_WithConcurrency(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
peers := []string{addr, addr, addr, addr}
|
||||
out, errs := tr.ExecAllWithConcurrency(context.Background(), peers, "echo hello", 2)
|
||||
for _, p := range peers {
|
||||
if e := errs[p]; e != nil {
|
||||
t.Errorf("peer %s: %v", p, e)
|
||||
}
|
||||
if string(out[p]) != "hello\n" {
|
||||
t.Errorf("out[%s] = %q", p, out[p])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAll_EmptyPeers(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
out, errs := tr.ExecAll(context.Background(), nil, "echo hello")
|
||||
if len(out) != 0 || len(errs) != 0 {
|
||||
t.Errorf("empty peers: out=%v errs=%v", out, errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAll_ContextCancelled(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
out, errs := tr.ExecAll(ctx, []string{addr, addr}, "echo hello")
|
||||
// With a cancelled context, all peers should fail.
|
||||
for _, p := range []string{addr, addr} {
|
||||
if errs[p] == nil && string(out[p]) == "" {
|
||||
// acceptable: either error or no output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- WriteAll tests ---
|
||||
|
||||
func TestWriteAll_AllSucceed(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
files := map[string][]emitter.File{
|
||||
addr: {
|
||||
{Path: "/w/a", Content: "alpha\n", Mode: "0644"},
|
||||
{Path: "/w/b", Content: "beta\n", Mode: "0644"},
|
||||
},
|
||||
}
|
||||
errs := tr.WriteAll(context.Background(), []string{addr}, files)
|
||||
for p, e := range errs {
|
||||
if e != nil {
|
||||
t.Errorf("peer %s: %v", p, e)
|
||||
}
|
||||
}
|
||||
srv.mu.Lock()
|
||||
if srv.files["/w/a"] != "alpha\n" {
|
||||
t.Errorf("file a = %q", srv.files["/w/a"])
|
||||
}
|
||||
if srv.files["/w/b"] != "beta\n" {
|
||||
t.Errorf("file b = %q", srv.files["/w/b"])
|
||||
}
|
||||
srv.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestWriteAll_OnePeerFails(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
bad := "127.0.0.1:1"
|
||||
files := map[string][]emitter.File{
|
||||
addr: {{Path: "/ok/f", Content: "ok\n", Mode: "0644"}},
|
||||
bad: {{Path: "/fail/f", Content: "fail\n", Mode: "0644"}},
|
||||
}
|
||||
errs := tr.WriteAll(context.Background(), []string{addr, bad}, files)
|
||||
if errs[addr] != nil {
|
||||
t.Errorf("good peer should not have error, got %v", errs[addr])
|
||||
}
|
||||
if errs[bad] == nil {
|
||||
t.Error("bad peer should have error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAll_EmptyPeers(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
errs := tr.WriteAll(context.Background(), nil, nil)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("empty peers: errs=%v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAll_WithConcurrency(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
files := map[string][]emitter.File{
|
||||
addr: {{Path: "/c/f", Content: "c\n", Mode: "0644"}},
|
||||
}
|
||||
errs := tr.WriteAllWithConcurrency(context.Background(), []string{addr}, files, 4)
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
t.Errorf("peer err: %v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAll_Idempotent(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
srv.mu.Lock()
|
||||
srv.files["/i/f"] = "same\n"
|
||||
srv.mu.Unlock()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
files := map[string][]emitter.File{
|
||||
addr: {{Path: "/i/f", Content: "same\n", Mode: "0644"}},
|
||||
}
|
||||
// Capture writes to verify idempotent skip.
|
||||
var writes int64
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
return &writeCountingSession{srv: srv, writes: &writes}, nil
|
||||
})
|
||||
// Pre-populate pool.
|
||||
client, err := tr.dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(addr, client)
|
||||
errs := tr.WriteAll(context.Background(), []string{addr}, files)
|
||||
if errs[addr] != nil {
|
||||
t.Errorf("WriteAll err: %v", errs[addr])
|
||||
}
|
||||
// sha256sum returns a hash that matches -> no write command.
|
||||
srv.mu.Lock()
|
||||
content := srv.files["/i/f"]
|
||||
srv.mu.Unlock()
|
||||
if content != "same\n" {
|
||||
t.Errorf("content changed to %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCountingSession counts how many write commands (mkdir + cat >)
|
||||
// are issued; returns the server's file content for sha256sum.
|
||||
type writeCountingSession struct {
|
||||
srv *fakeSSHServer
|
||||
writes *int64
|
||||
}
|
||||
|
||||
func (w *writeCountingSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||
c := trim(cmd)
|
||||
if startsWith(c, "sha256sum ") {
|
||||
path := unquote(trimPrefix(c, "sha256sum "))
|
||||
path = trimSuffix(path, " 2>/dev/null")
|
||||
w.srv.mu.Lock()
|
||||
content, ok := w.srv.files[path]
|
||||
w.srv.mu.Unlock()
|
||||
if !ok {
|
||||
return []byte(""), nil
|
||||
}
|
||||
sum := sha256HexStr([]byte(content))
|
||||
return []byte(sum + " " + path + "\n"), nil
|
||||
}
|
||||
if startsWith(c, "mkdir -p ") && contains(c, "cat >") {
|
||||
atomic.AddInt64(w.writes, 1)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (w *writeCountingSession) Close() error { return nil }
|
||||
|
||||
// Local string helpers to avoid importing strings in a way that
|
||||
// conflicts with the test's existing imports.
|
||||
func trim(s string) string {
|
||||
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||
s = s[1:]
|
||||
}
|
||||
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
func startsWith(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }
|
||||
func contains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func trimPrefix(s, prefix string) string {
|
||||
if startsWith(s, prefix) {
|
||||
return s[len(prefix):]
|
||||
}
|
||||
return s
|
||||
}
|
||||
func trimSuffix(s, suffix string) string {
|
||||
if len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix {
|
||||
return s[:len(s)-len(suffix)]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDefaultFanoutConcurrency(t *testing.T) {
|
||||
if DefaultFanoutConcurrency != 8 {
|
||||
t.Errorf("DefaultFanoutConcurrency = %d, want 8", DefaultFanoutConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMode_Fanout(t *testing.T) {
|
||||
// parseMode is in fanout.go; sanity-check here too.
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"0644", "644"},
|
||||
{"0755", "755"},
|
||||
{"bad", "644"},
|
||||
} {
|
||||
got := fmt.Sprintf("%o", parseMode(tc.in))
|
||||
if got != tc.want {
|
||||
t.Errorf("parseMode(%q) = %s, want %s", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = errors.New
|
||||
@@ -0,0 +1,101 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteFileIdempotent writes content to peer:path atomically (write-to-tmp
|
||||
// + mv, REQ-074) with mode, but only if the remote file's SHA-256 differs
|
||||
// from the local content's SHA-256 (C-18 content-addressed idempotency —
|
||||
// the SSH-push equivalent of the v0.8 X-Orca-Idempotency-Key).
|
||||
//
|
||||
// Returns written=true if the file was written, written=false if the
|
||||
// content already matched (skip). The default per-SCP timeout is
|
||||
// SCPTimeout (I-B-001).
|
||||
//
|
||||
// Atomicity: the content is written to a temp file in the same directory
|
||||
// as the target, then `mv`'d into place. The temp file is mode-appended
|
||||
// (e.g. `/etc/orca/foo.conf.orca-tmp-<rand>`) so the rename is atomic on
|
||||
// POSIX filesystems.
|
||||
func (t *Transport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
|
||||
localHash := sha256Hex(content)
|
||||
remoteHash, err := t.remoteSHA256(ctx, peer, path)
|
||||
if err == nil && remoteHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||
return false, nil
|
||||
}
|
||||
if err := t.writeFile(ctx, peer, path, content, mode); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeFile writes content to peer:path atomically (write-to-tmp + mv).
|
||||
// It writes the content via a single SSH exec (cat heredoc + chmod + mv),
|
||||
// keeping the transfer in one round-trip. The temp file lives next to the
|
||||
// target so the rename is atomic.
|
||||
func (t *Transport) writeFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
|
||||
dir, base := splitDir(path)
|
||||
tmpName := fmt.Sprintf(".orca-tmp-%s", randomToken(8))
|
||||
tmpPath := base + "/" + tmpName
|
||||
if dir == "" {
|
||||
tmpPath = tmpName
|
||||
}
|
||||
// Build the remote command: mkdir -p <dir> && cat > <tmp> <<'EOF'
|
||||
// ... EOF && chmod <mode> <tmp> && mv <tmp> <path>. The heredoc
|
||||
// delimiter is chosen to not appear in the content (we use a fixed
|
||||
// marker; content with the marker would break, but the marker is
|
||||
// sufficiently unusual).
|
||||
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
||||
modeStr := fmt.Sprintf("%04o", uint32(mode.Perm()))
|
||||
cmd := fmt.Sprintf(
|
||||
"mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s && mv -f %s %s",
|
||||
shellQuote(base),
|
||||
shellQuote(tmpPath),
|
||||
eof,
|
||||
string(content),
|
||||
eof,
|
||||
modeStr,
|
||||
shellQuote(tmpPath),
|
||||
shellQuote(tmpPath),
|
||||
shellQuote(path),
|
||||
)
|
||||
execCtx, cancel := context.WithTimeout(ctx, SCPTimeout)
|
||||
defer cancel()
|
||||
if _, err := t.execWithRetry(execCtx, peer, cmd, true); err != nil {
|
||||
return fmt.Errorf("sshpush: write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sha256Hex returns the lowercase hex SHA-256 digest of b.
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// splitDir returns the directory and the directory itself (for mkdir).
|
||||
// For "/etc/orca/foo.conf" it returns ("/etc/orca", "/etc/orca"). For
|
||||
// "foo.conf" it returns ("", ".").
|
||||
func splitDir(path string) (dir, base string) {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
return "", "."
|
||||
}
|
||||
return path[:idx], path[:idx]
|
||||
}
|
||||
|
||||
// randomToken returns a random hex token of the given byte length. Used
|
||||
// for temp-file naming to avoid collisions under parallel fan-out.
|
||||
func randomToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = byte(rand.Intn(256))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestSha256Hex(t *testing.T) {
|
||||
got := sha256Hex([]byte("hello"))
|
||||
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
if got != want {
|
||||
t.Errorf("sha256Hex = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDir(t *testing.T) {
|
||||
dir, base := splitDir("/etc/orca/foo.conf")
|
||||
if dir != "/etc/orca" || base != "/etc/orca" {
|
||||
t.Errorf("splitDir(/etc/orca/foo.conf) = (%q,%q), want (/etc/orca,/etc/orca)", dir, base)
|
||||
}
|
||||
dir, base = splitDir("foo.conf")
|
||||
if dir != "" || base != "." {
|
||||
t.Errorf("splitDir(foo.conf) = (%q,%q), want (\"\",.)", dir, base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomToken(t *testing.T) {
|
||||
a := randomToken(8)
|
||||
b := randomToken(8)
|
||||
if a == b {
|
||||
t.Error("randomToken returned same value twice")
|
||||
}
|
||||
if len(a) != 16 { // 8 bytes hex = 16 chars
|
||||
t.Errorf("randomToken(8) len = %d, want 16", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
// --- WriteFileIdempotent tests via the fake SSH server ---
|
||||
|
||||
func TestWriteFileIdempotent_WritesWhenFileMissing(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
content := []byte("first content\n")
|
||||
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/a.conf", content, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||
}
|
||||
if !written {
|
||||
t.Error("written=false, want true (file was missing)")
|
||||
}
|
||||
srv.mu.Lock()
|
||||
got := srv.files["/etc/orca/a.conf"]
|
||||
srv.mu.Unlock()
|
||||
if got != string(content) {
|
||||
t.Errorf("remote file = %q, want %q", got, string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileIdempotent_SkipsWhenContentMatches(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
content := []byte("same content\n")
|
||||
srv.mu.Lock()
|
||||
srv.files["/etc/orca/b.conf"] = string(content)
|
||||
srv.mu.Unlock()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/b.conf", content, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||
}
|
||||
if written {
|
||||
t.Error("written=true, want false (content matched)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileIdempotent_WritesWhenContentDiffers(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
srv.mu.Lock()
|
||||
srv.files["/etc/orca/c.conf"] = "old content\n"
|
||||
srv.mu.Unlock()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
newContent := []byte("new content\n")
|
||||
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/c.conf", newContent, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||
}
|
||||
if !written {
|
||||
t.Error("written=false, want true (content differed)")
|
||||
}
|
||||
srv.mu.Lock()
|
||||
got := srv.files["/etc/orca/c.conf"]
|
||||
srv.mu.Unlock()
|
||||
if got != string(newContent) {
|
||||
t.Errorf("remote file = %q, want %q", got, string(newContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFile_DelegatesToIdempotent(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
content := []byte("delegated\n")
|
||||
if err := tr.WriteFile(context.Background(), srv.addr(), "/etc/orca/d.conf", content, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
got := srv.files["/etc/orca/d.conf"]
|
||||
srv.mu.Unlock()
|
||||
if got != string(content) {
|
||||
t.Errorf("remote file = %q, want %q", got, string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure-logic idempotency tests via mock session (no SSH server) ---
|
||||
|
||||
// mockHashSession returns the hash of the file matching the sha256sum
|
||||
// command's path argument; for write commands (mkdir + cat >), it
|
||||
// records the write. This lets us test the idempotency decision logic
|
||||
// without a real SSH server.
|
||||
type mockHashSession struct {
|
||||
files map[string]string
|
||||
out []byte
|
||||
err error
|
||||
cmd string
|
||||
writeHook func(cmd string)
|
||||
}
|
||||
|
||||
func (m *mockHashSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||
m.cmd = cmd
|
||||
c := strings.TrimSpace(cmd)
|
||||
if strings.HasPrefix(c, "sha256sum ") {
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(c, "sha256sum "))
|
||||
rest = strings.TrimSuffix(rest, " 2>/dev/null")
|
||||
rest = strings.TrimSpace(rest)
|
||||
path := unquote(rest)
|
||||
content, ok := m.files[path]
|
||||
if !ok {
|
||||
return []byte(""), nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return []byte(hex.EncodeToString(sum[:]) + " " + path + "\n"), nil
|
||||
}
|
||||
if strings.HasPrefix(c, "mkdir -p ") && strings.Contains(c, "cat >") {
|
||||
if m.writeHook != nil {
|
||||
m.writeHook(c)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return m.out, m.err
|
||||
}
|
||||
func (m *mockHashSession) Close() error { return nil }
|
||||
|
||||
func TestWriteFileIdempotent_MockSkip(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
files := map[string]string{"/x/f": "match"}
|
||||
var writes int
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
return &mockHashSession{
|
||||
files: files,
|
||||
writeHook: func(string) { writes++ },
|
||||
}, nil
|
||||
})
|
||||
client, err := tr.dial(srv.addr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(srv.addr(), client)
|
||||
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("match"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||
}
|
||||
if written {
|
||||
t.Error("written=true, want false (hash matched)")
|
||||
}
|
||||
if writes != 0 {
|
||||
t.Errorf("writes = %d, want 0 (no write on hash match)", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileIdempotent_MockWrite(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
files := map[string]string{"/x/f": "old"}
|
||||
var writes int
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
return &mockHashSession{
|
||||
files: files,
|
||||
writeHook: func(string) { writes++ },
|
||||
}, nil
|
||||
})
|
||||
client, err := tr.dial(srv.addr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(srv.addr(), client)
|
||||
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("new"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||
}
|
||||
if !written {
|
||||
t.Error("written=false, want true (hash differed)")
|
||||
}
|
||||
if writes != 1 {
|
||||
t.Errorf("writes = %d, want 1", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileIdempotent_MockMissingFile(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
files := map[string]string{}
|
||||
var writes int
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
return &mockHashSession{
|
||||
files: files,
|
||||
writeHook: func(string) { writes++ },
|
||||
}, nil
|
||||
})
|
||||
client, err := tr.dial(srv.addr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(srv.addr(), client)
|
||||
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/new", []byte("fresh"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||
}
|
||||
if !written {
|
||||
t.Error("written=false, want true (file missing)")
|
||||
}
|
||||
if writes != 1 {
|
||||
t.Errorf("writes = %d, want 1", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSHA256_ParsesDigest(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
srv.mu.Lock()
|
||||
srv.files["/x/h"] = "abc"
|
||||
srv.mu.Unlock()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
got, err := tr.remoteSHA256(context.Background(), srv.addr(), "/x/h")
|
||||
if err != nil {
|
||||
t.Fatalf("remoteSHA256: %v", err)
|
||||
}
|
||||
want := fmt.Sprintf("%x", sha256.Sum256([]byte("abc")))
|
||||
if got != want {
|
||||
t.Errorf("remoteSHA256 = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSHA256_MissingFile(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
got, err := t_remoteSHA256_noErr(t, tr, srv.addr(), "/missing")
|
||||
if err != nil {
|
||||
t.Fatalf("remoteSHA256: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("remoteSHA256 = %q, want empty for missing file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func t_remoteSHA256_noErr(t *testing.T, tr *Transport, peer, path string) (string, error) {
|
||||
t.Helper()
|
||||
return tr.remoteSHA256(context.Background(), peer, path)
|
||||
}
|
||||
|
||||
func TestWriteFile_ModeApplied(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
if err := tr.WriteFile(context.Background(), srv.addr(), "/m/f", []byte("mode"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
got := srv.files["/m/f"]
|
||||
srv.mu.Unlock()
|
||||
if got != "mode" {
|
||||
t.Errorf("content = %q, want mode", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileIdempotent_ExecError(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
return &mockSession{err: fmt.Errorf("%w: boom", ErrPermanent)}, nil
|
||||
})
|
||||
client, err := tr.dial(srv.addr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(srv.addr(), client)
|
||||
_, err = tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("z"), 0o644)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMode(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want os.FileMode
|
||||
}{
|
||||
{"0644", 0o644},
|
||||
{"0755", 0o755},
|
||||
{"0600", 0o600},
|
||||
{"bad", 0o644},
|
||||
{"", 0o644},
|
||||
{"0", 0o644},
|
||||
} {
|
||||
got := parseMode(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("parseMode(%q) = %o, want %o", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = errors.New
|
||||
@@ -0,0 +1,483 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||
)
|
||||
|
||||
// Default timeouts and retry parameters (REQ-073, I-B-001).
|
||||
const (
|
||||
// ExecTimeout is the default per-exec timeout for a single SSH
|
||||
// command (I-B-001).
|
||||
ExecTimeout = 10 * time.Second
|
||||
// SCPTimeout is the default per-SCP timeout for a single file
|
||||
// transfer (I-B-001).
|
||||
SCPTimeout = 30 * time.Second
|
||||
// DialTimeout is the default SSH dial timeout.
|
||||
DialTimeout = 15 * time.Second
|
||||
// RetryInitial is the first backoff interval (v0.8 transport/retry.go).
|
||||
RetryInitial = 100 * time.Millisecond
|
||||
// RetryMax is the cap on backoff between attempts.
|
||||
RetryMax = 5 * time.Second
|
||||
// RetryMaxAttempts is the total attempt count (including the first).
|
||||
RetryMaxAttempts = 5
|
||||
)
|
||||
|
||||
// Sentinel errors. ErrTransient marks a transient failure worth
|
||||
// retrying; ErrPermanent marks a non-retryable failure (auth, host-key
|
||||
// mismatch, validation). These mirror the v0.8 transport sentinels
|
||||
// (reimplemented here since internal/transport is not imported).
|
||||
var (
|
||||
ErrTransient = errors.New("sshpush: transient error")
|
||||
ErrPermanent = errors.New("sshpush: permanent error")
|
||||
ErrNotConnected = errors.New("sshpush: not connected")
|
||||
)
|
||||
|
||||
// Transport is the SSH-push transport (REQ-073). It reuses one
|
||||
// *ssh.Client per peer across multiple operations within a single CLI
|
||||
// invocation (I-B-001). The zero value is NOT usable; construct one with
|
||||
// NewTransport.
|
||||
type Transport struct {
|
||||
// pool caches *ssh.Client per peer address ("host:port").
|
||||
pool sync.Map
|
||||
// keyPath is the SSH private key path (Ed25519, D-037).
|
||||
keyPath string
|
||||
// knownHostsPath is the v0.9 known_hosts path (paths.KnownHostsPath()
|
||||
// = ClusterDir()/known_hosts). It is stored for the v0.10-P14 migration
|
||||
// when proxmox.TOFUHostKeyCallback will accept a path parameter; today
|
||||
// the callback reads certpaths.KnownHostsPath() (the v0.8 flat layout)
|
||||
// directly, so this field is not yet read by dial(). Tests set
|
||||
// $ORCA_HOME so certpaths.KnownHostsPath() resolves under the temp dir.
|
||||
knownHostsPath string
|
||||
// user is the remote SSH user (default "orca", D-037).
|
||||
user string
|
||||
// signer is the parsed SSH private key signer, set lazily on first
|
||||
// dial.
|
||||
signer ssh.Signer
|
||||
signErr error
|
||||
// signerOnce guards signer initialization.
|
||||
signerOnce sync.Once
|
||||
|
||||
// dialer is the SSH dialer. Tests override it to inject a mock
|
||||
// server. The default uses ssh.DialContext via the context-aware
|
||||
// wrapper.
|
||||
dialer sshDialer
|
||||
|
||||
// sessionFactory returns a new session for a given client. Tests
|
||||
// override it to inject mock sessions without a real *ssh.Client.
|
||||
// When nil, the default (*ssh.Client).NewSession is used.
|
||||
sessionFactory func(*ssh.Client) (sshSession, error)
|
||||
|
||||
// mu guards the closed flag (pool iteration is sync.Map.Range).
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// sshSession is the minimal *ssh.Session surface the transport uses.
|
||||
// It lets tests substitute a mock without a real SSH server.
|
||||
type sshSession interface {
|
||||
CombinedOutput(cmd string) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// sshDialer is the SSH dialer interface (mirrors proxmox.sshDialerType).
|
||||
// The default uses ssh.Dial; tests inject mocks that return a fake
|
||||
// *ssh.Client or an error.
|
||||
type sshDialer interface {
|
||||
DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error)
|
||||
}
|
||||
|
||||
// defaultSSHDialer wraps ssh.Dial with a context-aware connect timeout.
|
||||
type defaultSSHDialer struct{}
|
||||
|
||||
func (defaultSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||
d := net.Dialer{Timeout: config.Timeout}
|
||||
if d.Timeout == 0 {
|
||||
d.Timeout = DialTimeout
|
||||
}
|
||||
conn, err := d.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ssh.NewClient(sshConn, chans, reqs), nil
|
||||
}
|
||||
|
||||
// NewTransport returns a Transport configured with the given SSH
|
||||
// private key path and known_hosts path. The known_hosts path is the v0.9
|
||||
// location (paths.KnownHostsPath); it is stored for the v0.10-P14
|
||||
// migration when the TOFU callback will accept a path parameter. Today
|
||||
// dial() delegates host-key verification to proxmox.TOFUHostKeyCallback,
|
||||
// which reads certpaths.KnownHostsPath() (the v0.8 flat layout under
|
||||
// $ORCA_HOME) directly — so callers must ensure $ORCA_HOME points at the
|
||||
// cluster root (the CLI sets this up). The remote user defaults to
|
||||
// "orca" (D-037); override with SetUser. The dialer defaults to the
|
||||
// real ssh.Dial-based dialer; tests call SetDialer to inject a mock.
|
||||
func NewTransport(keyPath, knownHostsPath string) *Transport {
|
||||
return &Transport{
|
||||
keyPath: keyPath,
|
||||
knownHostsPath: knownHostsPath,
|
||||
user: "orca",
|
||||
dialer: defaultSSHDialer{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetUser overrides the remote SSH user (default "orca").
|
||||
func (t *Transport) SetUser(user string) {
|
||||
if user != "" {
|
||||
t.user = user
|
||||
}
|
||||
}
|
||||
|
||||
// SetDialer overrides the SSH dialer (for tests).
|
||||
func (t *Transport) SetDialer(d sshDialer) {
|
||||
if d != nil {
|
||||
t.dialer = d
|
||||
}
|
||||
}
|
||||
|
||||
// SetSessionFactory overrides the session factory (for tests). The
|
||||
// factory is called per-exec/write/read to obtain a fresh session; it
|
||||
// must close the session when the test mock is done, or the transport
|
||||
// will call Close on the returned session.
|
||||
func (t *Transport) SetSessionFactory(f func(*ssh.Client) (sshSession, error)) {
|
||||
t.sessionFactory = f
|
||||
}
|
||||
|
||||
// dial returns the cached *ssh.Client for peer, dialing and caching on
|
||||
// first use (I-B-001 connection pooling). Returns an error if the dial
|
||||
// fails or the transport is closed.
|
||||
func (t *Transport) dial(peer string) (*ssh.Client, error) {
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return nil, ErrPermanent
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if c, ok := t.pool.Load(peer); ok {
|
||||
return c.(*ssh.Client), nil
|
||||
}
|
||||
// Lazily parse the private key signer (once across all dials).
|
||||
t.signerOnce.Do(func() {
|
||||
keyBytes, err := os.ReadFile(t.keyPath)
|
||||
if err != nil {
|
||||
t.signErr = fmt.Errorf("sshpush: read key %s: %w", t.keyPath, err)
|
||||
return
|
||||
}
|
||||
s, err := ssh.ParsePrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
t.signErr = fmt.Errorf("sshpush: parse key: %w", err)
|
||||
return
|
||||
}
|
||||
t.signer = s
|
||||
})
|
||||
if t.signErr != nil {
|
||||
return nil, t.signErr
|
||||
}
|
||||
// Host-key verification reuses the v0.8 TOFU wrapper (D-035). The
|
||||
// known_hosts file is flock-protected inside the callback on
|
||||
// first-connect capture, so we do NOT re-lock here.
|
||||
cb, err := proxmox.TOFUHostKeyCallback(peer, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sshpush: host-key callback: %w", err)
|
||||
}
|
||||
config := &ssh.ClientConfig{
|
||||
User: t.user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(t.signer)},
|
||||
HostKeyCallback: cb,
|
||||
Timeout: DialTimeout,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), DialTimeout)
|
||||
defer cancel()
|
||||
client, err := t.dialer.DialContext(ctx, "tcp", peer, config)
|
||||
if err != nil {
|
||||
return nil, classifyDialErr(err)
|
||||
}
|
||||
// Race: two goroutines dialing the same peer concurrently both
|
||||
// create a client. Last-wins; the loser is closed. This is rare
|
||||
// (dial is rare and the pool hit short-circuits) and harmless.
|
||||
if existing, loaded := t.pool.LoadOrStore(peer, client); loaded {
|
||||
_ = client.Close()
|
||||
return existing.(*ssh.Client), nil
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Exec runs cmd on peer over SSH and returns its combined output. The
|
||||
// default per-exec timeout is ExecTimeout (I-B-001); override by
|
||||
// passing a context with a shorter deadline. Transient failures are
|
||||
// retried with exponential backoff (100ms ×2, cap 5s, max 5 attempts —
|
||||
// the v0.8 transport/retry.go pattern, reimplemented here).
|
||||
func (t *Transport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||
return t.execWithRetry(ctx, peer, cmd, true)
|
||||
}
|
||||
|
||||
// execWithRetry runs the exec with retry. exec is treated as
|
||||
// idempotent (read-only) for retry purposes; the idempotency helpers
|
||||
// (WriteFileIdempotent) handle writes.
|
||||
func (t *Transport) execWithRetry(ctx context.Context, peer string, cmd string, idempotent bool) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= RetryMaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := t.execOnce(ctx, peer, cmd)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if errors.Is(err, ErrPermanent) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == RetryMaxAttempts {
|
||||
break
|
||||
}
|
||||
if !isTransient(err) {
|
||||
return nil, err
|
||||
}
|
||||
wait := backoff(RetryInitial, RetryMax, attempt)
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// execOnce runs the command a single time against peer.
|
||||
func (t *Transport) execOnce(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||
client, err := t.dial(peer)
|
||||
if err != nil {
|
||||
return nil, classifyDialErr(err)
|
||||
}
|
||||
sess, err := t.newSession(client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sshpush: new session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
type result struct {
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
out, err := sess.CombinedOutput(cmd)
|
||||
ch <- result{out, err}
|
||||
}()
|
||||
timeout := ExecTimeout
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(dl); remaining > 0 && remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("sshpush: exec timeout after %s: %w", timeout, ErrTransient)
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
return r.out, classifyExecErr(r.err)
|
||||
}
|
||||
return r.out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// newSession returns a session for client, using the override factory
|
||||
// when set (tests), otherwise the real *ssh.Client.NewSession.
|
||||
func (t *Transport) newSession(client *ssh.Client) (sshSession, error) {
|
||||
if t.sessionFactory != nil {
|
||||
return t.sessionFactory(client)
|
||||
}
|
||||
s, err := client.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &realSession{Session: s}, nil
|
||||
}
|
||||
|
||||
// realSession wraps *ssh.Session to satisfy the sshSession interface.
|
||||
type realSession struct {
|
||||
*ssh.Session
|
||||
}
|
||||
|
||||
func (r *realSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||
return r.Session.CombinedOutput(cmd)
|
||||
}
|
||||
|
||||
// WriteFile SCPs content to peer:path atomically (write-to-tmp + mv,
|
||||
// REQ-074). The default per-SCP timeout is SCPTimeout (I-B-001).
|
||||
// Idempotency: if the file already exists with the same SHA-256, the
|
||||
// write is skipped (C-18). Use WriteFileIdempotent for the explicit
|
||||
// written/skipped result.
|
||||
func (t *Transport) WriteFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
|
||||
_, err := t.WriteFileIdempotent(ctx, peer, path, content, mode)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadFile reads the file at peer:path via SSH cat.
|
||||
func (t *Transport) ReadFile(ctx context.Context, peer string, path string) ([]byte, error) {
|
||||
cmd := fmt.Sprintf("cat %s", shellQuote(path))
|
||||
out, err := t.Exec(ctx, peer, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close closes all pooled SSH clients (REQ-073). Safe to call
|
||||
// multiple times; subsequent calls are no-ops.
|
||||
func (t *Transport) Close() error {
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
t.closed = true
|
||||
t.mu.Unlock()
|
||||
var firstErr error
|
||||
t.pool.Range(func(key, value any) bool {
|
||||
if c, ok := value.(*ssh.Client); ok {
|
||||
if err := c.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
t.pool.Delete(key)
|
||||
return true
|
||||
})
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// backoff returns the wait duration for the n-th attempt (1-indexed).
|
||||
// Formula: min(Initial * 2^(n-1), Max), with up to 25% jitter (matches
|
||||
// v0.8 transport/retry.go).
|
||||
func backoff(initial, max time.Duration, n int) time.Duration {
|
||||
d := initial
|
||||
for i := 1; i < n; i++ {
|
||||
d *= 2
|
||||
if d > max {
|
||||
d = max
|
||||
break
|
||||
}
|
||||
}
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
jitter := time.Duration(rand.Int63n(int64(d) / 2))
|
||||
d = d - d/4 + jitter
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// isTransient reports whether err looks like a transient failure worth
|
||||
// retrying (mirrors v0.8 transport.IsTransient, reimplemented here).
|
||||
func isTransient(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, ErrTransient) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, ErrPermanent) {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
for _, sub := range []string{
|
||||
"connection refused", "i/o timeout", "EOF",
|
||||
"no such host", "connection reset", "timeout",
|
||||
"deadline exceeded", "temporarily unavailable",
|
||||
} {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyDialErr converts a raw ssh.Dial error into a transport error
|
||||
// (transient vs permanent). Auth failures and host-key mismatches are
|
||||
// permanent; everything else is transient.
|
||||
func classifyDialErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
s := err.Error()
|
||||
if strings.Contains(s, "unable to authenticate") || strings.Contains(s, "handshake failed") {
|
||||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||
}
|
||||
if strings.Contains(s, "host key") && strings.Contains(s, "mismatch") {
|
||||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||
}
|
||||
if strings.Contains(s, "knownhosts") {
|
||||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||||
}
|
||||
|
||||
// classifyExecErr converts a raw session exec error into a transport
|
||||
// error. Non-zero exit codes are NOT transient (the command ran; the
|
||||
// failure is logical, not network). Session-creation failures and
|
||||
// network-level errors are transient.
|
||||
func classifyExecErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *ssh.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("%w: exit %d", ErrPermanent, exitErr.ExitStatus())
|
||||
}
|
||||
s := err.Error()
|
||||
for _, sub := range []string{"EOF", "session closed", "channel closed"} {
|
||||
if strings.Contains(s, sub) {
|
||||
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||
}
|
||||
|
||||
// shellQuote single-quotes a path for safe shell interpolation. It
|
||||
// escapes embedded single-quotes via the standard '\” idiom.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// remoteSHA256 returns the SHA-256 of the file at peer:path via SSH
|
||||
// `sha256sum`, or ("", error) if the file is missing or the command
|
||||
// fails. The returned hash is the hex digest (lowercase, no filename).
|
||||
func (t *Transport) remoteSHA256(ctx context.Context, peer string, path string) (string, error) {
|
||||
cmd := fmt.Sprintf("sha256sum %s 2>/dev/null", shellQuote(path))
|
||||
out, err := t.execWithRetry(ctx, peer, cmd, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out = bytes.TrimSpace(out)
|
||||
if len(out) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return fields[0], nil
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// --- fakeSSHServer: a minimal in-process SSH server for hermetic tests.
|
||||
|
||||
type fakeSSHServer struct {
|
||||
listener net.Listener
|
||||
config *ssh.ServerConfig
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
files map[string]string
|
||||
hostKey ssh.Signer
|
||||
cmdCount int64
|
||||
execDelay time.Duration
|
||||
}
|
||||
|
||||
func newFakeSSHServer(t *testing.T) *fakeSSHServer {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("ssh signer: %v", err)
|
||||
}
|
||||
config := &ssh.ServerConfig{
|
||||
NoClientAuth: true,
|
||||
}
|
||||
config.AddHostKey(signer)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv := &fakeSSHServer{
|
||||
listener: ln,
|
||||
config: config,
|
||||
done: make(chan struct{}),
|
||||
files: make(map[string]string),
|
||||
hostKey: signer,
|
||||
}
|
||||
go srv.serve()
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) addr() string { return s.listener.Addr().String() }
|
||||
func (s *fakeSSHServer) hostPublicKey() ssh.PublicKey { return s.hostKey.PublicKey() }
|
||||
|
||||
func (s *fakeSSHServer) close() {
|
||||
_ = s.listener.Close()
|
||||
<-s.done
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) setExecDelay(d time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.execDelay = d
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) serve() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
close(s.done)
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handle(netConn net.Conn) {
|
||||
defer netConn.Close()
|
||||
_, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
newChan.Reject(ssh.UnknownChannelType, "only session")
|
||||
continue
|
||||
}
|
||||
go s.handleSession(newChan)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handleSession(newChan ssh.NewChannel) {
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
for req := range reqs {
|
||||
if req.Type != "exec" {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
var execReq struct{ Command string }
|
||||
if err := ssh.Unmarshal(req.Payload, &execReq); err != nil {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
atomic.AddInt64(&s.cmdCount, 1)
|
||||
s.mu.Lock()
|
||||
delay := s.execDelay
|
||||
s.mu.Unlock()
|
||||
if delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
out, code := s.runCommand(execReq.Command)
|
||||
_, _ = ch.Write(out)
|
||||
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// runCommand implements the minimal command surface the transport uses:
|
||||
// echo (for exec tests), sha256sum (for idempotency), cat (read), and the
|
||||
// heredoc-based write (cat > tmp <<EOF ... EOF && chmod ... && mv ...).
|
||||
func (s *fakeSSHServer) runCommand(cmd string) ([]byte, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
switch {
|
||||
case trimmed == "echo hello":
|
||||
return []byte("hello\n"), 0
|
||||
case strings.HasPrefix(trimmed, "sha256sum "):
|
||||
// Format: sha256sum '/path' 2>/dev/null
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "sha256sum "))
|
||||
rest = strings.TrimSuffix(rest, " 2>/dev/null")
|
||||
rest = strings.TrimSpace(rest)
|
||||
path := unquote(rest)
|
||||
content, ok := s.files[path]
|
||||
if !ok {
|
||||
// `2>/dev/null` swallows the error; sha256sum exits 1 but
|
||||
// stderr is suppressed. The transport treats empty output as
|
||||
// "file missing" (no hash), so return ("", 0).
|
||||
return []byte(""), 0
|
||||
}
|
||||
sum := sha256HexStr([]byte(content))
|
||||
return []byte(sum + " " + path + "\n"), 0
|
||||
case strings.HasPrefix(trimmed, "cat '"):
|
||||
path := unquote(strings.TrimPrefix(trimmed, "cat "))
|
||||
content, ok := s.files[path]
|
||||
if !ok {
|
||||
return []byte("cat: " + path + ": No such file or directory\n"), 1
|
||||
}
|
||||
return []byte(content), 0
|
||||
case strings.HasPrefix(trimmed, "mkdir -p ") && strings.Contains(trimmed, "cat >"):
|
||||
return s.handleWrite(trimmed)
|
||||
default:
|
||||
return []byte("sh: command not found\n"), 127
|
||||
}
|
||||
}
|
||||
|
||||
// handleWrite parses the heredoc write command produced by writeFile.
|
||||
// Command format:
|
||||
//
|
||||
// mkdir -p '<dir>' && cat > '<tmp>' <<'ORCA_PUSH_EOF_a1b2c3'
|
||||
// <content>
|
||||
// ORCA_PUSH_EOF_a1b2c3
|
||||
// chmod <mode> '<tmp>' && mv -f '<tmp>' '<path>'
|
||||
func (s *fakeSSHServer) handleWrite(cmd string) ([]byte, int) {
|
||||
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
||||
// Find the opening heredoc line: ... <<'EOF'\n
|
||||
openerIdx := strings.Index(cmd, "<<'"+eof+"'")
|
||||
if openerIdx < 0 {
|
||||
return []byte("sh: no heredoc opener\n"), 1
|
||||
}
|
||||
// Body starts after the opener line's newline.
|
||||
rest := cmd[openerIdx+len("<<'"+eof+"'"):]
|
||||
nl := strings.Index(rest, "\n")
|
||||
if nl < 0 {
|
||||
return []byte("sh: no body start\n"), 1
|
||||
}
|
||||
body := rest[nl+1:]
|
||||
// Body ends at the closing EOF marker on its own line.
|
||||
closeIdx := strings.Index(body, "\n"+eof+"\n")
|
||||
if closeIdx < 0 {
|
||||
// Maybe EOF is at the end without trailing newline.
|
||||
closeIdx = strings.Index(body, "\n"+eof)
|
||||
if closeIdx < 0 {
|
||||
return []byte("sh: no heredoc close\n"), 1
|
||||
}
|
||||
body = body[:closeIdx]
|
||||
} else {
|
||||
body = body[:closeIdx]
|
||||
}
|
||||
// Find the mv target: last quoted arg of "mv -f 'tmp' 'path'".
|
||||
mvIdx := strings.LastIndex(cmd, "mv -f ")
|
||||
if mvIdx < 0 {
|
||||
return []byte("sh: no mv\n"), 1
|
||||
}
|
||||
tail := cmd[mvIdx+len("mv -f "):]
|
||||
parts := splitQuoted(tail)
|
||||
if len(parts) < 2 {
|
||||
return []byte("sh: bad mv args\n"), 1
|
||||
}
|
||||
target := parts[1]
|
||||
s.files[target] = body
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
// splitQuoted splits a string of the form "'a' 'b'" into ["a","b"].
|
||||
func splitQuoted(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
in := false
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
if in {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
in = !in
|
||||
continue
|
||||
}
|
||||
if in {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sha256HexStr is a test-local copy of the sha256Hex helper.
|
||||
func sha256HexStr(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
// --- test helpers for transport setup ---
|
||||
|
||||
// setupORCAHome creates a temp ORCA_HOME with an empty known_hosts (at
|
||||
// the v0.8 flat location $ORCA_HOME/known_hosts, which is where
|
||||
// proxmox.TOFUHostKeyCallback reads via certpaths.KnownHostsPath()) and a
|
||||
// generated Ed25519 SSH key, returns the key path.
|
||||
func setupORCAHome(t *testing.T) (keyPath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
// certpaths.KnownHostsPath() = paths.Root()/known_hosts = $ORCA_HOME/known_hosts.
|
||||
knownHosts := filepath.Join(dir, "known_hosts")
|
||||
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
keyPath = filepath.Join(dir, "orca_ssh_key")
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return keyPath
|
||||
}
|
||||
|
||||
// realTransport returns a Transport wired to use the real SSH dialer
|
||||
// against a fake SSH server, with the server's host key pre-populated in
|
||||
// known_hosts (so the TOFU callback matches on first dial — no first-
|
||||
// connect write race in tests).
|
||||
func realTransport(t *testing.T, srv *fakeSSHServer) *Transport {
|
||||
t.Helper()
|
||||
keyPath := setupORCAHome(t)
|
||||
tr := NewTransport(keyPath, "")
|
||||
tr.SetUser("root")
|
||||
addr := srv.addr()
|
||||
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, srv.hostPublicKey())
|
||||
home := os.Getenv("ORCA_HOME")
|
||||
kh := filepath.Join(home, "known_hosts")
|
||||
if err := os.WriteFile(kh, []byte(line+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("pre-pop known_hosts: %v", err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// --- mock dialer + mock session for pure-logic tests (no real SSH) ---
|
||||
|
||||
type mockDialer struct {
|
||||
client *ssh.Client
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *mockDialer) DialContext(ctx context.Context, network, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
|
||||
m.calls++
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return m.client, nil
|
||||
}
|
||||
|
||||
type mockSession struct {
|
||||
out []byte
|
||||
err error
|
||||
cmd string
|
||||
}
|
||||
|
||||
func (m *mockSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||
m.cmd = cmd
|
||||
return m.out, m.err
|
||||
}
|
||||
func (m *mockSession) Close() error { return nil }
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestNewTransport_Defaults(t *testing.T) {
|
||||
tr := NewTransport("/tmp/key", "/tmp/kh")
|
||||
if tr.keyPath != "/tmp/key" {
|
||||
t.Errorf("keyPath = %q", tr.keyPath)
|
||||
}
|
||||
if tr.user != "orca" {
|
||||
t.Errorf("default user = %q, want orca", tr.user)
|
||||
}
|
||||
if tr.dialer == nil {
|
||||
t.Error("dialer is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUser(t *testing.T) {
|
||||
tr := NewTransport("/tmp/key", "/tmp/kh")
|
||||
tr.SetUser("root")
|
||||
if tr.user != "root" {
|
||||
t.Errorf("user = %q, want root", tr.user)
|
||||
}
|
||||
tr.SetUser("")
|
||||
if tr.user != "root" {
|
||||
t.Errorf("user = %q, want root", tr.user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoff(t *testing.T) {
|
||||
// Without jitter, attempt 1 -> 100ms, 2 -> 200ms, ... up to 5s cap.
|
||||
// The jittered result is in [d/4, 3d/4) where d is the capped base,
|
||||
// so for high attempts the result can reach 3*d/4 < 1.5*d. We bound
|
||||
// the upper end at 2x the cap to allow jitter headroom.
|
||||
for _, tc := range []struct {
|
||||
attempt int
|
||||
max time.Duration
|
||||
}{
|
||||
{1, 200 * time.Millisecond},
|
||||
{2, 400 * time.Millisecond},
|
||||
{6, 2 * RetryMax},
|
||||
{10, 2 * RetryMax},
|
||||
} {
|
||||
got := backoff(RetryInitial, RetryMax, tc.attempt)
|
||||
if got < 0 || got > tc.max {
|
||||
t.Errorf("backoff(%d) = %s, want in [0, %s]", tc.attempt, got, tc.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransient(t *testing.T) {
|
||||
if isTransient(nil) {
|
||||
t.Error("nil should not be transient")
|
||||
}
|
||||
if !isTransient(ErrTransient) {
|
||||
t.Error("ErrTransient should be transient")
|
||||
}
|
||||
if isTransient(ErrPermanent) {
|
||||
t.Error("ErrPermanent should not be transient")
|
||||
}
|
||||
if !isTransient(errors.New("connection refused")) {
|
||||
t.Error("connection refused should be transient")
|
||||
}
|
||||
if !isTransient(errors.New("i/o timeout")) {
|
||||
t.Error("i/o timeout should be transient")
|
||||
}
|
||||
if isTransient(errors.New("some other error")) {
|
||||
t.Error("unknown error should not be transient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDialErr(t *testing.T) {
|
||||
if got := classifyDialErr(nil); got != nil {
|
||||
t.Errorf("nil -> nil, got %v", got)
|
||||
}
|
||||
perm := classifyDialErr(errors.New("ssh: unable to authenticate"))
|
||||
if !errors.Is(perm, ErrPermanent) {
|
||||
t.Errorf("auth failure should be permanent, got %v", perm)
|
||||
}
|
||||
perm2 := classifyDialErr(errors.New("host key mismatch"))
|
||||
if !errors.Is(perm2, ErrPermanent) {
|
||||
t.Errorf("host key mismatch should be permanent, got %v", perm2)
|
||||
}
|
||||
trans := classifyDialErr(errors.New("connection refused"))
|
||||
if !errors.Is(trans, ErrTransient) {
|
||||
t.Errorf("connection refused should be transient, got %v", trans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyExecErr(t *testing.T) {
|
||||
exitErr := ssh.ExitError{}
|
||||
perm := classifyExecErr(&exitErr)
|
||||
if !errors.Is(perm, ErrPermanent) {
|
||||
t.Errorf("ExitError should be permanent, got %v", perm)
|
||||
}
|
||||
trans := classifyExecErr(errors.New("session closed"))
|
||||
if !errors.Is(trans, ErrTransient) {
|
||||
t.Errorf("session closed should be transient, got %v", trans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuote(t *testing.T) {
|
||||
got := shellQuote("/etc/orca/foo.conf")
|
||||
if got != "'/etc/orca/foo.conf'" {
|
||||
t.Errorf("shellQuote = %q", got)
|
||||
}
|
||||
got = shellQuote("it's a path")
|
||||
if got != "'it'\\''s a path'" {
|
||||
t.Errorf("shellQuote with quote = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_ExecSuccess_RealSSH(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
out, err := tr.Exec(context.Background(), srv.addr(), "echo hello")
|
||||
if err != nil {
|
||||
t.Fatalf("Exec: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "hello" {
|
||||
t.Errorf("out = %q, want hello", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_ExecRetry_TransientFailure(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
var calls int32
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
if n < 3 {
|
||||
return &mockSession{err: errors.New("EOF")}, nil
|
||||
}
|
||||
return &mockSession{out: []byte("ok\n")}, nil
|
||||
})
|
||||
client, err := tr.dial(srv.addr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(srv.addr(), client)
|
||||
out, err := tr.Exec(context.Background(), srv.addr(), "echo ok")
|
||||
if err != nil {
|
||||
t.Fatalf("Exec: %v (calls=%d)", err, atomic.LoadInt32(&calls))
|
||||
}
|
||||
if string(out) != "ok\n" {
|
||||
t.Errorf("out = %q, want ok\\n", out)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got < 3 {
|
||||
t.Errorf("calls = %d, want >= 3 (retried)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_ExecPermanentError_NoRetry(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
var calls int32
|
||||
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &mockSession{err: &ssh.ExitError{}}, nil
|
||||
})
|
||||
client, err := tr.dial(srv.addr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
tr.pool.Store(srv.addr(), client)
|
||||
_, err = tr.Exec(context.Background(), srv.addr(), "exit 1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Errorf("err should be permanent, got %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on permanent)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_ExecTimeout(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
srv.setExecDelay(500 * time.Millisecond)
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := tr.Exec(ctx, srv.addr(), "echo hello")
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, ErrTransient) {
|
||||
t.Errorf("err should be timeout/transient, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_ConnectionPoolReuse(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
addr := srv.addr()
|
||||
c1, err := tr.dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("first dial: %v", err)
|
||||
}
|
||||
c2, err := tr.dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("second dial: %v", err)
|
||||
}
|
||||
if c1 != c2 {
|
||||
t.Error("pool did not reuse client for same peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_CloseClosesAllClients(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
if _, err := tr.dial(srv.addr()); err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
count := 0
|
||||
tr.pool.Range(func(_, _ any) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
if count != 1 {
|
||||
t.Fatalf("pool has %d entries, want 1", count)
|
||||
}
|
||||
if err := tr.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
_, err := tr.dial(srv.addr())
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Errorf("dial after Close should be ErrPermanent, got %v", err)
|
||||
}
|
||||
if err := tr.Close(); err != nil {
|
||||
t.Errorf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_ReadFile_RealSSH(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
srv.mu.Lock()
|
||||
srv.files["/etc/orca/test.conf"] = "content-line\n"
|
||||
srv.mu.Unlock()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
out, err := tr.ReadFile(context.Background(), srv.addr(), "/etc/orca/test.conf")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(out) != "content-line\n" {
|
||||
t.Errorf("out = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_DialKeyParseFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("kh: %v", err)
|
||||
}
|
||||
keyPath := filepath.Join(dir, "bad_key")
|
||||
if err := os.WriteFile(keyPath, []byte("not a key"), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
tr := NewTransport(keyPath, "")
|
||||
tr.SetUser("root")
|
||||
_, err := tr.dial("127.0.0.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse key") {
|
||||
t.Errorf("err should mention parse key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_DialKeyMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("kh: %v", err)
|
||||
}
|
||||
tr := NewTransport(filepath.Join(dir, "missing_key"), "")
|
||||
tr.SetUser("root")
|
||||
_, err := tr.dial("127.0.0.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("expected read error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read key") {
|
||||
t.Errorf("err should mention read key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_DialMockFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("kh: %v", err)
|
||||
}
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
der, _ := x509.MarshalPKCS8PrivateKey(priv)
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
keyPath := filepath.Join(dir, "orca_ssh_key")
|
||||
_ = os.WriteFile(keyPath, pemBytes, 0o600)
|
||||
tr := NewTransport(keyPath, "")
|
||||
tr.SetUser("root")
|
||||
tr.SetDialer(&mockDialer{err: errors.New("connection refused")})
|
||||
_, err := tr.dial("127.0.0.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("expected dial error")
|
||||
}
|
||||
if !errors.Is(err, ErrTransient) {
|
||||
t.Errorf("connection refused should be transient, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user