docker-image-cleanup 0.1.0__tar.gz → 0.1.2__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.
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: docker-image-cleanup
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Clean up old Docker images from local storage with retention controls
5
5
  Keywords: docker,cleanup,images,disk-space
6
6
  Author: Michael Bianco
7
7
  Author-email: Michael Bianco <mike@mikebian.co>
8
8
  Requires-Dist: click>=8.3.0
9
9
  Requires-Dist: docker>=7.1.0
10
- Requires-Dist: structlog-config==0.6.0
10
+ Requires-Dist: pydantic>=2.12.5
11
+ Requires-Dist: structlog-config>=0.6.0
11
12
  Requires-Dist: whenever>=0.9.3
12
13
  Requires-Python: >=3.11
13
14
  Project-URL: Repository, https://github.com/iloveitaly/docker-image-cleanup
@@ -23,12 +24,6 @@ This tool is designed for managing images from self-hosted registries and probab
23
24
 
24
25
  ## Installation
25
26
 
26
- ```bash
27
- pip install docker-image-cleanup
28
- ```
29
-
30
- Or using `uv`:
31
-
32
27
  ```bash
33
28
  uv tool install docker-image-cleanup
34
29
  ```
@@ -66,6 +61,45 @@ docker-image-cleanup --min-age-days 7 myrepo/myimage
66
61
  docker-image-cleanup --num-recent 3 --min-age-days 7 myrepo/myimage
67
62
  ```
68
63
 
64
+ ## Docker
65
+
66
+ This tool is available as a Docker image on GHCR. It's designed to run on a schedule using a built-in cron system.
67
+
68
+ ### Environment Variables
69
+
70
+ - `TARGET_REPOS`: A space-separated list of image repositories to clean (e.g., `myrepo/image1 myrepo/image2`). **Required**.
71
+ - `SCHEDULE`: A cron expression for the cleanup schedule (default: `0 0 * * *` - daily at midnight).
72
+ - `NUM_RECENT`: Number of recent tags to keep (default: `5`).
73
+ - `MIN_AGE_DAYS`: Minimum age in days to keep tags (default: `30`).
74
+
75
+ ### Docker Run
76
+
77
+ You must mount the Docker socket so the container can interact with your local Docker daemon:
78
+
79
+ ```bash
80
+ docker run -d \
81
+ --name docker-image-cleanup \
82
+ -v /var/run/docker.sock:/var/run/docker.sock \
83
+ -e TARGET_REPOS="myrepo/image1 myrepo/image2" \
84
+ ghcr.io/iloveitaly/docker-image-cleanup:latest
85
+ ```
86
+
87
+ ### Docker Compose
88
+
89
+ ```yaml
90
+ services:
91
+ cleanup:
92
+ image: ghcr.io/iloveitaly/docker-image-cleanup:latest
93
+ restart: unless-stopped
94
+ volumes:
95
+ - /var/run/docker.sock:/var/run/docker.sock
96
+ environment:
97
+ - TARGET_REPOS=myrepo/image1 myrepo/image2
98
+ - SCHEDULE=0 0 * * *
99
+ - NUM_RECENT=5
100
+ - MIN_AGE_DAYS=30
101
+ ```
102
+
69
103
  ## Features
70
104
 
71
105
  - Removes old Docker images based on age and recency criteria
@@ -88,27 +122,4 @@ The cleanup process is straightforward:
88
122
 
89
123
  Images with multiple tags are handled intelligently. If all tags on an image would be removed, the entire image is deleted. If only some tags would be removed, those tags are untagged but the image remains.
90
124
 
91
- ## Requirements
92
-
93
- - Python 3.11+
94
- - Docker daemon running locally
95
- - Docker Python SDK
96
-
97
- ## Development
98
-
99
- ```bash
100
- # Clone the repository
101
- git clone https://github.com/iloveitaly/docker-image-cleanup
102
- cd docker-image-cleanup
103
-
104
- # Install dependencies
105
- uv sync
106
-
107
- # Run tests
108
- pytest
109
-
110
- # Run the CLI
111
- uv run docker-image-cleanup --help
112
- ```
113
-
114
125
  # [MIT License](LICENSE.md)
@@ -8,12 +8,6 @@ This tool is designed for managing images from self-hosted registries and probab
8
8
 
9
9
  ## Installation
10
10
 
11
- ```bash
12
- pip install docker-image-cleanup
13
- ```
14
-
15
- Or using `uv`:
16
-
17
11
  ```bash
18
12
  uv tool install docker-image-cleanup
19
13
  ```
@@ -51,6 +45,45 @@ docker-image-cleanup --min-age-days 7 myrepo/myimage
51
45
  docker-image-cleanup --num-recent 3 --min-age-days 7 myrepo/myimage
52
46
  ```
53
47
 
48
+ ## Docker
49
+
50
+ This tool is available as a Docker image on GHCR. It's designed to run on a schedule using a built-in cron system.
51
+
52
+ ### Environment Variables
53
+
54
+ - `TARGET_REPOS`: A space-separated list of image repositories to clean (e.g., `myrepo/image1 myrepo/image2`). **Required**.
55
+ - `SCHEDULE`: A cron expression for the cleanup schedule (default: `0 0 * * *` - daily at midnight).
56
+ - `NUM_RECENT`: Number of recent tags to keep (default: `5`).
57
+ - `MIN_AGE_DAYS`: Minimum age in days to keep tags (default: `30`).
58
+
59
+ ### Docker Run
60
+
61
+ You must mount the Docker socket so the container can interact with your local Docker daemon:
62
+
63
+ ```bash
64
+ docker run -d \
65
+ --name docker-image-cleanup \
66
+ -v /var/run/docker.sock:/var/run/docker.sock \
67
+ -e TARGET_REPOS="myrepo/image1 myrepo/image2" \
68
+ ghcr.io/iloveitaly/docker-image-cleanup:latest
69
+ ```
70
+
71
+ ### Docker Compose
72
+
73
+ ```yaml
74
+ services:
75
+ cleanup:
76
+ image: ghcr.io/iloveitaly/docker-image-cleanup:latest
77
+ restart: unless-stopped
78
+ volumes:
79
+ - /var/run/docker.sock:/var/run/docker.sock
80
+ environment:
81
+ - TARGET_REPOS=myrepo/image1 myrepo/image2
82
+ - SCHEDULE=0 0 * * *
83
+ - NUM_RECENT=5
84
+ - MIN_AGE_DAYS=30
85
+ ```
86
+
54
87
  ## Features
55
88
 
56
89
  - Removes old Docker images based on age and recency criteria
@@ -73,27 +106,4 @@ The cleanup process is straightforward:
73
106
 
74
107
  Images with multiple tags are handled intelligently. If all tags on an image would be removed, the entire image is deleted. If only some tags would be removed, those tags are untagged but the image remains.
75
108
 
76
- ## Requirements
77
-
78
- - Python 3.11+
79
- - Docker daemon running locally
80
- - Docker Python SDK
81
-
82
- ## Development
83
-
84
- ```bash
85
- # Clone the repository
86
- git clone https://github.com/iloveitaly/docker-image-cleanup
87
- cd docker-image-cleanup
88
-
89
- # Install dependencies
90
- uv sync
91
-
92
- # Run tests
93
- pytest
94
-
95
- # Run the CLI
96
- uv run docker-image-cleanup --help
97
- ```
98
-
99
109
  # [MIT License](LICENSE.md)
@@ -0,0 +1,286 @@
1
+ """
2
+ Cleans up old Docker images from local storage with retention controls.
3
+ """
4
+
5
+ from collections import defaultdict
6
+ from typing import Any
7
+
8
+ import click
9
+ import docker
10
+ import docker.errors
11
+ from pydantic import BaseModel, Field
12
+ from structlog_config import configure_logger
13
+ from whenever import Instant
14
+
15
+ log = configure_logger()
16
+
17
+
18
+ class CleanupConfig(BaseModel):
19
+ """Configuration for cleaning up Docker images."""
20
+
21
+ num_recent: int = Field(5, description="Number of recent tags to keep.")
22
+ min_age_days: int = Field(30, description="Minimum age in days to keep tags.")
23
+ dry_run: bool = Field(False, description="Simulate removal without executing.")
24
+
25
+
26
+ class ImageInfo(BaseModel):
27
+ """Abstract representation of a Docker image for logic processing."""
28
+
29
+ id: str
30
+ tags: list[str]
31
+ created: Instant
32
+ size: int
33
+ is_in_use: bool = False
34
+
35
+
36
+ def format_size(size_bytes: int) -> str:
37
+ """Converts bytes to a human-readable string (KB, MB, GB, etc.)."""
38
+ size = float(size_bytes)
39
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
40
+ if size < 1024:
41
+ return f"{size:.2f} {unit}"
42
+ size /= 1024
43
+ return f"{size:.2f} PB"
44
+
45
+
46
+ def parse_docker_image(
47
+ image_data: Any,
48
+ used_image_ids: set[
49
+ str
50
+ ], # Use Any due to pyright resolution issues with docker library types
51
+ ) -> ImageInfo:
52
+ """Parses docker.models.images.Image into our ImageInfo model."""
53
+ created_instant = Instant.parse_iso(image_data.attrs["Created"])
54
+ image_id = image_data.id
55
+
56
+ return ImageInfo(
57
+ id=image_id,
58
+ tags=image_data.tags or [],
59
+ created=created_instant,
60
+ size=image_data.attrs.get("Size", 0),
61
+ is_in_use=image_id in used_image_ids,
62
+ )
63
+
64
+
65
+ def get_images_to_process(
66
+ client: docker.DockerClient, repo_name: str, used_image_ids: set[str]
67
+ ) -> list[ImageInfo]:
68
+ """Fetches and parses Docker images for a given repository."""
69
+ images = client.images.list(name=repo_name)
70
+ if not images:
71
+ log.info("no images found for repository", repo=repo_name)
72
+ return []
73
+
74
+ parsed_images = [parse_docker_image(img, used_image_ids) for img in images]
75
+ return sorted(parsed_images, key=lambda img: img.created, reverse=True)
76
+
77
+
78
+ def determine_cleanup_actions(
79
+ images: list[ImageInfo], config: CleanupConfig, now: Instant, repo_name: str
80
+ ) -> dict[str, Any]:
81
+ """Determines which images/tags to remove based on criteria."""
82
+ if not images:
83
+ return {"images_to_delete": [], "tags_to_remove": [], "total_size_saved": 0}
84
+
85
+ keep_tags: set[str] = set()
86
+ for img in images[: config.num_recent]:
87
+ keep_tags.update(img.tags)
88
+
89
+ min_age_threshold = now.subtract(hours=config.min_age_days * 24)
90
+ for img in images:
91
+ if img.created >= min_age_threshold:
92
+ keep_tags.update(img.tags)
93
+
94
+ images_to_delete_ids: set[str] = set()
95
+ tags_to_remove: list[str] = []
96
+
97
+ images_by_id: dict[str, list[ImageInfo]] = defaultdict(list)
98
+ for img in images:
99
+ images_by_id[img.id].append(img)
100
+
101
+ total_size_saved = 0
102
+ repo_prefix = repo_name + ":"
103
+
104
+ for img_id, img_list in images_by_id.items():
105
+ all_image_tags = set()
106
+ for img in img_list:
107
+ all_image_tags.update(img.tags)
108
+
109
+ current_image_repo_tags = {
110
+ tag for tag in all_image_tags if tag.startswith(repo_prefix)
111
+ }
112
+
113
+ tags_for_this_image_to_remove = [
114
+ tag for tag in current_image_repo_tags if tag not in keep_tags
115
+ ]
116
+
117
+ if tags_for_this_image_to_remove:
118
+ tags_to_remove.extend(tags_for_this_image_to_remove)
119
+
120
+ if set(tags_for_this_image_to_remove) == current_image_repo_tags:
121
+ img_info = img_list[0]
122
+ if not img_info.is_in_use:
123
+ images_to_delete_ids.add(img_id)
124
+ total_size_saved += img_info.size
125
+ else:
126
+ log.warning(
127
+ "skipped image in use",
128
+ image_id=img_id,
129
+ tags=tags_for_this_image_to_remove,
130
+ )
131
+
132
+ return {
133
+ "images_to_delete": list(images_to_delete_ids),
134
+ "tags_to_remove": list(set(tags_to_remove)),
135
+ "total_size_saved": total_size_saved,
136
+ }
137
+
138
+
139
+ def execute_cleanup(
140
+ client: docker.DockerClient,
141
+ images_to_delete: list[str],
142
+ tags_to_remove: list[str],
143
+ total_size_saved: int,
144
+ dry_run: bool,
145
+ ) -> int:
146
+ """Executes the determined cleanup actions."""
147
+ executed_size_saved = 0
148
+
149
+ if dry_run:
150
+ if tags_to_remove:
151
+ log.info("would remove tags", tags=tags_to_remove)
152
+ if images_to_delete:
153
+ log.info("would remove images", image_ids=images_to_delete)
154
+
155
+ log.info(
156
+ "total space that would be saved",
157
+ bytes=total_size_saved,
158
+ human=format_size(total_size_saved),
159
+ )
160
+ return total_size_saved
161
+
162
+ for tag in tags_to_remove:
163
+ try:
164
+ client.images.remove(tag, force=False)
165
+ log.info("untagged image", tag=tag)
166
+ except docker.errors.APIError as e:
167
+ log.warning("skipped untagging", tag=tag, reason=str(e))
168
+
169
+ for img_id in images_to_delete:
170
+ try:
171
+ img = client.images.get(img_id)
172
+ size = img.attrs.get("Size", 0)
173
+ client.images.remove(img_id, force=False)
174
+ log.info("removed image", image_id=img_id, size=format_size(size))
175
+ executed_size_saved += size
176
+ except docker.errors.ImageNotFound:
177
+ log.warning("image already removed", image_id=img_id)
178
+ except docker.errors.APIError as e:
179
+ log.warning("skipped image removal", image_id=img_id, reason=str(e))
180
+
181
+ log.info(
182
+ "total space saved",
183
+ bytes=executed_size_saved,
184
+ human=format_size(executed_size_saved),
185
+ )
186
+ return executed_size_saved
187
+
188
+
189
+ @click.command()
190
+ @click.argument("image_repos", nargs=-1, required=True)
191
+ @click.option(
192
+ "--num-recent",
193
+ default=5,
194
+ type=int,
195
+ help="Number of recent tags to keep.",
196
+ show_default=True,
197
+ )
198
+ @click.option(
199
+ "--min-age-days",
200
+ default=30,
201
+ type=int,
202
+ help="Minimum age in days to keep tags.",
203
+ show_default=True,
204
+ )
205
+ @click.option(
206
+ "--dry-run",
207
+ is_flag=True,
208
+ default=False,
209
+ help="Simulate removal without executing.",
210
+ show_default=True,
211
+ )
212
+ def main(
213
+ image_repos: tuple[str, ...], num_recent: int, min_age_days: int, dry_run: bool
214
+ ):
215
+ """
216
+ Cleans up old Docker images from local storage with retention controls.
217
+
218
+ IMAGE_REPOS are the repository names to clean (e.g., 'my/app', 'ubuntu').
219
+ """
220
+ try:
221
+ client = docker.from_env()
222
+ now = Instant.now()
223
+
224
+ try:
225
+ all_containers = client.containers.list(all=True)
226
+ used_image_ids: set[str] = {
227
+ container.image.id
228
+ for container in all_containers
229
+ if container.image and container.image.id
230
+ }
231
+ log.debug("determined used image IDs", count=len(used_image_ids))
232
+ except docker.errors.APIError as e:
233
+ log.error(
234
+ "failed to list containers, cannot determine in-use images",
235
+ reason=str(e),
236
+ )
237
+ used_image_ids = set()
238
+
239
+ config = CleanupConfig(
240
+ num_recent=num_recent,
241
+ min_age_days=min_age_days,
242
+ dry_run=dry_run,
243
+ )
244
+
245
+ grand_total_saved = 0
246
+ for repo_name in image_repos:
247
+ log.info("processing repository", repo=repo_name)
248
+
249
+ images = get_images_to_process(client, repo_name, used_image_ids)
250
+
251
+ if not images:
252
+ continue
253
+
254
+ actions = determine_cleanup_actions(images, config, now, repo_name)
255
+
256
+ executed_bytes_saved = execute_cleanup(
257
+ client=client,
258
+ images_to_delete=actions["images_to_delete"],
259
+ tags_to_remove=actions["tags_to_remove"],
260
+ total_size_saved=actions["total_size_saved"],
261
+ dry_run=config.dry_run,
262
+ )
263
+ grand_total_saved += executed_bytes_saved
264
+
265
+ if len(image_repos) > 1:
266
+ result_msg = (
267
+ "grand total space that would be saved"
268
+ if dry_run
269
+ else "grand total space saved"
270
+ )
271
+ log.info(
272
+ result_msg,
273
+ bytes=grand_total_saved,
274
+ human=format_size(grand_total_saved),
275
+ )
276
+
277
+ except docker.errors.DockerException as e:
278
+ log.error("failed to connect to Docker daemon", reason=str(e))
279
+ exit(1)
280
+ except Exception:
281
+ log.exception("an unexpected error occurred")
282
+ exit(1)
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-image-cleanup"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Clean up old Docker images from local storage with retention controls"
5
5
  keywords = ["docker", "cleanup", "images", "disk-space"]
