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 // "" 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-") { t.Errorf("nil node should render alloc- 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-") { t.Errorf("empty hostname should render alloc- 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-.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{}