"""Write/remove per-route Traefik dynamic-config files. Mirrors furtka/furtka/https.py's snippet-write pattern: write to a temp file, then atomically rename over the target, so Traefik's file provider never observes a half-written route. Unlike https.py's Caddy target, no reload call is needed here — Traefik's file provider hot-reloads on change. Config is built with plain string formatting rather than a YAML library, to keep the control-plane's dependency footprint at zero (stdlib only, the same choice the rest of this repo makes) — the shape here is small and fixed enough that hand-formatting is simpler than it sounds. """ from __future__ import annotations import os from pathlib import Path from control_plane import paths def _route_file(route_id: str) -> Path: return paths.dynamic_dir() / f"route-{route_id}.yml" def write_route( route_id: str, subdomain: str, target_ip: str, target_port: int, cert_resolver: str | None, ) -> None: """(Re)write the dynamic-config file that makes `subdomain` proxy to `target_ip:target_port`. Idempotent — safe to call for an unchanged route, which is what reconciler.py relies on. """ if cert_resolver: tls_block = f" tls:\n certResolver: {cert_resolver}\n" else: tls_block = " tls: {}\n" content = ( "http:\n" " routers:\n" f" route-{route_id}:\n" f' rule: "Host(`{subdomain}`)"\n' " entryPoints: [websecure]\n" f" service: svc-{route_id}\n" f"{tls_block}" " services:\n" f" svc-{route_id}:\n" " loadBalancer:\n" " servers:\n" f' - url: "http://{target_ip}:{target_port}"\n' ) target = _route_file(route_id) target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(".tmp") tmp.write_text(content) os.replace(tmp, target) def remove_route(route_id: str) -> None: _route_file(route_id).unlink(missing_ok=True) def existing_route_ids() -> set[str]: return { f.name.removeprefix("route-").removesuffix(".yml") for f in paths.dynamic_dir().glob("route-*.yml") }