Files
devx/src/devx/tools/clean_images.py
T
emil 4c1ecbf4fa
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 27s
Build Images / detect-type (push) Successful in 42s
Post-merge / badges (push) Successful in 30s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 1m44s
DEVX-91: refactor: add find_task_by_identifier, config fallbacks for tools
2026-06-28 00:06:29 +00:00

256 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Clean up old Docker images from a Gitea container registry.
Queries the Gitea API for all versions of a package (container type) and
deletes all but the most recent N versions. The ``latest`` tag is always
preserved if present.
Usage::
# Clean up ci-base images, keep last 2 versions
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2
# Clean up multiple images
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--name ci-quality \\
--name ci-full \\
--keep 2
# Dry run (list what would be deleted)
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2 \\
--dry-run
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
"""
from __future__ import annotations
import os
import time
from typing import Any
import click
import requests
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
def list_package_versions(
api_url: str,
owner: str,
name: str,
token: str,
*,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""List all versions of a container package from the Gitea API.
Returns a list of version dicts, each containing at least ``version``
and ``created_at`` fields.
"""
url = f"{api_url}/packages/{owner}?type=container&name={name}"
headers = {"Authorization": f"token {token}"}
all_versions: list[dict[str, Any]] = []
page = 1
while True:
resp = requests.get(
f"{url}&page={page}&limit=50",
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if not data:
break
all_versions.extend(data)
if len(data) < 50:
break
page += 1
return all_versions
def delete_package_version(
api_url: str,
owner: str,
name: str,
version: str,
token: str,
*,
timeout: int = 30,
package_type: str = "container",
max_retries: int = 3,
) -> bool:
"""Delete a specific version of a container package.
Uses the Gitea API endpoint ``DELETE /packages/{owner}/{type}/{name}/{version}``.
Retries on transient failures (5xx, timeouts) up to ``max_retries`` times.
Returns True on success, False on failure.
"""
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
headers = {"Authorization": f"token {token}"}
for attempt in range(max_retries):
try:
resp = requests.delete(url, headers=headers, timeout=timeout)
except requests.RequestException:
if attempt < max_retries - 1:
time.sleep(2**attempt)
continue
return False
if resp.status_code in (204, 200):
return True
# 404 means already deleted — treat as success
if resp.status_code == 404:
return True
# 5xx is transient — retry
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
time.sleep(2**attempt)
continue
return False
return False
def sort_versions_by_date(
versions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Sort package versions by creation date, newest first.
Falls back to version string comparison if created_at is missing.
"""
def _sort_key(v: dict[str, Any]) -> str:
return str(v.get("created_at", v.get("version", "")))
return sorted(versions, key=_sort_key, reverse=True)
def select_for_deletion(
versions: list[dict[str, Any]],
keep: int,
) -> list[dict[str, Any]]:
"""Select versions to delete, keeping the most recent ``keep`` versions.
Versions named ``latest`` are always preserved.
"""
sorted_versions = sort_versions_by_date(versions)
to_delete = sorted_versions[keep:]
# Always preserve 'latest' tag
to_delete = [v for v in to_delete if v.get("version") != "latest"]
return to_delete
@click.command()
@click.option(
"--owner",
default=None,
help="Package owner (user or org, default: from [tool.devx] repo_owner).",
)
@click.option(
"--name",
"names",
multiple=True,
required=True,
help="Package name(s). Can be repeated.",
)
@click.option(
"--keep",
default=2,
type=int,
show_default=True,
help="Number of recent versions to keep (excluding 'latest').",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="List versions that would be deleted without actually deleting.",
)
@click.option(
"--api-url",
default=None,
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
)
def main(
owner: str | None,
names: tuple[str, ...],
keep: int,
dry_run: bool,
api_url: str | None,
) -> None:
"""Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
if not owner:
owner = REPO_OWNER
if not owner:
raise click.ClickException(_("Package owner not specified. Use --owner or set [tool.devx] repo_owner."))
base_url = api_url or GITEA_API_URL
total_deleted = 0
total_kept = 0
total_failed = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
click.echo(f"{'=' * 60}")
try:
versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc:
click.echo(
_("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True,
)
total_failed += 1
continue
if not versions:
click.echo(_("No versions found."))
continue
click.echo(f"Found {len(versions)} version(s):")
for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
if dry_run:
for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
total_kept += kept_count
continue
deleted_count = 0
failed_count = 0
for v in to_delete:
version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}")
deleted_count += 1
else:
click.echo(f" FAILED to delete: {version}", err=True)
failed_count += 1
total_deleted += deleted_count
total_kept += kept_count
total_failed += failed_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.")
if total_failed > 0:
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover