refactor(dashboard): discover via docker compose ls
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m8s

PRD 0018 chunk 5. The dashboard's operator-edit verbs
(`routes edit`, `pipelock edit`) enumerated running sidecars
via `docker ps --filter name=...` prefix scans. Switch to
`docker compose ls`-based discovery so the dashboard, cleanup
CLI, and launch step all agree on what's running.

Mechanics:

  - `claude_bottle/backend/docker/compose.py` grows three shared
    helpers: `list_compose_projects` (the JSON parse moved out
    of cleanup), `slug_from_compose_project` (inverse of
    `compose_project_name`), and `list_active_slugs` (sugar over
    the first two for the common "what's running?" question).
  - cleanup.py drops its private `_list_compose_projects` +
    `_PROJECT_PREFIX` in favor of the shared ones; `list_active`
    simplifies (one compose-ls call, not two).
  - dashboard.py's `_discover_sidecar_slugs` becomes
    `_discover_active_with_service`: cross-references the active
    slug list with a label-filtered `docker ps` so only bottles
    whose given service container is actually up surface in the
    edit menu. Bottles without an egress sidecar (no
    bottle.egress.routes) no longer appear for `routes edit`.

3 new unit tests cover the slug ↔ compose-project naming
contract; manual probe with a fake compose project confirms
both `discover_egress_slugs` and `discover_pipelock_slugs`
return the expected slug.
This commit is contained in:
2026-05-26 00:14:16 -04:00
parent 0ae544d2a6
commit 1fa3745832
4 changed files with 126 additions and 65 deletions
+6 -53
View File
@@ -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