diff --git a/.kiro/specs/plane-on-kubernetes/tasks.md b/.kiro/specs/plane-on-kubernetes/tasks.md index e026bcf..817f757 100644 --- a/.kiro/specs/plane-on-kubernetes/tasks.md +++ b/.kiro/specs/plane-on-kubernetes/tasks.md @@ -86,7 +86,7 @@ go-ahead** with a ceiling; steps that need a hand on a cluster that does not exi - `tests/test_plane_runner.py`: accepted body with `delivery`; 400 on an `http` or IP URL; PUT to a local server; inputs fetched from presigned-shaped URLs; the marker and retry-only boot unchanged. - _Requirements: 3.1_ -- [ ] 2.3 Egress without the receiver +- [x] 2.3 Egress without the receiver - `src/openultrasast/plane/egress.py`: `policy_for(task, workspaces, model, store_host)` emits one `tlsPassthrough` rule on 443 over the sorted union of store host, Git hosts, Model hosts; no `http` rule; an IP store host raises naming `S3_ENDPOINT`. diff --git a/src/openultrasast/plane/egress.py b/src/openultrasast/plane/egress.py index 58d3d9f..57a487f 100644 --- a/src/openultrasast/plane/egress.py +++ b/src/openultrasast/plane/egress.py @@ -84,17 +84,32 @@ def git_hosts(workspaces: Iterable[Workspace]) -> tuple[list[str], list[str]]: return sorted(tls), sorted(plain) -def policy_for(task: Task, workspaces: Iterable[Workspace], model: Model | None, receiver_host: str | None) -> dict[str, Any]: - """The task's EgressPolicy body: the receiver over HTTP/80, Git and Model hosts as TLS passthrough on 443.""" +def policy_for( + task: Task, + workspaces: Iterable[Workspace], + model: Model | None, + store_host: str | None, + receiver_host: str | None = None, +) -> dict[str, Any]: + """The task's EgressPolicy body (plane-on-kubernetes design section 3): one ``tlsPassthrough`` rule on 443 over + the sorted union of the store's hostname (``store_host``: where the runner PUTs its tar and GETs its inputs), the + bound Workspaces' https Git hosts and the bound Model's ``openultrasast.io/egress-hosts``. No ``http`` rule, unless + ``receiver_host`` names the old receiver (the path task 2.6 removes). An http Git repo cannot be reached under + this policy and is refused; a store given as an address is refused naming ``S3_ENDPOINT`` (the gateway allows + hostnames only).""" tls, plain = git_hosts(workspaces) - tls = sorted(set(tls) | set(model.egress_hosts if model else ())) - if receiver_host and not _is_ip(receiver_host): # an address cannot be allowed; only the direct test mode has one - plain = sorted({*plain, receiver_host.lower()}) - rules: list[dict[str, Any]] = [] if plain: - rules.append({"http": {"hostnames": plain, "ports": {"numbers": [80]}}}) - if tls: - rules.append({"tlsPassthrough": {"hostnames": tls, "ports": {"numbers": [443]}}}) + raise ValueError(f"Task/{task.metadata.name}: http Git repos ({', '.join(plain)}) cannot be reached: the policy grants TLS only") + hosts = set(tls) | set(model.egress_hosts if model else ()) + if store_host: + if _is_ip(store_host): + raise ValueError(f"Task/{task.metadata.name}: the store endpoint is an address ({store_host}); S3_ENDPOINT must be a hostname") + hosts.add(store_host.lower()) + rules: list[dict[str, Any]] = [] + if receiver_host and not _is_ip(receiver_host): # the receiver path, kept for group 2 only (task 2.6 deletes it) + rules.append({"http": {"hostnames": [receiver_host.lower()], "ports": {"numbers": [80]}}}) + if hosts: + rules.append({"tlsPassthrough": {"hostnames": sorted(hosts), "ports": {"numbers": [443]}}}) return {"rules": rules} diff --git a/src/openultrasast/plane/reconciler.py b/src/openultrasast/plane/reconciler.py index 882c078..efab978 100644 --- a/src/openultrasast/plane/reconciler.py +++ b/src/openultrasast/plane/reconciler.py @@ -341,7 +341,7 @@ def _execute( docs = render_task(run, entry, manifests, receiver.url, receiver.dial) manifest.write_text(yaml.safe_dump_all(docs, sort_keys=False), encoding="utf-8") model = manifests.models.get(task.metadata.annotations.get(MODEL_ANNOTATION) or "") - policy = policy_for(task, [manifests.workspaces[b.name] for b in task.workspaces], model, urlsplit(receiver.url).hostname) + policy = policy_for(task, [manifests.workspaces[b.name] for b in task.workspaces], model, None, urlsplit(receiver.url).hostname) try: # Req 4.6: the bound Model's credential is read here and only ever sent in the start request start = router.starter(atespace, ax_name, run.metadata.name, credentials(model)) ax.apply(manifest) # creates the Substrate actor; its egress is denied until the policy exists diff --git a/tests/test_plane_egress.py b/tests/test_plane_egress.py index fad22a3..c369967 100644 --- a/tests/test_plane_egress.py +++ b/tests/test_plane_egress.py @@ -1,8 +1,10 @@ """ai-service-plane task 0: the per-task egress policy, its kubectl-ate calls, and the runner's dialled delivery. -Agent Substrate's egress gateway denies by default; :func:`egress.policy_for` must grant exactly the receiver (HTTP -by name), the bound Workspaces' Git hosts and the bound Model's API host (TLS passthrough), and nothing a gateway -would reject or that would intercept TLS: no IP address, no wildcard, no ``https`` rule. +Agent Substrate's egress gateway denies by default; :func:`egress.policy_for` must grant exactly the memory store's +host (where the runner PUTs its tar and GETs its inputs), the bound Workspaces' Git hosts and the bound Model's API +host, all as one TLS passthrough rule on 443 (plane-on-kubernetes 2.3), and nothing a gateway would reject or that +would intercept TLS: no IP address, no wildcard, no ``https`` rule, no ``http`` rule (the receiver path, kept for +group 2 only, is the one exception). """ from __future__ import annotations @@ -70,24 +72,59 @@ def assert_tight(policy: dict) -> None: ipaddress.ip_address(host) -def test_model_free_task_gets_only_the_receiver(manifests) -> None: - policy = egress.policy_for(manifests.tasks["facts"], [], None, egress.RECEIVER_HOST) - assert policy == {"rules": [{"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}]} +STORE = "files.store.example" + + +def test_model_free_task_gets_only_the_store(manifests) -> None: + policy = egress.policy_for(manifests.tasks["facts"], [], None, STORE) + assert policy == {"rules": [{"tlsPassthrough": {"hostnames": [STORE], "ports": {"numbers": [443]}}}]} assert_tight(policy) + assert egress.policy_for(manifests.tasks["facts"], [], None, None) == {"rules": []}, "no store, no Git, no Model: nothing" + + +def test_the_receiver_path_adds_the_one_http_rule_until_it_is_removed(manifests) -> None: + policy = egress.policy_for(manifests.tasks["facts"], [], None, None, receiver_host=egress.RECEIVER_HOST) + assert policy == {"rules": [{"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}]} + assert egress.policy_for(manifests.tasks["facts"], [], None, None, receiver_host="127.0.0.1") == {"rules": []} def test_model_bound_task_with_git_workspaces(manifests) -> None: task = manifests.tasks["verify"] - policy = egress.policy_for(task, [manifests.workspaces["cases"]], manifests.models["deepseek-flash"], egress.RECEIVER_HOST) + policy = egress.policy_for(task, [manifests.workspaces["cases"]], manifests.models["deepseek-flash"], STORE) assert policy["rules"] == [ - {"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}, - {"tlsPassthrough": {"hostnames": ["api.deepseek.com", "github.com"], "ports": {"numbers": [443]}}}, - ], "one github.com for two repos; the ssh and IP-address repos add nothing" + {"tlsPassthrough": {"hostnames": ["api.deepseek.com", STORE, "github.com"], "ports": {"numbers": [443]}}}, + ], "one rule: the store, one github.com for two repos, the Model host; the ssh and IP-address repos add nothing" assert_tight(policy) -def test_an_ip_receiver_is_not_granted(manifests) -> None: - assert egress.policy_for(manifests.tasks["facts"], [], None, "127.0.0.1") == {"rules": []} +def test_an_ip_store_endpoint_is_refused_naming_the_variable(manifests) -> None: + with pytest.raises(ValueError, match="S3_ENDPOINT must be a hostname"): + egress.policy_for(manifests.tasks["facts"], [], None, "127.0.0.1") + + +def test_an_http_git_repo_is_refused(tmp_path: Path) -> None: + path = tmp_path / "m.yaml" + path.write_text(MANIFESTS.replace("https://github.com/example-org/alpha", "http://mirror.example/alpha"), encoding="utf-8") + loaded = load_manifests([path]) + with pytest.raises(ValueError, match="http Git repos .*mirror.example.* cannot be reached"): + egress.policy_for(loaded.tasks["verify"], [loaded.workspaces["cases"]], None, STORE) + + +def test_every_committed_template_renders_the_expected_policy() -> None: + """For each template under plane/tasks/: the store, the Model's hosts when bound, nothing else; one TLS rule.""" + from openultrasast.plane.generate import TEMPLATES + + plane = Path("plane") + loaded = load_manifests([plane / "tasks" / f"{name}.yaml" for name in TEMPLATES] + sorted((plane / "models").glob("*.yaml"))) + assert set(loaded.tasks) == set(TEMPLATES) + for name, task in loaded.tasks.items(): + model = loaded.models.get(task.metadata.annotations.get("openultrasast.io/model") or "") + policy = egress.policy_for(task, [], model, STORE) + expected = sorted({STORE, *(model.egress_hosts if model else ())}) + assert policy == {"rules": [{"tlsPassthrough": {"hostnames": expected, "ports": {"numbers": [443]}}}]}, name + assert_tight(policy) + bound = {n for n, t in loaded.tasks.items() if "openultrasast.io/model" in t.metadata.annotations} + assert bound == {"verify", "roles"}, "the model-bound templates, and only they, get the provider host" def test_deepseek_model_manifest_names_the_endpoint_host() -> None: @@ -121,7 +158,7 @@ def calls(tmp_path: Path) -> list[str]: return [" ".join(line.split()[2:4]) for line in (tmp_path / "ax.log").read_text().splitlines()] -POLICY = {"rules": [{"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}]} +POLICY = {"rules": [{"tlsPassthrough": {"hostnames": ["files.store.example"], "ports": {"numbers": [443]}}}]} def test_apply_creates_reads_back_and_delete_confirms_the_cascade(tmp_path: Path, kubectl_ate: Path) -> None: