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.
- clean_to_execute-0.0.1/PKG-INFO +101 -0
- clean_to_execute-0.0.1/README.md +90 -0
- clean_to_execute-0.0.1/pyproject.toml +23 -0
- clean_to_execute-0.0.1/setup.cfg +4 -0
- clean_to_execute-0.0.1/src/clean_to_execute.egg-info/PKG-INFO +101 -0
- clean_to_execute-0.0.1/src/clean_to_execute.egg-info/SOURCES.txt +9 -0
- clean_to_execute-0.0.1/src/clean_to_execute.egg-info/dependency_links.txt +1 -0
- clean_to_execute-0.0.1/src/clean_to_execute.egg-info/entry_points.txt +2 -0
- clean_to_execute-0.0.1/src/clean_to_execute.egg-info/top_level.txt +1 -0
- clean_to_execute-0.0.1/src/clean_to_execute.py +459 -0
- clean_to_execute-0.0.1/tests/test_clean_to_execute.py +296 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|