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-.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-.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-.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-.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-: // rule: PathPrefix("/") // service: orca- // tls: // certResolver: orca // domains: // - main: "" // services: // orca-: // loadBalancer: // servers: // - url: "unix:///run/orca/alloc-/port-.sock" // healthCheck: // path: /healthz // interval: // timeout: // // The alloc-id placeholder is "" 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 "" is emitted. func allocIDFor(node *Node) string { if node == nil || strings.TrimSpace(node.Hostname) == "" { return "" } return node.Hostname }