#!/usr/bin/env python3 """Prune stale runner images from a rootless Docker daemon. Usage: python3 prune_runner_images.py [--dry-run] Removes all images matching the runner-images pattern from the local Docker daemon so the runner pulls a fresh :latest on the next job. Environment variables: DOCKER_HOST — Docker daemon socket (set by caller) XDG_RUNTIME_DIR — Runtime directory (set by caller) """ from __future__ import annotations import argparse import re import subprocess # nosec B404 import sys from collections.abc import Sequence #: Pattern for images we want to prune (repository:tag format). IMAGE_PATTERN = re.compile(r"runner-images/(ci-base|ci-quality|ci-full)") def list_docker_images() -> list[str]: """List all images in the local Docker daemon as repository:tag strings. Returns: List of ``repository:tag`` strings (excluding ```` entries). """ result = subprocess.run( # nosec B603 ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], capture_output=True, text=True, check=True, ) return [line.strip() for line in result.stdout.splitlines() if line.strip() and "" not in line] def filter_runner_images(images: Sequence[str]) -> list[str]: """Filter image list to only runner-images entries. Args: images: List of ``repository:tag`` strings. Returns: Subset matching the runner-images pattern. """ return [img for img in images if IMAGE_PATTERN.search(img)] def remove_images(images: Sequence[str], dry_run: bool = False) -> list[str]: """Remove the given images from the local Docker daemon. Args: images: List of ``repository:tag`` strings to remove. dry_run: If True, print what would be removed but don't execute. Returns: List of images that were removed (or would be removed in dry-run). """ removed: list[str] = [] for img in images: if dry_run: print(f"[dry-run] would remove: {img}") removed.append(img) continue result = subprocess.run( # nosec B603 ["docker", "rmi", "-f", img], capture_output=True, text=True, ) if result.returncode == 0: print(f"removed: {img}") removed.append(img) else: print(f"failed to remove {img}: {result.stderr.strip()}", file=sys.stderr) return removed def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Prune stale runner images.") parser.add_argument( "--dry-run", action="store_true", help="Print what would be removed without executing.", ) args = parser.parse_args(argv) all_images = list_docker_images() runner_images = filter_runner_images(all_images) if not runner_images: print("no runner images found to prune") return 0 removed = remove_images(runner_images, dry_run=args.dry_run) print(f"pruned {len(removed)} image(s)") return 0 if __name__ == "__main__": # pragma: no cover sys.exit(main())