refactor(cleanup): compose-ls driven, plus orphan state-dir reaping
PRD 0018 chunk 4. `claude-bottle cleanup` now derives its work
from `docker compose ls --all --format json`, filtered to projects
whose name starts with `claude-bottle-`. Per project: one `compose
down --volumes` removes the containers + the compose-managed
networks atomically.
The plan also enumerates three fallback buckets:
- Stray containers — `claude-bottle-*` containers with no
`com.docker.compose.project` label (left over from pre-compose
code paths). Cleared via `docker rm -f`.
- Stray networks — `claude-bottle-*` networks with no compose
project label. Cleared via `docker network rm`.
- Orphan state dirs — per-bottle `~/.claude-bottle/state/<id>/`
dirs with no live project AND no `.preserve` marker. The
`.preserve` marker (capability-block or auto-preserve-on-crash)
explicitly opts-out of reaping; manual `rm -rf` is the only
path for preserved state.
cli/cleanup.py collapses to a single y/N prompt — backend.prepare_cleanup
returns everything in one plan, backend.cleanup processes everything,
no more double-prompt for state. The CLI-side state-dir enumeration
+ `_state_summary` flags from PR #25 are gone; the backend's
orphan-detection rules subsume them.
This commit is contained in:
@@ -1,8 +1,21 @@
|
||||
"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan.
|
||||
|
||||
Holds the tuples of container and network names that
|
||||
DockerBottleBackend.cleanup will remove. The y/N preflight reads
|
||||
these via `print`; the CLI short-circuits via `empty`.
|
||||
PRD 0018 chunk 4: cleanup is centered on compose projects. `docker
|
||||
compose ls` is the source of truth for what's running; the plan
|
||||
carries the projects to `compose down`, plus three fallback buckets
|
||||
for legacy / orphan resources:
|
||||
|
||||
- stray_containers: pre-compose `claude-bottle-*` containers not
|
||||
attached to any compose project. Cleared via `docker rm -f`.
|
||||
- stray_networks: same idea for networks. Cleared via
|
||||
`docker network rm`.
|
||||
- orphan_state_dirs: per-bottle state dirs under
|
||||
~/.claude-bottle/state/ that have no live compose project AND
|
||||
no `.preserve` marker. Reaped via `shutil.rmtree`.
|
||||
|
||||
Compose-managed networks are removed by `compose down --volumes`,
|
||||
so they don't appear in stray_networks for a normal project — only
|
||||
truly leftover ones.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,20 +30,30 @@ from .. import BottleCleanupPlan
|
||||
@dataclass(frozen=True)
|
||||
class DockerBottleCleanupPlan(BottleCleanupPlan):
|
||||
"""Resources DockerBottleBackend.cleanup will remove. Produced by
|
||||
`prepare_cleanup` from a snapshot of `docker ps -a` + `docker
|
||||
network ls`; sorted so the y/N output is stable."""
|
||||
`prepare_cleanup`; sorted so the y/N output is stable."""
|
||||
|
||||
containers: tuple[str, ...]
|
||||
networks: tuple[str, ...]
|
||||
projects: tuple[str, ...]
|
||||
stray_containers: tuple[str, ...]
|
||||
stray_networks: tuple[str, ...]
|
||||
orphan_state_dirs: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not self.containers and not self.networks
|
||||
return (
|
||||
not self.projects
|
||||
and not self.stray_containers
|
||||
and not self.stray_networks
|
||||
and not self.orphan_state_dirs
|
||||
)
|
||||
|
||||
def print(self) -> None:
|
||||
print(file=sys.stderr)
|
||||
for name in self.containers:
|
||||
info(f"container: {name}")
|
||||
for name in self.networks:
|
||||
info(f"network: {name}")
|
||||
for name in self.projects:
|
||||
info(f"compose project: {name}")
|
||||
for name in self.stray_containers:
|
||||
info(f"stray container: {name}")
|
||||
for name in self.stray_networks:
|
||||
info(f"stray network: {name}")
|
||||
for name in self.orphan_state_dirs:
|
||||
info(f"orphan state: {name}")
|
||||
print(file=sys.stderr)
|
||||
|
||||
@@ -1,76 +1,180 @@
|
||||
"""Cleanup + active-listing for the Docker bottle backend.
|
||||
|
||||
`prepare_cleanup` enumerates orphaned `claude-bottle-` containers and
|
||||
networks; `cleanup` removes them. `list_active` queries the same
|
||||
namespace for ad-hoc inspection. All three share a single concern:
|
||||
acting on resources whose names start with `claude-bottle-`.
|
||||
PRD 0018 chunk 4: cleanup is centered on `docker compose ls`.
|
||||
Pre-compose code paths could leave bare containers / networks
|
||||
without a compose project; those still show up via the prefix
|
||||
scan, just as a fallback bucket alongside the project list.
|
||||
|
||||
`prepare_cleanup` enumerates:
|
||||
|
||||
- Live compose projects whose name starts with `claude-bottle-`.
|
||||
- `claude-bottle-*` containers that aren't part of any compose
|
||||
project (legacy orphans).
|
||||
- `claude-bottle-*` networks that aren't tied to a compose
|
||||
project (legacy orphans; compose-managed networks come down
|
||||
with `compose down --volumes` and don't appear here).
|
||||
- State dirs under ~/.claude-bottle/state/<identity>/ with no
|
||||
live compose project AND no `.preserve` marker.
|
||||
|
||||
`cleanup` removes everything in the plan.
|
||||
|
||||
`list_active` queries the same compose project namespace and prints
|
||||
each project's services for ad-hoc inspection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import info
|
||||
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))
|
||||
|
||||
|
||||
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}",
|
||||
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(f"docker ps failed: {result.stderr.strip()}")
|
||||
return []
|
||||
out: list[str] = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if not line:
|
||||
continue
|
||||
name, _, project = line.partition("\t")
|
||||
# Stray = no compose label. Compose-managed containers carry
|
||||
# `com.docker.compose.project=<name>`; we'll reap those via
|
||||
# `compose down`, not via container rm.
|
||||
if not project:
|
||||
out.append(name)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _list_prefixed_networks() -> list[str]:
|
||||
"""All claude-bottle-prefixed networks not currently attached
|
||||
to a compose project. Compose-managed networks have a
|
||||
`com.docker.compose.project` label; bare ones (from pre-compose
|
||||
code paths) don't."""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "ls",
|
||||
"--filter", f"name={_PROJECT_PREFIX}",
|
||||
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(f"docker network ls failed: {result.stderr.strip()}")
|
||||
return []
|
||||
out: list[str] = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if not line:
|
||||
continue
|
||||
name, _, project = line.partition("\t")
|
||||
if not project:
|
||||
out.append(name)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]:
|
||||
"""State identities whose compose project isn't running and
|
||||
that don't have a `.preserve` marker. `.preserve` means the
|
||||
user (or an auto-preserve-on-crash) wants the state kept for
|
||||
`resume`."""
|
||||
state_root = _supervise.claude_bottle_root() / "state"
|
||||
if not state_root.is_dir():
|
||||
return []
|
||||
orphans: list[str] = []
|
||||
for child in sorted(state_root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
identity = child.name
|
||||
project = f"{_PROJECT_PREFIX}{identity}"
|
||||
if project in live_projects:
|
||||
continue
|
||||
if is_preserved(identity):
|
||||
continue
|
||||
orphans.append(identity)
|
||||
return orphans
|
||||
|
||||
|
||||
def prepare_cleanup() -> DockerBottleCleanupPlan:
|
||||
"""Enumerate all claude-bottle-prefixed containers (running or
|
||||
stopped) and networks. No removals — caller confirms first."""
|
||||
"""Enumerate everything cleanup will touch. No removals."""
|
||||
docker_mod.require_docker()
|
||||
|
||||
# `docker ps -a --filter name=...` uses regex matching; anchor at
|
||||
# the start so we don't pick up containers that merely contain
|
||||
# "claude-bottle-" mid-name.
|
||||
cr = subprocess.run(
|
||||
[
|
||||
"docker", "ps", "-a",
|
||||
"--filter", "name=^claude-bottle-",
|
||||
"--format", "{{.Names}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
projects = _list_compose_projects()
|
||||
project_set = set(projects)
|
||||
return DockerBottleCleanupPlan(
|
||||
projects=tuple(projects),
|
||||
stray_containers=tuple(_list_prefixed_containers()),
|
||||
stray_networks=tuple(_list_prefixed_networks()),
|
||||
orphan_state_dirs=tuple(_list_orphan_state_dirs(project_set)),
|
||||
)
|
||||
containers = tuple(sorted(
|
||||
line for line in (cr.stdout or "").splitlines() if line
|
||||
))
|
||||
|
||||
# `docker network ls --filter name=...` uses substring matching.
|
||||
# "claude-bottle-" is specific enough that false positives are
|
||||
# not a concern.
|
||||
nr = subprocess.run(
|
||||
[
|
||||
"docker", "network", "ls",
|
||||
"--filter", "name=claude-bottle-",
|
||||
"--format", "{{.Name}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
networks = tuple(sorted(
|
||||
line for line in (nr.stdout or "").splitlines() if line
|
||||
))
|
||||
|
||||
return DockerBottleCleanupPlan(containers=containers, networks=networks)
|
||||
|
||||
|
||||
def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
||||
"""Remove the containers and networks listed in the plan.
|
||||
Containers first; networks would refuse to delete while containers
|
||||
are still attached."""
|
||||
for name in plan.containers:
|
||||
info(f"removing container {name}")
|
||||
"""Remove everything in the plan. Projects first (whose `compose
|
||||
down` reaps their containers + networks atomically), then stray
|
||||
legacy resources, then orphan state dirs."""
|
||||
for project in plan.projects:
|
||||
info(f"docker compose down ({project})")
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-p", project, "down", "--volumes"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(
|
||||
f"compose down failed for {project}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
|
||||
for name in plan.stray_containers:
|
||||
info(f"removing stray container {name}")
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
for name in plan.networks:
|
||||
info(f"removing network {name}")
|
||||
|
||||
for name in plan.stray_networks:
|
||||
info(f"removing stray network {name}")
|
||||
subprocess.run(
|
||||
["docker", "network", "rm", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -78,27 +182,50 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
||||
check=False,
|
||||
)
|
||||
|
||||
for identity in plan.orphan_state_dirs:
|
||||
path = bottle_state_dir(identity)
|
||||
info(f"removing orphan state dir {path}")
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except OSError as e:
|
||||
warn(f"failed to remove {path}: {e}")
|
||||
|
||||
|
||||
def list_active() -> None:
|
||||
"""Print all running claude-bottle containers (name + status).
|
||||
Prints a single-line banner if there are 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", "ps",
|
||||
"--filter", "name=^claude-bottle-",
|
||||
"--format", "{{.Names}}\t{{.Status}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
["docker", "compose", "ls", "--format", "json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
containers = (result.stdout or "").strip()
|
||||
if not containers:
|
||||
info("no active claude-bottle containers")
|
||||
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:
|
||||
info("no active claude-bottle compose projects")
|
||||
return
|
||||
print()
|
||||
for line in containers.splitlines():
|
||||
name, _, status = line.partition("\t")
|
||||
info(f"container: {name} status: {status}")
|
||||
for project in active:
|
||||
info(f"compose project: {project}")
|
||||
ps = subprocess.run(
|
||||
["docker", "compose", "-p", project, "ps", "--format",
|
||||
"{{.Service}}\t{{.Name}}\t{{.Status}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
for line in (ps.stdout or "").splitlines():
|
||||
service, _, rest = line.partition("\t")
|
||||
name, _, status = rest.partition("\t")
|
||||
info(f" {service:12s} {name} ({status})")
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user