236 lines
9.0 KiB
Python
236 lines
9.0 KiB
Python
"""Host-side helper to apply a routes.yaml change to a running
|
||
egress sidecar (PRD 0014 retargeted by PRD 0017 chunk 3, PRD 0053).
|
||
|
||
Used by the supervise dashboard when the operator approves an
|
||
egress-block proposal. Fetches current routes.yaml, validates,
|
||
writes into the sidecar, then SIGHUPs to reload.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import cast
|
||
|
||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||
from ...egress_addon_core import load_routes
|
||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||
from .bottle_state import egress_state_dir
|
||
from .sidecar_bundle import sidecar_bundle_container_name
|
||
|
||
|
||
def _render_routes_payload(routes_list: list[dict[str, object]]) -> str:
|
||
"""Render a list-of-dicts routes payload as YAML matching the
|
||
shape `egress_render_routes` produces."""
|
||
if not routes_list:
|
||
return "routes: []\n"
|
||
lines: list[str] = ["routes:"]
|
||
for entry in routes_list:
|
||
host = str(entry.get("host", ""))
|
||
lines.append(f' - host: "{host}"')
|
||
auth_scheme = entry.get("auth_scheme")
|
||
token_env = entry.get("token_env")
|
||
if auth_scheme and token_env:
|
||
lines.append(f' auth_scheme: "{auth_scheme}"')
|
||
lines.append(f' token_env: "{token_env}"')
|
||
matches_obj = entry.get("matches")
|
||
if isinstance(matches_obj, list) and matches_obj:
|
||
lines.append(" matches:")
|
||
for match_entry in matches_obj:
|
||
me = cast(dict[str, object], match_entry)
|
||
first_key = True
|
||
if "paths" in me:
|
||
lines.append(" - paths:")
|
||
first_key = False
|
||
for pd in cast(list[dict[str, str]], me["paths"]):
|
||
if "type" in pd:
|
||
lines.append(f' - type: "{pd["type"]}"')
|
||
lines.append(f' value: "{pd["value"]}"')
|
||
else:
|
||
lines.append(f' - value: "{pd["value"]}"')
|
||
if "methods" in me:
|
||
methods_str = ", ".join(
|
||
f'"{m}"' for m in cast(list[str], me["methods"])
|
||
)
|
||
prefix = " - " if first_key else " "
|
||
lines.append(f'{prefix}methods: [{methods_str}]')
|
||
first_key = False
|
||
if first_key:
|
||
lines.append(" - {}")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _egress_routes_host_path(slug: str) -> Path:
|
||
return egress_state_dir(slug) / "egress_routes.yaml"
|
||
|
||
|
||
class EgressApplyError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def fetch_current_routes(slug: str) -> str:
|
||
container = sidecar_bundle_container_name(slug)
|
||
r = subprocess.run(
|
||
["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
|
||
capture_output=True, text=True, check=False,
|
||
)
|
||
if r.returncode != 0:
|
||
raise EgressApplyError(
|
||
f"could not read routes.yaml from {container}: "
|
||
f"{(r.stderr or '').strip() or 'container not running?'}"
|
||
)
|
||
return r.stdout
|
||
|
||
|
||
def validate_routes_content(content: str) -> None:
|
||
try:
|
||
load_routes(content)
|
||
except ValueError as e:
|
||
raise EgressApplyError(
|
||
f"proposed routes.yaml is not valid: {e}"
|
||
) from e
|
||
|
||
|
||
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||
container = sidecar_bundle_container_name(slug)
|
||
before = fetch_current_routes(slug)
|
||
validate_routes_content(new_content)
|
||
|
||
target = _egress_routes_host_path(slug)
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_text(new_content)
|
||
target.chmod(0o644)
|
||
sig = subprocess.run(
|
||
["docker", "kill", "--signal", "HUP", container],
|
||
capture_output=True, text=True, check=False,
|
||
)
|
||
if sig.returncode != 0:
|
||
raise EgressApplyError(
|
||
f"failed to SIGHUP {container}: "
|
||
f"{(sig.stderr or '').strip()}"
|
||
)
|
||
|
||
return before, new_content
|
||
|
||
|
||
def _merge_single_route(
|
||
current_yaml: str, new_route: dict[str, object],
|
||
) -> str:
|
||
"""Merge a single proposed route into the current routes.yaml.
|
||
|
||
- Host absent → append the route.
|
||
- Host present → union the match paths (proposed ∪ existing).
|
||
Auth is preserved from existing route.
|
||
"""
|
||
try:
|
||
cfg = parse_yaml_subset(current_yaml)
|
||
except YamlSubsetError as e:
|
||
raise EgressApplyError(
|
||
f"current routes.yaml is not valid YAML: {e}"
|
||
) from e
|
||
routes = cfg.get("routes")
|
||
if not isinstance(routes, list):
|
||
raise EgressApplyError(
|
||
"current routes.yaml: 'routes' is not a list"
|
||
)
|
||
routes_typed = cast(list[object], routes)
|
||
|
||
new_host = str(new_route.get("host", "")).lower()
|
||
if not new_host:
|
||
raise EgressApplyError(
|
||
"proposed route is missing 'host'"
|
||
)
|
||
|
||
# Build proposed matches from the input
|
||
proposed_matches = new_route.get("matches")
|
||
if proposed_matches is None:
|
||
# Accept legacy path_allowlist from agent proposals and convert
|
||
proposed_paths = new_route.get("path_allowlist")
|
||
if isinstance(proposed_paths, list) and proposed_paths:
|
||
proposed_matches = [{"paths": [{"value": p} for p in proposed_paths]}]
|
||
|
||
for entry in routes_typed:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
entry_typed = cast(dict[str, object], entry)
|
||
if str(entry_typed.get("host", "")).lower() == new_host:
|
||
# Merge matches: union path values from proposed into existing
|
||
if isinstance(proposed_matches, list) and proposed_matches:
|
||
existing_matches = entry_typed.get("matches")
|
||
if not isinstance(existing_matches, list):
|
||
existing_matches = []
|
||
# Simple merge: collect all existing path values, add new ones
|
||
existing_paths: set[str] = set()
|
||
for me in existing_matches:
|
||
me_typed = cast(dict[str, object], me) if isinstance(me, dict) else {}
|
||
paths = me_typed.get("paths")
|
||
if isinstance(paths, list):
|
||
for p in paths:
|
||
p_typed = cast(dict[str, object], p) if isinstance(p, dict) else {}
|
||
val = p_typed.get("value")
|
||
if isinstance(val, str):
|
||
existing_paths.add(val)
|
||
new_paths: list[str] = []
|
||
for me in proposed_matches:
|
||
me_typed = cast(dict[str, object], me) if isinstance(me, dict) else {}
|
||
paths = me_typed.get("paths")
|
||
if isinstance(paths, list):
|
||
for p in paths:
|
||
p_typed = cast(dict[str, object], p) if isinstance(p, dict) else {}
|
||
val = p_typed.get("value")
|
||
if isinstance(val, str) and val not in existing_paths:
|
||
new_paths.append(val)
|
||
existing_paths.add(val)
|
||
if new_paths:
|
||
existing_matches.append(
|
||
{"paths": [{"value": p} for p in new_paths]}
|
||
)
|
||
entry_typed["matches"] = existing_matches
|
||
break
|
||
else:
|
||
entry_typed: dict[str, object] = {"host": new_route.get("host")} # type: ignore
|
||
if isinstance(proposed_matches, list) and proposed_matches:
|
||
entry_typed["matches"] = proposed_matches
|
||
auth = new_route.get("auth")
|
||
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"): # type: ignore
|
||
auth_typed = cast(dict[str, object], auth)
|
||
existing_slots = sorted({
|
||
str(r_entry.get("token_env", ""))
|
||
for r_entry_obj in routes_typed
|
||
if isinstance(r_entry_obj, dict)
|
||
for r_entry in [cast(dict[str, object], r_entry_obj)]
|
||
if r_entry.get("token_env")
|
||
})
|
||
next_idx = len(existing_slots)
|
||
entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme")))
|
||
entry_typed["token_env"] = f"EGRESS_TOKEN_{next_idx}"
|
||
routes_typed.append(entry_typed)
|
||
|
||
return _render_routes_payload(cast(list[dict[str, object]], routes_typed))
|
||
|
||
|
||
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
|
||
try:
|
||
proposed = json.loads(proposed_route_json)
|
||
except json.JSONDecodeError as e:
|
||
raise EgressApplyError(
|
||
f"proposed route is not valid JSON: {e}"
|
||
) from e
|
||
if not isinstance(proposed, dict):
|
||
raise EgressApplyError(
|
||
"proposed route must be a JSON object"
|
||
)
|
||
current = fetch_current_routes(slug)
|
||
merged = _merge_single_route(current, proposed)
|
||
return apply_routes_change(slug, merged)
|
||
|
||
|
||
__all__ = [
|
||
"EgressApplyError",
|
||
"add_route",
|
||
"apply_routes_change",
|
||
"fetch_current_routes",
|
||
"validate_routes_content",
|
||
]
|