ae6eb5a27b
P03 — Update stanza (rolling/canary/blue-green): - internal/spec/schema/update.go: UpdateValidator (strategy enum, max_parallel 1..count, duration parsing, canary int/% forms, auto_promote). 98.2% cov. - internal/emitter/update.go: RenderUpdatePlan computes the step sequence (rolling batches, canary 1+promote+rest, blue-green all+cutover). Pure plan, no execution (v0.10-P10 is transactional). 73.7-100% cov. P04 — Lifecycle hooks (systemd ExecStop semantics): - Extended internal/emitter/systemd.go: post_start -> ExecStartPost=, pre_stop -> ExecStop=. Order: ExecStart -> ExecStartPost -> ExecStop -> socket lines. 8 lifecycle tests. 100% cov on systemd.go. P08 — Socket plumbing (R-007): - internal/emitter/socket.go: SocketEmitter renders RuntimeDirectory=orca/ alloc-<id> per port (mode 0750, orca:orca). ExecStartPre TCP-bind marker when service.bind=127.0.0.1. SocketPath(allocID,portName) helper. 100% cov. - Alloc-id is spec.Name placeholder; real id assigned by scheduler at submit. 22 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. ---ci--- project: orca phase: P03/P04/P08 milestone: v0.9 status: execute ---/ci---
266 lines
8.2 KiB
Go
266 lines
8.2 KiB
Go
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
|
|
}
|