feat(plane): per-task egress policy; receiver by name through the gateway

Agent Substrate's egress gateway denies an actor without an EgressPolicy, so
no task could deliver its artifacts or reach a model. egress.py grants each
task exactly: http/80 to ousast-receiver.ax-system.svc.cluster.local, and
TLS passthrough/443 to the bound Workspaces' https Git hosts and the bound
Model's openultrasast.io/egress-hosts (api.deepseek.com for deepseek-flash).
No IPs, no wildcards, no https (MITM) rules.

The reconciler writes the policy with kubectl-ate between ax apply and
resume, reads it back, waits OUSAST_EGRESS_SETTLE (11 s, the gateway's
cached deny), and after ax delete task confirms the policy went with its
actor (kubectl-ate has no delete verb; the store cascades).

The receiver is a selector-less Service (ops/ax/receiver-service.yaml.tmpl,
applied by up.sh together with the no-build agentgateway egress gateway)
on port 18090 of the host. Measured live: the gateway checks the policy on
Host but connects to the address the actor dialled, so the runner dials the
Service's ClusterIP (OUSAST_ARTIFACT_DIAL) with the receiver's name as Host.
The runner now labels its delivery with the Run's task name (OUSAST_TASK),
which is what the receiver accepts; the ax Task name was refused with 403.
The Receiver moved to egress.py to keep reconciler.py at 460 lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-29 20:51:21 +02:00
co-authored by Claude Opus 5.5
parent 0744a157bf
commit 137c6c8ed5
15 changed files with 703 additions and 82 deletions
+48
View File
@@ -62,3 +62,51 @@ def _scratch_under_tmp_path(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: p
scratch = tmp_path_factory.mktemp("scratch")
monkeypatch.setattr(tempfile, "tempdir", str(scratch))
FAKE_KUBECTL_ATE = r'''#!{python}
"""Fake kubectl-ate for egress policies: an actor exists while ``applied/<actor>.json`` exists and is not gone
(the fake ax's record); its policy goes with it, as Substrate's store cascades. Every call is logged to ax.log."""
import json, sys, time
from pathlib import Path
HERE = Path(__file__).resolve().parent
argv = sys.argv[1:]
if argv[:1] == ["--context"]:
argv = argv[2:]
verb, actor, space = argv[0], argv[2], argv[argv.index("-a") + 1]
with (HERE / "ax.log").open("a") as log:
log.write(f"{time.monotonic():.4f} egress {verb} {actor}\n")
record = HERE / "applied" / f"{actor}.json"
alive = record.exists() and not json.loads(record.read_text()).get("gone")
store = HERE / "policies" / f"{space}_{actor}.json"
if not alive:
store.unlink(missing_ok=True)
sys.exit(print(f'Error: actor "{actor}" in atespace "{space}" not found', file=sys.stderr) or 1)
if verb == "get":
if not store.exists(): # kubectl-ate: no policy is a valid state, exit 0 with nothing on stdout
sys.exit(print(f'actor "{actor}" in atespace "{space}" has no egress policy', file=sys.stderr) or 0)
print(store.read_text())
elif verb in ("create", "update"):
body = json.loads(sys.stdin.read())
old = json.loads(store.read_text()) if store.exists() else None
if verb == "create" and old is not None:
sys.exit(print("Error: already exists", file=sys.stderr) or 1)
version = 1 if old is None else old["metadata"]["version"] + 1
body["metadata"] = {"name": "default", "atespace": space, "uid": "uid-1", "version": version}
store.parent.mkdir(exist_ok=True)
store.write_text(json.dumps(body))
(HERE / "policies" / f"{space}_{actor}.written.json").write_text(json.dumps(body)) # survives the cascade
print(f"egress policy {verb}d")
'''
@pytest.fixture
def kubectl_ate(tmp_path, monkeypatch: pytest.MonkeyPatch):
"""A fake ``kubectl-ate`` in ``tmp_path`` (shared with the fake ax there); no settle wait."""
path = tmp_path / "kubectl-ate"
path.write_text(FAKE_KUBECTL_ATE.replace("{python}", sys.executable), encoding="utf-8")
path.chmod(0o755)
monkeypatch.setenv("OUSAST_KUBECTL_ATE", str(path))
monkeypatch.setenv("OUSAST_EGRESS_SETTLE", "0")
return path