refactor(gateway): split data-plane files into egress/supervisor/git_gate services
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / integration-docker (pull_request) Successful in 17s
test / unit (pull_request) Successful in 49s
lint / lint (push) Failing after 2m49s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 18s
test / publish-infra (pull_request) Has been skipped

Group the gateway's data-plane modules into three service sub-packages
mirroring the host-side trio (bot_bottle.egress / .supervisor / .git_gate):

  gateway/egress/     addon_core, addon, dlp_config, dlp_detectors
  gateway/supervisor/ server            (was supervise_server)
  gateway/git_gate/   render, http_backend

Prefix-stripped filenames now that the package namespaces them; each
sub-package has a thin docstring __init__ (no eager imports, cheap leaf
loads). The two cross-cutting files stay at the gateway root:
policy_resolver (shared per-client lookup) and gateway_init, renamed to
bootstrap now that gateway/ already namespaces it.

Updated all importers (bot_bottle + tests), the in-VM/container `-m`
launch strings, the Dockerfile.gateway addon shim + ENTRYPOINT, and the
five gateway entries in scripts/critical-modules.txt. Full unit suite
green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 17:00:51 -04:00
parent a446551acb
commit ce744a85c4
40 changed files with 113 additions and 90 deletions
+4 -4
View File
@@ -8,8 +8,8 @@
# this image's mitmproxy / git / gitleaks payload. # this image's mitmproxy / git / gitleaks payload.
# #
# Collapses the prior per-daemon images (egress, git-gate, # Collapses the prior per-daemon images (egress, git-gate,
# supervise) into one. A small stdlib-Python init supervisor at # supervise) into one. A small stdlib-Python init supervisor
# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and # (`bot_bottle.gateway.bootstrap`) spawns all daemons, forwards SIGTERM, and
# propagates per-daemon stdout/stderr to the container log with a # propagates per-daemon stdout/stderr to the container log with a
# `[name]` prefix. See PRD 0024 for the rationale. # `[name]` prefix. See PRD 0024 for the rationale.
# #
@@ -102,7 +102,7 @@ RUN pip install --no-cache-dir /src/
# WORKDIR here also creates /app so the shim + COPYs below can write into it # WORKDIR here also creates /app so the shim + COPYs below can write into it
# (nothing created /app before this point). # (nothing created /app before this point).
WORKDIR /app WORKDIR /app
RUN printf 'from bot_bottle.gateway.egress_addon import addons\n' > /app/egress_addon.py RUN printf 'from bot_bottle.gateway.egress.addon import addons\n' > /app/egress_addon.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh RUN chmod +x /app/egress-entrypoint.sh
@@ -123,4 +123,4 @@ EXPOSE 8888 9099 9418 9420 9100
# PID 1 is the supervisor. It owns signal handling and exit-code # PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself. # propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway.gateway_init"] ENTRYPOINT ["python3", "-m", "bot_bottle.gateway.bootstrap"]
+1 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from ..bottle_state import egress_state_dir from ..bottle_state import egress_state_dir
from ..egress import EGRESS_ROUTES_FILENAME from ..egress import EGRESS_ROUTES_FILENAME
from ..gateway.egress_addon_core import LOG_OFF, load_config from ..gateway.egress.addon_core import LOG_OFF, load_config
class EgressApplyError(RuntimeError): class EgressApplyError(RuntimeError):
+1 -1
View File
@@ -542,7 +542,7 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" pyt
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\ BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\ BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\ BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\
python3 -m bot_bottle.gateway.gateway_init & python3 -m bot_bottle.gateway.bootstrap &
# Reap as PID 1; children are backgrounded, so `wait` blocks. # Reap as PID 1; children are backgrounded, so `wait` blocks.
while : ; do wait ; done while : ; do wait ; done
+1 -1
View File
@@ -107,7 +107,7 @@ def _init_script(port: int) -> str:
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469). # control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} " f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} " f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
f"python3 -m bot_bottle.gateway.gateway_init ) &\n" f"python3 -m bot_bottle.gateway.bootstrap ) &\n"
"while : ; do wait ; done\n" "while : ; do wait ; done\n"
) )
+1 -1
View File
@@ -46,7 +46,7 @@ from ...bottle_state import (
) )
from ...egress import Egress from ...egress import Egress
from ...git_gate import GitGate from ...git_gate import GitGate
from ...gateway.git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...image_cache import check_stale from ...image_cache import check_stale
from ...log import die, info, warn from ...log import die, info, warn
from .. import BottleImages from .. import BottleImages
+1 -1
View File
@@ -13,7 +13,7 @@ Layout:
contract). contract).
The runtime enforcement (the mitmproxy addon) lives in The runtime enforcement (the mitmproxy addon) lives in
`bot_bottle.gateway.egress_addon*`. Public names are re-exported lazily via `bot_bottle.gateway.egress.addon*`. Public names are re-exported lazily via
`__getattr__`, so `from bot_bottle.egress import …` keeps working and importing `__getattr__`, so `from bot_bottle.egress import …` keeps working and importing
`egress.plan` (the contract's dependency) stays light. `egress.plan` (the contract's dependency) stays light.
""" """
+1 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..gateway.egress_addon_core import Route from ..gateway.egress.addon_core import Route
@dataclass(frozen=True) @dataclass(frozen=True)
+2 -2
View File
@@ -4,7 +4,7 @@
routes, render the gateway's `routes.yaml`, assign per-route token slots, and routes, render the gateway's `routes.yaml`, assign per-route token slots, and
plant the exfil canary. The service also resolves the launch-time token values plant the exfil canary. The service also resolves the launch-time token values
and the agent/gateway env entries the backend injects. The runtime enforcement and the agent/gateway env entries the backend injects. The runtime enforcement
(the mitmproxy addon) lives in `bot_bottle.gateway.egress_addon*`. (the mitmproxy addon) lives in `bot_bottle.gateway.egress.addon*`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,7 +14,7 @@ import secrets
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..gateway.egress_addon_core import ( from ..gateway.egress.addon_core import (
ON_MATCH_REDACT, ON_MATCH_REDACT,
HeaderMatch as CoreHeaderMatch, HeaderMatch as CoreHeaderMatch,
MatchEntry as CoreMatchEntry, MatchEntry as CoreMatchEntry,
@@ -100,8 +100,8 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
)), )),
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")), _DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), _DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.gateway.git_http_backend")), _DaemonSpec("git-http", ("python3", "-m", "bot_bottle.gateway.git_gate.http_backend")),
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.gateway.supervise_server")), _DaemonSpec("supervise", ("python3", "-m", "bot_bottle.gateway.supervisor.server")),
) )
+9
View File
@@ -0,0 +1,9 @@
"""Gateway-side (data-plane) egress service: the mitmproxy addon and its
pure decision core, DLP detectors, and route/DLP config parsing.
These are the long-running data-plane pieces (loaded by mitmdump inside the
`bot-bottle-gateway` image), distinct from the host-side `bot_bottle.egress`
service that renders routes and prepares env. Import the concrete modules
directly (`from bot_bottle.gateway.egress.addon_core import ...`) — this
package deliberately does no eager work so leaf imports stay cheap.
"""
@@ -16,8 +16,8 @@ import typing
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
from bot_bottle.constants import IDENTITY_HEADER from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.gateway.dlp_detectors import redact_tokens, strip_crlf from bot_bottle.gateway.egress.dlp_detectors import redact_tokens, strip_crlf
from bot_bottle.gateway.egress_addon_core import ( from bot_bottle.gateway.egress.addon_core import (
LOG_BLOCKS, LOG_BLOCKS,
LOG_FULL, LOG_FULL,
DEFAULT_OUTBOUND_ON_MATCH, DEFAULT_OUTBOUND_ON_MATCH,
@@ -16,11 +16,11 @@ import re
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass
from ..yaml_subset import YamlSubsetError, parse_yaml_subset from ...yaml_subset import YamlSubsetError, parse_yaml_subset
# DLP detector-config parsing lives in a sibling module. Re-exported below # DLP detector-config parsing lives in a sibling module. Re-exported below
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working. # so existing `from egress_addon_core import ON_MATCH_*` callers keep working.
from .egress_dlp_config import ( from .dlp_config import (
DEFAULT_OUTBOUND_ON_MATCH, DEFAULT_OUTBOUND_ON_MATCH,
INBOUND_DETECTOR_NAMES, INBOUND_DETECTOR_NAMES,
ON_MATCH_BLOCK, ON_MATCH_BLOCK,
@@ -19,7 +19,7 @@ from math import log2
from collections import Counter from collections import Counter
from urllib.parse import quote as url_quote from urllib.parse import quote as url_quote
from .egress_addon_core import ScanResult from .addon_core import ScanResult
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+7
View File
@@ -0,0 +1,7 @@
"""Gateway-side (data-plane) git-gate service: pure host-side hook rendering
(PRD 0008) and the smart-HTTP backend that fronts git-gate repos.
The host-side git-gate service (provisioning, preflight) lives in
`bot_bottle.git_gate`; this is the data-plane counterpart. Import the concrete
modules directly (`from bot_bottle.gateway.git_gate.render import ...`).
"""
@@ -14,8 +14,8 @@ import shlex
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER from ...constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
from ..manifest import ManifestBottle, ManifestGitEntry from ...manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the gateway. The # Short network alias for git-gate inside the gateway. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name. # agent's `.gitconfig` insteadOf rewrites resolve through this name.
@@ -0,0 +1,7 @@
"""Gateway-side (data-plane) supervise service: the supervise daemon's HTTP
server (PRD 0013).
The host-side supervise control lives in `bot_bottle.orchestrator.supervisor`;
this is the in-gateway daemon the agents reach. Import the concrete module
directly (`from bot_bottle.gateway.supervisor.server import ...`).
"""
@@ -58,7 +58,7 @@ import typing
from dataclasses import dataclass from dataclasses import dataclass
from bot_bottle.constants import IDENTITY_HEADER from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.gateway.egress_addon_core import ( from bot_bottle.gateway.egress.addon_core import (
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict, LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
) )
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
+13 -13
View File
@@ -14,7 +14,7 @@ Layout:
service delegates to. service delegates to.
The rendering + the in-gateway hook execution live in The rendering + the in-gateway hook execution live in
`bot_bottle.gateway.git_gate_render`. The public names are re-exported lazily `bot_bottle.gateway.git_gate.render`. The public names are re-exported lazily
via `__getattr__`, so `from bot_bottle.git_gate import …` keeps working and via `__getattr__`, so `from bot_bottle.git_gate import …` keeps working and
importing `git_gate.plan` (the contract's dependency) doesn't drag in the importing `git_gate.plan` (the contract's dependency) doesn't drag in the
provisioning / forge-API code. provisioning / forge-API code.
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ..gateway.git_gate_render import ( from ..gateway.git_gate.render import (
GIT_GATE_HOSTNAME, GIT_GATE_HOSTNAME,
GIT_GATE_TIMEOUT_SECS, GIT_GATE_TIMEOUT_SECS,
GitGateUpstream, GitGateUpstream,
@@ -55,17 +55,17 @@ _LAZY: dict[str, str] = {
"revoke_git_gate_provisioned_keys": ".provision", "revoke_git_gate_provisioned_keys": ".provision",
"_provision_dynamic_key": ".provision", "_provision_dynamic_key": ".provision",
"_resolve_identity_file": ".provision", "_resolve_identity_file": ".provision",
"GIT_GATE_HOSTNAME": "..gateway.git_gate_render", "GIT_GATE_HOSTNAME": "..gateway.git_gate.render",
"GIT_GATE_TIMEOUT_SECS": "..gateway.git_gate_render", "GIT_GATE_TIMEOUT_SECS": "..gateway.git_gate.render",
"GitGateUpstream": "..gateway.git_gate_render", "GitGateUpstream": "..gateway.git_gate.render",
"git_gate_upstreams_for_bottle": "..gateway.git_gate_render", "git_gate_upstreams_for_bottle": "..gateway.git_gate.render",
"git_gate_render_gitconfig": "..gateway.git_gate_render", "git_gate_render_gitconfig": "..gateway.git_gate.render",
"git_gate_known_hosts_line": "..gateway.git_gate_render", "git_gate_known_hosts_line": "..gateway.git_gate.render",
"git_gate_render_entrypoint": "..gateway.git_gate_render", "git_gate_render_entrypoint": "..gateway.git_gate.render",
"git_gate_render_provision": "..gateway.git_gate_render", "git_gate_render_provision": "..gateway.git_gate.render",
"git_gate_render_hook": "..gateway.git_gate_render", "git_gate_render_hook": "..gateway.git_gate.render",
"git_gate_render_access_hook": "..gateway.git_gate_render", "git_gate_render_access_hook": "..gateway.git_gate.render",
"_gitconfig_validate_value": "..gateway.git_gate_render", "_gitconfig_validate_value": "..gateway.git_gate.render",
} }
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..gateway.git_gate_render import GitGateUpstream from ..gateway.git_gate.render import GitGateUpstream
@dataclass(frozen=True) @dataclass(frozen=True)
+1 -1
View File
@@ -17,7 +17,7 @@ from ..bottle_state import globalize_slug
from ..errors import MissingEnvVarError from ..errors import MissingEnvVarError
from ..log import info from ..log import info
from ..manifest import ManifestBottle, ManifestGitEntry from ..manifest import ManifestBottle, ManifestGitEntry
from ..gateway.git_gate_render import GitGateUpstream from ..gateway.git_gate.render import GitGateUpstream
if TYPE_CHECKING: if TYPE_CHECKING:
from .plan import GitGatePlan from .plan import GitGatePlan
+2 -2
View File
@@ -8,7 +8,7 @@ upstream before fetches. The agent never sees the upstream credential.
`GitGate` is the host-side service the backend drives at launch: build the plan `GitGate` is the host-side service the backend drives at launch: build the plan
(`prepare`), provision / revoke the per-upstream deploy keys, and preflight the (`prepare`), provision / revoke the per-upstream deploy keys, and preflight the
upstream host keys. The rendering it emits + the in-gateway hook execution live upstream host keys. The rendering it emits + the in-gateway hook execution live
in `bot_bottle.gateway.git_gate_render`. in `bot_bottle.gateway.git_gate.render`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,7 +16,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from ..manifest import Manifest, ManifestBottle from ..manifest import Manifest, ManifestBottle
from ..gateway.git_gate_render import ( from ..gateway.git_gate.render import (
GitGateUpstream, GitGateUpstream,
git_gate_known_hosts_line, git_gate_known_hosts_line,
git_gate_render_access_hook, git_gate_render_access_hook,
+5 -5
View File
@@ -8,18 +8,18 @@
# #
# One module path per line, relative to the repo root. Blank lines and # One module path per line, relative to the repo root. Blank lines and
# `#` comments are ignored. # `#` comments are ignored.
bot_bottle/egress_addon.py bot_bottle/gateway/egress/addon.py
bot_bottle/egress_addon_core.py bot_bottle/gateway/egress/addon_core.py
bot_bottle/dlp_detectors.py bot_bottle/gateway/egress/dlp_detectors.py
bot_bottle/egress.py bot_bottle/egress.py
bot_bottle/manifest.py bot_bottle/manifest.py
bot_bottle/manifest_egress.py bot_bottle/manifest_egress.py
bot_bottle/manifest_agent.py bot_bottle/manifest_agent.py
bot_bottle/manifest_schema.py bot_bottle/manifest_schema.py
bot_bottle/git_gate.py bot_bottle/git_gate.py
bot_bottle/git_gate_render.py bot_bottle/gateway/git_gate/render.py
bot_bottle/git_gate_provision.py bot_bottle/git_gate_provision.py
bot_bottle/git_http_backend.py bot_bottle/gateway/git_gate/http_backend.py
bot_bottle/supervise.py bot_bottle/supervise.py
bot_bottle/yaml_subset.py bot_bottle/yaml_subset.py
bot_bottle/bottle_state.py bot_bottle/bottle_state.py
+1 -1
View File
@@ -91,7 +91,7 @@ class TestGatewayImage(unittest.TestCase):
# Probe that the package imports resolve inside the image. # Probe that the package imports resolve inside the image.
rc, out = self._run_in_image( rc, out = self._run_in_image(
"python3", "-c", "python3", "-c",
"from bot_bottle.supervisor import types; from bot_bottle.gateway import supervise_server; print('ok')", "from bot_bottle.supervisor import types; from bot_bottle.gateway.supervisor import server as supervise_server; print('ok')",
) )
self.assertEqual(0, rc, msg=out) self.assertEqual(0, rc, msg=out)
self.assertIn("ok", out) self.assertIn("ok", out)
+4 -4
View File
@@ -7,7 +7,7 @@ import base64
import gzip import gzip
import unittest import unittest
from bot_bottle.gateway.dlp_detectors import ( from bot_bottle.gateway.egress.dlp_detectors import (
ENTROPY_BLOCK_THRESHOLD, ENTROPY_BLOCK_THRESHOLD,
PARTIAL_MATCH_MIN_LEN, PARTIAL_MATCH_MIN_LEN,
REDACT, REDACT,
@@ -465,17 +465,17 @@ class TestMatchedAndSafeTokens(unittest.TestCase):
class TestStripCrlf(unittest.TestCase): class TestStripCrlf(unittest.TestCase):
def test_removes_url_encoded_crlf(self): def test_removes_url_encoded_crlf(self):
from bot_bottle.gateway.dlp_detectors import strip_crlf from bot_bottle.gateway.egress.dlp_detectors import strip_crlf
out = strip_crlf("next=%0d%0aX-Injected: evil") out = strip_crlf("next=%0d%0aX-Injected: evil")
self.assertNotRegex(out, r"%0[dD]%0[aA]") self.assertNotRegex(out, r"%0[dD]%0[aA]")
def test_removes_literal_header_injection(self): def test_removes_literal_header_injection(self):
from bot_bottle.gateway.dlp_detectors import strip_crlf from bot_bottle.gateway.egress.dlp_detectors import strip_crlf
out = strip_crlf("value\r\nX-Injected: evil") out = strip_crlf("value\r\nX-Injected: evil")
self.assertIsNone(scan_crlf_injection(out)) self.assertIsNone(scan_crlf_injection(out))
def test_leaves_clean_text_unchanged(self): def test_leaves_clean_text_unchanged(self):
from bot_bottle.gateway.dlp_detectors import strip_crlf from bot_bottle.gateway.egress.dlp_detectors import strip_crlf
self.assertEqual("/api/v1/data?q=hello", strip_crlf("/api/v1/data?q=hello")) self.assertEqual("/api/v1/data?q=hello", strip_crlf("/api/v1/data?q=hello"))
class TestAlnumProjection(unittest.TestCase): class TestAlnumProjection(unittest.TestCase):
+11 -11
View File
@@ -344,7 +344,7 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual([], parse_yaml_subset(rendered)["routes"]) self.assertEqual([], parse_yaml_subset(rendered)["routes"])
def test_round_trip_through_addon_core(self): def test_round_trip_through_addon_core(self):
from bot_bottle.gateway.egress_addon_core import load_config from bot_bottle.gateway.egress.addon_core import load_config
b = _bottle([ b = _bottle([
{"host": "api.github.com", {"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
@@ -363,7 +363,7 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual("", addon_routes[2].auth_scheme) self.assertEqual("", addon_routes[2].auth_scheme)
def test_dlp_round_trips(self): def test_dlp_round_trips(self):
from bot_bottle.gateway.egress_addon_core import load_config from bot_bottle.gateway.egress.addon_core import load_config
b = _bottle([{"host": "x.example", "dlp": { b = _bottle([{"host": "x.example", "dlp": {
"outbound_detectors": ["token_patterns"], "outbound_detectors": ["token_patterns"],
"inbound_detectors": False, "inbound_detectors": False,
@@ -375,7 +375,7 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual((), addon_routes[0].inbound_detectors) self.assertEqual((), addon_routes[0].inbound_detectors)
def test_outbound_on_match_round_trips(self): def test_outbound_on_match_round_trips(self):
from bot_bottle.gateway.egress_addon_core import load_config from bot_bottle.gateway.egress.addon_core import load_config
b = _bottle([{"host": "logs.example", "dlp": { b = _bottle([{"host": "logs.example", "dlp": {
"outbound_on_match": "redact", "outbound_on_match": "redact",
}}]) }}])
@@ -392,7 +392,7 @@ class TestRenderRoutes(unittest.TestCase):
self.assertNotIn("outbound_on_match", rendered) self.assertNotIn("outbound_on_match", rendered)
def test_git_fetch_policy_round_trips(self): def test_git_fetch_policy_round_trips(self):
from bot_bottle.gateway.egress_addon_core import load_config from bot_bottle.gateway.egress.addon_core import load_config
b = _bottle([{"host": "github.com", "git": {"fetch": True}}]) b = _bottle([{"host": "github.com", "git": {"fetch": True}}])
routes = egress_routes_for_bottle(b) routes = egress_routes_for_bottle(b)
rendered = egress_render_routes(routes) rendered = egress_render_routes(routes)
@@ -405,7 +405,7 @@ class TestRenderRoutes(unittest.TestCase):
it, but the renderer in between dropped it so the flag never reached it, but the renderer in between dropped it so the flag never reached
the proxy and registry pulls kept failing with "unauthorized" while the proxy and registry pulls kept failing with "unauthorized" while
the config looked correct everywhere it was inspected.""" the config looked correct everywhere it was inspected."""
from bot_bottle.gateway.egress_addon_core import load_config from bot_bottle.gateway.egress.addon_core import load_config
b = _bottle([{"host": "registry-1.docker.io", "preserve_auth": True}]) b = _bottle([{"host": "registry-1.docker.io", "preserve_auth": True}])
routes = egress_routes_for_bottle(b) routes = egress_routes_for_bottle(b)
rendered = egress_render_routes(routes) rendered = egress_render_routes(routes)
@@ -416,7 +416,7 @@ class TestRenderRoutes(unittest.TestCase):
b = _bottle([{"host": "x.example"}]) b = _bottle([{"host": "x.example"}])
rendered = egress_render_routes(egress_routes_for_bottle(b)) rendered = egress_render_routes(egress_routes_for_bottle(b))
self.assertNotIn("preserve_auth", rendered) self.assertNotIn("preserve_auth", rendered)
from bot_bottle.gateway.egress_addon_core import load_config from bot_bottle.gateway.egress.addon_core import load_config
self.assertFalse(load_config(rendered).routes[0].preserve_auth) self.assertFalse(load_config(rendered).routes[0].preserve_auth)
def test_log_zero_omitted_from_render(self): def test_log_zero_omitted_from_render(self):
@@ -434,7 +434,7 @@ class TestRenderRoutes(unittest.TestCase):
self.assertTrue(rendered.startswith(f"log: {level}\n")) self.assertTrue(rendered.startswith(f"log: {level}\n"))
def test_log_level_round_trips_to_addon_core(self): def test_log_level_round_trips_to_addon_core(self):
from bot_bottle.gateway.egress_addon_core import load_config, LOG_FULL from bot_bottle.gateway.egress.addon_core import load_config, LOG_FULL
b = _bottle([{"host": "x.example"}]) b = _bottle([{"host": "x.example"}])
routes = egress_routes_for_bottle(b) routes = egress_routes_for_bottle(b)
rendered = egress_render_routes(routes, log=LOG_FULL) rendered = egress_render_routes(routes, log=LOG_FULL)
@@ -444,7 +444,7 @@ class TestRenderRoutes(unittest.TestCase):
def test_log_via_manifest_flows_to_render(self): def test_log_via_manifest_flows_to_render(self):
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from bot_bottle.gateway.egress_addon_core import load_config, LOG_BLOCKS from bot_bottle.gateway.egress.addon_core import load_config, LOG_BLOCKS
m = ManifestIndex.from_json_obj({ m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": { "bottles": {"dev": {"egress": {
"log": 1, "log": 1,
@@ -512,7 +512,7 @@ class TestRenderRoutesEscaping(unittest.TestCase):
self.assertEqual('Bear"er', parsed[0]["inspect"]["auth_scheme"]) self.assertEqual('Bear"er', parsed[0]["inspect"]["auth_scheme"])
def test_path_value_with_double_quote_round_trips(self): def test_path_value_with_double_quote_round_trips(self):
from bot_bottle.gateway.egress_addon_core import PathMatch, MatchEntry from bot_bottle.gateway.egress.addon_core import PathMatch, MatchEntry
routes = (EgressRoute( routes = (EgressRoute(
host="api.example", host="api.example",
matches=(MatchEntry(paths=(PathMatch(type="prefix", value='/v1/"quoted"/'),)),), matches=(MatchEntry(paths=(PathMatch(type="prefix", value='/v1/"quoted"/'),)),),
@@ -521,7 +521,7 @@ class TestRenderRoutesEscaping(unittest.TestCase):
self.assertEqual('/v1/"quoted"/', parsed[0]["inspect"]["matches"][0]["paths"][0]["value"]) self.assertEqual('/v1/"quoted"/', parsed[0]["inspect"]["matches"][0]["paths"][0]["value"])
def test_header_value_with_double_quote_round_trips(self): def test_header_value_with_double_quote_round_trips(self):
from bot_bottle.gateway.egress_addon_core import HeaderMatch, MatchEntry from bot_bottle.gateway.egress.addon_core import HeaderMatch, MatchEntry
routes = (EgressRoute( routes = (EgressRoute(
host="api.example", host="api.example",
matches=(MatchEntry(headers=(HeaderMatch(name="x-h", value='val"ue'),)),), matches=(MatchEntry(headers=(HeaderMatch(name="x-h", value='val"ue'),)),),
@@ -598,7 +598,7 @@ class TestCanaryGeneration(unittest.TestCase):
self.assertNotEqual(plan_a.canary, plan_b.canary) self.assertNotEqual(plan_a.canary, plan_b.canary)
def test_canary_detected_by_scan_known_secrets(self): def test_canary_detected_by_scan_known_secrets(self):
from bot_bottle.gateway.dlp_detectors import scan_known_secrets from bot_bottle.gateway.egress.dlp_detectors import scan_known_secrets
plan = self._make_plan() plan = self._make_plan()
env = {plan.canary_env: plan.canary} env = {plan.canary_env: plan.canary}
+4 -4
View File
@@ -12,7 +12,7 @@ import unittest
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit from urllib.parse import urlsplit
from bot_bottle.gateway.egress_addon_core import ( from bot_bottle.gateway.egress.addon_core import (
LOG_BLOCKS, LOG_BLOCKS,
LOG_FULL, LOG_FULL,
LOG_OFF, LOG_OFF,
@@ -1377,15 +1377,15 @@ class TestScanOutboundEnhanced(unittest.TestCase):
class TestOutboundDetectorNames(unittest.TestCase): class TestOutboundDetectorNames(unittest.TestCase):
def test_entropy_in_outbound_detector_names(self): def test_entropy_in_outbound_detector_names(self):
from bot_bottle.gateway.egress_addon_core import OUTBOUND_DETECTOR_NAMES from bot_bottle.gateway.egress.addon_core import OUTBOUND_DETECTOR_NAMES
self.assertIn("entropy", OUTBOUND_DETECTOR_NAMES) self.assertIn("entropy", OUTBOUND_DETECTOR_NAMES)
def test_known_secrets_in_outbound_detector_names(self): def test_known_secrets_in_outbound_detector_names(self):
from bot_bottle.gateway.egress_addon_core import OUTBOUND_DETECTOR_NAMES from bot_bottle.gateway.egress.addon_core import OUTBOUND_DETECTOR_NAMES
self.assertIn("known_secrets", OUTBOUND_DETECTOR_NAMES) self.assertIn("known_secrets", OUTBOUND_DETECTOR_NAMES)
def test_token_patterns_in_outbound_detector_names(self): def test_token_patterns_in_outbound_detector_names(self):
from bot_bottle.gateway.egress_addon_core import OUTBOUND_DETECTOR_NAMES from bot_bottle.gateway.egress.addon_core import OUTBOUND_DETECTOR_NAMES
self.assertIn("token_patterns", OUTBOUND_DETECTOR_NAMES) self.assertIn("token_patterns", OUTBOUND_DETECTOR_NAMES)
@@ -36,7 +36,7 @@ def _ensure_shims() -> None:
_ensure_shims() _ensure_shims()
from bot_bottle.gateway.egress_addon import EgressAddon # noqa: E402 (import after shims) from bot_bottle.gateway.egress.addon import EgressAddon # noqa: E402 (import after shims)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+4 -4
View File
@@ -194,15 +194,15 @@ def _ensure_shims() -> None:
_ensure_shims() _ensure_shims()
import bot_bottle.gateway.egress_addon as _ea_mod # noqa: E402 (after shims) import bot_bottle.gateway.egress.addon as _ea_mod # noqa: E402 (after shims)
from bot_bottle.gateway.egress_addon import EgressAddon # noqa: E402 (after shims) from bot_bottle.gateway.egress.addon import EgressAddon # noqa: E402 (after shims)
from bot_bottle.gateway.egress_addon import ( # noqa: E402 from bot_bottle.gateway.egress.addon import ( # noqa: E402
DEFAULT_INBOUND_SCAN_LIMIT_BYTES, DEFAULT_INBOUND_SCAN_LIMIT_BYTES,
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS, DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS,
_inbound_scan_limit_from_env, _inbound_scan_limit_from_env,
_token_allow_timeout_from_env, _token_allow_timeout_from_env,
) )
from bot_bottle.gateway.egress_addon_core import ( # noqa: E402 from bot_bottle.gateway.egress.addon_core import ( # noqa: E402
Config, Config,
LOG_BLOCKS, LOG_BLOCKS,
LOG_FULL, LOG_FULL,
+1 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import unittest import unittest
from bot_bottle.gateway.egress_addon_core import ( from bot_bottle.gateway.egress.addon_core import (
HeaderMatch, HeaderMatch,
MatchEntry, MatchEntry,
PathMatch, PathMatch,
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import unittest import unittest
from bot_bottle.gateway.egress_addon_core import ( from bot_bottle.gateway.egress.addon_core import (
DENY_RESOLVER_ERROR, DENY_RESOLVER_ERROR,
DENY_UNATTRIBUTED, DENY_UNATTRIBUTED,
DENY_UNPARSEABLE, DENY_UNPARSEABLE,
+1 -1
View File
@@ -72,7 +72,7 @@ class TestBuildInfraRootfs(unittest.TestCase):
self.assertIn("bot_bottle.orchestrator", init) self.assertIn("bot_bottle.orchestrator", init)
# Gateway launches via the installed package (there is no # Gateway launches via the installed package (there is no
# /app/gateway_init.py file since the daemons moved into bot_bottle). # /app/gateway_init.py file since the daemons moved into bot_bottle).
self.assertIn("bot_bottle.gateway.gateway_init", init) self.assertIn("bot_bottle.gateway.bootstrap", init)
self.assertIn("export PATH=", init) self.assertIn("export PATH=", init)
# Persistent registry volume mounted at the DB dir before the CP starts. # Persistent registry volume mounted at the DB dir before the CP starts.
self.assertIn("/dev/vdb", init) self.assertIn("/dev/vdb", init)
+4 -4
View File
@@ -1,6 +1,6 @@
"""Unit: gateway data-plane init supervisor (PRD 0070; PRD 0024 bundle shape). """Unit: gateway data-plane init supervisor (PRD 0070; PRD 0024 bundle shape).
Tests both the helper functions in `bot_bottle.gateway.gateway_init` Tests both the helper functions in `bot_bottle.gateway.bootstrap`
and the supervisor's end-to-end signal / exit-code behavior. The and the supervisor's end-to-end signal / exit-code behavior. The
end-to-end tests use real subprocesses (`sleep`, `/bin/sh -c '...'`) end-to-end tests use real subprocesses (`sleep`, `/bin/sh -c '...'`)
short-lived, no docker required so they run under `tests/unit/` short-lived, no docker required so they run under `tests/unit/`
@@ -18,7 +18,7 @@ import warnings
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.gateway.gateway_init import ( from bot_bottle.gateway.bootstrap import (
_DaemonSpec, _DaemonSpec,
_Supervisor, _Supervisor,
_argv_for_daemon, _argv_for_daemon,
@@ -489,7 +489,7 @@ class TestSupervisor(unittest.TestCase):
time.sleep(0.3) # let `trap` register time.sleep(0.3) # let `trap` register
sup.request_shutdown(reason="test") sup.request_shutdown(reason="test")
with patch("bot_bottle.gateway.gateway_init._GRACE_SECONDS", 0.3): with patch("bot_bottle.gateway.bootstrap._GRACE_SECONDS", 0.3):
rc = self._drive(sup, max_wait_s=4.0) rc = self._drive(sup, max_wait_s=4.0)
# Process was SIGKILL'd → returncode -9 on POSIX. # Process was SIGKILL'd → returncode -9 on POSIX.
@@ -531,7 +531,7 @@ class TestMainEndToEnd(unittest.TestCase):
helper = ( helper = (
"import os, runpy, sys\n" "import os, runpy, sys\n"
"from bot_bottle.gateway import gateway_init as si\n" "from bot_bottle.gateway import bootstrap as si\n"
"si._DAEMONS = (\n" "si._DAEMONS = (\n"
f" si._DaemonSpec('alpha', ({SLEEP!r},'30')),\n" f" si._DaemonSpec('alpha', ({SLEEP!r},'30')),\n"
f" si._DaemonSpec('beta', ({SLEEP!r},'30')),\n" f" si._DaemonSpec('beta', ({SLEEP!r},'30')),\n"
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import unittest import unittest
from bot_bottle.gateway.git_gate_render import ( from bot_bottle.gateway.git_gate.render import (
GitGateUpstream, GitGateUpstream,
git_gate_render_entrypoint, git_gate_render_entrypoint,
git_gate_render_provision, git_gate_render_provision,
+8 -8
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
from bot_bottle.git_gate import GIT_GATE_TIMEOUT_SECS from bot_bottle.git_gate import GIT_GATE_TIMEOUT_SECS
from bot_bottle.gateway.git_http_backend import GitHttpHandler, MAX_BODY_BYTES from bot_bottle.gateway.git_gate.http_backend import GitHttpHandler, MAX_BODY_BYTES
# The git-http backend is resolver-only: every request is attributed to a # The git-http backend is resolver-only: every request is attributed to a
@@ -199,7 +199,7 @@ class TestGitHttpBackend(unittest.TestCase):
subprocess.CompletedProcess(["git"], 0, backend_response, b""), subprocess.CompletedProcess(["git"], 0, backend_response, b""),
] ]
with mock.patch( with mock.patch(
"bot_bottle.gateway.git_http_backend.subprocess.run", "bot_bottle.gateway.git_gate.http_backend.subprocess.run",
side_effect=calls, side_effect=calls,
) as run: ) as run:
request = urllib.request.Request( request = urllib.request.Request(
@@ -265,7 +265,7 @@ class TestGitHttpBackend(unittest.TestCase):
subprocess.CompletedProcess(["git"], 0, backend_response, b""), subprocess.CompletedProcess(["git"], 0, backend_response, b""),
] ]
with mock.patch( with mock.patch(
"bot_bottle.gateway.git_http_backend.subprocess.run", "bot_bottle.gateway.git_gate.http_backend.subprocess.run",
side_effect=calls, side_effect=calls,
) as run: ) as run:
req = urllib.request.Request( req = urllib.request.Request(
@@ -309,7 +309,7 @@ class TestGitHttpBackend(unittest.TestCase):
denial = b"git-gate: upstream fetch failed; refusing to serve stale data\n" denial = b"git-gate: upstream fetch failed; refusing to serve stale data\n"
with mock.patch( with mock.patch(
"bot_bottle.gateway.git_http_backend.subprocess.run", "bot_bottle.gateway.git_gate.http_backend.subprocess.run",
return_value=subprocess.CompletedProcess( return_value=subprocess.CompletedProcess(
["hook"], 1, b"", denial, ["hook"], 1, b"", denial,
), ),
@@ -355,7 +355,7 @@ class TestGitHttpBackend(unittest.TestCase):
self.addCleanup(server.server_close) self.addCleanup(server.server_close)
with mock.patch( with mock.patch(
"bot_bottle.gateway.git_http_backend.subprocess.run", "bot_bottle.gateway.git_gate.http_backend.subprocess.run",
return_value=subprocess.CompletedProcess( return_value=subprocess.CompletedProcess(
["hook"], 2, b"", b"", ["hook"], 2, b"", b"",
), ),
@@ -402,7 +402,7 @@ class TestGitHttpBackend(unittest.TestCase):
self.addCleanup(server.server_close) self.addCleanup(server.server_close)
with mock.patch( with mock.patch(
"bot_bottle.gateway.git_http_backend.subprocess.run", "bot_bottle.gateway.git_gate.http_backend.subprocess.run",
side_effect=PermissionError(13, "Permission denied"), side_effect=PermissionError(13, "Permission denied"),
): ):
buf = io.StringIO() buf = io.StringIO()
@@ -461,7 +461,7 @@ class TestMalformedStatusHeader(unittest.TestCase):
def _get_with_backend_response(self, cgi_response: bytes) -> int: def _get_with_backend_response(self, cgi_response: bytes) -> int:
with mock.patch( with mock.patch(
"bot_bottle.gateway.git_http_backend.subprocess.run", "bot_bottle.gateway.git_gate.http_backend.subprocess.run",
return_value=mock.Mock(returncode=0, stdout=cgi_response), return_value=mock.Mock(returncode=0, stdout=cgi_response),
): ):
req = urllib.request.Request( req = urllib.request.Request(
@@ -545,7 +545,7 @@ class TestContentLengthBounds(unittest.TestCase):
# With a valid Content-Length the handler proceeds into # With a valid Content-Length the handler proceeds into
# git http-backend; that will fail (no real git repo) but the # git http-backend; that will fail (no real git repo) but the
# status won't be 400 or 413. # status won't be 400 or 413.
with mock.patch("bot_bottle.gateway.git_http_backend.subprocess.run") as run: with mock.patch("bot_bottle.gateway.git_gate.http_backend.subprocess.run") as run:
run.return_value = mock.Mock( run.return_value = mock.Mock(
returncode=0, returncode=0,
stdout=( stdout=(
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.gateway.git_http_backend import resolve_sandbox_root from bot_bottle.gateway.git_gate.http_backend import resolve_sandbox_root
from bot_bottle.gateway.policy_resolver import PolicyResolveError from bot_bottle.gateway.policy_resolver import PolicyResolveError
_BASE = Path("/git") _BASE = Path("/git")
+1 -1
View File
@@ -48,7 +48,7 @@ class TestInfraRun(unittest.TestCase):
self.assertIn("bot_bottle.orchestrator", script) self.assertIn("bot_bottle.orchestrator", script)
# Gateway launches via the installed package (there is no # Gateway launches via the installed package (there is no
# /app/gateway_init.py file since the daemons moved into bot_bottle). # /app/gateway_init.py file since the daemons moved into bot_bottle).
self.assertIn("bot_bottle.gateway.gateway_init", script) self.assertIn("bot_bottle.gateway.bootstrap", script)
self.assertIn("127.0.0.1", script) # they reach each other on loopback self.assertIn("127.0.0.1", script) # they reach each other on loopback
def test_db_is_a_container_only_volume(self) -> None: def test_db_is_a_container_only_volume(self) -> None:
+1 -1
View File
@@ -7,7 +7,7 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.gateway.egress_addon_core import LOG_BLOCKS, load_config from bot_bottle.gateway.egress.addon_core import LOG_BLOCKS, load_config
from bot_bottle.orchestrator.registration import ( from bot_bottle.orchestrator.registration import (
RegistrationInputs, RegistrationInputs,
egress_policy, egress_policy,
+2 -2
View File
@@ -22,8 +22,8 @@ from bot_bottle.orchestrator import supervisor as _sv
from bot_bottle.orchestrator.store import queue_store as _qs from bot_bottle.orchestrator.store import queue_store as _qs
from bot_bottle.store import audit_store as _as from bot_bottle.store import audit_store as _as
from bot_bottle.gateway import supervise_server # noqa: E402 from bot_bottle.gateway.supervisor import server as supervise_server # noqa: E402
from bot_bottle.gateway.supervise_server import ( from bot_bottle.gateway.supervisor.server import (
ERR_INTERNAL, ERR_INTERNAL,
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
ERR_INVALID_REQUEST, ERR_INVALID_REQUEST,