6
6
  readme = "README.md"
@@ -8,7 +8,8 @@ requires-python = ">=3.11"
8
8
  dependencies = [
9
9
  "click>=8.3.0",
10
10
  "docker>=7.1.0",
11
- "structlog-config==0.6.0",
11
+ "pydantic>=2.12.5",
12
+ "structlog-config>=0.6.0",
12
13
  "whenever>=0.9.3",
13
14
  ]
14
15
  authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
@@ -19,7 +20,7 @@ urls = { "Repository" = "https://github.com/iloveitaly/docker-image-cleanup" }
19
20
  docker-image-cleanup = "docker_image_cleanup:main"
20
21
 
21
22
  [build-system]
22
- requires = ["uv_build>=0.8.11,<0.9.0"]
23
+ requires = ["uv_build>=0.9.0,<0.10.0"]
23
24
  build-backend = "uv_build"
24
25
 
25
26
  [tool.uv.build-backend]
@@ -27,5 +28,10 @@ build-backend = "uv_build"
27
28
  module-root = ""
28
29
 
29
30
  [dependency-groups]
30
- dev = ["pytest>=8.3.3","pyright>=1.1.380","ruff>=0.8.0"]
31
+ dev = ["pytest>=8.3.3", "pyright[nodejs]>=1.1.380", "ruff>=0.8.0"]
31
32
 
