DEVX-88: fix: correct clean_images delete URL and add retry with error handling

This commit is contained in:
emil
2026-06-27 21:40:49 +00:00
parent 925b99b7db
commit 598238e4d6
3 changed files with 176 additions and 13 deletions
+34 -4
View File
@@ -34,6 +34,7 @@ Authentication uses ``CI_GITEA_TOKEN`` environment variable.
from __future__ import annotations
import os
import time
from typing import Any
import click
@@ -85,15 +86,37 @@ def delete_package_version(
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}/{name}/{version}"
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout)
return resp.status_code in (204, 200)
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(
@@ -171,6 +194,7 @@ def main(
total_deleted = 0
total_kept = 0
total_failed = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
@@ -182,6 +206,7 @@ def main(
_("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True,
)
total_failed += 1
continue
if not versions:
@@ -203,6 +228,7 @@ def main(
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):
@@ -210,11 +236,15 @@ def main(
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}.")
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
+8
View File
@@ -1391,6 +1391,14 @@
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
+134 -9
View File
@@ -321,6 +321,25 @@ class TestCleanImagesAPI:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp) as mock_del:
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
# Verify URL includes container type
url = mock_del.call_args.args[0]
assert "/container/" in url
def test_delete_package_version_404_treated_as_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
@@ -336,7 +355,7 @@ class TestCleanImagesAPI:
def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
mock_resp = MagicMock(status_code=403)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
@@ -349,6 +368,110 @@ class TestCleanImagesAPI:
is False
)
def test_delete_package_version_retries_on_5xx(self) -> None:
from devx.tools.clean_images import delete_package_version
responses = [
MagicMock(status_code=500),
MagicMock(status_code=502),
MagicMock(status_code=204),
]
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=3,
)
is True
)
def test_delete_package_version_retries_on_exception(self) -> None:
import requests as req
from devx.tools.clean_images import delete_package_version
responses = [
req.ConnectionError("network down"),
MagicMock(status_code=204),
]
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=3,
)
is True
)
def test_delete_package_version_exhausts_retries_on_exception(self) -> None:
import requests as req
from devx.tools.clean_images import delete_package_version
with patch(
"devx.tools.clean_images.requests.delete",
side_effect=req.ConnectionError("network down"),
):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=2,
)
is False
)
def test_delete_package_version_exhausts_retries_on_5xx(self) -> None:
from devx.tools.clean_images import delete_package_version
with patch(
"devx.tools.clean_images.requests.delete",
return_value=MagicMock(status_code=500),
):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=2,
)
is False
)
def test_delete_package_version_zero_retries(self) -> None:
from devx.tools.clean_images import delete_package_version
with patch("devx.tools.clean_images.requests.delete") as mock_del:
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=0,
)
is False
)
mock_del.assert_not_called()
class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None:
@@ -557,7 +680,7 @@ class TestCLICleanImages:
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert result.exit_code != 0
assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None:
@@ -571,13 +694,15 @@ class TestCLICleanImages:
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500)
delete_resp = MagicMock(status_code=403)
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "FAILED" in result.output
with patch("devx.tools.clean_images.time.sleep"):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code != 0
assert "FAILED" in result.output
assert "failed" in result.output.lower()