Merge pull request 'refactor(dashboard): discover via docker compose ls' (#37) from chunk-5-dashboard into main
This commit was merged in pull request #37.
This commit is contained in:
@@ -24,52 +24,22 @@ each project's services for ad-hoc inspection.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ... import supervise as _supervise
|
from ... import supervise as _supervise
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from .bottle_state import bottle_state_dir, is_preserved
|
from .bottle_state import bottle_state_dir, is_preserved
|
||||||
|
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
||||||
|
|
||||||
_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))
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
def _list_prefixed_containers() -> list[str]:
|
||||||
"""All claude-bottle-prefixed containers, running or stopped."""
|
"""All claude-bottle-prefixed containers, running or stopped."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-a",
|
["docker", "ps", "-a",
|
||||||
"--filter", f"name=^{_PROJECT_PREFIX}",
|
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
||||||
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
@@ -96,7 +66,7 @@ def _list_prefixed_networks() -> list[str]:
|
|||||||
code paths) don't."""
|
code paths) don't."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "network", "ls",
|
["docker", "network", "ls",
|
||||||
"--filter", f"name={_PROJECT_PREFIX}",
|
"--filter", f"name={COMPOSE_PROJECT_PREFIX}",
|
||||||
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||||
capture_output=True, text=True, check=False,
|
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():
|
if not child.is_dir():
|
||||||
continue
|
continue
|
||||||
identity = child.name
|
identity = child.name
|
||||||
project = f"{_PROJECT_PREFIX}{identity}"
|
project = f"{COMPOSE_PROJECT_PREFIX}{identity}"
|
||||||
if project in live_projects:
|
if project in live_projects:
|
||||||
continue
|
continue
|
||||||
if is_preserved(identity):
|
if is_preserved(identity):
|
||||||
@@ -138,7 +108,7 @@ def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]:
|
|||||||
def prepare_cleanup() -> DockerBottleCleanupPlan:
|
def prepare_cleanup() -> DockerBottleCleanupPlan:
|
||||||
"""Enumerate everything cleanup will touch. No removals."""
|
"""Enumerate everything cleanup will touch. No removals."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
projects = _list_compose_projects()
|
projects = list_compose_projects()
|
||||||
project_set = set(projects)
|
project_set = set(projects)
|
||||||
return DockerBottleCleanupPlan(
|
return DockerBottleCleanupPlan(
|
||||||
projects=tuple(projects),
|
projects=tuple(projects),
|
||||||
@@ -195,24 +165,7 @@ def list_active() -> None:
|
|||||||
"""Print every active claude-bottle compose project + its
|
"""Print every active claude-bottle compose project + its
|
||||||
services. Empty banner when there are none."""
|
services. Empty banner when there are none."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
projects = _list_compose_projects()
|
active = list_compose_projects(include_stopped=False)
|
||||||
# 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]
|
|
||||||
if not active:
|
if not active:
|
||||||
info("no active claude-bottle compose projects")
|
info("no active claude-bottle compose projects")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -399,12 +399,74 @@ COMPOSE_FILE_NAME = "docker-compose.yml"
|
|||||||
COMPOSE_LOG_NAME = "compose.log"
|
COMPOSE_LOG_NAME = "compose.log"
|
||||||
|
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_PREFIX = "claude-bottle-"
|
||||||
|
|
||||||
|
|
||||||
def compose_project_name(slug: str) -> str:
|
def compose_project_name(slug: str) -> str:
|
||||||
"""Stable mapping from slug → compose project. Matches the
|
"""Stable mapping from slug → compose project. Matches the
|
||||||
`name:` field the renderer emits, so `docker compose ls`
|
`name:` field the renderer emits, so `docker compose ls`
|
||||||
enumeration and direct CLI invocations agree on the project
|
enumeration and direct CLI invocations agree on the project
|
||||||
identifier."""
|
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:
|
def compose_file_path(state_dir: Path) -> Path:
|
||||||
@@ -499,6 +561,7 @@ def compose_down(project: str, compose_file: Path) -> None:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"COMPOSE_FILE_NAME",
|
"COMPOSE_FILE_NAME",
|
||||||
"COMPOSE_LOG_NAME",
|
"COMPOSE_LOG_NAME",
|
||||||
|
"COMPOSE_PROJECT_PREFIX",
|
||||||
"bottle_plan_to_compose",
|
"bottle_plan_to_compose",
|
||||||
"compose_down",
|
"compose_down",
|
||||||
"compose_dump_logs",
|
"compose_dump_logs",
|
||||||
@@ -506,5 +569,8 @@ __all__ = [
|
|||||||
"compose_log_path",
|
"compose_log_path",
|
||||||
"compose_project_name",
|
"compose_project_name",
|
||||||
"compose_up",
|
"compose_up",
|
||||||
|
"list_active_slugs",
|
||||||
|
"list_compose_projects",
|
||||||
|
"slug_from_compose_project",
|
||||||
"write_compose_file",
|
"write_compose_file",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ from ..backend.docker.capability_apply import (
|
|||||||
CapabilityApplyError,
|
CapabilityApplyError,
|
||||||
apply_capability_change,
|
apply_capability_change,
|
||||||
)
|
)
|
||||||
|
from ..backend.docker.compose import (
|
||||||
|
COMPOSE_PROJECT_PREFIX,
|
||||||
|
list_active_slugs,
|
||||||
|
)
|
||||||
from ..backend.docker.egress_apply import (
|
from ..backend.docker.egress_apply import (
|
||||||
EgressApplyError,
|
EgressApplyError,
|
||||||
add_route,
|
add_route,
|
||||||
@@ -79,15 +83,23 @@ class QueuedProposal:
|
|||||||
queue_dir: Path
|
queue_dir: Path
|
||||||
|
|
||||||
|
|
||||||
def _discover_sidecar_slugs(name_prefix: str) -> list[str]:
|
def _discover_active_with_service(service: str) -> list[str]:
|
||||||
"""Slugs of bottles whose sidecar container names start with
|
"""Slugs of bottles whose compose project is up AND has the
|
||||||
`name_prefix`. Empty list if docker isn't reachable or not
|
named service container running. PRD 0018 chunk 5 grounded the
|
||||||
installed."""
|
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:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker", "ps",
|
"docker", "ps",
|
||||||
"--filter", f"name=^{name_prefix}",
|
"--filter", f"label=com.docker.compose.service={service}",
|
||||||
|
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}{service}-",
|
||||||
"--format", "{{.Names}}",
|
"--format", "{{.Names}}",
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
@@ -96,24 +108,27 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return []
|
return []
|
||||||
|
prefix = f"{COMPOSE_PROJECT_PREFIX}{service}-"
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for line in (r.stdout or "").splitlines():
|
for line in (r.stdout or "").splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith(name_prefix):
|
if line.startswith(prefix):
|
||||||
out.append(line[len(name_prefix):])
|
slug = line[len(prefix):]
|
||||||
return sorted(out)
|
if slug in slugs:
|
||||||
|
out.append(slug)
|
||||||
|
return sorted(set(out))
|
||||||
|
|
||||||
|
|
||||||
def discover_egress_slugs() -> list[str]:
|
def discover_egress_slugs() -> list[str]:
|
||||||
"""Slugs of bottles with a running egress sidecar. Used by
|
"""Slugs of bottles with a running egress sidecar. Used by
|
||||||
the operator-initiated `routes edit` verb."""
|
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]:
|
def discover_pipelock_slugs() -> list[str]:
|
||||||
"""Slugs of bottles with a running pipelock sidecar. Used by
|
"""Slugs of bottles with a running pipelock sidecar. Used by
|
||||||
the operator-initiated `pipelock edit` verb."""
|
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:
|
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from claude_bottle.backend import BottleSpec
|
from claude_bottle.backend import BottleSpec
|
||||||
from claude_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
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 (
|
from claude_bottle.egress import (
|
||||||
EgressPlan,
|
EgressPlan,
|
||||||
EgressRoute,
|
EgressRoute,
|
||||||
@@ -450,5 +455,27 @@ class TestFullMatrix(unittest.TestCase):
|
|||||||
self.assertEqual(expected, set(s.keys()))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user