33
+ [tool.pyright]
34
+ exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
35
+
36
+ [tool.ruff]
37
+ extend-exclude = ["playground.py", "playground/"]
@@ -1,132 +0,0 @@
1
- import click
2
- import docker
3
- import docker.errors
4
- import structlog
5
- from collections import defaultdict
6
- from whenever import Instant
7
- from structlog_config import configure_logger
8
-
9
- configure_logger()
10
- log = structlog.get_logger()
11
-
12
-
13
- def human_size(size_bytes):
14
- size = size_bytes
15
- for unit in ["B", "KB", "MB", "GB", "TB"]:
16
- if size < 1024:
17
- return f"{size:.2f} {unit}"
18
- size /= 1024
19
- return f"{size:.2f} PB"
20
-
21
-
22
- def clean_repo(client, image_repo, num_recent, min_age_days, dry_run, now):
23
- images = client.images.list(name=image_repo)
24
- if not images:
25
- log.info("no images", repo=image_repo)
26
- return 0
27
-
28
- sorted_images = sorted(
29
- images, key=lambda img: Instant.parse_iso(img.attrs["Created"]), reverse=True
30
- )
31
-
32
- keep_tags = set()
33
- for img in sorted_images[:num_recent]:
34
- keep_tags.update(img.tags)
35
-
36
- min_age = now.subtract(hours=min_age_days * 24)
37
- for img in images:
38
- created = Instant.parse_iso(img.attrs["Created"])
39
- log.debug("inspecting image", image_id=img.id, tags=img.tags, created=created)
40
- if created >= min_age:
41
- keep_tags.update(img.tags)
42
-
43
- tags_to_remove = []
44
- for img in images:
45
- for tag in img.tags:
46
- if tag not in keep_tags and tag.startswith(image_repo + ":"):
47
- tags_to_remove.append((tag, img))
48
-
49
- remove_by_id = defaultdict(list)
50
- for tag, img in tags_to_remove:
51
- remove_by_id[img.id].append(tag)
52
-
53
- total_saved = 0
54
- for img_id, remove_tags in remove_by_id.items():
55
- img = client.images.get(img_id)
56
- all_tags = img.tags
57
- if set(remove_tags) == set(all_tags):
58
- in_use = (
59
- len(client.containers.list(all=True, filters={"ancestor": img_id})) > 0
60
- )
61
- if in_use:
62
- log.warning("skipped image in use", image_id=img_id, tags=remove_tags)
63
- continue
64
- size = img.attrs.get("Size", 0)
65
- if dry_run:
66
- log.info(
67
- "would remove image",
68
- image_id=img_id,
69
- tags=remove_tags,
70
- size=human_size(size),
71
- )
72
- else:
73
- for tag in remove_tags:
74
- client.images.remove(tag, force=False)
75
- log.info(
76
- "removed image",
77
- image_id=img_id,
78
- tags=remove_tags,
79
- size=human_size(size),
80
- )
81
- total_saved += size
82
- else:
83
- if dry_run:
84
- log.info("would untag", tags=remove_tags)
85
- else:
86
- for tag in remove_tags:
87
- try:
88
- client.images.remove(tag, force=False)
89
- log.info("untagged", tag=tag)
90
- except docker.errors.APIError as e:
91
- log.warning("skipped untag", tag=tag, reason=str(e))
92
-
93
- msg = "total space that would be saved" if dry_run else "total space saved"
94
- log.info(msg, bytes=total_saved, human=human_size(total_saved))
95
- return total_saved
96
-
97
-
98
- @click.command()
99
- @click.argument("image_repos", nargs=-1, required=True)
100
- @click.option(
101
- "--num-recent", default=5, help="Number of recent tags to keep.", show_default=True
102
- )
103
- @click.option(
104
- "--min-age-days",
105
- default=30,
106
- help="Minimum age in days to keep tags.",
107
- show_default=True,
108
- )
109
- @click.option(
110
- "--dry-run",
111
- is_flag=True,
112
- help="Simulate removal without executing.",
113
- show_default=True,
114
- )
115
- def main(image_repos, num_recent, min_age_days, dry_run):
116
- client = docker.from_env()
117
- now = Instant.now()
118
-
119
- grand_total = 0
120
- for image_repo in image_repos:
121
- log.info("cleaning repo", repo=image_repo)
122
- grand_total += clean_repo(
123
- client, image_repo, num_recent, min_age_days, dry_run, now
124
- )
125
-
126
- if len(image_repos) > 1:
127
- msg = (
128
- "grand total space that would be saved"
129
- if dry_run
130
- else "grand total space saved"
131
- )
132
- log.info(msg, bytes=grand_total, human=human_size(grand_total))