diff --git a/claude_bottle/backend/docker/cleanup.py b/claude_bottle/backend/docker/cleanup.py index 82378d4..1627907 100644 --- a/claude_bottle/backend/docker/cleanup.py +++ b/claude_bottle/backend/docker/cleanup.py @@ -24,52 +24,22 @@ each project's services for ad-hoc inspection. from __future__ import annotations -import json import shutil import subprocess -from pathlib import Path from ... import supervise as _supervise from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_state import bottle_state_dir, is_preserved - - -_PROJECT_PREFIX = "claude-bottle-" - - -def _list_compose_projects() -> list[str]: - """Return the names of all currently-known compose projects - (running OR stopped) whose name starts with `claude-bottle-`. - `docker compose ls --all` reports both up + exited states.""" - result = subprocess.run( - ["docker", "compose", "ls", "--all", "--format", "json"], - capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - warn(f"docker compose ls failed: {result.stderr.strip()}") - return [] - try: - projects = json.loads(result.stdout or "[]") - except json.JSONDecodeError as e: - warn(f"docker compose ls returned malformed JSON: {e}") - return [] - names: list[str] = [] - for p in projects: - if not isinstance(p, dict): - continue - name = str(p.get("Name", "")) - if name.startswith(_PROJECT_PREFIX): - names.append(name) - return sorted(set(names)) +from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects def _list_prefixed_containers() -> list[str]: """All claude-bottle-prefixed containers, running or stopped.""" result = subprocess.run( ["docker", "ps", "-a", - "--filter", f"name=^{_PROJECT_PREFIX}", + "--filter", f"name=^{COMPOSE_PROJECT_PREFIX}", "--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"], capture_output=True, text=True, check=False, ) @@ -96,7 +66,7 @@ def _list_prefixed_networks() -> list[str]: code paths) don't.""" result = subprocess.run( ["docker", "network", "ls", - "--filter", f"name={_PROJECT_PREFIX}", + "--filter", f"name={COMPOSE_PROJECT_PREFIX}", "--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"], capture_output=True, text=True, check=False, ) @@ -126,7 +96,7 @@ def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]: if not child.is_dir(): continue identity = child.name - project = f"{_PROJECT_PREFIX}{identity}" + project = f"{COMPOSE_PROJECT_PREFIX}{identity}" if project in live_projects: continue if is_preserved(identity): @@ -138,7 +108,7 @@ def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]: def prepare_cleanup() -> DockerBottleCleanupPlan: """Enumerate everything cleanup will touch. No removals.""" docker_mod.require_docker() - projects = _list_compose_projects() + projects = list_compose_projects() project_set = set(projects) return DockerBottleCleanupPlan( projects=tuple(projects), @@ -195,24 +165,7 @@ def list_active() -> None: """Print every active claude-bottle compose project + its services. Empty banner when there are none.""" docker_mod.require_docker() - projects = _list_compose_projects() - # Filter to projects with at least one running container — `compose ls` - # already filters by default to active projects unless `--all` was - # set; double-check by querying status. - result = subprocess.run( - ["docker", "compose", "ls", "--format", "json"], - capture_output=True, text=True, check=False, - ) - running_names: set[str] = set() - if result.returncode == 0: - try: - data = json.loads(result.stdout or "[]") - running_names = { - str(p.get("Name", "")) for p in data if isinstance(p, dict) - } - except json.JSONDecodeError: - pass - active = [p for p in projects if p in running_names] + active = list_compose_projects(include_stopped=False) if not active: info("no active claude-bottle compose projects") return diff --git a/claude_bottle/backend/docker/compose.py b/claude_bottle/backend/docker/compose.py index c61e305..227c030 100644 --- a/claude_bottle/backend/docker/compose.py +++ b/claude_bottle/backend/docker/compose.py @@ -399,12 +399,74 @@ COMPOSE_FILE_NAME = "docker-compose.yml" COMPOSE_LOG_NAME = "compose.log" +COMPOSE_PROJECT_PREFIX = "claude-bottle-" + + def compose_project_name(slug: str) -> str: """Stable mapping from slug → compose project. Matches the `name:` field the renderer emits, so `docker compose ls` enumeration and direct CLI invocations agree on the project identifier.""" - return f"claude-bottle-{slug}" + return f"{COMPOSE_PROJECT_PREFIX}{slug}" + + +def slug_from_compose_project(project: str) -> str: + """Inverse of `compose_project_name`: strip the prefix to get + the underlying slug. Returns empty string if the project name + doesn't start with the expected prefix.""" + if not project.startswith(COMPOSE_PROJECT_PREFIX): + return "" + return project[len(COMPOSE_PROJECT_PREFIX):] + + +def list_compose_projects(*, include_stopped: bool = True) -> list[str]: + """All compose project names starting with `claude-bottle-`. + `include_stopped=True` (default) runs `docker compose ls --all` + so exited projects appear too; pass False to get only projects + with at least one running container. + + Returns [] on docker daemon errors or malformed output rather + than raising — callers should treat the empty list as "no + projects discoverable", not "no projects exist".""" + argv = ["docker", "compose", "ls", "--format", "json"] + if include_stopped: + argv.insert(3, "--all") + try: + result = subprocess.run( + argv, capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + # docker binary not on PATH — same shape as a daemon-down + # error from the caller's POV: no projects discoverable. + return [] + if result.returncode != 0: + warn(f"docker compose ls failed: {result.stderr.strip()}") + return [] + try: + projects = json.loads(result.stdout or "[]") + except json.JSONDecodeError as e: + warn(f"docker compose ls returned malformed JSON: {e}") + return [] + names: list[str] = [] + for p in projects: + if not isinstance(p, dict): + continue + name = str(p.get("Name", "")) + if name.startswith(COMPOSE_PROJECT_PREFIX): + names.append(name) + return sorted(set(names)) + + +def list_active_slugs(*, include_stopped: bool = False) -> list[str]: + """Slugs (project name minus prefix) of currently-running + bottles. Used by the dashboard's operator-edit verbs to choose + a bottle to apply a config edit to.""" + return sorted( + slug for slug in ( + slug_from_compose_project(p) + for p in list_compose_projects(include_stopped=include_stopped) + ) if slug + ) def compose_file_path(state_dir: Path) -> Path: @@ -499,6 +561,7 @@ def compose_down(project: str, compose_file: Path) -> None: __all__ = [ "COMPOSE_FILE_NAME", "COMPOSE_LOG_NAME", + "COMPOSE_PROJECT_PREFIX", "bottle_plan_to_compose", "compose_down", "compose_dump_logs", @@ -506,5 +569,8 @@ __all__ = [ "compose_log_path", "compose_project_name", "compose_up", + "list_active_slugs", + "list_compose_projects", + "slug_from_compose_project", "write_compose_file", ] diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 029165b..28998bf 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -27,6 +27,10 @@ from ..backend.docker.capability_apply import ( CapabilityApplyError, apply_capability_change, ) +from ..backend.docker.compose import ( + COMPOSE_PROJECT_PREFIX, + list_active_slugs, +) from ..backend.docker.egress_apply import ( EgressApplyError, add_route, @@ -79,15 +83,23 @@ class QueuedProposal: queue_dir: Path -def _discover_sidecar_slugs(name_prefix: str) -> list[str]: - """Slugs of bottles whose sidecar container names start with - `name_prefix`. Empty list if docker isn't reachable or not - installed.""" +def _discover_active_with_service(service: str) -> list[str]: + """Slugs of bottles whose compose project is up AND has the + named service container running. PRD 0018 chunk 5 grounded the + discovery on `docker compose ls` so all the dashboard verbs + agree with the cleanup CLI about what's running. A second + `docker ps` filter narrows by service label — a bottle without + egress routes has no egress service, and the operator-edit + flow shouldn't offer it for routes editing.""" + slugs = list_active_slugs() + if not slugs: + return [] try: r = subprocess.run( [ "docker", "ps", - "--filter", f"name=^{name_prefix}", + "--filter", f"label=com.docker.compose.service={service}", + "--filter", f"name=^{COMPOSE_PROJECT_PREFIX}{service}-", "--format", "{{.Names}}", ], capture_output=True, text=True, check=False, @@ -96,24 +108,27 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]: return [] if r.returncode != 0: return [] + prefix = f"{COMPOSE_PROJECT_PREFIX}{service}-" out: list[str] = [] for line in (r.stdout or "").splitlines(): line = line.strip() - if line.startswith(name_prefix): - out.append(line[len(name_prefix):]) - return sorted(out) + if line.startswith(prefix): + slug = line[len(prefix):] + if slug in slugs: + out.append(slug) + return sorted(set(out)) def discover_egress_slugs() -> list[str]: """Slugs of bottles with a running egress sidecar. Used by the operator-initiated `routes edit` verb.""" - return _discover_sidecar_slugs("claude-bottle-egress-") + return _discover_active_with_service("egress") def discover_pipelock_slugs() -> list[str]: """Slugs of bottles with a running pipelock sidecar. Used by the operator-initiated `pipelock edit` verb.""" - return _discover_sidecar_slugs("claude-bottle-pipelock-") + return _discover_active_with_service("pipelock") def _approval_status(qp: QueuedProposal, verb: str) -> str: diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 11aa01c..2a6050b 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -14,7 +14,12 @@ from pathlib import Path from claude_bottle.backend import BottleSpec from claude_bottle.backend.docker.bottle_plan import DockerBottlePlan -from claude_bottle.backend.docker.compose import bottle_plan_to_compose +from claude_bottle.backend.docker.compose import ( + COMPOSE_PROJECT_PREFIX, + bottle_plan_to_compose, + compose_project_name, + slug_from_compose_project, +) from claude_bottle.egress import ( EgressPlan, EgressRoute, @@ -450,5 +455,27 @@ class TestFullMatrix(unittest.TestCase): self.assertEqual(expected, set(s.keys())) +class TestProjectNaming(unittest.TestCase): + """The slug ↔ compose-project mapping is the contract dashboard, + cleanup, and launch all rely on. Lock it down.""" + + def test_compose_project_name_is_prefix_plus_slug(self): + self.assertEqual( + f"{COMPOSE_PROJECT_PREFIX}myagent-abc12", + compose_project_name("myagent-abc12"), + ) + + def test_slug_from_compose_project_is_inverse(self): + self.assertEqual( + "myagent-abc12", + slug_from_compose_project(f"{COMPOSE_PROJECT_PREFIX}myagent-abc12"), + ) + + def test_slug_from_unrelated_project_returns_empty(self): + # Defends against `docker compose ls` including non-bottle + # projects on a host with other compose setups. + self.assertEqual("", slug_from_compose_project("other-project")) + + if __name__ == "__main__": unittest.main()