refactor(de-sidecar): rename sidecar_init→gateway_init, drop docker's dead per-bottle compose renderer
Part of the de-sidecar cleanup (#385 discussion): the per-bottle companion container is the old architecture. - Rename `bot_bottle/sidecar_init.py` → `gateway_init.py` (it's the gateway image's PID-1 supervisor); env var `BOT_BOTTLE_SIDECAR_DAEMONS` → `BOT_BOTTLE_GATEWAY_DAEMONS`; log prefix `sidecar-init:` → `gateway-init:`. Update Dockerfile.gateway COPY/ENTRYPOINT and the test. - Remove the dead per-bottle compose renderer from `backend/docker/compose.py` (`bottle_plan_to_compose`, `_sidecar_bundle_service`, `_agent_service`, and the network/bind/proxy helpers). Docker's live path uses `consolidated_agent_compose`; only the compose *lifecycle* helpers (up/down/ls/write) remain. Trim `test_compose.py` to the surviving helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
+7
-8
@@ -1,16 +1,15 @@
|
||||
# Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway).
|
||||
#
|
||||
# The egress / git-gate / supervise *data plane* — one image, shared by
|
||||
# the consolidated per-host gateway (PRD 0070) and, on backends that
|
||||
# still run a per-bottle bundle, each bottle's sidecar. It is NOT the
|
||||
# The egress / git-gate / supervise *data plane* — one image, run by
|
||||
# the consolidated per-host gateway (PRD 0070). It is NOT the
|
||||
# orchestrator control plane: that is the separate, lean
|
||||
# `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which
|
||||
# ships only python + the stdlib-only `bot_bottle` package and none of
|
||||
# this image's mitmproxy / git / gitleaks payload.
|
||||
#
|
||||
# Collapses the prior per-sidecar images (egress, git-gate,
|
||||
# Collapses the prior per-daemon images (egress, git-gate,
|
||||
# supervise) into one. A small stdlib-Python init supervisor at
|
||||
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
||||
# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and
|
||||
# propagates per-daemon stdout/stderr to the container log with a
|
||||
# `[name]` prefix. See PRD 0024 for the rationale.
|
||||
#
|
||||
@@ -20,7 +19,7 @@
|
||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||
# /app/egress-entrypoint.sh mitmdump launcher
|
||||
# /app/supervise_server.py + .py supervise MCP server
|
||||
# /app/sidecar_init.py PID 1 supervisor
|
||||
# /app/gateway_init.py PID 1 supervisor
|
||||
# /etc/egress/routes.yaml bind-mounted at run time
|
||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||
@@ -84,7 +83,7 @@ COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||
COPY bot_bottle/store_manager.py /app/store_manager.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||
COPY bot_bottle/gateway_init.py /app/gateway_init.py
|
||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
RUN chmod +x /app/egress-entrypoint.sh
|
||||
@@ -110,4 +109,4 @@ WORKDIR /app
|
||||
|
||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||
# propagation; no `exec` chain in the entrypoint itself.
|
||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
||||
ENTRYPOINT ["python3", "/app/gateway_init.py"]
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
"""Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1).
|
||||
"""Docker compose lifecycle helpers (PRD 0018).
|
||||
|
||||
`bottle_plan_to_compose(plan)` returns a Compose v2 spec dict
|
||||
describing the per-bottle container topology — one project per
|
||||
bottle instance, services for the agent + every applicable sidecar,
|
||||
two networks, no named volumes.
|
||||
|
||||
Pure function. No I/O, no subprocess. Expects every launch-time
|
||||
field (network names, CA host paths, etc.) on the plan's inner
|
||||
plans to be populated; chunks 2+3 own that ordering.
|
||||
|
||||
Conditional services follow the plan content:
|
||||
|
||||
- agent + sidecars bundle: always.
|
||||
- git-gate: iff plan.git_gate_plan.upstreams.
|
||||
- egress: iff plan.egress_plan.routes.
|
||||
- supervise: iff plan.supervise_plan is not None.
|
||||
Serialize a compose spec to disk, drive `docker compose up/down`,
|
||||
dump the merged log on teardown, and enumerate `bot-bottle-*`
|
||||
projects. The spec itself is built by `consolidated_compose.py`
|
||||
(the consolidated per-host gateway topology); this module owns the
|
||||
I/O side that persists and runs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,233 +15,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
from ...supervise import (
|
||||
DB_PATH_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
)
|
||||
from ...util import expand_tilde
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import (
|
||||
EGRESS_CA_IN_CONTAINER,
|
||||
EGRESS_PORT,
|
||||
)
|
||||
from .git_gate import (
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
)
|
||||
from . import network as network_mod
|
||||
from .sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
sidecar_bundle_container_name,
|
||||
)
|
||||
|
||||
|
||||
# Repo root, used as the build context for the bundle Dockerfile.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Render a Compose v2 spec dict from a fully-resolved
|
||||
DockerBottlePlan.
|
||||
|
||||
The plan must have its inner plans (`git_gate_plan`,
|
||||
`egress_plan`, `supervise_plan`) populated with launch-time
|
||||
fields — network names, CA host paths. The renderer doesn't
|
||||
validate; callers feed it a fully-resolved plan or get an
|
||||
incomplete compose spec back.
|
||||
"""
|
||||
project = f"bot-bottle-{plan.slug}"
|
||||
services: dict[str, Any] = {
|
||||
"sidecars": _sidecar_bundle_service(plan),
|
||||
"agent": _agent_service(plan),
|
||||
}
|
||||
return {
|
||||
"name": project,
|
||||
"services": services,
|
||||
"networks": _networks(plan),
|
||||
}
|
||||
|
||||
|
||||
def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Compose-managed networks with explicit `name:` matching the
|
||||
existing slug-suffixed convention. Compose creates them on `up`
|
||||
and destroys them on `down`. The internal one is `--internal`
|
||||
(no default gateway); the egress one is a normal user-defined
|
||||
bridge."""
|
||||
return {
|
||||
"internal": {
|
||||
"name": network_mod.network_name_for_slug(plan.slug),
|
||||
"internal": True,
|
||||
},
|
||||
"egress": {
|
||||
"name": network_mod.network_egress_name_for_slug(plan.slug),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]:
|
||||
"""One bind-mount entry in the long-form `volumes:` shape.
|
||||
Long form is preferred over `host:target:ro` strings because
|
||||
it's easier to inspect in tests and survives whitespace in
|
||||
host paths."""
|
||||
return {
|
||||
"type": "bind",
|
||||
"source": str(host),
|
||||
"target": target,
|
||||
"read_only": read_only,
|
||||
}
|
||||
|
||||
|
||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""The `sidecars` service: one container per bottle, bundle
|
||||
image, all daemons under a Python init supervisor.
|
||||
|
||||
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
|
||||
egress is always present; git-gate / supervise are conditional.
|
||||
"""
|
||||
daemons: list[str] = ["egress"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
daemons.append("git-gate")
|
||||
if plan.supervise_plan is not None:
|
||||
daemons.append("supervise")
|
||||
|
||||
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
||||
volumes: list[dict[str, Any]] = []
|
||||
|
||||
# --- egress -------------------------------------------------------
|
||||
ep = plan.egress_plan
|
||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
||||
if ep.routes:
|
||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
||||
env.extend(egress_sidecar_env_entries(ep))
|
||||
|
||||
# --- git-gate -----------------------------------------------------
|
||||
gp = plan.git_gate_plan
|
||||
if gp.upstreams:
|
||||
volumes += [
|
||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
||||
]
|
||||
for u in gp.upstreams:
|
||||
keypath = expand_tilde(u.identity_file)
|
||||
volumes.append(_bind(
|
||||
keypath,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||
))
|
||||
if u.known_hosts_file:
|
||||
volumes.append(_bind(
|
||||
u.known_hosts_file,
|
||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||
))
|
||||
|
||||
# --- supervise ----------------------------------------------------
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
volumes.append({
|
||||
"type": "bind",
|
||||
"source": str(sp.db_path),
|
||||
"target": DB_PATH_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
internal_aliases = [EGRESS_HOSTNAME]
|
||||
if gp.upstreams:
|
||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||
if sp is not None:
|
||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": SIDECAR_BUNDLE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
},
|
||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||
"networks": {
|
||||
"internal": {"aliases": internal_aliases},
|
||||
"egress": None,
|
||||
},
|
||||
"environment": env,
|
||||
"volumes": volumes,
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Agent container. Runs `sleep infinity`; claude is `docker
|
||||
exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the
|
||||
egress sidecar."""
|
||||
proxy_url = _agent_proxy_url(plan)
|
||||
no_proxy = _agent_no_proxy(plan)
|
||||
env: list[str] = [
|
||||
f"HTTPS_PROXY={proxy_url}",
|
||||
f"HTTP_PROXY={proxy_url}",
|
||||
f"https_proxy={proxy_url}",
|
||||
f"http_proxy={proxy_url}",
|
||||
f"NO_PROXY={no_proxy}",
|
||||
f"no_proxy={no_proxy}",
|
||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
||||
]
|
||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
||||
env.append(f"{name}={value}")
|
||||
# Forwarded vars (OAuth token, manifest host-interpolations):
|
||||
# bare name → inherits from compose-up process env, value
|
||||
# never lands on argv or in the compose file.
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": plan.image,
|
||||
"container_name": plan.container_name,
|
||||
"command": ["sleep", "infinity"],
|
||||
"networks": {"internal": None},
|
||||
"environment": env,
|
||||
}
|
||||
if plan.use_runsc:
|
||||
service["runtime"] = "runsc"
|
||||
|
||||
# The init supervisor inside the bundle owns intra-bundle
|
||||
# daemon ordering, so the agent only waits for the bundle
|
||||
# container itself.
|
||||
service["depends_on"] = ["sidecars"]
|
||||
|
||||
return service
|
||||
|
||||
|
||||
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
||||
"""Agent's HTTP_PROXY — always points at egress."""
|
||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
||||
|
||||
|
||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||
"""NO_PROXY for the agent: loopback always; supervise hostname
|
||||
when the supervise sidecar is up (MCP long-poll must bypass
|
||||
the egress proxy)."""
|
||||
hosts = ["localhost", "127.0.0.1"]
|
||||
if plan.supervise_plan is not None:
|
||||
hosts.append(SUPERVISE_HOSTNAME)
|
||||
return ",".join(hosts)
|
||||
|
||||
|
||||
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
||||
@@ -442,7 +206,6 @@ __all__ = [
|
||||
"COMPOSE_FILE_NAME",
|
||||
"COMPOSE_LOG_NAME",
|
||||
"COMPOSE_PROJECT_PREFIX",
|
||||
"bottle_plan_to_compose",
|
||||
"compose_down",
|
||||
"compose_dump_logs",
|
||||
"compose_file_path",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1).
|
||||
"""Gateway data-plane supervisor (PRD 0070; PRD 0024 bundle shape).
|
||||
|
||||
PID 1 inside the `bot-bottle-gateway` data-plane image. Spawns
|
||||
the configured daemons (egress, git-gate, supervise),
|
||||
@@ -7,20 +7,20 @@ stdout+stderr to the container log with a `[name] ` prefix.
|
||||
|
||||
Failure policy (interim): when a child dies unexpectedly, the
|
||||
supervisor logs the death and leaves the surviving children
|
||||
running. The bundle stays up; whatever the dead daemon served
|
||||
running. The gateway stays up; whatever the dead daemon served
|
||||
will start failing, surfacing in the agent's own error path.
|
||||
The supervisor itself exits only when (a) the operator/compose
|
||||
sends SIGTERM/SIGINT, or (b) every child has died.
|
||||
The supervisor itself exits only when (a) the operator sends
|
||||
SIGTERM/SIGINT, or (b) every child has died.
|
||||
|
||||
Failure policy (eventual): on unexpected death, the supervisor
|
||||
restarts the daemon and emits a notification to the supervise
|
||||
sidecar so the operator sees the event. That lands in a later
|
||||
PR; the interim policy is "don't take the bundle down for one
|
||||
daemon so the operator sees the event. That lands in a later
|
||||
PR; the interim policy is "don't take the gateway down for one
|
||||
sick daemon."
|
||||
|
||||
Daemon subset is env-driven. The compose renderer narrows it via
|
||||
`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that
|
||||
don't use git-gate or supervise. Default: all daemons.
|
||||
Daemon subset is env-driven via `BOT_BOTTLE_GATEWAY_DAEMONS=egress`
|
||||
for callers that don't use git-gate or supervise. Default: all
|
||||
daemons.
|
||||
|
||||
Stdlib-only by design — adding supervisord/s6/runit for four
|
||||
daemons is heavier than this script.
|
||||
@@ -103,8 +103,8 @@ def _selected_daemons(
|
||||
env: dict[str, str],
|
||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||
) -> tuple[_DaemonSpec, ...]:
|
||||
"""Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env
|
||||
var. Unknown names in the list are ignored — the renderer is the
|
||||
"""Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env
|
||||
var. Unknown names in the list are ignored — the caller is the
|
||||
source of truth for which daemons are wired.
|
||||
|
||||
`all_daemons` defaults to `_DAEMONS` resolved at call time (not
|
||||
@@ -112,7 +112,7 @@ def _selected_daemons(
|
||||
`_DAEMONS` and have the new value take effect."""
|
||||
if all_daemons is None:
|
||||
all_daemons = _DAEMONS
|
||||
raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
|
||||
raw = env.get("BOT_BOTTLE_GATEWAY_DAEMONS", "").strip()
|
||||
if not raw:
|
||||
return tuple(all_daemons)
|
||||
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
||||
@@ -120,7 +120,7 @@ def _selected_daemons(
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
sys.stdout.write(f"sidecar-init: {msg}\n")
|
||||
sys.stdout.write(f"gateway-init: {msg}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
+4
-415
@@ -1,434 +1,23 @@
|
||||
"""Unit: compose-spec renderer (PRD 0018 chunk 1).
|
||||
"""Unit: docker compose lifecycle helpers (PRD 0018).
|
||||
|
||||
Pure-function tests for `bottle_plan_to_compose`. Fixtures build a
|
||||
fully-resolved DockerBottlePlan in memory; the renderer just
|
||||
translates it to the compose dict. Conditional-service matrix is
|
||||
covered via parameterized cases (git on/off × egress on/off ×
|
||||
supervise on/off).
|
||||
The compose *spec* is built by `consolidated_compose.py`; these
|
||||
tests cover the I/O-side helpers in `compose.py` — the slug ↔
|
||||
project mapping and `docker compose ls` enumeration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.backend.docker.compose import (
|
||||
COMPOSE_PROJECT_PREFIX,
|
||||
bottle_plan_to_compose,
|
||||
compose_project_name,
|
||||
list_active_slugs,
|
||||
list_compose_projects,
|
||||
slug_from_compose_project,
|
||||
)
|
||||
from bot_bottle.egress import (
|
||||
EgressPlan,
|
||||
EgressRoute,
|
||||
)
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
|
||||
|
||||
SLUG = "demo-abc12"
|
||||
STAGE = Path("/tmp/cb-stage")
|
||||
STATE = Path("/tmp/cb-state")
|
||||
|
||||
|
||||
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
|
||||
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
||||
The renderer only reads from the plan, not the manifest, so this
|
||||
is just here to back BottleSpec."""
|
||||
bottle: dict[str, object] = {}
|
||||
if supervise:
|
||||
bottle["supervise"] = True
|
||||
if with_git:
|
||||
bottle["git-gate"] = {"repos": {
|
||||
"upstream": {
|
||||
"url": "ssh://git@example.com:22/x/y.git",
|
||||
"key": {"provider": "static", "path": "/etc/hostname"},
|
||||
},
|
||||
}}
|
||||
if with_egress:
|
||||
bottle["egress"] = {
|
||||
"routes": [{
|
||||
"host": "api.example",
|
||||
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
|
||||
}],
|
||||
}
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
|
||||
|
||||
|
||||
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
||||
return GitGatePlan(
|
||||
slug=SLUG,
|
||||
entrypoint_script=STATE / "git-gate" / "entrypoint.sh",
|
||||
hook_script=STATE / "git-gate" / "pre-receive",
|
||||
access_hook_script=STATE / "git-gate" / "access-hook",
|
||||
upstreams=upstreams,
|
||||
internal_network=f"bot-bottle-net-{SLUG}",
|
||||
egress_network=f"bot-bottle-egress-{SLUG}",
|
||||
)
|
||||
|
||||
|
||||
def _egress_plan(
|
||||
routes: tuple[EgressRoute, ...] = (),
|
||||
*,
|
||||
canary: bool = False,
|
||||
) -> EgressPlan:
|
||||
token_env_map = {
|
||||
r.token_env: r.token_ref
|
||||
for r in routes
|
||||
if r.token_env
|
||||
}
|
||||
return EgressPlan(
|
||||
slug=SLUG,
|
||||
routes_path=STATE / "egress" / "routes.yaml",
|
||||
routes=routes,
|
||||
token_env_map=token_env_map,
|
||||
internal_network=f"bot-bottle-net-{SLUG}",
|
||||
egress_network=f"bot-bottle-egress-{SLUG}",
|
||||
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
|
||||
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
|
||||
canary="fake-canary-value" if canary else "",
|
||||
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
||||
)
|
||||
|
||||
|
||||
def _supervise_plan() -> SupervisePlan:
|
||||
return SupervisePlan(
|
||||
slug=SLUG,
|
||||
db_path=STATE / "bot-bottle.db",
|
||||
internal_network=f"bot-bottle-net-{SLUG}",
|
||||
)
|
||||
|
||||
|
||||
def _plan(
|
||||
*,
|
||||
with_git: bool = False,
|
||||
with_egress: bool = False,
|
||||
supervise: bool = False,
|
||||
canary: bool = False,
|
||||
) -> DockerBottlePlan:
|
||||
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
||||
matrix the renderer's conditional-service logic branches on."""
|
||||
upstreams: tuple[GitGateUpstream, ...] = ()
|
||||
if with_git:
|
||||
upstreams = (GitGateUpstream(
|
||||
name="upstream",
|
||||
upstream_url="ssh://git@example.com:22/x/y.git",
|
||||
upstream_host="example.com",
|
||||
upstream_port="22",
|
||||
identity_file="/etc/hostname",
|
||||
known_host_key="",
|
||||
known_hosts_file=STATE / "git-gate" / "upstream-known_hosts",
|
||||
),)
|
||||
routes: tuple[EgressRoute, ...] = ()
|
||||
if with_egress:
|
||||
routes = (EgressRoute(
|
||||
host="api.example",
|
||||
auth_scheme="Bearer",
|
||||
token_env="EGRESS_TOKEN_0",
|
||||
token_ref="TOK",
|
||||
roles=(),
|
||||
),)
|
||||
|
||||
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||
spec = BottleSpec(
|
||||
manifest=index,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=index.load_for_agent("demo"),
|
||||
stage_dir=STAGE,
|
||||
slug=SLUG,
|
||||
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
||||
git_gate_plan=_git_gate_plan(upstreams),
|
||||
egress_plan=_egress_plan(routes, canary=canary),
|
||||
supervise_plan=_supervise_plan() if supervise else None,
|
||||
use_runsc=False,
|
||||
agent_provision=AgentProvisionPlan(
|
||||
template="claude",
|
||||
command="claude",
|
||||
prompt_mode="append_file",
|
||||
image="bot-bottle-claude:latest",
|
||||
dockerfile="",
|
||||
guest_home="/home/node",
|
||||
instance_name=f"bot-bottle-{SLUG}",
|
||||
prompt_file=STAGE / "prompt",
|
||||
guest_env={},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestProjectAndNetworks(unittest.TestCase):
|
||||
def test_project_name(self):
|
||||
spec = bottle_plan_to_compose(_plan())
|
||||
self.assertEqual(f"bot-bottle-{SLUG}", spec["name"])
|
||||
|
||||
def test_internal_network_is_internal(self):
|
||||
spec = bottle_plan_to_compose(_plan())
|
||||
net = spec["networks"]["internal"]
|
||||
self.assertEqual(f"bot-bottle-net-{SLUG}", net["name"])
|
||||
self.assertTrue(net["internal"])
|
||||
|
||||
def test_egress_network_is_external_bridge(self):
|
||||
spec = bottle_plan_to_compose(_plan())
|
||||
net = spec["networks"]["egress"]
|
||||
self.assertEqual(f"bot-bottle-egress-{SLUG}", net["name"])
|
||||
# No `internal:` key on the egress network — defaults to a
|
||||
# normal user-defined bridge.
|
||||
self.assertNotIn("internal", net)
|
||||
|
||||
|
||||
class TestAgentAlwaysPresent(unittest.TestCase):
|
||||
def test_agent_in_services(self):
|
||||
s = bottle_plan_to_compose(_plan())["services"]
|
||||
self.assertIn("agent", s)
|
||||
|
||||
def test_agent_command(self):
|
||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
||||
self.assertEqual(["sleep", "infinity"], s["command"])
|
||||
|
||||
def test_agent_image_uses_runtime_image(self):
|
||||
plan = _plan()
|
||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
||||
self.assertEqual(plan.image, s["image"])
|
||||
|
||||
def test_agent_only_on_internal_network(self):
|
||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
||||
self.assertEqual({"internal"}, set(s["networks"].keys()))
|
||||
|
||||
def test_agent_proxy_always_via_egress(self):
|
||||
for with_egress in (False, True):
|
||||
with self.subTest(with_egress=with_egress):
|
||||
s = bottle_plan_to_compose(
|
||||
_plan(with_egress=with_egress)
|
||||
)["services"]["agent"]
|
||||
proxy_lines = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")]
|
||||
self.assertEqual(1, len(proxy_lines))
|
||||
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0])
|
||||
|
||||
def test_agent_proxy_via_egress_when_egress_present(self):
|
||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"]
|
||||
proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0]
|
||||
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy)
|
||||
|
||||
def test_agent_no_proxy_adds_supervise_when_enabled(self):
|
||||
s = bottle_plan_to_compose(
|
||||
_plan(supervise=True)
|
||||
)["services"]["agent"]
|
||||
no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0]
|
||||
self.assertIn("supervise", no_proxy)
|
||||
|
||||
def test_agent_forwarded_env_uses_bare_names(self):
|
||||
# Bare NAME → compose inherits value from the up-process env,
|
||||
# so secret token values stay out of the file.
|
||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
||||
self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", s["environment"])
|
||||
|
||||
def test_agent_provider_env_uses_literal_values(self):
|
||||
plan = _plan()
|
||||
provision = AgentProvisionPlan(
|
||||
template="codex",
|
||||
command="codex",
|
||||
prompt_mode="read_prompt_file",
|
||||
image="bot-bottle-codex:latest",
|
||||
dockerfile="",
|
||||
guest_home="/home/node",
|
||||
instance_name=f"bot-bottle-{SLUG}",
|
||||
prompt_file=STAGE / "prompt",
|
||||
guest_env={"CODEX_HOME": "/home/node/.codex"},
|
||||
)
|
||||
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
|
||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
||||
self.assertIn("CODEX_HOME=/home/node/.codex", s["environment"])
|
||||
|
||||
def test_agent_runsc_runtime(self):
|
||||
plan = _plan()
|
||||
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore
|
||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
||||
self.assertEqual("runsc", s["runtime"])
|
||||
|
||||
def test_agent_depends_only_on_sidecars(self):
|
||||
# Bundle shape: the init supervisor owns intra-bundle daemon
|
||||
# ordering, so the agent waits on the bundle container alone.
|
||||
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
|
||||
with self.subTest(**kwargs):
|
||||
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
||||
self.assertEqual(["sidecars"], s["depends_on"])
|
||||
|
||||
def test_agent_has_no_current_config_mount_with_supervise(self):
|
||||
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
||||
self.assertNotIn("volumes", with_sv)
|
||||
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
|
||||
self.assertNotIn("volumes", without_sv)
|
||||
|
||||
|
||||
class TestSidecarBundleShape(unittest.TestCase):
|
||||
"""The compose renderer emits exactly one `sidecars` service in
|
||||
place of the daemons it owns (egress + git-gate + supervise).
|
||||
PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely,
|
||||
so the bundle is the only thing exercised here."""
|
||||
|
||||
def _render(self, **plan_kwargs: object) -> Any: # type: ignore
|
||||
return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore
|
||||
|
||||
def test_emits_two_services_minimal(self):
|
||||
spec = self._render()
|
||||
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
||||
|
||||
def test_emits_two_services_full_matrix(self):
|
||||
spec = self._render(with_git=True, with_egress=True, supervise=True)
|
||||
# Still two services — the bundle absorbs git-gate/egress/supervise.
|
||||
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
||||
|
||||
def test_bundle_uses_bundle_image_and_dockerfile(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
self.assertEqual("bot-bottle-gateway:latest", sc["image"])
|
||||
self.assertEqual("Dockerfile.gateway", sc["build"]["dockerfile"])
|
||||
|
||||
def test_bundle_container_name_uses_sidecars_prefix(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
||||
|
||||
def test_bundle_joins_both_networks(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
self.assertEqual({"internal", "egress"}, set(sc["networks"].keys()))
|
||||
|
||||
def test_internal_aliases_include_egress_shortname(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||
self.assertIn("egress", aliases)
|
||||
|
||||
def test_internal_aliases_omit_inactive_sidecars(self):
|
||||
# With no git-gate / supervise, those names are NOT aliased
|
||||
# — keeps the alias list honest about what's actually
|
||||
# listening inside the bundle.
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||
self.assertNotIn("git-gate", aliases)
|
||||
self.assertNotIn("supervise", aliases)
|
||||
|
||||
def test_internal_aliases_include_active_sidecars(self):
|
||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||
self.assertIn("git-gate", aliases)
|
||||
self.assertIn("supervise", aliases)
|
||||
|
||||
def test_daemons_csv_lists_only_active(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
daemons = {
|
||||
line.split("=", 1)[1]
|
||||
for line in sc["environment"]
|
||||
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=")
|
||||
}
|
||||
self.assertEqual({"egress"}, daemons)
|
||||
|
||||
def test_daemons_csv_expands_with_optional_sidecars(self):
|
||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
||||
for line in sc["environment"]:
|
||||
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="):
|
||||
csv = line.split("=", 1)[1]
|
||||
break
|
||||
else:
|
||||
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
|
||||
self.assertEqual(
|
||||
["egress", "git-gate", "supervise"],
|
||||
csv.split(","),
|
||||
)
|
||||
|
||||
def test_bundle_env_does_not_set_https_proxy(self):
|
||||
# HTTPS_PROXY at the container level would route git-gate's
|
||||
# git fetches through the proxy. Scoping it to mitmdump is
|
||||
# the job of egress_entrypoint.sh; the bundle env must not
|
||||
# leak it.
|
||||
sc = self._render(with_egress=True)["services"]["sidecars"]
|
||||
for line in sc["environment"]:
|
||||
self.assertFalse(
|
||||
line.startswith("HTTPS_PROXY=")
|
||||
or line.startswith("HTTP_PROXY=")
|
||||
or line.startswith("NO_PROXY="),
|
||||
f"bundle env must not set {line!r}",
|
||||
)
|
||||
|
||||
def test_egress_token_env_present_when_routes_declared(self):
|
||||
sc = self._render(with_egress=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertIn("EGRESS_TOKEN_0", env_strings)
|
||||
|
||||
def test_egress_token_env_omitted_when_no_routes(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertNotIn("EGRESS_TOKEN_0", env_strings)
|
||||
|
||||
def test_canary_env_registered_as_sensitive_in_sidecar(self):
|
||||
sc = self._render(canary=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings)
|
||||
self.assertIn(
|
||||
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
||||
env_strings,
|
||||
)
|
||||
|
||||
def test_canary_env_visible_to_agent(self):
|
||||
agent = self._render(canary=True)["services"]["agent"]
|
||||
env_strings = agent["environment"]
|
||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings)
|
||||
|
||||
def test_supervise_env_present_when_active(self):
|
||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
||||
self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings)
|
||||
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
|
||||
|
||||
def test_volumes_always_includes_egress_ca(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
targets = {v["target"] for v in sc["volumes"]}
|
||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
||||
|
||||
def test_volumes_union_full_matrix(self):
|
||||
sc = self._render(with_git=True, with_egress=True, supervise=True)[
|
||||
"services"]["sidecars"]
|
||||
targets = {v["target"] for v in sc["volumes"]}
|
||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
||||
self.assertIn("/etc/egress", targets)
|
||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
||||
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
||||
self.assertIn("/run/supervise/bot-bottle.db", targets)
|
||||
|
||||
def test_extra_hosts_omitted_for_git_upstreams(self):
|
||||
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||
self.assertNotIn("extra_hosts", sc)
|
||||
|
||||
def test_agent_depends_on_bundle_only(self):
|
||||
sc = self._render(with_git=True, with_egress=True, supervise=True)[
|
||||
"services"]["agent"]
|
||||
self.assertEqual(["sidecars"], sc["depends_on"])
|
||||
|
||||
def test_agent_proxy_url_resolves_via_bundle_alias(self):
|
||||
# With egress active, the agent's HTTPS_PROXY points at
|
||||
# `egress` shortname; bundle aliases `egress` to itself so
|
||||
# the URL keeps working without an agent-side change.
|
||||
spec = self._render(with_egress=True)
|
||||
sc = spec["services"]["agent"]
|
||||
proxy = next(e for e in sc["environment"] if e.startswith("HTTPS_PROXY="))
|
||||
self.assertIn("egress", proxy)
|
||||
self.assertIn("egress",
|
||||
spec["services"]["sidecars"]["networks"]["internal"]["aliases"])
|
||||
|
||||
|
||||
class TestProjectNaming(unittest.TestCase):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit: sidecar bundle init supervisor (PRD 0024 chunk 1).
|
||||
"""Unit: gateway data-plane init supervisor (PRD 0070; PRD 0024 bundle shape).
|
||||
|
||||
Tests both the helper functions in `bot_bottle.sidecar_init`
|
||||
Tests both the helper functions in `bot_bottle.gateway_init`
|
||||
and the supervisor's end-to-end signal / exit-code behavior. The
|
||||
end-to-end tests use real subprocesses (`/bin/sleep`,
|
||||
`/bin/sh -c '...'`) — short-lived, no docker required — so they
|
||||
@@ -18,7 +18,7 @@ import warnings
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.sidecar_init import (
|
||||
from bot_bottle.gateway_init import (
|
||||
_DaemonSpec,
|
||||
_Supervisor,
|
||||
_argv_for_daemon,
|
||||
@@ -80,18 +80,18 @@ class TestSelectedDaemons(unittest.TestCase):
|
||||
["egress", "git-gate", "supervise"])
|
||||
|
||||
def test_empty_returns_all(self):
|
||||
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""},
|
||||
got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": ""},
|
||||
all_daemons=self._DAEMONS)
|
||||
self.assertEqual(3, len(got))
|
||||
|
||||
def test_whitespace_only_returns_all(self):
|
||||
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "},
|
||||
got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": " "},
|
||||
all_daemons=self._DAEMONS)
|
||||
self.assertEqual(3, len(got))
|
||||
|
||||
def test_explicit_subset(self):
|
||||
got = _selected_daemons(
|
||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"},
|
||||
{"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,git-gate"},
|
||||
all_daemons=self._DAEMONS,
|
||||
)
|
||||
self.assertEqual([d.name for d in got], ["egress", "git-gate"])
|
||||
@@ -100,7 +100,7 @@ class TestSelectedDaemons(unittest.TestCase):
|
||||
# Order in the env var doesn't matter; the result follows
|
||||
# the canonical _DAEMONS order so egress starts first.
|
||||
got = _selected_daemons(
|
||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"},
|
||||
{"BOT_BOTTLE_GATEWAY_DAEMONS": "supervise,git-gate,egress"},
|
||||
all_daemons=self._DAEMONS,
|
||||
)
|
||||
self.assertEqual([d.name for d in got],
|
||||
@@ -108,14 +108,14 @@ class TestSelectedDaemons(unittest.TestCase):
|
||||
|
||||
def test_unknown_names_ignored(self):
|
||||
got = _selected_daemons(
|
||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,bogus"},
|
||||
{"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,bogus"},
|
||||
all_daemons=self._DAEMONS,
|
||||
)
|
||||
self.assertEqual([d.name for d in got], ["egress"])
|
||||
|
||||
def test_whitespace_in_names_stripped(self):
|
||||
got = _selected_daemons(
|
||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "},
|
||||
{"BOT_BOTTLE_GATEWAY_DAEMONS": " egress , git-gate "},
|
||||
all_daemons=self._DAEMONS,
|
||||
)
|
||||
self.assertEqual([d.name for d in got], ["egress", "git-gate"])
|
||||
@@ -441,7 +441,7 @@ class TestSupervisor(unittest.TestCase):
|
||||
time.sleep(0.3) # let `trap` register
|
||||
sup.request_shutdown(reason="test")
|
||||
|
||||
with patch("bot_bottle.sidecar_init._GRACE_SECONDS", 0.3):
|
||||
with patch("bot_bottle.gateway_init._GRACE_SECONDS", 0.3):
|
||||
rc = self._drive(sup, max_wait_s=4.0)
|
||||
|
||||
# Process was SIGKILL'd → returncode -9 on POSIX.
|
||||
@@ -464,7 +464,7 @@ class TestSupervisor(unittest.TestCase):
|
||||
|
||||
|
||||
class TestMainEndToEnd(unittest.TestCase):
|
||||
"""Run sidecar_init.py as a real subprocess to cover the
|
||||
"""Run gateway_init.py as a real subprocess to cover the
|
||||
signal-handler installation path. Skipped on platforms
|
||||
without /bin/sleep + /bin/sh."""
|
||||
|
||||
@@ -477,20 +477,20 @@ class TestMainEndToEnd(unittest.TestCase):
|
||||
def _run(self, daemons_csv: str, send_signal: int | None,
|
||||
wait_before_signal: float = 0.4,
|
||||
overall_timeout: float = 6.0) -> tuple[int, str]:
|
||||
"""Spawn sidecar_init.main() in a child process with the
|
||||
"""Spawn gateway_init.main() in a child process with the
|
||||
DAEMONS list patched to harmless `sleep 30` commands.
|
||||
Returns (returncode, captured stdout)."""
|
||||
|
||||
helper = (
|
||||
"import os, runpy, sys\n"
|
||||
"from bot_bottle import sidecar_init as si\n"
|
||||
"from bot_bottle import gateway_init as si\n"
|
||||
"si._DAEMONS = (\n"
|
||||
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
|
||||
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
|
||||
")\n"
|
||||
"sys.exit(si.main([]))\n"
|
||||
)
|
||||
env = {**os.environ, "BOT_BOTTLE_SIDECAR_DAEMONS": daemons_csv}
|
||||
env = {**os.environ, "BOT_BOTTLE_GATEWAY_DAEMONS": daemons_csv}
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c", helper],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
@@ -504,7 +504,7 @@ class TestMainEndToEnd(unittest.TestCase):
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
out_b, _ = proc.communicate()
|
||||
self.fail("sidecar_init main() did not exit before timeout")
|
||||
self.fail("gateway_init main() did not exit before timeout")
|
||||
return proc.returncode, out_b.decode("utf-8", errors="replace")
|
||||
|
||||
def test_sigterm_clean_shutdown(self):
|
||||
Reference in New Issue
Block a user