dea472f443
Replace internal/traefik/install.go binary+systemd installer with a
podman-container reconciler (R-024). The reconciler is idempotent:
inspect → start-if-stopped → pull+run-if-absent.
Container run flags (research-validated):
--restart=unless-stopped (not always; research Topic 6)
--network host (binds 127.0.0.1:8080/8443 on host/LXC loopback)
-v /etc/traefik/traefik.yml:ro (overrides baked default; C-58)
-v /etc/traefik/dynamic:ro (orca writes atomically via SSH-push)
-v /etc/orca/step-ca-root.crt:ro (future mTLS; v0.14 uses tls:{})
No :Z SELinux flag (research Topic 7)
C-50: ensurePodmanLocal/Remote installs podman if absent.
C-57: removeLegacySystemdUnitLocal/Remote stops+disables+removes
the v0.13 orca-traefik.service + /usr/local/bin/traefik before
starting the podman container (upgrade path).
upgrade.go cutover rewritten to use the reconciler.
TLS model fix (research Topic 4): drop certResolver: orca from
dynamic config (traefik v3.3 only supports acme/tailscale resolvers,
not CA-file-based). Emit tls: {} instead. Real mTLS via dynamic
tls.certificates + clientAuth.caFiles deferred to v0.15 (grill
G-003, confidence 0.55 < 0.60).
Callsites updated:
init.go: installTraefikLocal → ensureTraefikContainerLocal
linux/bootstrap.go: traefik.InstallRemote → EnsureTraefikContainerRemote
proxmox/bootstrap.go: same
traefik_install.go: wrapper updated
Tests: internal/traefik/install_test.go (new) — ImageRef, podmanRunArgs,
container-running/stopped/absent paths, legacy systemd removal (C-57).
---ci---
project: orca
phase: 2
milestone: v0.14
status: execute
---/ci---
303 lines
12 KiB
Go
303 lines
12 KiB
Go
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 from
|
|
// v0.11. As of v0.14 (RESEARCH_v0.14 Topic 4), traefik v3.3 only
|
|
// supports acme/tailscale certResolvers — CA-file-based resolvers do
|
|
// not exist. The dynamic config now emits `tls: {}` instead. Real
|
|
// mTLS via dynamic tls.certificates + clientAuth.caFiles is deferred
|
|
// to v0.15. This constant is retained for documentation.
|
|
//
|
|
// Deprecated: v0.14 removed certResolver from the dynamic config.
|
|
const traefikRouterTLSCertResolver = "orca"
|
|
|
|
// defaultTrustDomain is the SPIFFE trust domain. v0.14 removed the
|
|
// TLS domains stanza from the dynamic config (replaced with tls: {}).
|
|
// Retained for documentation; will be used by v0.15 mTLS.
|
|
//
|
|
// Deprecated: v0.14 removed TLS domains from the dynamic config.
|
|
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)
|
|
reg.Register("service:pve-ct", e)
|
|
reg.Register("service:pve-vm", 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)
|
|
|
|
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(" 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
|
|
}
|
|
|
|
// traefikStaticConfigPath is the canonical on-peer path for the
|
|
// Traefik static config (R-017, D-220).
|
|
const traefikStaticConfigPath = "/etc/traefik/traefik.yml"
|
|
|
|
// TraefikStaticOpts controls the Traefik static-config binding. The
|
|
// default (PublicBinding="hybrid", R-017) binds the entrypoints to the
|
|
// loopback so nftables owns the public surface; the opt-out
|
|
// ("traefik-on-public-ip") binds them to the wildcard so Traefik owns
|
|
// the public surface directly (legacy / single-host deployments).
|
|
type TraefikStaticOpts struct {
|
|
// PublicBinding selects the binding model:
|
|
// "hybrid" (default, R-017): loopback bind + nft DNAT.
|
|
// "traefik-on-public-ip": Traefik binds :443/:80 directly.
|
|
PublicBinding string
|
|
}
|
|
|
|
// withDefaults returns a copy of o with the default PublicBinding
|
|
// applied when empty.
|
|
func (o TraefikStaticOpts) withDefaults() TraefikStaticOpts {
|
|
out := o
|
|
if strings.TrimSpace(out.PublicBinding) == "" {
|
|
out.PublicBinding = "hybrid"
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (o TraefikStaticOpts) publicWebSecure() string {
|
|
if o.PublicBinding == "traefik-on-public-ip" {
|
|
return ":443"
|
|
}
|
|
return "127.0.0.1:8443"
|
|
}
|
|
func (o TraefikStaticOpts) publicWeb() string {
|
|
if o.PublicBinding == "traefik-on-public-ip" {
|
|
return ":80"
|
|
}
|
|
return "127.0.0.1:8080"
|
|
}
|
|
|
|
// RenderTraefikStaticConfig renders /etc/traefik/traefik.yml (REQ-100,
|
|
// D-220). The static config pins the entrypoint bind addresses (R-017
|
|
// hybrid default: loopback + nft DNAT; opt-out: Traefik on the public
|
|
// IP), the file provider (watch=/etc/traefik/dynamic/orca.yml), and
|
|
// json log + access log.
|
|
func (TraefikEmitter) RenderTraefikStaticConfig(opts TraefikStaticOpts) ([]File, error) {
|
|
o := opts.withDefaults()
|
|
content := renderTraefikStaticYAML(o)
|
|
return []File{{Path: traefikStaticConfigPath, Content: content, Mode: "0644"}}, nil
|
|
}
|
|
|
|
// renderTraefikStaticYAML builds the static-config YAML. The shape is
|
|
// load-bearing for `orca doctor ingress` (which greps the live file
|
|
// for the bind addresses).
|
|
func renderTraefikStaticYAML(o TraefikStaticOpts) string {
|
|
var b strings.Builder
|
|
b.WriteString("entryPoints:\n")
|
|
b.WriteString(" websecure:\n")
|
|
b.WriteString(fmt.Sprintf(" address: %q\n", o.publicWebSecure()))
|
|
b.WriteString(" web:\n")
|
|
b.WriteString(fmt.Sprintf(" address: %q\n", o.publicWeb()))
|
|
b.WriteString(" traefik:\n")
|
|
b.WriteString(fmt.Sprintf(" address: %q\n", "127.0.0.1:8081"))
|
|
b.WriteString("\nproviders:\n")
|
|
b.WriteString(" file:\n")
|
|
b.WriteString(fmt.Sprintf(" directory: %q\n", traefikDynamicDir))
|
|
b.WriteString(" watch: true\n")
|
|
b.WriteString("\nlog:\n")
|
|
b.WriteString(" level: INFO\n")
|
|
b.WriteString(" format: json\n")
|
|
b.WriteString("\naccessLog:\n")
|
|
b.WriteString(" format: json\n")
|
|
return b.String()
|
|
}
|