From 0c158c571846660b9d004ee10f7b1aebbfac6bfb Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 20:40:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20slice=2013b=20=E2=80=94?= =?UTF-8?q?=20consolidated=20registration=20inputs=20(egress=20policy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges the per-bottle `prepare` output to the consolidated registry: `registration_inputs(plan)` turns a prepared egress plan into the backend-neutral inputs `Orchestrator.launch_bottle` takes. - egress_policy(plan): the policy blob = the exact routes YAML the per-bottle sidecar read from a file; the multi-tenant gateway's PolicyResolver now fetches it from the registry per request instead. Same egress_render_routes, so a bottle's allow-list is byte-identical moving onto the shared gateway. - RegistrationInputs bundles policy + metadata; metadata carries the human slug so the shared registry can map a minted bottle id back to a name (console / supervise display). Host-side glue (imports bot_bottle.egress) used by the launch path — not the lean orchestrator control-plane process. Not yet wired into launch (that's 13c/13d), consistent with the slice-6/10 pattern of landing the primitive before the cut-over. Key test: the policy round-trips through load_config back to the same routes + log level — the resolver-served policy reconstructs the per-bottle egress config exactly. pyright 0 errors; pylint 9.83/10; unit suite green (1718 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/orchestrator/registration.py | 57 ++++++++++++++++++++ tests/unit/test_orchestrator_registration.py | 56 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 bot_bottle/orchestrator/registration.py create mode 100644 tests/unit/test_orchestrator_registration.py diff --git a/bot_bottle/orchestrator/registration.py b/bot_bottle/orchestrator/registration.py new file mode 100644 index 0000000..6773a52 --- /dev/null +++ b/bot_bottle/orchestrator/registration.py @@ -0,0 +1,57 @@ +"""Consolidated registration inputs (PRD 0070, docker slice). + +Bridges the existing per-bottle `prepare` output to the consolidated +registry: turns a prepared bottle's egress plan into the backend-neutral +inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and +launch **metadata**. + +The policy blob is the exact routes YAML the per-bottle egress sidecar used +to read from a file; in the consolidated model the multi-tenant gateway's +`PolicyResolver` fetches it from the registry per request (keyed by source +IP) instead. Same render, so consolidated and single-tenant egress apply +byte-identical policy — a bottle's allow-list doesn't change when it moves +onto the shared gateway. + +Host-side glue (imports `bot_bottle.egress`), used by the launch path — not +by the lean orchestrator control-plane process itself. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from ..egress import EgressPlan, egress_render_routes + + +@dataclass(frozen=True) +class RegistrationInputs: + """What `Orchestrator.launch_bottle` needs to register a bottle, derived + from its prepared plan. `policy` is served verbatim by the gateway's + `/resolve`; `metadata` is opaque forward-compat state — it carries the + human slug so the console / supervise can show a name, not just the + minted bottle id.""" + + policy: str + metadata: str + + +def egress_policy(plan: EgressPlan) -> str: + """The bottle's egress policy blob: the routes YAML the gateway serves + and the addon parses with `load_config`. Identical to the per-bottle + `routes.yaml` render, so the consolidated path applies the same + allow-list.""" + return egress_render_routes(plan.routes, log=plan.log) + + +def registration_inputs(plan: EgressPlan) -> RegistrationInputs: + """Assemble the orchestrator registration inputs from a prepared egress + plan. `metadata` records the slug so the shared registry can map a minted + bottle id back to its human name.""" + return RegistrationInputs( + policy=egress_policy(plan), + metadata=json.dumps({"slug": plan.slug}), + ) + + +__all__ = ["RegistrationInputs", "egress_policy", "registration_inputs"] diff --git a/tests/unit/test_orchestrator_registration.py b/tests/unit/test_orchestrator_registration.py new file mode 100644 index 0000000..7e14684 --- /dev/null +++ b/tests/unit/test_orchestrator_registration.py @@ -0,0 +1,56 @@ +"""Unit: consolidated registration inputs — egress policy round-trip (PRD 0070).""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from bot_bottle.egress import EgressPlan, EgressRoute +from bot_bottle.egress_addon_core import LOG_BLOCKS, load_config +from bot_bottle.orchestrator.registration import ( + RegistrationInputs, + egress_policy, + registration_inputs, +) + + +def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0) -> EgressPlan: + return EgressPlan( + slug=slug, + routes_path=Path("/unused/routes.yaml"), + routes=routes, + token_env_map={}, + log=log, + ) + + +class TestEgressPolicy(unittest.TestCase): + def test_policy_round_trips_through_load_config(self) -> None: + # The policy the gateway serves must parse back to the same allow-list + # the per-bottle sidecar applied — moving onto the shared gateway must + # not change a bottle's egress. + routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org")) + cfg = load_config(egress_policy(_plan(routes))) + self.assertEqual(("api.example.com", "pypi.org"), tuple(r.host for r in cfg.routes)) + + def test_policy_preserves_log_level(self) -> None: + plan = _plan((EgressRoute(host="x.example.com"),), log=LOG_BLOCKS) + self.assertEqual(LOG_BLOCKS, load_config(egress_policy(plan)).log) + + def test_empty_routes_yield_deny_all(self) -> None: + cfg = load_config(egress_policy(_plan(()))) + self.assertEqual((), cfg.routes) # no routes → default-deny + + +class TestRegistrationInputs(unittest.TestCase): + def test_bundles_policy_and_slug_metadata(self) -> None: + plan = _plan((EgressRoute(host="api.example.com"),), slug="my-bot") + inputs = registration_inputs(plan) + self.assertIsInstance(inputs, RegistrationInputs) + self.assertEqual("my-bot", json.loads(inputs.metadata)["slug"]) + self.assertEqual(egress_policy(plan), inputs.policy) # same blob egress_policy renders + + +if __name__ == "__main__": + unittest.main()