clean-to-execute 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: clean-to-execute
3
+ Version: 0.0.1
4
+ Summary: Safely clean the Docker Compose resources for a local GitHub repository.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Clean to Execute
13
+
14
+ `clean-to-execute` removes Docker Compose resources for a local clone of a
15
+ GitHub repository.
16
+
17
+ The repository is found recursively by inspecting Git remotes under the user's
18
+ home directory. Directory names alone are not trusted. Docker Compose files
19
+ must be at the repository root.
20
+
21
+ ## Requirements
22
+
23
+ - Python 3.10 or newer
24
+ - Git
25
+ - Docker with the Compose plugin
26
+ - Access to a running Docker daemon
27
+
28
+ ## Installation
29
+
30
+ From this directory:
31
+
32
+ ```bash
33
+ python3 -m pip install .
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Execute cleanup:
39
+
40
+ ```bash
41
+ clean-to-execute --organization kaizten --repository example
42
+ ```
43
+
44
+ Preview cleanup without changing Docker resources:
45
+
46
+ ```bash
47
+ clean-to-execute --organization kaizten --repository example --dry-run
48
+ ```
49
+
50
+ Search somewhere other than `~`:
51
+
52
+ ```bash
53
+ clean-to-execute \
54
+ --organization kaizten \
55
+ --repository example \
56
+ --root /workspaces
57
+ ```
58
+
59
+ Cleanup is destructive by default. Use `--dry-run` when only a preview is
60
+ required.
61
+
62
+ ## Cleanup scope
63
+
64
+ The command recognizes `compose.yaml`, `compose.yml`, `docker-compose.yaml`,
65
+ and `docker-compose.yml` at the repository root. Docker Compose renders the
66
+ effective configuration, including its normal override and environment
67
+ interpolation behavior. All Compose profiles are activated for inspection and
68
+ shutdown, so services and ports behind profiles are included without requiring
69
+ the caller to name each profile.
70
+
71
+ By default, the command:
72
+
73
+ 1. Stops and removes the entire Compose project, including orphan containers,
74
+ so all of its published ports are released.
75
+ 2. Deletes the project's non-external named and anonymous volumes.
76
+ 3. Deletes all other dangling Docker volumes, while preserving external
77
+ volumes declared by this Compose project.
78
+ 4. Force-removes local images referenced by the Compose configuration whose
79
+ namespace matches the GitHub organization.
80
+
81
+ The namespace comparison works across registries. For example, all of these
82
+ belong to the GitHub organization `kaizten`:
83
+
84
+ - `kaizten/api`
85
+ - `docker.io/kaizten/api`
86
+ - `ghcr.io/kaizten/api`
87
+ - `registry.example.com/kaizten/api`
88
+
89
+ Containers outside the selected Compose project are never stopped or removed.
90
+ An external container may still prevent Docker from deleting an image; that is
91
+ reported as a cleanup failure.
92
+
93
+ If multiple local clones match the same GitHub remote, the command lists them
94
+ and exits instead of choosing one implicitly.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ python3 -m unittest discover -s tests -v
100
+ ./build-package.sh
101
+ ```
@@ -0,0 +1,90 @@
1
+ # Clean to Execute
2
+
3
+ `clean-to-execute` removes Docker Compose resources for a local clone of a
4
+ GitHub repository.
5
+
6
+ The repository is found recursively by inspecting Git remotes under the user's
7
+ home directory. Directory names alone are not trusted. Docker Compose files
8
+ must be at the repository root.
9
+
10
+ ## Requirements
11
+
12
+ - Python 3.10 or newer
13
+ - Git
14
+ - Docker with the Compose plugin
15
+ - Access to a running Docker daemon
16
+
17
+ ## Installation
18
+
19
+ From this directory:
20
+
21
+ ```bash
22
+ python3 -m pip install .
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Execute cleanup:
28
+
29
+ ```bash
30
+ clean-to-execute --organization kaizten --repository example
31
+ ```
32
+
33
+ Preview cleanup without changing Docker resources:
34
+
35
+ ```bash
36
+ clean-to-execute --organization kaizten --repository example --dry-run
37
+ ```
38
+
39
+ Search somewhere other than `~`:
40
+
41
+ ```bash
42
+ clean-to-execute \
43
+ --organization kaizten \
44
+ --repository example \
45
+ --root /workspaces
46
+ ```
47
+
48
+ Cleanup is destructive by default. Use `--dry-run` when only a preview is
49
+ required.
50
+
51
+ ## Cleanup scope
52
+
53
+ The command recognizes `compose.yaml`, `compose.yml`, `docker-compose.yaml`,
54
+ and `docker-compose.yml` at the repository root. Docker Compose renders the
55
+ effective configuration, including its normal override and environment
56
+ interpolation behavior. All Compose profiles are activated for inspection and
57
+ shutdown, so services and ports behind profiles are included without requiring
58
+ the caller to name each profile.
59
+
60
+ By default, the command:
61
+
62
+ 1. Stops and removes the entire Compose project, including orphan containers,
63
+ so all of its published ports are released.
64
+ 2. Deletes the project's non-external named and anonymous volumes.
65
+ 3. Deletes all other dangling Docker volumes, while preserving external
66
+ volumes declared by this Compose project.
67
+ 4. Force-removes local images referenced by the Compose configuration whose
68
+ namespace matches the GitHub organization.
69
+
70
+ The namespace comparison works across registries. For example, all of these
71
+ belong to the GitHub organization `kaizten`:
72
+
73
+ - `kaizten/api`
74
+ - `docker.io/kaizten/api`
75
+ - `ghcr.io/kaizten/api`
76
+ - `registry.example.com/kaizten/api`
77
+
78
+ Containers outside the selected Compose project are never stopped or removed.
79
+ An external container may still prevent Docker from deleting an image; that is
80
+ reported as a cleanup failure.
81
+
82
+ If multiple local clones match the same GitHub remote, the command lists them
83
+ and exits instead of choosing one implicitly.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ python3 -m unittest discover -s tests -v
89
+ ./build-package.sh
90
+ ```
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clean-to-execute"
7
+ version = "0.0.1"
8
+ description = "Safely clean the Docker Compose resources for a local GitHub repository."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{ name = "Kaizten Analytics" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ ]
17
+
18
+ [project.scripts]
19
+ clean-to-execute = "clean_to_execute:main"
20
+
21
+ [tool.setuptools]
22
+ package-dir = { "" = "src" }
23
+ py-modules = ["clean_to_execute"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: clean-to-execute
3
+ Version: 0.0.1
4
+ Summary: Safely clean the Docker Compose resources for a local GitHub repository.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Clean to Execute
13
+
14
+ `clean-to-execute` removes Docker Compose resources for a local clone of a
15
+ GitHub repository.
16
+
17
+ The repository is found recursively by inspecting Git remotes under the user's
18
+ home directory. Directory names alone are not trusted. Docker Compose files
19
+ must be at the repository root.
20
+
21
+ ## Requirements
22
+
23
+ - Python 3.10 or newer
24
+ - Git
25
+ - Docker with the Compose plugin
26
+ - Access to a running Docker daemon
27
+
28
+ ## Installation
29
+
30
+ From this directory:
31
+
32
+ ```bash
33
+ python3 -m pip install .
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Execute cleanup:
39
+
40
+ ```bash
41
+ clean-to-execute --organization kaizten --repository example
42
+ ```
43
+
44
+ Preview cleanup without changing Docker resources:
45
+
46
+ ```bash
47
+ clean-to-execute --organization kaizten --repository example --dry-run
48
+ ```
49
+
50
+ Search somewhere other than `~`:
51
+
52
+ ```bash
53
+ clean-to-execute \
54
+ --organization kaizten \
55
+ --repository example \
56
+ --root /workspaces
57
+ ```
58
+
59
+ Cleanup is destructive by default. Use `--dry-run` when only a preview is
60
+ required.
61
+
62
+ ## Cleanup scope
63
+
64
+ The command recognizes `compose.yaml`, `compose.yml`, `docker-compose.yaml`,
65
+ and `docker-compose.yml` at the repository root. Docker Compose renders the
66
+ effective configuration, including its normal override and environment
67
+ interpolation behavior. All Compose profiles are activated for inspection and
68
+ shutdown, so services and ports behind profiles are included without requiring
69
+ the caller to name each profile.
70
+
71
+ By default, the command:
72
+
73
+ 1. Stops and removes the entire Compose project, including orphan containers,
74
+ so all of its published ports are released.
75
+ 2. Deletes the project's non-external named and anonymous volumes.
76
+ 3. Deletes all other dangling Docker volumes, while preserving external
77
+ volumes declared by this Compose project.
78
+ 4. Force-removes local images referenced by the Compose configuration whose
79
+ namespace matches the GitHub organization.
80
+
81
+ The namespace comparison works across registries. For example, all of these
82
+ belong to the GitHub organization `kaizten`:
83
+
84
+ - `kaizten/api`
85
+ - `docker.io/kaizten/api`
86
+ - `ghcr.io/kaizten/api`
87
+ - `registry.example.com/kaizten/api`
88
+
89
+ Containers outside the selected Compose project are never stopped or removed.
90
+ An external container may still prevent Docker from deleting an image; that is
91
+ reported as a cleanup failure.
92
+
93
+ If multiple local clones match the same GitHub remote, the command lists them
94
+ and exits instead of choosing one implicitly.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ python3 -m unittest discover -s tests -v
100
+ ./build-package.sh
101
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/clean_to_execute.py
4
+ src/clean_to_execute.egg-info/PKG-INFO
5
+ src/clean_to_execute.egg-info/SOURCES.txt
6
+ src/clean_to_execute.egg-info/dependency_links.txt
7
+ src/clean_to_execute.egg-info/entry_points.txt
8
+ src/clean_to_execute.egg-info/top_level.txt
9
+ tests/test_clean_to_execute.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clean-to-execute = clean_to_execute:main
@@ -0,0 +1 @@
1
+ clean_to_execute
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env python3
2
+ """Clean Docker Compose resources belonging to a local GitHub repository."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import urllib.parse
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Sequence
17
+
18
+
19
+ COMPOSE_FILES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
20
+ SKIP_DIRS = {
21
+ ".cache",
22
+ ".git",
23
+ ".hg",
24
+ ".mypy_cache",
25
+ ".pytest_cache",
26
+ ".svn",
27
+ ".tox",
28
+ ".venv",
29
+ "__pycache__",
30
+ "build",
31
+ "dist",
32
+ "node_modules",
33
+ "venv",
34
+ }
35
+
36
+
37
+ class CleanError(RuntimeError):
38
+ """A user-facing validation or cleanup error."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CommandResult:
43
+ args: tuple[str, ...]
44
+ returncode: int
45
+ stdout: str
46
+ stderr: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ContainerInfo:
51
+ container_id: str
52
+ name: str
53
+ image: str
54
+ ports: tuple[str, ...]
55
+ volumes: tuple[str, ...] = ()
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class CleanupPlan:
60
+ repository: Path
61
+ project_name: str
62
+ containers: tuple[ContainerInfo, ...]
63
+ declared_ports: tuple[str, ...]
64
+ project_volumes: tuple[str, ...]
65
+ external_volumes: tuple[str, ...]
66
+ dangling_volumes: tuple[str, ...]
67
+ organization_images: tuple[str, ...]
68
+ local_organization_images: tuple[str, ...]
69
+
70
+
71
+ def command(args: Sequence[str], cwd: Path | None = None) -> CommandResult:
72
+ try:
73
+ completed = subprocess.run(
74
+ list(args),
75
+ cwd=cwd,
76
+ check=False,
77
+ capture_output=True,
78
+ text=True,
79
+ )
80
+ except OSError as exc:
81
+ raise CleanError(f"Could not run {args[0]}: {exc}") from exc
82
+ return CommandResult(tuple(args), completed.returncode, completed.stdout.strip(), completed.stderr.strip())
83
+
84
+
85
+ def require_success(result: CommandResult, context: str) -> str:
86
+ if result.returncode == 0:
87
+ return result.stdout
88
+ detail = result.stderr or result.stdout or f"exit status {result.returncode}"
89
+ raise CleanError(f"{context}: {detail}")
90
+
91
+
92
+ def github_name(value: str) -> str:
93
+ if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?", value):
94
+ raise argparse.ArgumentTypeError("must be a GitHub organization or repository name")
95
+ return value
96
+
97
+
98
+ def build_parser() -> argparse.ArgumentParser:
99
+ parser = argparse.ArgumentParser(
100
+ description="Clean Docker Compose resources for a local GitHub repository."
101
+ )
102
+ parser.add_argument("--organization", required=True, type=github_name, help="GitHub organization name.")
103
+ parser.add_argument("--repository", required=True, type=github_name, help="GitHub repository name.")
104
+ parser.add_argument(
105
+ "--root",
106
+ type=Path,
107
+ default=Path.home(),
108
+ help="Directory to search recursively. Defaults to the user home directory.",
109
+ )
110
+ parser.add_argument("--dry-run", action="store_true", help="Preview the cleanup without changing Docker resources.")
111
+ return parser
112
+
113
+
114
+ def normalize_github_remote(url: str) -> tuple[str, str] | None:
115
+ value = url.strip()
116
+ scp_match = re.match(r"^[^@\s]+@github\.com:([^/]+)/(.+?)(?:\.git)?$", value, re.IGNORECASE)
117
+ if scp_match:
118
+ return scp_match.group(1).lower(), re.sub(r"\.git$", "", scp_match.group(2), flags=re.IGNORECASE).lower()
119
+
120
+ parsed = urllib.parse.urlparse(value)
121
+ if parsed.hostname and parsed.hostname.lower() == "github.com":
122
+ parts = [part for part in parsed.path.strip("/").split("/") if part]
123
+ if len(parts) == 2:
124
+ return parts[0].lower(), re.sub(r"\.git$", "", parts[1], flags=re.IGNORECASE).lower()
125
+ return None
126
+
127
+
128
+ def repository_remotes(repository: Path) -> set[tuple[str, str]]:
129
+ result = command(["git", "-C", str(repository), "remote", "-v"])
130
+ if result.returncode != 0:
131
+ return set()
132
+ remotes: set[tuple[str, str]] = set()
133
+ for line in result.stdout.splitlines():
134
+ fields = line.split()
135
+ if len(fields) < 2:
136
+ continue
137
+ normalized = normalize_github_remote(fields[1])
138
+ if normalized:
139
+ remotes.add(normalized)
140
+ return remotes
141
+
142
+
143
+ def find_repository(root: Path, organization: str, repository: str) -> Path:
144
+ search_root = root.expanduser().resolve()
145
+ if not search_root.exists():
146
+ raise CleanError(f"Search root does not exist: {search_root}")
147
+ if not search_root.is_dir():
148
+ raise CleanError(f"Search root is not a directory: {search_root}")
149
+ if shutil.which("git") is None:
150
+ raise CleanError("Required command not found: git")
151
+
152
+ target = (organization.lower(), repository.lower())
153
+ matches: list[Path] = []
154
+ for current_text, dirs, files in os.walk(search_root, topdown=True):
155
+ is_repository = ".git" in dirs or ".git" in files
156
+ dirs[:] = sorted(name for name in dirs if name not in SKIP_DIRS)
157
+ if not is_repository:
158
+ continue
159
+ current = Path(current_text)
160
+ if target in repository_remotes(current):
161
+ matches.append(current.resolve())
162
+
163
+ unique_matches = sorted(set(matches))
164
+ full_name = f"{organization}/{repository}"
165
+ if not unique_matches:
166
+ raise CleanError(f"No local GitHub repository matched {full_name} under {search_root}.")
167
+ if len(unique_matches) > 1:
168
+ paths = "\n".join(f" - {path}" for path in unique_matches)
169
+ raise CleanError(f"Multiple local clones matched {full_name}:\n{paths}")
170
+ return unique_matches[0]
171
+
172
+
173
+ def ensure_compose_file(repository: Path) -> None:
174
+ if not any((repository / name).is_file() for name in COMPOSE_FILES):
175
+ names = ", ".join(COMPOSE_FILES)
176
+ raise CleanError(f"No root-level Docker Compose file found in {repository}. Expected one of: {names}")
177
+
178
+
179
+ def ensure_docker(repository: Path) -> None:
180
+ if shutil.which("docker") is None:
181
+ raise CleanError("Required command not found: docker")
182
+ require_success(command(["docker", "compose", "version"], cwd=repository), "Docker Compose is unavailable")
183
+ require_success(
184
+ command(["docker", "info", "--format", "{{.ServerVersion}}"], cwd=repository),
185
+ "Docker daemon is not running or is not accessible",
186
+ )
187
+
188
+
189
+ def compose_config(repository: Path) -> dict[str, Any]:
190
+ output = require_success(
191
+ command(["docker", "compose", "--profile", "*", "config", "--format", "json"], cwd=repository),
192
+ "Could not render the Docker Compose configuration",
193
+ )
194
+ try:
195
+ payload = json.loads(output)
196
+ except json.JSONDecodeError as exc:
197
+ raise CleanError(f"Docker Compose returned invalid JSON: {exc}") from exc
198
+ if not isinstance(payload, dict):
199
+ raise CleanError("Docker Compose returned an unexpected configuration.")
200
+ return payload
201
+
202
+
203
+ def image_namespace(image: str) -> str | None:
204
+ reference = image.split("@", 1)[0]
205
+ last_slash = reference.rfind("/")
206
+ last_colon = reference.rfind(":")
207
+ if last_colon > last_slash:
208
+ reference = reference[:last_colon]
209
+ parts = reference.split("/")
210
+ if len(parts) < 2:
211
+ return None
212
+ first = parts[0]
213
+ registry_present = "." in first or ":" in first or first.lower() == "localhost"
214
+ if registry_present:
215
+ return parts[1].lower() if len(parts) >= 3 else None
216
+ return first.lower()
217
+
218
+
219
+ def organization_images(config: dict[str, Any], organization: str) -> tuple[str, ...]:
220
+ images: set[str] = set()
221
+ services = config.get("services", {})
222
+ if isinstance(services, dict):
223
+ for service in services.values():
224
+ if not isinstance(service, dict):
225
+ continue
226
+ image = service.get("image")
227
+ if isinstance(image, str) and image_namespace(image) == organization.lower():
228
+ images.add(image)
229
+ return tuple(sorted(images))
230
+
231
+
232
+ def volume_sets(config: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
233
+ project: set[str] = set()
234
+ external: set[str] = set()
235
+ volumes = config.get("volumes", {})
236
+ if not isinstance(volumes, dict):
237
+ return (), ()
238
+ for key, value in volumes.items():
239
+ details = value if isinstance(value, dict) else {}
240
+ name = details.get("name", key)
241
+ if not isinstance(name, str):
242
+ continue
243
+ if details.get("external") is True:
244
+ external.add(name)
245
+ else:
246
+ project.add(name)
247
+ return tuple(sorted(project)), tuple(sorted(external))
248
+
249
+
250
+ def configured_ports(config: dict[str, Any]) -> tuple[str, ...]:
251
+ """Return every host port declared by every service, including profiled services."""
252
+ found: set[str] = set()
253
+ services = config.get("services", {})
254
+ if not isinstance(services, dict):
255
+ return ()
256
+ for service in services.values():
257
+ if not isinstance(service, dict):
258
+ continue
259
+ ports = service.get("ports", [])
260
+ if not isinstance(ports, list):
261
+ continue
262
+ for port in ports:
263
+ if not isinstance(port, dict) or port.get("published") in (None, ""):
264
+ continue
265
+ host_ip = port.get("host_ip") or "0.0.0.0"
266
+ protocol = port.get("protocol") or "tcp"
267
+ target = port.get("target")
268
+ destination = f"->{target}/{protocol}" if target not in (None, "") else ""
269
+ found.add(f"{host_ip}:{port['published']}{destination}")
270
+ return tuple(sorted(found))
271
+
272
+
273
+ def compose_container_ids(repository: Path) -> tuple[str, ...]:
274
+ output = require_success(
275
+ command(["docker", "compose", "--profile", "*", "ps", "--all", "--quiet"], cwd=repository),
276
+ "Could not list Docker Compose containers",
277
+ )
278
+ return tuple(line.strip() for line in output.splitlines() if line.strip())
279
+
280
+
281
+ def published_ports(network_settings: Any) -> tuple[str, ...]:
282
+ found: set[str] = set()
283
+ if not isinstance(network_settings, dict):
284
+ return ()
285
+ ports = network_settings.get("Ports", {})
286
+ if not isinstance(ports, dict):
287
+ return ()
288
+ for container_port, bindings in ports.items():
289
+ if not isinstance(bindings, list):
290
+ continue
291
+ for binding in bindings:
292
+ if not isinstance(binding, dict) or not binding.get("HostPort"):
293
+ continue
294
+ host_ip = binding.get("HostIp") or "0.0.0.0"
295
+ found.add(f"{host_ip}:{binding['HostPort']}->{container_port}")
296
+ return tuple(sorted(found))
297
+
298
+
299
+ def inspect_containers(container_ids: tuple[str, ...]) -> tuple[ContainerInfo, ...]:
300
+ if not container_ids:
301
+ return ()
302
+ output = require_success(command(["docker", "inspect", *container_ids]), "Could not inspect Compose containers")
303
+ try:
304
+ payload = json.loads(output)
305
+ except json.JSONDecodeError as exc:
306
+ raise CleanError(f"Docker inspect returned invalid JSON: {exc}") from exc
307
+ containers: list[ContainerInfo] = []
308
+ for item in payload:
309
+ if not isinstance(item, dict):
310
+ continue
311
+ config = item.get("Config") if isinstance(item.get("Config"), dict) else {}
312
+ mounted_volumes = {
313
+ str(mount.get("Name"))
314
+ for mount in item.get("Mounts", [])
315
+ if isinstance(mount, dict) and mount.get("Type") == "volume" and mount.get("Name")
316
+ }
317
+ containers.append(
318
+ ContainerInfo(
319
+ container_id=str(item.get("Id", "")),
320
+ name=str(item.get("Name", "")).lstrip("/"),
321
+ image=str(config.get("Image", "")),
322
+ ports=published_ports(item.get("NetworkSettings")),
323
+ volumes=tuple(sorted(mounted_volumes)),
324
+ )
325
+ )
326
+ return tuple(sorted(containers, key=lambda item: item.name))
327
+
328
+
329
+ def dangling_volumes() -> tuple[str, ...]:
330
+ output = require_success(
331
+ command(["docker", "volume", "ls", "--quiet", "--filter", "dangling=true"]),
332
+ "Could not list dangling Docker volumes",
333
+ )
334
+ return tuple(sorted({line.strip() for line in output.splitlines() if line.strip()}))
335
+
336
+
337
+ def local_images(images: tuple[str, ...]) -> tuple[str, ...]:
338
+ found: list[str] = []
339
+ for image in images:
340
+ result = command(["docker", "image", "inspect", image])
341
+ if result.returncode == 0:
342
+ found.append(image)
343
+ return tuple(found)
344
+
345
+
346
+ def create_plan(repository: Path, organization: str) -> CleanupPlan:
347
+ config = compose_config(repository)
348
+ project_name = str(config.get("name") or repository.name)
349
+ images = organization_images(config, organization)
350
+ project_volumes, external_volumes = volume_sets(config)
351
+ ids = compose_container_ids(repository)
352
+ containers = inspect_containers(ids)
353
+ attached_volumes = {name for container in containers for name in container.volumes}
354
+ removable_volumes = (set(project_volumes) | attached_volumes) - set(external_volumes)
355
+ return CleanupPlan(
356
+ repository=repository,
357
+ project_name=project_name,
358
+ containers=containers,
359
+ declared_ports=configured_ports(config),
360
+ project_volumes=tuple(sorted(removable_volumes)),
361
+ external_volumes=external_volumes,
362
+ dangling_volumes=dangling_volumes(),
363
+ organization_images=images,
364
+ local_organization_images=local_images(images),
365
+ )
366
+
367
+
368
+ def display_items(label: str, items: Sequence[str]) -> None:
369
+ print(f"{label}:")
370
+ if not items:
371
+ print(" (none)")
372
+ return
373
+ for item in items:
374
+ print(f" - {item}")
375
+
376
+
377
+ def display_plan(plan: CleanupPlan, execute: bool) -> None:
378
+ print(f"Mode: {'execute' if execute else 'preview'}")
379
+ print(f"Repository: {plan.repository}")
380
+ print(f"Compose project: {plan.project_name}")
381
+ container_lines = []
382
+ ports: set[str] = set()
383
+ for container in plan.containers:
384
+ container_lines.append(f"{container.name or container.container_id[:12]} ({container.image})")
385
+ ports.update(container.ports)
386
+ ports.update(plan.declared_ports)
387
+ display_items("Compose containers to stop and remove", container_lines)
388
+ display_items("Published ports to free", sorted(ports))
389
+ display_items("Compose volumes to remove", plan.project_volumes)
390
+ display_items("External Compose volumes to preserve", plan.external_volumes)
391
+ dangling = [name for name in plan.dangling_volumes if name not in set(plan.external_volumes)]
392
+ display_items("Dangling volumes to remove", dangling)
393
+ display_items("Organization images referenced by Compose", plan.organization_images)
394
+ display_items("Local organization images to remove", plan.local_organization_images)
395
+
396
+
397
+ def execute_plan(plan: CleanupPlan) -> list[str]:
398
+ failures: list[str] = []
399
+ down = command(
400
+ ["docker", "compose", "--profile", "*", "down", "--volumes", "--remove-orphans"],
401
+ cwd=plan.repository,
402
+ )
403
+ if down.returncode != 0:
404
+ failures.append(f"Compose shutdown failed: {down.stderr or down.stdout or down.returncode}")
405
+
406
+ try:
407
+ current_dangling = dangling_volumes()
408
+ except CleanError as exc:
409
+ failures.append(str(exc))
410
+ current_dangling = ()
411
+ protected = set(plan.external_volumes)
412
+ for volume in current_dangling:
413
+ if volume in protected:
414
+ continue
415
+ result = command(["docker", "volume", "rm", volume])
416
+ if result.returncode != 0:
417
+ failures.append(f"Could not remove volume {volume}: {result.stderr or result.stdout or result.returncode}")
418
+
419
+ for image in plan.local_organization_images:
420
+ result = command(["docker", "image", "rm", "--force", image])
421
+ if result.returncode != 0:
422
+ failures.append(f"Could not remove image {image}: {result.stderr or result.stdout or result.returncode}")
423
+ return failures
424
+
425
+
426
+ def run(args: argparse.Namespace) -> int:
427
+ repository = find_repository(args.root, args.organization, args.repository)
428
+ ensure_compose_file(repository)
429
+ ensure_docker(repository)
430
+ plan = create_plan(repository, args.organization)
431
+ display_plan(plan, execute=not args.dry_run)
432
+ if args.dry_run:
433
+ print("\nDry run complete. No Docker resources were changed.")
434
+ return 0
435
+
436
+ failures = execute_plan(plan)
437
+ if failures:
438
+ print("\nCleanup completed with errors:", file=sys.stderr)
439
+ for failure in failures:
440
+ print(f" - {failure}", file=sys.stderr)
441
+ return 1
442
+ print("\nCleanup completed successfully.")
443
+ return 0
444
+
445
+
446
+ def main(argv: list[str] | None = None) -> int:
447
+ args = build_parser().parse_args(argv)
448
+ try:
449
+ return run(args)
450
+ except CleanError as exc:
451
+ print(f"Error: {exc}", file=sys.stderr)
452
+ return 1
453
+ except KeyboardInterrupt:
454
+ print("\nInterrupted.", file=sys.stderr)
455
+ return 130
456
+
457
+
458
+ if __name__ == "__main__":
459
+ raise SystemExit(main())
@@ -0,0 +1,296 @@
1
+ import argparse
2
+ import importlib.util
3
+ import io
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from contextlib import redirect_stderr, redirect_stdout
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+
13
+ MODULE_PATH = Path(__file__).parents[1] / "src" / "clean_to_execute.py"
14
+ SPEC = importlib.util.spec_from_file_location("clean_to_execute", MODULE_PATH)
15
+ clean = importlib.util.module_from_spec(SPEC)
16
+ assert SPEC.loader is not None
17
+ sys.modules[SPEC.name] = clean
18
+ SPEC.loader.exec_module(clean)
19
+
20
+
21
+ def result(args, returncode=0, stdout="", stderr=""):
22
+ return clean.CommandResult(tuple(args), returncode, stdout, stderr)
23
+
24
+
25
+ class ArgumentTests(unittest.TestCase):
26
+ def test_required_arguments_and_cleanup_default(self):
27
+ args = clean.build_parser().parse_args(["--organization", "Kaizten", "--repository", "tools"])
28
+ self.assertEqual(args.organization, "Kaizten")
29
+ self.assertEqual(args.repository, "tools")
30
+ self.assertFalse(args.dry_run)
31
+
32
+ def test_yes_argument_is_not_supported(self):
33
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
34
+ clean.build_parser().parse_args(["--organization", "kaizten", "--repository", "tools", "--yes"])
35
+ self.assertEqual(raised.exception.code, 2)
36
+
37
+ def test_invalid_name_is_rejected(self):
38
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
39
+ clean.build_parser().parse_args(["--organization", "bad/name", "--repository", "tools"])
40
+
41
+
42
+ class DiscoveryTests(unittest.TestCase):
43
+ def test_normalize_github_remote(self):
44
+ self.assertEqual(clean.normalize_github_remote("git@github.com:Kaizten/Tools.git"), ("kaizten", "tools"))
45
+ self.assertEqual(
46
+ clean.normalize_github_remote("https://github.com/Kaizten/Tools.git"),
47
+ ("kaizten", "tools"),
48
+ )
49
+ self.assertEqual(
50
+ clean.normalize_github_remote("ssh://git@github.com/Kaizten/Tools.git"),
51
+ ("kaizten", "tools"),
52
+ )
53
+ self.assertIsNone(clean.normalize_github_remote("https://gitlab.com/Kaizten/Tools.git"))
54
+
55
+ def test_find_repository_matches_remote_and_ignores_node_modules(self):
56
+ with tempfile.TemporaryDirectory() as directory:
57
+ root = Path(directory)
58
+ wanted = root / "nested" / "checkout"
59
+ ignored = root / "node_modules" / "copy"
60
+ (wanted / ".git").mkdir(parents=True)
61
+ (ignored / ".git").mkdir(parents=True)
62
+
63
+ def remotes(path):
64
+ return {("kaizten", "tools")} if path in {wanted, ignored} else set()
65
+
66
+ with mock.patch.object(clean.shutil, "which", return_value="/usr/bin/git"), mock.patch.object(
67
+ clean, "repository_remotes", side_effect=remotes
68
+ ):
69
+ found = clean.find_repository(root, "KAIZTEN", "TOOLS")
70
+ self.assertEqual(found, wanted)
71
+
72
+ def test_find_repository_reports_duplicates(self):
73
+ with tempfile.TemporaryDirectory() as directory:
74
+ root = Path(directory)
75
+ for name in ("one", "two"):
76
+ (root / name / ".git").mkdir(parents=True)
77
+ with mock.patch.object(clean.shutil, "which", return_value="git"), mock.patch.object(
78
+ clean, "repository_remotes", return_value={("kaizten", "tools")}
79
+ ):
80
+ with self.assertRaisesRegex(clean.CleanError, "Multiple local clones"):
81
+ clean.find_repository(root, "kaizten", "tools")
82
+
83
+ def test_find_repository_reports_missing_match(self):
84
+ with tempfile.TemporaryDirectory() as directory, mock.patch.object(clean.shutil, "which", return_value="git"):
85
+ with self.assertRaisesRegex(clean.CleanError, "No local GitHub repository matched"):
86
+ clean.find_repository(Path(directory), "kaizten", "missing")
87
+
88
+
89
+ class ComposeParsingTests(unittest.TestCase):
90
+ def test_image_namespace_across_registries(self):
91
+ cases = {
92
+ "kaizten/api:latest": "kaizten",
93
+ "docker.io/kaizten/api:1": "kaizten",
94
+ "ghcr.io/Kaizten/api@sha256:abc": "kaizten",
95
+ "localhost:5000/kaizten/team/api": "kaizten",
96
+ "postgres:17": None,
97
+ "ghcr.io/postgres": None,
98
+ }
99
+ for reference, expected in cases.items():
100
+ with self.subTest(reference=reference):
101
+ self.assertEqual(clean.image_namespace(reference), expected)
102
+
103
+ def test_organization_images_excludes_other_namespaces(self):
104
+ config = {
105
+ "services": {
106
+ "api": {"image": "ghcr.io/kaizten/api:latest"},
107
+ "worker": {"image": "kaizten/worker:1"},
108
+ "similar": {"image": "ghcr.io/kaizten-labs/api:latest"},
109
+ "db": {"image": "postgres:17"},
110
+ }
111
+ }
112
+ self.assertEqual(
113
+ clean.organization_images(config, "Kaizten"),
114
+ ("ghcr.io/kaizten/api:latest", "kaizten/worker:1"),
115
+ )
116
+
117
+ def test_volume_sets_separates_external_volumes(self):
118
+ config = {
119
+ "volumes": {
120
+ "data": {"name": "project_data"},
121
+ "shared": {"name": "company_shared", "external": True},
122
+ "cache": None,
123
+ }
124
+ }
125
+ project, external = clean.volume_sets(config)
126
+ self.assertEqual(project, ("cache", "project_data"))
127
+ self.assertEqual(external, ("company_shared",))
128
+
129
+ def test_published_ports_formats_bindings(self):
130
+ settings = {
131
+ "Ports": {
132
+ "80/tcp": [{"HostIp": "127.0.0.1", "HostPort": "8080"}],
133
+ "443/tcp": [{"HostIp": "", "HostPort": "8443"}],
134
+ "9000/tcp": None,
135
+ }
136
+ }
137
+ self.assertEqual(
138
+ clean.published_ports(settings),
139
+ ("0.0.0.0:8443->443/tcp", "127.0.0.1:8080->80/tcp"),
140
+ )
141
+
142
+ def test_configured_ports_includes_services_behind_profiles(self):
143
+ config = {
144
+ "services": {
145
+ "frontend": {
146
+ "profiles": ["full"],
147
+ "ports": [{"target": 80, "published": "80", "protocol": "tcp", "mode": "ingress"}],
148
+ },
149
+ "backend": {
150
+ "profiles": ["back-end", "full"],
151
+ "ports": [
152
+ {
153
+ "target": 8080,
154
+ "published": "8080",
155
+ "host_ip": "127.0.0.1",
156
+ "protocol": "tcp",
157
+ }
158
+ ],
159
+ },
160
+ }
161
+ }
162
+ self.assertEqual(
163
+ clean.configured_ports(config),
164
+ ("0.0.0.0:80->80/tcp", "127.0.0.1:8080->8080/tcp"),
165
+ )
166
+
167
+ def test_inspect_containers_includes_anonymous_and_named_volume_mounts(self):
168
+ payload = [
169
+ {
170
+ "Id": "abc",
171
+ "Name": "/demo-api-1",
172
+ "Config": {"Image": "kaizten/api"},
173
+ "NetworkSettings": {"Ports": {}},
174
+ "Mounts": [
175
+ {"Type": "volume", "Name": "demo_data"},
176
+ {"Type": "bind", "Source": "/tmp/source"},
177
+ {"Type": "volume", "Name": "anonymous-id"},
178
+ ],
179
+ }
180
+ ]
181
+ with mock.patch.object(clean, "command", return_value=result([], stdout=json.dumps(payload))):
182
+ containers = clean.inspect_containers(("abc",))
183
+ self.assertEqual(containers[0].volumes, ("anonymous-id", "demo_data"))
184
+
185
+
186
+ class WorkflowTests(unittest.TestCase):
187
+ def make_plan(self, repository: Path):
188
+ return clean.CleanupPlan(
189
+ repository=repository,
190
+ project_name="demo",
191
+ containers=(clean.ContainerInfo("abc", "demo-api-1", "kaizten/api", ("0.0.0.0:8080->80/tcp",)),),
192
+ declared_ports=("0.0.0.0:80->80/tcp",),
193
+ project_volumes=("demo_data",),
194
+ external_volumes=("shared",),
195
+ dangling_volumes=("old", "shared"),
196
+ organization_images=("kaizten/api",),
197
+ local_organization_images=("kaizten/api",),
198
+ )
199
+
200
+ def test_dry_run_performs_no_cleanup(self):
201
+ args = argparse.Namespace(organization="kaizten", repository="tools", root=Path("~"), dry_run=True)
202
+ plan = self.make_plan(Path("/repo"))
203
+ with mock.patch.object(clean, "find_repository", return_value=Path("/repo")), mock.patch.object(
204
+ clean, "ensure_compose_file"
205
+ ), mock.patch.object(clean, "ensure_docker"), mock.patch.object(
206
+ clean, "create_plan", return_value=plan
207
+ ), mock.patch.object(clean, "execute_plan") as execute, redirect_stdout(io.StringIO()) as output:
208
+ status = clean.run(args)
209
+ self.assertEqual(status, 0)
210
+ execute.assert_not_called()
211
+ self.assertIn("Dry run complete", output.getvalue())
212
+
213
+ def test_default_invocation_executes_cleanup(self):
214
+ args = argparse.Namespace(organization="kaizten", repository="tools", root=Path("~"), dry_run=False)
215
+ plan = self.make_plan(Path("/repo"))
216
+ with mock.patch.object(clean, "find_repository", return_value=Path("/repo")), mock.patch.object(
217
+ clean, "ensure_compose_file"
218
+ ), mock.patch.object(clean, "ensure_docker"), mock.patch.object(
219
+ clean, "create_plan", return_value=plan
220
+ ), mock.patch.object(clean, "execute_plan", return_value=[]) as execute, redirect_stdout(io.StringIO()):
221
+ status = clean.run(args)
222
+ self.assertEqual(status, 0)
223
+ execute.assert_called_once_with(plan)
224
+
225
+ def test_execute_uses_compose_down_preserves_external_and_forces_images(self):
226
+ plan = self.make_plan(Path("/repo"))
227
+ calls = []
228
+
229
+ def fake_command(args, cwd=None):
230
+ calls.append((tuple(args), cwd))
231
+ return result(args)
232
+
233
+ with mock.patch.object(clean, "command", side_effect=fake_command), mock.patch.object(
234
+ clean, "dangling_volumes", return_value=("old", "shared", "new")
235
+ ):
236
+ failures = clean.execute_plan(plan)
237
+
238
+ self.assertEqual(failures, [])
239
+ self.assertIn(
240
+ (("docker", "compose", "--profile", "*", "down", "--volumes", "--remove-orphans")),
241
+ [item[0] for item in calls],
242
+ )
243
+ self.assertIn(("docker", "volume", "rm", "old"), [item[0] for item in calls])
244
+ self.assertIn(("docker", "volume", "rm", "new"), [item[0] for item in calls])
245
+ self.assertNotIn(("docker", "volume", "rm", "shared"), [item[0] for item in calls])
246
+ self.assertIn(("docker", "image", "rm", "--force", "kaizten/api"), [item[0] for item in calls])
247
+
248
+ def test_execute_aggregates_failures_and_continues(self):
249
+ plan = self.make_plan(Path("/repo"))
250
+
251
+ def fake_command(args, cwd=None):
252
+ if args[1:2] == ["compose"] and "down" in args:
253
+ return result(args, 1, stderr="down failed")
254
+ if args[1:3] == ["volume", "rm"]:
255
+ return result(args, 1, stderr="volume failed")
256
+ if args[1:3] == ["image", "rm"]:
257
+ return result(args, 1, stderr="image failed")
258
+ return result(args)
259
+
260
+ with mock.patch.object(clean, "command", side_effect=fake_command), mock.patch.object(
261
+ clean, "dangling_volumes", return_value=("old",)
262
+ ):
263
+ failures = clean.execute_plan(plan)
264
+ self.assertEqual(len(failures), 3)
265
+
266
+ def test_main_reports_clean_error(self):
267
+ with mock.patch.object(clean, "run", side_effect=clean.CleanError("broken")), redirect_stderr(
268
+ io.StringIO()
269
+ ) as error:
270
+ status = clean.main(["--organization", "kaizten", "--repository", "tools"])
271
+ self.assertEqual(status, 1)
272
+ self.assertIn("Error: broken", error.getvalue())
273
+
274
+ def test_compose_config_rejects_invalid_json(self):
275
+ with mock.patch.object(clean, "command", return_value=result([], stdout="not-json")):
276
+ with self.assertRaisesRegex(clean.CleanError, "invalid JSON"):
277
+ clean.compose_config(Path("/repo"))
278
+
279
+ def test_compose_inspection_activates_all_profiles(self):
280
+ with mock.patch.object(clean, "command", return_value=result([], stdout='{"services": {}}')) as run_command:
281
+ clean.compose_config(Path("/repo"))
282
+ self.assertEqual(
283
+ run_command.call_args.args[0],
284
+ ["docker", "compose", "--profile", "*", "config", "--format", "json"],
285
+ )
286
+
287
+ with mock.patch.object(clean, "command", return_value=result([], stdout="")) as run_command:
288
+ clean.compose_container_ids(Path("/repo"))
289
+ self.assertEqual(
290
+ run_command.call_args.args[0],
291
+ ["docker", "compose", "--profile", "*", "ps", "--all", "--quiet"],
292
+ )
293
+
294
+
295
+ if __name__ == "__main__":
296
+ unittest.main()