Merge pull request 'feat(compose): bundle shape behind feature flag (PRD 0024 chunk 2)' (#56) from prd-0024-chunk-2-renderer-collapse into main
This commit was merged in pull request #56.
This commit is contained in:
@@ -83,6 +83,12 @@ from .pipelock import (
|
||||
pipelock_container_name,
|
||||
)
|
||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
sidecar_bundle_container_name,
|
||||
sidecar_bundle_enabled,
|
||||
)
|
||||
from .supervise import (
|
||||
SUPERVISE_DOCKERFILE,
|
||||
SUPERVISE_IMAGE,
|
||||
@@ -109,16 +115,24 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
project = f"claude-bottle-{plan.slug}"
|
||||
services: dict[str, Any] = {}
|
||||
|
||||
services["pipelock"] = _pipelock_service(plan)
|
||||
if sidecar_bundle_enabled():
|
||||
# PRD 0024 bundle shape: one `sidecars` service running all
|
||||
# four daemons under the bundle image's init supervisor.
|
||||
services["sidecars"] = _sidecar_bundle_service(plan)
|
||||
else:
|
||||
# Legacy four-sidecar shape. Kept side-by-side behind the
|
||||
# flag through chunks 2-4 so existing operators don't have
|
||||
# to migrate atomically.
|
||||
services["pipelock"] = _pipelock_service(plan)
|
||||
|
||||
if plan.git_gate_plan.upstreams:
|
||||
services["git-gate"] = _git_gate_service(plan)
|
||||
if plan.git_gate_plan.upstreams:
|
||||
services["git-gate"] = _git_gate_service(plan)
|
||||
|
||||
if plan.egress_plan.routes:
|
||||
services["egress"] = _egress_service(plan)
|
||||
if plan.egress_plan.routes:
|
||||
services["egress"] = _egress_service(plan)
|
||||
|
||||
if plan.supervise_plan is not None:
|
||||
services["supervise"] = _supervise_service(plan)
|
||||
if plan.supervise_plan is not None:
|
||||
services["supervise"] = _supervise_service(plan)
|
||||
|
||||
services["agent"] = _agent_service(plan)
|
||||
|
||||
@@ -185,6 +199,134 @@ def _pipelock_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""The single `sidecars` service that replaces the four
|
||||
per-sidecar containers (PRD 0024). One container per bottle,
|
||||
bundle image, all four daemons under a Python init supervisor.
|
||||
|
||||
Mechanics:
|
||||
|
||||
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS`
|
||||
env. pipelock is always present; egress / git-gate /
|
||||
supervise are conditional on the plan, identical to the
|
||||
legacy branching.
|
||||
- Volumes are the UNION of what the four prior services
|
||||
bind-mounted, preserving the same in-container paths so
|
||||
every daemon finds its config / hooks / CA where it
|
||||
expects.
|
||||
- Environment is the union of *daemon-private* env vars
|
||||
(EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc).
|
||||
HTTPS_PROXY is NOT propagated here — see the comment in
|
||||
egress_entrypoint.sh; setting it at the container level
|
||||
would route git-gate's git fetches through pipelock,
|
||||
which is wrong.
|
||||
- Network aliases register every legacy short/long
|
||||
hostname (pipelock, egress, git-gate, supervise plus
|
||||
their `claude-bottle-<service>-<slug>` long forms) so
|
||||
any existing inter-service reference (notably the
|
||||
agent's HTTPS_PROXY and depends_on lookups) resolves to
|
||||
the bundle.
|
||||
"""
|
||||
daemons: list[str] = ["egress", "pipelock"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
daemons.append("git-gate")
|
||||
if plan.supervise_plan is not None:
|
||||
daemons.append("supervise")
|
||||
|
||||
env: list[str] = [f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
||||
volumes: list[dict[str, Any]] = []
|
||||
|
||||
# --- pipelock ----------------------------------------------------
|
||||
pp = plan.proxy_plan
|
||||
volumes += [
|
||||
_bind(pp.yaml_path, "/etc/pipelock.yaml"),
|
||||
_bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER),
|
||||
_bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER),
|
||||
]
|
||||
|
||||
# --- egress (always part of the bundle; the EGRESS_UPSTREAM_*
|
||||
# env vars + ca bind-mounts are needed iff routes exist; when
|
||||
# the bottle has no routes the egress daemon falls back to its
|
||||
# `regular@9099` mode and is unused) -----------------------------
|
||||
ep = plan.egress_plan
|
||||
if ep.routes:
|
||||
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}")
|
||||
env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}")
|
||||
volumes += [
|
||||
_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER),
|
||||
_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER),
|
||||
_bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER),
|
||||
]
|
||||
for token_env in sorted(ep.token_env_map.keys()):
|
||||
env.append(token_env)
|
||||
|
||||
# --- git-gate ----------------------------------------------------
|
||||
extra_hosts: list[str] = []
|
||||
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",
|
||||
))
|
||||
extra_map = git_gate_aggregate_extra_hosts(gp.upstreams)
|
||||
extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())]
|
||||
|
||||
# --- supervise ---------------------------------------------------
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
volumes.append({
|
||||
"type": "bind",
|
||||
"source": str(sp.queue_dir),
|
||||
"target": QUEUE_DIR_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
|
||||
# Internal-network aliases: every shortname + long-form legacy
|
||||
# name routes to the bundle so the agent's HTTPS_PROXY URL
|
||||
# (which references either `pipelock` or `egress`) keeps
|
||||
# resolving without an agent-side change.
|
||||
internal_aliases = [
|
||||
pipelock_container_name(plan.slug),
|
||||
EGRESS_HOSTNAME,
|
||||
egress_container_name(plan.slug),
|
||||
]
|
||||
if gp.upstreams:
|
||||
internal_aliases.append(git_gate_container_name(plan.slug))
|
||||
if sp is not None:
|
||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
||||
internal_aliases.append(supervise_container_name(plan.slug))
|
||||
|
||||
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,
|
||||
}
|
||||
if extra_hosts:
|
||||
service["extra_hosts"] = extra_hosts
|
||||
return service
|
||||
|
||||
|
||||
def _git_gate_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Git-gate sidecar. Built from Dockerfile.git-gate. Entrypoint
|
||||
+ pre-receive hook + access-hook bind-mount from the stage
|
||||
@@ -352,14 +494,20 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
if volumes:
|
||||
service["volumes"] = volumes
|
||||
|
||||
depends_on = ["pipelock"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
depends_on.append("git-gate")
|
||||
if plan.egress_plan.routes:
|
||||
depends_on.append("egress")
|
||||
if plan.supervise_plan is not None:
|
||||
depends_on.append("supervise")
|
||||
service["depends_on"] = depends_on
|
||||
if sidecar_bundle_enabled():
|
||||
# Bundle shape: a single dependency. The init supervisor
|
||||
# owns intra-bundle daemon ordering, so the agent only
|
||||
# waits for the bundle container itself.
|
||||
service["depends_on"] = ["sidecars"]
|
||||
else:
|
||||
depends_on = ["pipelock"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
depends_on.append("git-gate")
|
||||
if plan.egress_plan.routes:
|
||||
depends_on.append("egress")
|
||||
if plan.supervise_plan is not None:
|
||||
depends_on.append("supervise")
|
||||
service["depends_on"] = depends_on
|
||||
|
||||
return service
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Sidecar bundle constants + helpers for the Docker backend
|
||||
(PRD 0024 chunk 2).
|
||||
|
||||
The bundle image (built by Dockerfile.sidecars, see PRD 0024
|
||||
chunk 1) collapses pipelock + egress + git-gate + supervise into
|
||||
one container per bottle. Whether the renderer emits the bundle
|
||||
shape (one `sidecars` service) or the legacy four-sidecar shape
|
||||
is controlled by `CLAUDE_BOTTLE_SIDECAR_BUNDLE`; chunk 2 ships
|
||||
both shapes side by side behind the flag so existing operators
|
||||
keep working unchanged while the bundle path soaks.
|
||||
|
||||
This module is intentionally tiny — just the constants + the
|
||||
flag + the container-name helper. The compose-renderer branch
|
||||
that consumes it lives in `compose.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# Bundle image. Defaults to a built-locally tag (built from the
|
||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
||||
# pinning to a published digest can override via env, matching
|
||||
# the existing `CLAUDE_BOTTLE_PIPELOCK_IMAGE` shape.
|
||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_SIDECAR_IMAGE",
|
||||
"claude-bottle-sidecars:latest",
|
||||
)
|
||||
|
||||
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
|
||||
|
||||
|
||||
def sidecar_bundle_container_name(slug: str) -> str:
|
||||
"""`claude-bottle-sidecars-<slug>`. Same prefix scheme as the
|
||||
per-sidecar containers it replaces, so the dashboard's
|
||||
discovery-by-prefix logic keeps working."""
|
||||
return f"claude-bottle-sidecars-{slug}"
|
||||
|
||||
|
||||
def sidecar_bundle_enabled(env: dict[str, str] | None = None) -> bool:
|
||||
"""Feature-flag check. The flag is opt-in for chunk 2:
|
||||
unset / "" / "0" / "false" → legacy four-sidecar shape;
|
||||
anything else → bundle shape. Chunks 4-5 flip the default and
|
||||
then delete the flag.
|
||||
|
||||
`env` defaults to `os.environ` at call time so tests can
|
||||
monkey-patch the environment without re-importing the module."""
|
||||
if env is None:
|
||||
env = dict(os.environ)
|
||||
raw = env.get("CLAUDE_BOTTLE_SIDECAR_BUNDLE", "").strip().lower()
|
||||
return raw not in ("", "0", "false", "no", "off")
|
||||
@@ -32,4 +32,18 @@ if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then
|
||||
TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED"
|
||||
fi
|
||||
|
||||
# Scope the proxy env to this process tree only. In the bundle
|
||||
# image (PRD 0024) the four daemons share one container — setting
|
||||
# HTTPS_PROXY at the container level would route git-gate's git
|
||||
# pushes through pipelock, which is wrong (pipelock doesn't proxy
|
||||
# SSH and would block public git repos). Setting them here means
|
||||
# only mitmdump's subprocess inherits them. In the legacy
|
||||
# four-sidecar setup these env vars are also set in compose; here
|
||||
# they're additionally defensive.
|
||||
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
||||
export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY"
|
||||
export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY"
|
||||
export NO_PROXY="localhost,127.0.0.1"
|
||||
fi
|
||||
|
||||
exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py
|
||||
|
||||
@@ -455,6 +455,214 @@ class TestFullMatrix(unittest.TestCase):
|
||||
self.assertEqual(expected, set(s.keys()))
|
||||
|
||||
|
||||
class TestSidecarBundleShape(unittest.TestCase):
|
||||
"""PRD 0024 chunk 2: with CLAUDE_BOTTLE_SIDECAR_BUNDLE=1 set,
|
||||
the renderer emits a `sidecars` service in place of the four
|
||||
per-sidecar services. The legacy four-sidecar tests above
|
||||
cover the flag-off shape; these lock down the flag-on shape."""
|
||||
|
||||
def _render(self, **plan_kwargs):
|
||||
from unittest.mock import patch
|
||||
with patch.dict("os.environ", {"CLAUDE_BOTTLE_SIDECAR_BUNDLE": "1"}):
|
||||
return bottle_plan_to_compose(_plan(**plan_kwargs))
|
||||
|
||||
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("claude-bottle-sidecars:latest", sc["image"])
|
||||
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
||||
|
||||
def test_bundle_container_name_uses_sidecars_prefix(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
self.assertEqual(f"claude-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_cover_pipelock_and_egress_shortnames(self):
|
||||
# The agent's HTTPS_PROXY url references either `egress` or
|
||||
# `pipelock` (long form). Both must resolve to the bundle.
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||
self.assertIn("egress", aliases)
|
||||
self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases)
|
||||
self.assertIn(f"claude-bottle-egress-{SLUG}", 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(f"claude-bottle-git-gate-{SLUG}", 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(f"claude-bottle-git-gate-{SLUG}", aliases)
|
||||
self.assertIn("supervise", aliases)
|
||||
self.assertIn(f"claude-bottle-supervise-{SLUG}", aliases)
|
||||
|
||||
def test_daemons_csv_lists_only_active(self):
|
||||
# Egress + pipelock are always in the daemon set even when
|
||||
# the bottle has no routes (egress falls back to regular@9099
|
||||
# and is just unused; cheaper than special-casing).
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
daemons = {
|
||||
line.split("=", 1)[1]
|
||||
for line in sc["environment"]
|
||||
if line.startswith("CLAUDE_BOTTLE_SIDECAR_DAEMONS=")
|
||||
}
|
||||
self.assertEqual({"egress,pipelock"}, 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("CLAUDE_BOTTLE_SIDECAR_DAEMONS="):
|
||||
csv = line.split("=", 1)[1]
|
||||
break
|
||||
else:
|
||||
self.fail("CLAUDE_BOTTLE_SIDECAR_DAEMONS not in env")
|
||||
self.assertEqual(
|
||||
["egress", "pipelock", "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 pipelock. 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_env_present_when_routes_declared(self):
|
||||
sc = self._render(with_egress=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertTrue(any(
|
||||
e.startswith("EGRESS_UPSTREAM_PROXY=") for e in env_strings))
|
||||
self.assertTrue(any(
|
||||
e.startswith("EGRESS_UPSTREAM_CA=") for e in env_strings))
|
||||
# Token env name is forwarded as a bare entry.
|
||||
self.assertIn("EGRESS_TOKEN_0", env_strings)
|
||||
|
||||
def test_egress_env_omitted_when_no_routes(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
for e in env_strings:
|
||||
self.assertFalse(e.startswith("EGRESS_UPSTREAM_PROXY="))
|
||||
self.assertFalse(e.startswith("EGRESS_UPSTREAM_CA="))
|
||||
|
||||
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.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
|
||||
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
|
||||
|
||||
def test_volumes_union_minimal_includes_pipelock(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
targets = {v["target"] for v in sc["volumes"]}
|
||||
self.assertIn("/etc/pipelock.yaml", 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"]}
|
||||
# Pipelock + egress + git-gate + supervise paths all
|
||||
# present.
|
||||
self.assertIn("/etc/pipelock.yaml", targets)
|
||||
self.assertIn("/etc/egress/routes.yaml", targets)
|
||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
||||
# supervise queue dir target = QUEUE_DIR_IN_CONTAINER
|
||||
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
||||
for t in targets))
|
||||
|
||||
def test_extra_hosts_emitted_for_git_upstreams(self):
|
||||
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||
self.assertIn("example.com:10.0.0.1", sc.get("extra_hosts", []))
|
||||
|
||||
def test_extra_hosts_omitted_when_no_git(self):
|
||||
sc = self._render()["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 TestSidecarBundleFlag(unittest.TestCase):
|
||||
"""The flag's parser: covers the cases sidecar_bundle_enabled
|
||||
has to interpret correctly so an operator-supplied value lands
|
||||
in the expected shape."""
|
||||
|
||||
def _enabled(self, value: str | None) -> bool:
|
||||
from claude_bottle.backend.docker.sidecar_bundle import (
|
||||
sidecar_bundle_enabled,
|
||||
)
|
||||
env: dict[str, str] = {}
|
||||
if value is not None:
|
||||
env["CLAUDE_BOTTLE_SIDECAR_BUNDLE"] = value
|
||||
return sidecar_bundle_enabled(env)
|
||||
|
||||
def test_unset_disabled(self):
|
||||
self.assertFalse(self._enabled(None))
|
||||
|
||||
def test_empty_disabled(self):
|
||||
self.assertFalse(self._enabled(""))
|
||||
|
||||
def test_zero_disabled(self):
|
||||
self.assertFalse(self._enabled("0"))
|
||||
|
||||
def test_false_disabled(self):
|
||||
self.assertFalse(self._enabled("false"))
|
||||
self.assertFalse(self._enabled("FALSE"))
|
||||
|
||||
def test_no_disabled(self):
|
||||
self.assertFalse(self._enabled("no"))
|
||||
self.assertFalse(self._enabled("off"))
|
||||
|
||||
def test_one_enabled(self):
|
||||
self.assertTrue(self._enabled("1"))
|
||||
|
||||
def test_true_enabled(self):
|
||||
self.assertTrue(self._enabled("true"))
|
||||
|
||||
def test_arbitrary_truthy_enabled(self):
|
||||
# The flag treats anything other than the known falsy
|
||||
# values as truthy — operator typos default to "enabled"
|
||||
# which is the safer interpretation for an opt-in flag.
|
||||
self.assertTrue(self._enabled("yes"))
|
||||
|
||||
|
||||
class TestProjectNaming(unittest.TestCase):
|
||||
"""The slug ↔ compose-project mapping is the contract dashboard,
|
||||
cleanup, and launch all rely on. Lock it down."""
|
||||
|
||||
Reference in New Issue
Block a user