Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea00158fa5 | |||
| ae6eb5a27b | |||
| 19542dd8c9 | |||
| 436641782c |
@@ -1 +1 @@
|
|||||||
{ "phase": "P01", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T03:45:00Z", "milestone_complete": false, "verify": { "build": "pass", "go_test": "20/20", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
|
{ "phase": "P03/P04/P08", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T04:05:00Z", "milestone_complete": false, "verify": { "build": "pass", "go_test": "22/22", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SocketEmitter renders the systemd directives that implement the
|
||||||
|
// R-007 socket-plumbing contract: workloads bind to
|
||||||
|
// /run/orca/alloc-<id>/port-<name>.sock unless overridden via
|
||||||
|
// service.bind = "127.0.0.1" (the only documented opt-in).
|
||||||
|
//
|
||||||
|
// The systemd side of the contract uses two directives:
|
||||||
|
//
|
||||||
|
// - RuntimeDirectory=orca/alloc-<alloc-id> — systemd creates
|
||||||
|
// /run/orca/alloc-<alloc-id>/ owned by the service user (orca:orca)
|
||||||
|
// with mode 0750. The directory is removed when the unit stops
|
||||||
|
// (RuntimeDirectory= semantics). P08 emits one RuntimeDirectory=
|
||||||
|
// line per port so each port's socket directory is created; the
|
||||||
|
// alloc-id placeholder is spec.Name (the real alloc-id is assigned
|
||||||
|
// by the scheduler at submit time — see allocIDFor).
|
||||||
|
//
|
||||||
|
// - ExecStartPre= — only when service.bind is "127.0.0.1" (the TCP
|
||||||
|
// opt-in). In that case the workload binds a TCP port directly
|
||||||
|
// (no socket), and the ExecStartPre is a placeholder that records
|
||||||
|
// the bind (the actual bind happens in the process; the directive
|
||||||
|
// is a no-op marker so operators can see the bind mode in the unit
|
||||||
|
// file). When service.bind is empty (the default), the workload
|
||||||
|
// binds the socket and no ExecStartPre is emitted for sockets.
|
||||||
|
//
|
||||||
|
// The socket path format is /run/orca/alloc-<alloc-id>/port-<port-name>.sock
|
||||||
|
// where alloc-id is a PLACEHOLDER (spec.Name) — the real alloc-id is
|
||||||
|
// assigned at submit time by the scheduler. The placeholder is
|
||||||
|
// documented in the rendered unit via a comment so operators reading
|
||||||
|
// the unit file understand the substitution.
|
||||||
|
//
|
||||||
|
// P08 is a PLAN/plumbing layer — the actual socket activation (socket
|
||||||
|
// unit files, systemd socket-activation passing the pre-bound socket
|
||||||
|
// fd to the process) lands in v0.10. P08 just renders the
|
||||||
|
// RuntimeDirectory= lines and the optional TCP-bind ExecStartPre so
|
||||||
|
// the directory exists at runtime.
|
||||||
|
type SocketEmitter struct{}
|
||||||
|
|
||||||
|
// runtimeDirectoryRoot is the systemd RuntimeDirectory path root.
|
||||||
|
// systemd joins this with the RuntimeDirectory= value to create
|
||||||
|
// /run/orca/alloc-<id>. The leading slash is implicit in systemd
|
||||||
|
// (RuntimeDirectory= is relative to /run).
|
||||||
|
const runtimeDirectoryRoot = "orca"
|
||||||
|
|
||||||
|
// SocketPath returns the R-007 socket path for a port on the given
|
||||||
|
// alloc-id. The alloc-id is the placeholder spec.Name when the real
|
||||||
|
// alloc-id is not yet known (the scheduler assigns the real alloc-id
|
||||||
|
// at submit time).
|
||||||
|
func SocketPath(allocID, portName string) string {
|
||||||
|
return fmt.Sprintf("/run/orca/alloc-%s/port-%s.sock", allocID, portName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSocketLines renders the systemd directives that implement
|
||||||
|
// the R-007 socket plumbing for the given spec. The lines are returned
|
||||||
|
// WITHOUT a trailing newline so the caller (the systemd emitter) can
|
||||||
|
// append them to the [Service] block with consistent formatting.
|
||||||
|
//
|
||||||
|
// The returned lines are:
|
||||||
|
//
|
||||||
|
// - one RuntimeDirectory= line per port (so each port's socket
|
||||||
|
// directory is created by systemd at unit start).
|
||||||
|
// - a comment documenting the alloc-id placeholder.
|
||||||
|
// - when service.bind is "127.0.0.1", an ExecStartPre= marker that
|
||||||
|
// records the TCP opt-in (the actual bind is in the process).
|
||||||
|
//
|
||||||
|
// Returns an empty slice when the spec has no ports (no socket
|
||||||
|
// plumbing needed — e.g. a Job or a port-less DaemonSet).
|
||||||
|
func (SocketEmitter) RenderSocketLines(spec *jobspec.WorkloadSpec) []string {
|
||||||
|
if spec == nil || len(spec.Ports) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allocID := allocIDForSocket(spec)
|
||||||
|
var lines []string
|
||||||
|
// One RuntimeDirectory= per port. systemd dedupes identical
|
||||||
|
// values, but we emit one per port so the unit file is
|
||||||
|
// self-documenting (each port maps to a directory entry).
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
lines = append(lines, fmt.Sprintf("RuntimeDirectory=%s/alloc-%s", runtimeDirectoryRoot, allocID))
|
||||||
|
// Document the socket path this directory serves. systemd
|
||||||
|
// ignores comment lines (lines starting with '#').
|
||||||
|
lines = append(lines, fmt.Sprintf("# socket: %s", SocketPath(allocID, p.Name)))
|
||||||
|
}
|
||||||
|
// TCP opt-in: when service.bind is 127.0.0.1, the workload binds
|
||||||
|
// a TCP port directly instead of the socket. We emit an
|
||||||
|
// ExecStartPre marker so the bind mode is visible in the unit
|
||||||
|
// file. The actual bind is in the process; the marker is a
|
||||||
|
// no-op (echo to journald).
|
||||||
|
if spec.Service != nil && strings.TrimSpace(spec.Service.Bind) != "" {
|
||||||
|
if isTCPOptIn(spec.Service.Bind) {
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
lines = append(lines, fmt.Sprintf("ExecStartPre=/bin/echo orca: bind %s port %s (tcp, R-007 opt-in)", spec.Service.Bind, p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocIDForSocket returns the alloc-id placeholder for the spec. The
|
||||||
|
// real alloc-id is assigned by the scheduler at submit time; P08 uses
|
||||||
|
// spec.Name as a deterministic placeholder so the rendered unit is
|
||||||
|
// stable across re-renders. This mirrors the Traefik emitter's
|
||||||
|
// allocIDFor (which uses the node hostname for the Traefik
|
||||||
|
// dynamic-config server URL); the systemd unit is per-alloc, so
|
||||||
|
// spec.Name is the right placeholder here.
|
||||||
|
func allocIDForSocket(spec *jobspec.WorkloadSpec) string {
|
||||||
|
if spec == nil || strings.TrimSpace(spec.Name) == "" {
|
||||||
|
return "<allocID>"
|
||||||
|
}
|
||||||
|
return spec.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTCPOptIn returns true when the bind value is the documented
|
||||||
|
// 127.0.0.1 TCP opt-in (R-007). Other valid IPs (::1, etc.) are also
|
||||||
|
// TCP opt-ins (any non-empty bind opts out of the socket default); we
|
||||||
|
// only emit the marker for 127.0.0.1 because that is the only
|
||||||
|
// documented opt-in per the PRD — other IPs are accepted by the
|
||||||
|
// schema validator but are operator-specific and we do not
|
||||||
|
// second-guess them.
|
||||||
|
func isTCPOptIn(bind string) bool {
|
||||||
|
return strings.TrimSpace(bind) == "127.0.0.1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_NoPorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
if len(lines) != 0 {
|
||||||
|
t.Errorf("got %d lines, want 0 for no ports: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_NilSpec(t *testing.T) {
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(nil)
|
||||||
|
if lines != nil {
|
||||||
|
t.Errorf("nil spec should return nil, got %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_SinglePort(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
// Expect: RuntimeDirectory + comment. No TCP bind (default socket).
|
||||||
|
wantRT := "RuntimeDirectory=orca/alloc-web"
|
||||||
|
if !contains(lines, wantRT) {
|
||||||
|
t.Errorf("lines %v missing %q", lines, wantRT)
|
||||||
|
}
|
||||||
|
wantSock := "# socket: /run/orca/alloc-web/port-http.sock"
|
||||||
|
if !contains(lines, wantSock) {
|
||||||
|
t.Errorf("lines %v missing %q", lines, wantSock)
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("socket bind should not emit ExecStartPre (no TCP opt-in): %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_MultiplePorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "api",
|
||||||
|
Ports: []jobspec.PortSpec{
|
||||||
|
{Name: "http", Port: 8080},
|
||||||
|
{Name: "grpc", Port: 9090},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
// Two RuntimeDirectory lines (one per port).
|
||||||
|
count := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
if l == "RuntimeDirectory=orca/alloc-api" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("RuntimeDirectory count = %d, want 2 (one per port)", count)
|
||||||
|
}
|
||||||
|
if !contains(lines, "# socket: /run/orca/alloc-api/port-http.sock") {
|
||||||
|
t.Errorf("missing http socket comment")
|
||||||
|
}
|
||||||
|
if !contains(lines, "# socket: /run/orca/alloc-api/port-grpc.sock") {
|
||||||
|
t.Errorf("missing grpc socket comment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_TCPBind127(t *testing.T) {
|
||||||
|
// service.bind = 127.0.0.1 → TCP opt-in → ExecStartPre marker per port.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
found := false
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=/bin/echo orca: bind 127.0.0.1 port http (tcp, R-007 opt-in)") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing TCP bind ExecStartPre marker; lines: %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_TCPBindIPv6(t *testing.T) {
|
||||||
|
// Non-127.0.0.1 bind is accepted by schema but not the documented
|
||||||
|
// opt-in; the marker is only emitted for 127.0.0.1. The
|
||||||
|
// RuntimeDirectory lines are still emitted (the directory exists
|
||||||
|
// regardless of bind mode — sockets or TCP).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "::1"},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("::1 bind should NOT emit TCP marker (only 127.0.0.1 is documented opt-in): %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(lines, "RuntimeDirectory=orca/alloc-web") {
|
||||||
|
t.Errorf("RuntimeDirectory should still be emitted for ::1 bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_EmptyBindSocket(t *testing.T) {
|
||||||
|
// Empty bind → default socket → no TCP marker, but RuntimeDirectory emitted.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: ""},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("empty bind should NOT emit TCP marker: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(lines, "RuntimeDirectory=orca/alloc-web") {
|
||||||
|
t.Errorf("RuntimeDirectory missing for empty bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_NilService(t *testing.T) {
|
||||||
|
// No service block → default socket → no TCP marker, but RuntimeDirectory emitted.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("nil service should NOT emit TCP marker: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(lines, "RuntimeDirectory=orca/alloc-web") {
|
||||||
|
t.Errorf("RuntimeDirectory missing for nil service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_SocketPath(t *testing.T) {
|
||||||
|
got := SocketPath("alloc-123", "http")
|
||||||
|
want := "/run/orca/alloc-alloc-123/port-http.sock"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SocketPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_AllocIDPlaceholderNilSpec(t *testing.T) {
|
||||||
|
if got := allocIDForSocket(nil); got != "<allocID>" {
|
||||||
|
t.Errorf("allocIDForSocket(nil) = %q, want <allocID>", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_AllocIDPlaceholderEmptyName(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Name: " "}
|
||||||
|
if got := allocIDForSocket(spec); got != "<allocID>" {
|
||||||
|
t.Errorf("allocIDForSocket(empty name) = %q, want <allocID>", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_AllocIDPlaceholderNamedSpec(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Name: "web"}
|
||||||
|
if got := allocIDForSocket(spec); got != "web" {
|
||||||
|
t.Errorf("allocIDForSocket(web) = %q, want web", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_SocketPathPlaceholder(t *testing.T) {
|
||||||
|
got := SocketPath("<allocID>", "grpc")
|
||||||
|
want := "/run/orca/alloc-<allocID>/port-grpc.sock"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SocketPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_IntegratesSocketLines(t *testing.T) {
|
||||||
|
// End-to-end: the systemd unit for a Service with ports contains
|
||||||
|
// the RuntimeDirectory line emitted by the SocketEmitter.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "RuntimeDirectory=orca/alloc-web\n") {
|
||||||
|
t.Errorf("unit missing RuntimeDirectory line\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "# socket: /run/orca/alloc-web/port-http.sock\n") {
|
||||||
|
t.Errorf("unit missing socket path comment\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_IntegratesSocketLinesTCPBind(t *testing.T) {
|
||||||
|
// When service.bind = 127.0.0.1, the unit contains the ExecStartPre
|
||||||
|
// TCP-bind marker.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStartPre=/bin/echo orca: bind 127.0.0.1 port http (tcp, R-007 opt-in)\n") {
|
||||||
|
t.Errorf("unit missing TCP bind ExecStartPre marker\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_NoSocketLinesForPortlessSpec(t *testing.T) {
|
||||||
|
// A Job with no ports → no RuntimeDirectory line in the unit.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if strings.Contains(c, "RuntimeDirectory=") {
|
||||||
|
t.Errorf("portless spec should not emit RuntimeDirectory\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "# socket:") {
|
||||||
|
t.Errorf("portless spec should not emit socket comment\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains reports whether the slice contains the string s.
|
||||||
|
func contains(lines []string, s string) bool {
|
||||||
|
for _, l := range lines {
|
||||||
|
if l == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+88
-20
@@ -8,21 +8,30 @@ import (
|
|||||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemdEmitter is a stub Emitter implementation for the "process"
|
// SystemdEmitter is the Emitter implementation for the "process"
|
||||||
// runtime. It renders a minimal systemd unit file for the workload.
|
// runtime. It renders the systemd unit file for the workload,
|
||||||
|
// including the lifecycle hooks (P04) and the R-007 socket plumbing
|
||||||
|
// (P08).
|
||||||
//
|
//
|
||||||
// This is a STUB — the full systemd emitter (with lifecycle hooks,
|
// Lifecycle hooks map to systemd semantics (PRD §10.1):
|
||||||
// sockets, EnvironmentFile, LoadCredential) lands in later phases:
|
//
|
||||||
|
// - lifecycle.pre_stop → ExecStop= (the command run on stop; systemd
|
||||||
|
// runs ExecStop, then kills the main process after the deadline).
|
||||||
|
// - lifecycle.post_start → ExecStartPost= (runs after the main
|
||||||
|
// process starts).
|
||||||
|
//
|
||||||
|
// systemd has no ExecStartPre equivalent for a "pre_start" hook; the
|
||||||
|
// spec does not define pre_start (only pre_stop and post_start per
|
||||||
|
// PRD §10.1), so no mapping is needed.
|
||||||
|
//
|
||||||
|
// The unit name carries the `orca-v1-` prefix per the dual-write
|
||||||
|
// window (REQ-090) so the v0.9 SSH-push path does not collide with the
|
||||||
|
// v0.8 daemon's `orca-<job>.service` units during the migration
|
||||||
|
// window.
|
||||||
|
//
|
||||||
|
// Later phases extend this emitter:
|
||||||
//
|
//
|
||||||
// - P04: lifecycle hooks (ExecStop, ExecStartPre/Post, timeouts)
|
|
||||||
// - P08: socket plumbing (R-007)
|
|
||||||
// - v0.10-P03: secrets via EnvironmentFile= + LoadCredential=
|
// - v0.10-P03: secrets via EnvironmentFile= + LoadCredential=
|
||||||
//
|
|
||||||
// P0c ships only the minimal [Service]\nExecStart=... shape to prove
|
|
||||||
// the Emitter interface end-to-end. The unit name carries the
|
|
||||||
// `orca-v1-` prefix per the dual-write window (REQ-090) so the v0.9
|
|
||||||
// SSH-push path does not collide with the v0.8 daemon's
|
|
||||||
// `orca-<job>.service` units during the migration window.
|
|
||||||
type SystemdEmitter struct{}
|
type SystemdEmitter struct{}
|
||||||
|
|
||||||
// unitNamePrefix is the v0.9 SSH-push unit-name prefix. The v0.8
|
// unitNamePrefix is the v0.9 SSH-push unit-name prefix. The v0.8
|
||||||
@@ -32,15 +41,28 @@ type SystemdEmitter struct{}
|
|||||||
// updating the dual-write window contract.
|
// updating the dual-write window contract.
|
||||||
const unitNamePrefix = "orca-v1-"
|
const unitNamePrefix = "orca-v1-"
|
||||||
|
|
||||||
// Render renders a minimal systemd unit file for a process-runtime
|
// Render renders the systemd unit file for a process-runtime workload.
|
||||||
// workload. The unit name is `/etc/systemd/system/<unitNamePrefix><spec.Name>.service`
|
// The unit name is /etc/systemd/system/<unitNamePrefix><spec.Name>.service
|
||||||
// and the content is a minimal `[Service]` block with the runtime
|
// and the content is a [Service] block with ExecStart, optional
|
||||||
// command as ExecStart. Mode is 0644 (the lead applier chmods after
|
// ExecStartPost (lifecycle.post_start), optional ExecStop
|
||||||
// atomic rename).
|
// (lifecycle.pre_stop), and the R-007 socket-plumbing lines
|
||||||
|
// (RuntimeDirectory=, optional TCP-bind ExecStartPre). Mode is 0644.
|
||||||
|
//
|
||||||
|
// The rendered shape is:
|
||||||
|
//
|
||||||
|
// [Service]
|
||||||
|
// ExecStart=<runtime command>
|
||||||
|
// ExecStartPost=<post_start command 1>
|
||||||
|
// ExecStartPost=<post_start command 2>
|
||||||
|
// ExecStop=<pre_stop command 1>
|
||||||
|
// ExecStop=<pre_stop command 2>
|
||||||
|
// RuntimeDirectory=orca/alloc-<alloc-id>
|
||||||
|
// # socket: /run/orca/alloc-<alloc-id>/port-<name>.sock
|
||||||
|
// ExecStartPre=/bin/echo orca: bind 127.0.0.1 port <name> (tcp, R-007 opt-in)
|
||||||
//
|
//
|
||||||
// Returns an error if the spec is nil, the spec is missing its name,
|
// Returns an error if the spec is nil, the spec is missing its name,
|
||||||
// or the runtime command is empty (a workload with no command has
|
// the runtime block is nil, or the runtime command is empty (a
|
||||||
// nothing to ExecStart).
|
// workload with no command has nothing to ExecStart).
|
||||||
func (SystemdEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
func (SystemdEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||||
if spec == nil {
|
if spec == nil {
|
||||||
return nil, errors.New("emitter/systemd: spec is nil")
|
return nil, errors.New("emitter/systemd: spec is nil")
|
||||||
@@ -55,6 +77,52 @@ func (SystemdEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, er
|
|||||||
return nil, errors.New("emitter/systemd: runtime command is empty")
|
return nil, errors.New("emitter/systemd: runtime command is empty")
|
||||||
}
|
}
|
||||||
path := fmt.Sprintf("/etc/systemd/system/%s%s.service", unitNamePrefix, spec.Name)
|
path := fmt.Sprintf("/etc/systemd/system/%s%s.service", unitNamePrefix, spec.Name)
|
||||||
content := fmt.Sprintf("[Service]\nExecStart=%s\n", spec.Runtime.Command)
|
content := renderSystemdUnit(spec)
|
||||||
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderSystemdUnit renders the full [Service] block for the spec,
|
||||||
|
// including ExecStart, lifecycle hooks (ExecStartPost, ExecStop), and
|
||||||
|
// the R-007 socket-plumbing lines (RuntimeDirectory=, optional
|
||||||
|
// TCP-bind ExecStartPre). The output is a single string with a
|
||||||
|
// trailing newline per line.
|
||||||
|
func renderSystemdUnit(spec *jobspec.WorkloadSpec) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[Service]\n")
|
||||||
|
b.WriteString(fmt.Sprintf("ExecStart=%s\n", spec.Runtime.Command))
|
||||||
|
// Lifecycle: post_start → ExecStartPost (runs after start).
|
||||||
|
for _, cmd := range lifecyclePostStart(spec) {
|
||||||
|
b.WriteString(fmt.Sprintf("ExecStartPost=%s\n", cmd))
|
||||||
|
}
|
||||||
|
// Lifecycle: pre_stop → ExecStop (runs before the process is killed).
|
||||||
|
for _, cmd := range lifecyclePreStop(spec) {
|
||||||
|
b.WriteString(fmt.Sprintf("ExecStop=%s\n", cmd))
|
||||||
|
}
|
||||||
|
// R-007 socket plumbing: RuntimeDirectory= per port + optional
|
||||||
|
// TCP-bind ExecStartPre.
|
||||||
|
for _, line := range (SocketEmitter{}).RenderSocketLines(spec) {
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lifecyclePostStart returns the post_start lifecycle commands for
|
||||||
|
// the spec, or nil when the spec has no lifecycle block or no
|
||||||
|
// post_start commands.
|
||||||
|
func lifecyclePostStart(spec *jobspec.WorkloadSpec) []string {
|
||||||
|
if spec.Lifecycle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return spec.Lifecycle.PostStart
|
||||||
|
}
|
||||||
|
|
||||||
|
// lifecyclePreStop returns the pre_stop lifecycle commands for the
|
||||||
|
// spec, or nil when the spec has no lifecycle block or no pre_stop
|
||||||
|
// commands.
|
||||||
|
func lifecyclePreStop(spec *jobspec.WorkloadSpec) []string {
|
||||||
|
if spec.Lifecycle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return spec.Lifecycle.PreStop
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecyclePostStart(t *testing.T) {
|
||||||
|
// lifecycle.post_start → ExecStartPost (one line per command).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PostStart: []string{"/usr/bin/sleep 1", "/usr/bin/curl localhost/healthz"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStartPost=/usr/bin/sleep 1\n") {
|
||||||
|
t.Errorf("missing ExecStartPost for sleep 1\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "ExecStartPost=/usr/bin/curl localhost/healthz\n") {
|
||||||
|
t.Errorf("missing ExecStartPost for curl\n%s", c)
|
||||||
|
}
|
||||||
|
// ExecStart must still be present.
|
||||||
|
if !strings.Contains(c, "ExecStart=/usr/local/bin/httpd -f\n") {
|
||||||
|
t.Errorf("missing ExecStart\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecyclePreStop(t *testing.T) {
|
||||||
|
// lifecycle.pre_stop → ExecStop (one line per command).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PreStop: []string{"/usr/local/bin/httpd -graceful", "/usr/bin/sleep 5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStop=/usr/local/bin/httpd -graceful\n") {
|
||||||
|
t.Errorf("missing ExecStop for graceful\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "ExecStop=/usr/bin/sleep 5\n") {
|
||||||
|
t.Errorf("missing ExecStop for sleep 5\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleBoth(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PostStart: []string{"/bin/after-start"},
|
||||||
|
PreStop: []string{"/bin/before-stop"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
// ExecStartPost must appear before ExecStop (post_start runs after
|
||||||
|
// start; pre_stop runs before stop — the order in the unit file
|
||||||
|
// reflects the lifecycle order).
|
||||||
|
startIdx := strings.Index(c, "ExecStart=")
|
||||||
|
postIdx := strings.Index(c, "ExecStartPost=")
|
||||||
|
stopIdx := strings.Index(c, "ExecStop=")
|
||||||
|
if startIdx < 0 || postIdx < 0 || stopIdx < 0 {
|
||||||
|
t.Fatalf("missing one of ExecStart/ExecStartPost/ExecStop\n%s", c)
|
||||||
|
}
|
||||||
|
if !(startIdx < postIdx && postIdx < stopIdx) {
|
||||||
|
t.Errorf("expected order ExecStart < ExecStartPost < ExecStop\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleNilOmitsDirectives(t *testing.T) {
|
||||||
|
// No lifecycle block → no ExecStartPost / ExecStop lines.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if strings.Contains(c, "ExecStartPost=") {
|
||||||
|
t.Errorf("ExecStartPost should be omitted when no lifecycle\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStop=") {
|
||||||
|
t.Errorf("ExecStop should be omitted when no lifecycle\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleEmptyListsOmitted(t *testing.T) {
|
||||||
|
// Lifecycle block present but empty lists → no ExecStartPost / ExecStop.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if strings.Contains(c, "ExecStartPost=") {
|
||||||
|
t.Errorf("ExecStartPost should be omitted for empty PostStart\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStop=") {
|
||||||
|
t.Errorf("ExecStop should be omitted for empty PreStop\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleOnlyPostStart(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PostStart: []string{"/bin/notify-up"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStartPost=/bin/notify-up\n") {
|
||||||
|
t.Errorf("missing ExecStartPost\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStop=") {
|
||||||
|
t.Errorf("ExecStop should be omitted when only PostStart set\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleOnlyPreStop(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PreStop: []string{"/bin/notify-down"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStop=/bin/notify-down\n") {
|
||||||
|
t.Errorf("missing ExecStop\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStartPost=") {
|
||||||
|
t.Errorf("ExecStartPost should be omitted when only PreStop set\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdatePlan is the computed update sequence for a Service (P03). It is
|
||||||
|
// a PLAN, not an execution — the transactional execution lands in
|
||||||
|
// v0.10-P10. Each step describes a discrete action the executor takes:
|
||||||
|
// start a set of allocs (Action="start"), wait for them to become
|
||||||
|
// healthy (WaitForHealthy=true), or cutover from old to new
|
||||||
|
// (Action="cutover" for blue-green). The Allocs field carries
|
||||||
|
// placeholder alloc names of the form "<spec.Name>-<index>" where
|
||||||
|
// index is 1-based (the scheduler assigns the real alloc-id at submit
|
||||||
|
// time; P03 uses spec.Name as a placeholder per the socket layer
|
||||||
|
// contract — see SocketEmitter).
|
||||||
|
type UpdatePlan struct {
|
||||||
|
Steps []UpdateStep
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStep is a single step in an UpdatePlan. Action is one of
|
||||||
|
// "start", "wait", "cutover", "promote". Allocs is the list of
|
||||||
|
// placeholder alloc names the step applies to. WaitForHealthy is true
|
||||||
|
// when the executor must wait for the allocs in this step to pass
|
||||||
|
// their health check before proceeding to the next step (driven by
|
||||||
|
// min_healthy_time / healthy_deadline on the spec, which the executor
|
||||||
|
// — not the plan — enforces).
|
||||||
|
type UpdateStep struct {
|
||||||
|
Action string
|
||||||
|
Allocs []string
|
||||||
|
WaitForHealthy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxParallelFor returns the effective max_parallel for the spec,
|
||||||
|
// defaulting to 1 when unset (0) and clamping to count (the validator
|
||||||
|
// already rejects out-of-range values; this is a defensive clamp for
|
||||||
|
// direct callers that bypass the validator).
|
||||||
|
func maxParallelFor(spec *jobspec.WorkloadSpec) int {
|
||||||
|
if spec.Update == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if spec.Update.MaxParallel < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if spec.Count > 0 && spec.Update.MaxParallel > spec.Count {
|
||||||
|
return spec.Count
|
||||||
|
}
|
||||||
|
return spec.Update.MaxParallel
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocName returns the placeholder alloc name for index i (1-based).
|
||||||
|
// The real alloc-id is assigned by the scheduler at submit time; P03
|
||||||
|
// uses spec.Name as the placeholder per the socket-layer contract.
|
||||||
|
func allocName(spec *jobspec.WorkloadSpec, i int) string {
|
||||||
|
return fmt.Sprintf("%s-%d", spec.Name, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allAllocs returns the placeholder alloc names for the full count of
|
||||||
|
// the spec (1..count).
|
||||||
|
func allAllocs(spec *jobspec.WorkloadSpec) []string {
|
||||||
|
out := make([]string, 0, spec.Count)
|
||||||
|
for i := 1; i <= spec.Count; i++ {
|
||||||
|
out = append(out, allocName(spec, i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// canaryCount returns the integer canary count for the spec. The
|
||||||
|
// canary field accepts an integer count or a percentage ("<n>%"). For
|
||||||
|
// a percentage, the count is ceil(count * n / 100) with a minimum of 1
|
||||||
|
// when n > 0 (a 10% canary of a 3-replica service is 1 alloc, not 0).
|
||||||
|
// When the canary field is empty, the default is 1 (a single canary
|
||||||
|
// alloc — the smallest meaningful canary).
|
||||||
|
func canaryCount(spec *jobspec.WorkloadSpec) int {
|
||||||
|
if spec.Update == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
c := strings.TrimSpace(spec.Update.Canary)
|
||||||
|
if c == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(c, "%") {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(c, "%")))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
allocs := spec.Count * n / 100
|
||||||
|
if allocs < 1 {
|
||||||
|
allocs = 1
|
||||||
|
}
|
||||||
|
return allocs
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(c)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if spec.Count > 0 && n > spec.Count {
|
||||||
|
return spec.Count
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderUpdatePlan computes the rolling/canary/blue-green update
|
||||||
|
// sequence for a Service spec. Returns an *UpdatePlan describing the
|
||||||
|
// steps; the actual transactional execution lands in v0.10-P10.
|
||||||
|
//
|
||||||
|
// The three strategies:
|
||||||
|
//
|
||||||
|
// - rolling: allocs are started in batches of max_parallel. Each
|
||||||
|
// batch waits for healthy before the next batch starts. This is
|
||||||
|
// the simplest strategy and the default for stateless services.
|
||||||
|
//
|
||||||
|
// - canary: a single canary alloc (or N per the canary field) is
|
||||||
|
// started first and waits for healthy. After the canary is
|
||||||
|
// healthy, the plan emits a "promote" step (manual or auto per
|
||||||
|
// auto_promote); the remaining allocs are then started in
|
||||||
|
// max_parallel batches.
|
||||||
|
//
|
||||||
|
// - blue-green: all new allocs are started in parallel (a single
|
||||||
|
// "start" step with the full count). After they are healthy, a
|
||||||
|
// "cutover" step swaps traffic from the old allocs to the new
|
||||||
|
// ones. The old allocs are then stopped (the stop is implicit in
|
||||||
|
// the cutover step for the plan; v0.10-P10 makes it explicit).
|
||||||
|
//
|
||||||
|
// Returns an error if the spec is nil, the update block is nil, or
|
||||||
|
// the strategy is unknown (the validator should have caught these,
|
||||||
|
// but RenderUpdatePlan is defensive — emitters are called from
|
||||||
|
// render paths that may bypass the schema validator).
|
||||||
|
func RenderUpdatePlan(spec *jobspec.WorkloadSpec) (*UpdatePlan, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, fmt.Errorf("emitter/update: spec is nil")
|
||||||
|
}
|
||||||
|
if spec.Update == nil {
|
||||||
|
return nil, fmt.Errorf("emitter/update: update block is nil")
|
||||||
|
}
|
||||||
|
if spec.Count < 1 {
|
||||||
|
return nil, fmt.Errorf("emitter/update: count must be ≥ 1, got %d", spec.Count)
|
||||||
|
}
|
||||||
|
switch spec.Update.Strategy {
|
||||||
|
case "rolling":
|
||||||
|
return renderRollingPlan(spec), nil
|
||||||
|
case "canary":
|
||||||
|
return renderCanaryPlan(spec), nil
|
||||||
|
case "blue-green":
|
||||||
|
return renderBlueGreenPlan(spec), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("emitter/update: unknown strategy %q (want rolling, canary, or blue-green)", spec.Update.Strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderRollingPlan emits the rolling-update plan: allocs in batches
|
||||||
|
// of max_parallel, each batch waiting for healthy before the next.
|
||||||
|
func renderRollingPlan(spec *jobspec.WorkloadSpec) *UpdatePlan {
|
||||||
|
plan := &UpdatePlan{}
|
||||||
|
batch := maxParallelFor(spec)
|
||||||
|
allocs := allAllocs(spec)
|
||||||
|
for i := 0; i < len(allocs); i += batch {
|
||||||
|
end := i + batch
|
||||||
|
if end > len(allocs) {
|
||||||
|
end = len(allocs)
|
||||||
|
}
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: allocs[i:end],
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCanaryPlan emits the canary-update plan: a canary batch first
|
||||||
|
// (size per the canary field, default 1), a "promote" step, then the
|
||||||
|
// remaining allocs in max_parallel batches.
|
||||||
|
func renderCanaryPlan(spec *jobspec.WorkloadSpec) *UpdatePlan {
|
||||||
|
plan := &UpdatePlan{}
|
||||||
|
allocs := allAllocs(spec)
|
||||||
|
canary := canaryCount(spec)
|
||||||
|
if canary > len(allocs) {
|
||||||
|
canary = len(allocs)
|
||||||
|
}
|
||||||
|
if canary < 1 {
|
||||||
|
canary = 1
|
||||||
|
}
|
||||||
|
// Step 1: start the canary alloc(s) and wait for healthy.
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: allocs[:canary],
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
// Step 2: promote (manual or auto per auto_promote).
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "promote",
|
||||||
|
Allocs: allocs[:canary],
|
||||||
|
})
|
||||||
|
// Step 3+: remaining allocs in max_parallel batches.
|
||||||
|
batch := maxParallelFor(spec)
|
||||||
|
remaining := allocs[canary:]
|
||||||
|
for i := 0; i < len(remaining); i += batch {
|
||||||
|
end := i + batch
|
||||||
|
if end > len(remaining) {
|
||||||
|
end = len(remaining)
|
||||||
|
}
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: remaining[i:end],
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBlueGreenPlan emits the blue-green update plan: all new allocs
|
||||||
|
// start in parallel, wait for healthy, then cutover (swap traffic).
|
||||||
|
func renderBlueGreenPlan(spec *jobspec.WorkloadSpec) *UpdatePlan {
|
||||||
|
plan := &UpdatePlan{}
|
||||||
|
allocs := allAllocs(spec)
|
||||||
|
// Step 1: start ALL new allocs in parallel (blue-green does not
|
||||||
|
// batch — the new fleet stands up alongside the old).
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: allocs,
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
// Step 2: cutover — swap traffic from old to new. The old allocs
|
||||||
|
// are stopped implicitly as part of the cutover (v0.10-P10 makes
|
||||||
|
// the stop explicit in the transactional plane).
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "cutover",
|
||||||
|
Allocs: allocs,
|
||||||
|
WaitForHealthy: false,
|
||||||
|
})
|
||||||
|
return plan
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_RollingBatches(t *testing.T) {
|
||||||
|
// count=4, max_parallel=2 → 2 batches of 2.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 2 {
|
||||||
|
t.Fatalf("got %d steps, want 2", len(plan.Steps))
|
||||||
|
}
|
||||||
|
for i, s := range plan.Steps {
|
||||||
|
if s.Action != "start" {
|
||||||
|
t.Errorf("step %d action = %q, want start", i, s.Action)
|
||||||
|
}
|
||||||
|
if !s.WaitForHealthy {
|
||||||
|
t.Errorf("step %d WaitForHealthy = false, want true", i)
|
||||||
|
}
|
||||||
|
if len(s.Allocs) != 2 {
|
||||||
|
t.Errorf("step %d allocs = %d, want 2", i, len(s.Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Allocs[0] != "web-1" || plan.Steps[0].Allocs[1] != "web-2" {
|
||||||
|
t.Errorf("step 0 allocs = %v, want [web-1 web-2]", plan.Steps[0].Allocs)
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Allocs[0] != "web-3" || plan.Steps[1].Allocs[1] != "web-4" {
|
||||||
|
t.Errorf("step 1 allocs = %v, want [web-3 web-4]", plan.Steps[1].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_RollingUnevenBatches(t *testing.T) {
|
||||||
|
// count=5, max_parallel=2 → 3 batches: 2, 2, 1.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 5,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 3 {
|
||||||
|
t.Fatalf("got %d steps, want 3", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if len(plan.Steps[2].Allocs) != 1 {
|
||||||
|
t.Errorf("step 2 allocs = %d, want 1 (remainder)", len(plan.Steps[2].Allocs))
|
||||||
|
}
|
||||||
|
if plan.Steps[2].Allocs[0] != "web-5" {
|
||||||
|
t.Errorf("step 2 allocs = %v, want [web-5]", plan.Steps[2].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_RollingMaxParallelUnset(t *testing.T) {
|
||||||
|
// max_parallel unset (0) → default 1 → 4 batches of 1.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 4 {
|
||||||
|
t.Fatalf("got %d steps, want 4 (one per alloc, batch=1)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
for _, s := range plan.Steps {
|
||||||
|
if len(s.Allocs) != 1 {
|
||||||
|
t.Errorf("allocs = %d, want 1", len(s.Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryDefaultOne(t *testing.T) {
|
||||||
|
// canary unset → default 1 canary alloc, then 3 in batches of 2.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
MaxParallel: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Step 0: canary (1 alloc). Step 1: promote. Steps 2..: remaining.
|
||||||
|
if plan.Steps[0].Action != "start" || len(plan.Steps[0].Allocs) != 1 {
|
||||||
|
t.Errorf("step 0 = %+v, want canary start with 1 alloc", plan.Steps[0])
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Allocs[0] != "web-1" {
|
||||||
|
t.Errorf("canary alloc = %q, want web-1", plan.Steps[0].Allocs[0])
|
||||||
|
}
|
||||||
|
if !plan.Steps[0].WaitForHealthy {
|
||||||
|
t.Error("canary step should wait for healthy")
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Action != "promote" {
|
||||||
|
t.Errorf("step 1 action = %q, want promote", plan.Steps[1].Action)
|
||||||
|
}
|
||||||
|
// Remaining: web-2, web-3, web-4 in batches of 2 → [web-2,web-3], [web-4].
|
||||||
|
if len(plan.Steps) != 4 {
|
||||||
|
t.Fatalf("got %d steps, want 4 (canary + promote + 2 batches)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if len(plan.Steps[2].Allocs) != 2 || plan.Steps[2].Allocs[0] != "web-2" {
|
||||||
|
t.Errorf("step 2 = %v, want [web-2 web-3]", plan.Steps[2].Allocs)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[3].Allocs) != 1 || plan.Steps[3].Allocs[0] != "web-4" {
|
||||||
|
t.Errorf("step 3 = %v, want [web-4]", plan.Steps[3].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryPercent(t *testing.T) {
|
||||||
|
// count=10, canary=20% → 2 canary allocs, then 8 in batches of 3.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 10,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
MaxParallel: 3,
|
||||||
|
Canary: "20%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 2 {
|
||||||
|
t.Errorf("canary step allocs = %d, want 2 (20%% of 10)", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
// Remaining 8 in batches of 3 → ceil(8/3)=3 batches.
|
||||||
|
// Steps: canary, promote, batch(3), batch(3), batch(2) = 5 steps.
|
||||||
|
if len(plan.Steps) != 5 {
|
||||||
|
t.Fatalf("got %d steps, want 5", len(plan.Steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryIntegerCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 2 {
|
||||||
|
t.Errorf("canary step allocs = %d, want 2", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Allocs[0] != "web-1" || plan.Steps[0].Allocs[1] != "web-2" {
|
||||||
|
t.Errorf("canary allocs = %v, want [web-1 web-2]", plan.Steps[0].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryFullCount(t *testing.T) {
|
||||||
|
// canary == count → no remaining allocs after canary.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Steps: canary start (3), promote. No remaining batches.
|
||||||
|
if len(plan.Steps) != 2 {
|
||||||
|
t.Fatalf("got %d steps, want 2 (canary + promote, no remainder)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Action != "promote" {
|
||||||
|
t.Errorf("step 1 action = %q, want promote", plan.Steps[1].Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_BlueGreen(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "blue-green",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Step 0: start all 4 in parallel. Step 1: cutover.
|
||||||
|
if len(plan.Steps) != 2 {
|
||||||
|
t.Fatalf("got %d steps, want 2", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Action != "start" {
|
||||||
|
t.Errorf("step 0 action = %q, want start", plan.Steps[0].Action)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 4 {
|
||||||
|
t.Errorf("step 0 allocs = %d, want 4 (all new in parallel)", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
if !plan.Steps[0].WaitForHealthy {
|
||||||
|
t.Error("blue-green start step should wait for healthy")
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Action != "cutover" {
|
||||||
|
t.Errorf("step 1 action = %q, want cutover", plan.Steps[1].Action)
|
||||||
|
}
|
||||||
|
if plan.Steps[1].WaitForHealthy {
|
||||||
|
t.Error("cutover step should NOT wait for healthy (already healthy)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_BlueGreenAllocs(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "api",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "blue-green",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"api-1", "api-2", "api-3"}
|
||||||
|
if len(plan.Steps[0].Allocs) != 3 {
|
||||||
|
t.Errorf("allocs = %v, want %v", plan.Steps[0].Allocs, want)
|
||||||
|
}
|
||||||
|
for i, a := range want {
|
||||||
|
if plan.Steps[0].Allocs[i] != a {
|
||||||
|
t.Errorf("alloc[%d] = %q, want %q", i, plan.Steps[0].Allocs[i], a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_NilSpec(t *testing.T) {
|
||||||
|
_, err := RenderUpdatePlan(nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "spec is nil") {
|
||||||
|
t.Errorf("error = %q, want 'spec is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_NilUpdate(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Service", Name: "web", Count: 1}
|
||||||
|
_, err := RenderUpdatePlan(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil update block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update block is nil") {
|
||||||
|
t.Errorf("error = %q, want 'update block is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CountZero(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 0,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := RenderUpdatePlan(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for count 0")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "count must be") {
|
||||||
|
t.Errorf("error = %q, want 'count must be'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_UnknownStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "recreate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := RenderUpdatePlan(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown strategy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown strategy") {
|
||||||
|
t.Errorf("error = %q, want 'unknown strategy'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_AllStrategies(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: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: strat,
|
||||||
|
MaxParallel: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan(%s): %v", strat, err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) == 0 {
|
||||||
|
t.Errorf("strategy %s produced 0 steps", strat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_PromoteStepCarriesCanaryAllocs(t *testing.T) {
|
||||||
|
// The promote step lists the canary allocs so the executor knows
|
||||||
|
// which allocs are being promoted from canary to stable.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
promote := plan.Steps[1]
|
||||||
|
if promote.Action != "promote" {
|
||||||
|
t.Fatalf("step 1 action = %q, want promote", promote.Action)
|
||||||
|
}
|
||||||
|
if len(promote.Allocs) != 2 {
|
||||||
|
t.Errorf("promote allocs = %d, want 2 (the canary allocs)", len(promote.Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_MaxParallelClampedToCount(t *testing.T) {
|
||||||
|
// max_parallel > count is clamped to count (defensive; validator
|
||||||
|
// rejects this but the emitter is defensive against direct
|
||||||
|
// callers).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 99,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Clamped to 2 → single batch of 2.
|
||||||
|
if len(plan.Steps) != 1 {
|
||||||
|
t.Errorf("got %d steps, want 1 (clamped)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 2 {
|
||||||
|
t.Errorf("step 0 allocs = %d, want 2", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
-14
@@ -26,14 +26,13 @@ type WorkloadSpec struct {
|
|||||||
Body string
|
Body string
|
||||||
|
|
||||||
// Kind-specific blocks consumed by the P0c schema validators
|
// Kind-specific blocks consumed by the P0c schema validators
|
||||||
// (internal/spec/schema). The Markdown parser does not populate
|
// (internal/spec/schema). P02 populates Restart, Update, Service,
|
||||||
// these yet; later phases (P02 service block, P03 update stanza,
|
// Health, Constraints, Affinity, Lifecycle from the Markdown
|
||||||
// P04 lifecycle hooks) extend the parser. P0c only defines the
|
// frontmatter (the rest are still populated by later phases).
|
||||||
// struct shape so validators can reference the fields.
|
|
||||||
|
|
||||||
// Restart is the restart policy block. Required for Service and
|
// Restart is the restart policy block. Required for Service and
|
||||||
// DaemonSet; optional for Job (defaults to never/on-failure).
|
// 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
|
Restart *RestartBlock
|
||||||
|
|
||||||
// Schedule is the schedule block. For Job it carries an optional
|
// Schedule is the schedule block. For Job it carries an optional
|
||||||
@@ -43,15 +42,35 @@ type WorkloadSpec struct {
|
|||||||
Schedule *ScheduleBlock
|
Schedule *ScheduleBlock
|
||||||
|
|
||||||
// Update is the rolling/canary update stanza. Required for
|
// 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
|
Update *UpdateBlock
|
||||||
|
|
||||||
// Service is the service block (Traefik route definition). For
|
// Service is the service block (Traefik route definition). For
|
||||||
// Service kind it is implied; Job and DaemonSet do not carry a
|
// Service kind it is implied; Job and DaemonSet do not carry a
|
||||||
// Traefik route by default (D-175). Populated by the P02 service
|
// Traefik route by default (D-175). P02 populates it from the
|
||||||
// block phase.
|
// `service:` frontmatter block.
|
||||||
Service *ServiceBlock
|
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
|
// Timeout is an optional execution timeout (duration string) for
|
||||||
// Job. Populated by P04.
|
// Job. Populated by P04.
|
||||||
Timeout string
|
Timeout string
|
||||||
@@ -67,7 +86,8 @@ type RuntimeBlock struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RestartBlock is the restart policy block. Mode is one of never,
|
// 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 {
|
type RestartBlock struct {
|
||||||
Mode string
|
Mode string
|
||||||
MaxRetries int
|
MaxRetries int
|
||||||
@@ -83,18 +103,56 @@ type ScheduleBlock struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBlock is the rolling/canary update stanza. Required for Service.
|
// 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 {
|
type UpdateBlock struct {
|
||||||
Strategy string
|
Strategy string
|
||||||
MaxSurge int
|
MaxSurge int
|
||||||
|
MaxParallel int
|
||||||
|
MinHealthyTime string
|
||||||
|
HealthyDeadline string
|
||||||
|
Canary string
|
||||||
|
AutoPromote bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceBlock is the Traefik route definition. For Service it is
|
// ServiceBlock is the Traefik route definition. For Service it is
|
||||||
// implied (Traefik route YES); Job and DaemonSet do not carry one by
|
// 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 {
|
type ServiceBlock struct {
|
||||||
Host string
|
Host string
|
||||||
RouteID 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.
|
// PortSpec is a minimal port binding entry. HostIP is optional.
|
||||||
@@ -319,10 +377,19 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
|||||||
secEnv
|
secEnv
|
||||||
secSecrets
|
secSecrets
|
||||||
secVolumes
|
secVolumes
|
||||||
|
secRestart
|
||||||
|
secUpdate
|
||||||
|
secService
|
||||||
|
secHealth
|
||||||
|
secLifecycle
|
||||||
|
secAffinity
|
||||||
|
secConstraints
|
||||||
)
|
)
|
||||||
cur := secNone
|
cur := secNone
|
||||||
var curPort *PortSpec
|
var curPort *PortSpec
|
||||||
var curVol *VolumeSpec
|
var curVol *VolumeSpec
|
||||||
|
var curAffinity *AffinityRule
|
||||||
|
var lifecycleCur string
|
||||||
|
|
||||||
flushPort := func() {
|
flushPort := func() {
|
||||||
if curPort != nil {
|
if curPort != nil {
|
||||||
@@ -336,6 +403,12 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
|||||||
curVol = nil
|
curVol = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flushAffinity := func() {
|
||||||
|
if curAffinity != nil {
|
||||||
|
spec.Affinity = append(spec.Affinity, *curAffinity)
|
||||||
|
curAffinity = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for lineNo, raw := range lines {
|
for lineNo, raw := range lines {
|
||||||
line := stripComment(raw)
|
line := stripComment(raw)
|
||||||
@@ -349,6 +422,7 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
|||||||
// Flush any pending nested entry before switching sections.
|
// Flush any pending nested entry before switching sections.
|
||||||
flushPort()
|
flushPort()
|
||||||
flushVol()
|
flushVol()
|
||||||
|
flushAffinity()
|
||||||
cur = secNone
|
cur = secNone
|
||||||
|
|
||||||
key, val, ok := splitKV(trimmed)
|
key, val, ok := splitKV(trimmed)
|
||||||
@@ -392,8 +466,42 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
|||||||
}
|
}
|
||||||
case "volumes":
|
case "volumes":
|
||||||
cur = secVolumes
|
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:
|
default:
|
||||||
// Unknown top-level keys are ignored (forward-compat).
|
// Unknown top-level key are ignored (forward-compat).
|
||||||
cur = secNone
|
cur = secNone
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -464,10 +572,159 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
|||||||
} else if curVol != nil {
|
} else if curVol != nil {
|
||||||
applyVolumeKV(curVol, trimmed)
|
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()
|
flushPort()
|
||||||
flushVol()
|
flushVol()
|
||||||
|
flushAffinity()
|
||||||
return spec, nil
|
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).
|
// validateWorkload enforces required fields and kind validity (R-012).
|
||||||
func validateWorkload(spec *WorkloadSpec) error {
|
func validateWorkload(spec *WorkloadSpec) error {
|
||||||
if spec.Kind == "" {
|
if spec.Kind == "" {
|
||||||
|
|||||||
@@ -355,3 +355,349 @@ func TestParseMarkdown_UnknownKeyIgnored(t *testing.T) {
|
|||||||
t.Fatalf("ParseMarkdown should ignore unknown keys: %v", err)
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
@@ -44,8 +45,11 @@ type JobValidator struct{}
|
|||||||
// - ports required (at least one)
|
// - ports required (at least one)
|
||||||
// - count ≥ 1
|
// - count ≥ 1
|
||||||
// - restart required (mode must be service)
|
// - restart required (mode must be service)
|
||||||
// - update required
|
// - update required (strategy must be rolling/canary/blue-green)
|
||||||
// - runtime required
|
// - 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)
|
// - service block implied (Traefik route YES)
|
||||||
type ServiceValidator struct{}
|
type ServiceValidator struct{}
|
||||||
|
|
||||||
@@ -109,18 +113,59 @@ func (ServiceValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
|||||||
}
|
}
|
||||||
if spec.Restart == nil {
|
if spec.Restart == nil {
|
||||||
errs = append(errs, "restart block required for Service")
|
errs = append(errs, "restart block required for Service")
|
||||||
} else if spec.Restart.Mode != "service" {
|
} else {
|
||||||
errs = append(errs, fmt.Sprintf("restart mode must be %q for Service, got %q", "service", spec.Restart.Mode))
|
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 {
|
if spec.Update == nil {
|
||||||
errs = append(errs, "update block required for Service")
|
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 {
|
if spec.Runtime == nil {
|
||||||
errs = append(errs, "runtime block required for Service")
|
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)
|
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.
|
// Validate validates a DaemonSet spec. See DaemonSetValidator for the rules.
|
||||||
func (DaemonSetValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
func (DaemonSetValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||||
if spec == nil {
|
if spec == nil {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func TestServiceValidator_ValidFull(t *testing.T) {
|
|||||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/http"},
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/http"},
|
||||||
Restart: &jobspec.RestartBlock{Mode: "service"},
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
Update: &jobspec.UpdateBlock{Strategy: "rolling", MaxSurge: 1},
|
Update: &jobspec.UpdateBlock{Strategy: "rolling", MaxSurge: 1},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http", Interval: "5s"},
|
||||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
}
|
}
|
||||||
v := ServiceValidator{}
|
v := ServiceValidator{}
|
||||||
@@ -159,16 +160,43 @@ func TestServiceValidator_WrongRestartMode(t *testing.T) {
|
|||||||
Name: "web",
|
Name: "web",
|
||||||
Count: 1,
|
Count: 1,
|
||||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||||
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
}
|
}
|
||||||
err := ServiceValidator{}.Validate(spec)
|
err := ServiceValidator{}.Validate(spec)
|
||||||
if err == nil {
|
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") {
|
if !strings.Contains(err.Error(), "restart mode") {
|
||||||
t.Errorf("error = %q, want 'restart mode must be'", err.Error())
|
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) {
|
func TestDaemonSetValidator_Valid(t *testing.T) {
|
||||||
spec := &jobspec.WorkloadSpec{
|
spec := &jobspec.WorkloadSpec{
|
||||||
Kind: "DaemonSet",
|
Kind: "DaemonSet",
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateValidator validates the rolling/canary/blue-green update stanza
|
||||||
|
// (P03). The schema validator (ServiceValidator) already enforces that
|
||||||
|
// the strategy is one of rolling/canary/blue-green and that the update
|
||||||
|
// block is present for a Service. UpdateValidator adds the
|
||||||
|
// field-level validation:
|
||||||
|
//
|
||||||
|
// - max_parallel: integer in [1, count] (defaults to 1 when unset)
|
||||||
|
// - min_healthy_time: a valid time.Duration when set (time.ParseDuration)
|
||||||
|
// - healthy_deadline: a valid time.Duration when set (time.ParseDuration)
|
||||||
|
// - canary: an integer count in [0, count] OR a percentage string of
|
||||||
|
// the form "<n>%" where n is in [0, 100] (the parser already accepts
|
||||||
|
// both shapes; the validator accepts them too). Only meaningful
|
||||||
|
// for the canary strategy; ignored (but still validated for shape)
|
||||||
|
// for rolling/blue-green.
|
||||||
|
// - auto_promote: boolean (no validation beyond the parser's
|
||||||
|
// true/false parse; the field is always populated)
|
||||||
|
//
|
||||||
|
// The validator is pure (no I/O). Violations return a clear error
|
||||||
|
// listing every problem found, mirroring the per-field style of
|
||||||
|
// ServiceValidator.
|
||||||
|
type UpdateValidator struct{}
|
||||||
|
|
||||||
|
// Validate validates the UpdateBlock on the given spec. The spec must
|
||||||
|
// be non-nil and carry a Count (services have count ≥ 1 per
|
||||||
|
// ServiceValidator). When spec.Update is nil the validator returns an
|
||||||
|
// error (the update block is required for Service; this validator
|
||||||
|
// assumes the caller has already established the spec is a Service).
|
||||||
|
func (UpdateValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||||
|
if spec == nil {
|
||||||
|
return fmt.Errorf("schema/Update: spec is nil")
|
||||||
|
}
|
||||||
|
if spec.Update == nil {
|
||||||
|
return fmt.Errorf("schema/Update: update block is nil")
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
u := spec.Update
|
||||||
|
|
||||||
|
switch u.Strategy {
|
||||||
|
case "rolling", "canary", "blue-green":
|
||||||
|
case "":
|
||||||
|
errs = append(errs, "update strategy required (one of rolling, canary, blue-green)")
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("update strategy %q invalid (want one of rolling, canary, blue-green)", u.Strategy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// max_parallel defaults to 1 when unset (0); validate the range
|
||||||
|
// only when the user has set it explicitly.
|
||||||
|
if u.MaxParallel != 0 {
|
||||||
|
if u.MaxParallel < 1 {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.max_parallel must be ≥ 1, got %d", u.MaxParallel))
|
||||||
|
}
|
||||||
|
if spec.Count > 0 && u.MaxParallel > spec.Count {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.max_parallel %d exceeds count %d (must be 1..count)", u.MaxParallel, spec.Count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.MinHealthyTime != "" {
|
||||||
|
if _, err := time.ParseDuration(u.MinHealthyTime); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.min_healthy_time %q is not a valid duration: %v", u.MinHealthyTime, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.HealthyDeadline != "" {
|
||||||
|
if _, err := time.ParseDuration(u.HealthyDeadline); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.healthy_deadline %q is not a valid duration: %v", u.HealthyDeadline, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// canary accepts an integer count (0..count) or a percentage
|
||||||
|
// ("<n>%" with n in 0..100). The field is only meaningful for the
|
||||||
|
// canary strategy but we validate the shape regardless so a typo
|
||||||
|
// in a rolling/blue-green stanza still surfaces.
|
||||||
|
if u.Canary != "" {
|
||||||
|
if err := validateCanary(u.Canary, spec.Count); err != nil {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto_promote is a bool; no extra validation beyond the parser.
|
||||||
|
|
||||||
|
return composeErrors("schema/Update", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCanary validates the canary field shape: either an integer
|
||||||
|
// count (0..count) or a percentage string "<n>%" (n in 0..100). count
|
||||||
|
// is the spec.Count; when count is 0 (e.g. a DaemonSet or unset), the
|
||||||
|
// integer-count upper bound is not enforced (only the percentage
|
||||||
|
// bound is enforced, since percentage does not depend on count).
|
||||||
|
func validateCanary(canary string, count int) error {
|
||||||
|
c := strings.TrimSpace(canary)
|
||||||
|
if c == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(c, "%") {
|
||||||
|
nStr := strings.TrimSuffix(c, "%")
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(nStr))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update.canary %q is not a valid percentage (want \"<n>%%\")", canary)
|
||||||
|
}
|
||||||
|
if n < 0 || n > 100 {
|
||||||
|
return fmt.Errorf("update.canary percentage %d out of range (want 0..100)", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update.canary %q is not a valid count or percentage (want integer or \"<n>%%\")", canary)
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return fmt.Errorf("update.canary count %d must be ≥ 0", n)
|
||||||
|
}
|
||||||
|
if count > 0 && n > count {
|
||||||
|
return fmt.Errorf("update.canary count %d exceeds count %d (must be 0..count)", n, count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidRolling(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 2,
|
||||||
|
MinHealthyTime: "30s",
|
||||||
|
HealthyDeadline: "5m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
v := UpdateValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanary(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
MaxParallel: 2,
|
||||||
|
MinHealthyTime: "30s",
|
||||||
|
HealthyDeadline: "5m",
|
||||||
|
Canary: "10%",
|
||||||
|
AutoPromote: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidBlueGreen(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "blue-green",
|
||||||
|
MinHealthyTime: "1m",
|
||||||
|
HealthyDeadline: "10m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanaryIntegerCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("integer canary count 1 should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanaryZeroPercent(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "0%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("0%% canary should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanaryHundredPercent(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "100%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("100%% canary should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_EmptyDurationsOK(t *testing.T) {
|
||||||
|
// Empty min_healthy_time / healthy_deadline should be accepted
|
||||||
|
// (they are optional; defaults are applied by the executor).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("empty durations should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_NilSpec(t *testing.T) {
|
||||||
|
if err := (UpdateValidator{}).Validate(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_NilUpdateBlock(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Service", Name: "web", Count: 2}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil update block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update block is nil") {
|
||||||
|
t.Errorf("error = %q, want 'update block is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_InvalidStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "recreate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid strategy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "strategy") || !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Errorf("error = %q, want 'strategy ... invalid'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_EmptyStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty strategy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "strategy required") {
|
||||||
|
t.Errorf("error = %q, want 'strategy required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelZero(t *testing.T) {
|
||||||
|
// max_parallel=0 means "unset" → default 1; accepted.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("max_parallel=0 (unset) should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelNegative(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for negative max_parallel")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "max_parallel") {
|
||||||
|
t.Errorf("error = %q, want 'max_parallel'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelExceedsCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for max_parallel > count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeds count") {
|
||||||
|
t.Errorf("error = %q, want 'exceeds count'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelEqualsCount(t *testing.T) {
|
||||||
|
// max_parallel == count is the upper bound; valid.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("max_parallel==count should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_InvalidMinHealthyTime(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MinHealthyTime: "not-a-duration",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid min_healthy_time")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "min_healthy_time") {
|
||||||
|
t.Errorf("error = %q, want 'min_healthy_time'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_InvalidHealthyDeadline(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
HealthyDeadline: "nope",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid healthy_deadline")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "healthy_deadline") {
|
||||||
|
t.Errorf("error = %q, want 'healthy_deadline'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryNegative(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for negative canary count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "canary") {
|
||||||
|
t.Errorf("error = %q, want 'canary'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryExceedsCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canary > count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeds count") {
|
||||||
|
t.Errorf("error = %q, want 'exceeds count'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentOver100(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "150%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canary > 100%")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "out of range") {
|
||||||
|
t.Errorf("error = %q, want 'out of range'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentNegative(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "-10%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for negative canary percent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "out of range") {
|
||||||
|
t.Errorf("error = %q, want 'out of range'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryNotANumber(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric canary")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a valid") {
|
||||||
|
t.Errorf("error = %q, want 'not a valid'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentNotANumber(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "xx%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric canary percent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a valid percentage") {
|
||||||
|
t.Errorf("error = %q, want 'not a valid percentage'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryCountZeroOK(t *testing.T) {
|
||||||
|
// canary=0 is the lower bound; valid.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("canary=0 should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentWithoutCountOK(t *testing.T) {
|
||||||
|
// A percentage canary does not depend on count; valid even when
|
||||||
|
// count is 0 (e.g. DaemonSet-shaped spec bypassing the Service
|
||||||
|
// validator — defensive).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 0,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "25%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("percentage canary with count=0 should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MultipleErrors(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "recreate",
|
||||||
|
MaxParallel: 99,
|
||||||
|
MinHealthyTime: "nope",
|
||||||
|
HealthyDeadline: "also-nope",
|
||||||
|
Canary: "200%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"strategy",
|
||||||
|
"max_parallel",
|
||||||
|
"min_healthy_time",
|
||||||
|
"healthy_deadline",
|
||||||
|
"canary",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q missing %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that UpdateValidator implements Validator.
|
||||||
|
var _ Validator = UpdateValidator{}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user