refactor(dashboard): discover via docker compose ls
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user