Skip to content

Commit 701752e

Browse files
authored
chore: update image retention management (#1897)
quay.io is buggy when updating expiration date Use a manual image retention management script which should be more robust anyway in case we switch to another registry
1 parent d32689e commit 701752e

File tree

4 files changed

+210
-4
lines changed

4 files changed

+210
-4
lines changed

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,8 @@ In order to reduce the space used by obsolete images on `quay.io <https://quay.i
404404
the following retention policy is applied:
405405

406406
- images will default to 5 years retention
407-
- tags used by `cibuildwheel <https://github.com/pypa/cibuildwheel>`_ releases will never expire
408-
- when a specific manylinux/musllinux policy variant goes out of support, its latest tag will be manually updated to never expire
407+
- tags used by `cibuildwheel <https://github.com/pypa/cibuildwheel>`_ 1.10.0 and later releases (including pre-releases) will never expire
408+
- when a specific manylinux/musllinux policy variant goes out of support, its latest tag will never expire
409409

410410
Building Docker images
411411
----------------------

deploy_multiarch.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ for IMAGE in "${IMAGES[@]}"; do
4848
SRC_IMAGES+=("docker://quay.io/pypa/${IMAGE}_${ARCH}:${TAG_TO_PUSH}")
4949
done
5050
MANIFEST="${IMAGE}:${TAG_TO_PUSH}"
51-
if ! podman manifest create --annotation "quay.expires-after=260w" "${MANIFEST}" "${SRC_IMAGES[@]}"; then
51+
if ! podman manifest create "${MANIFEST}" "${SRC_IMAGES[@]}"; then
5252
echo "::error ::failed to create '${MANIFEST}' manifest using ${SRC_IMAGES[*]}"
5353
else
5454
if ! podman manifest push --all "${MANIFEST}" "docker://quay.io/pypa/${IMAGE}:${TAG_TO_PUSH}"; then

docker/Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,6 @@ RUN --mount=type=bind,from=static_clang,target=/tmp/cross-compiler,ro \
211211

212212

213213
FROM runtime_base
214-
LABEL quay.expires-after="260w"
215214
COPY --from=build_tcl_tk /manylinux-rootfs /
216215
COPY --from=build_mpdecimal /manylinux-rootfs /
217216
COPY --from=build_zstd /manylinux-rootfs /

tools/delete_expired_images.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# /// script
2+
# dependencies = ["packaging", "requests"]
3+
# ///
4+
5+
import argparse
6+
import configparser
7+
import datetime
8+
import functools
9+
import os
10+
import re
11+
import subprocess
12+
import sys
13+
import tempfile
14+
from collections import defaultdict
15+
from pathlib import Path
16+
17+
from packaging.version import Version
18+
from requests import Session
19+
from requests.adapters import HTTPAdapter
20+
from urllib3.util import Retry
21+
22+
23+
@functools.cache
24+
def requests_session() -> Session:
25+
retries = Retry(
26+
total=3,
27+
backoff_factor=0.1,
28+
status_forcelist=[500, 502, 503, 504],
29+
allowed_methods={"GET", "POST"},
30+
)
31+
adapter = HTTPAdapter(max_retries=retries)
32+
s = Session()
33+
s.mount("https://", adapter)
34+
s.mount("http://", adapter)
35+
return s
36+
37+
38+
def update_cibuildwheel_tags(version: str, tags: dict[str, set[str]]) -> None:
39+
print(f"Updating image tags for cibuildwheel {version}")
40+
subprocess.run(
41+
["git", "checkout", version],
42+
check=True,
43+
stdin=subprocess.DEVNULL,
44+
stdout=subprocess.DEVNULL,
45+
stderr=subprocess.DEVNULL,
46+
)
47+
config_path = Path("cibuildwheel/resources/pinned_docker_images.cfg")
48+
if not config_path.is_file():
49+
print(f"::error::no configuration for cibuildwheel {version}")
50+
sys.exit(1)
51+
config = configparser.ConfigParser()
52+
config.read(config_path)
53+
for section in config.sections():
54+
for value in config[section].values():
55+
if not value.startswith("quay.io/pypa/"):
56+
continue
57+
image, tag = value[13:].split(":")
58+
tags[image].add(tag)
59+
60+
61+
def get_cibuildwheel_tags() -> dict[str, set[str]]:
62+
result = defaultdict(set)
63+
cwd = os.getcwd()
64+
with tempfile.TemporaryDirectory() as tmpdir:
65+
subprocess.run(
66+
["git", "clone", "--tags", "https://github.com/pypa/cibuildwheel.git", str(tmpdir)],
67+
check=True,
68+
stdin=subprocess.DEVNULL,
69+
stdout=subprocess.DEVNULL,
70+
stderr=subprocess.DEVNULL,
71+
)
72+
try:
73+
os.chdir(tmpdir)
74+
git_tags = subprocess.run(
75+
["git", "tag", "--list"],
76+
check=True,
77+
stdin=subprocess.DEVNULL,
78+
capture_output=True,
79+
text=True,
80+
)
81+
versions = [version for version in git_tags.stdout.splitlines() if version]
82+
for version in versions:
83+
version_ = Version(version)
84+
if version_ < Version("1.10.0"):
85+
# skip older cibuildwheel versions; only protect images from 1.10.0 and later
86+
continue
87+
update_cibuildwheel_tags(version, result)
88+
finally:
89+
os.chdir(cwd)
90+
return result
91+
92+
93+
def get_images_to_delete(
94+
expiration_date: datetime.date, cibuildwheel_tags: dict[str, set[str]]
95+
) -> list[str]:
96+
known_missing = {
97+
"manylinux_2_24_ppc64le:2021-09-06-7b0bd5d", # cibuildwheel v2.1.2
98+
"musllinux_1_1_s390x:2021-10-06-94da8f1", # cibuildwheel v2.1.3
99+
"manylinux_2_24_aarch64:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
100+
"manylinux_2_24_i686:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
101+
"manylinux_2_24_ppc64le:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
102+
"manylinux_2_24_s390x:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
103+
"musllinux_1_1_i686:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
104+
"musllinux_1_1_ppc64le:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
105+
"musllinux_1_1_s390x:2021-09-19-a5ef179", # cibuildwheel v2.2.0a1
106+
"musllinux_1_1_s390x:2022-10-12-4e18a23", # cibuildwheel v2.11.1
107+
"musllinux_1_1_ppc64le:2024-06-03-e195670", # cibuildwheel v2.19.0
108+
"musllinux_1_1_s390x:2024-06-03-e195670", # cibuildwheel v2.19.0
109+
}
110+
tag_re = re.compile(r"^(?P<year>\d+)[-.](?P<month>\d+)[-.](?P<day>\d+)[-.]")
111+
images_to_delete_candidates = defaultdict(set)
112+
# keep the last tag before dropping python versions (likely already part of cibuildwheel tags)
113+
all_tags_to_keep = {
114+
"2021-02-06-3d322a5", # last tag before python 2.7 drop
115+
"2021-05-01-28d233a", # last tag before python 3.5 drop
116+
"2025-05-03-cdd80a2", # last tag before python 3.6/3.7 drop
117+
"2025.05.03-1", # last tag before python 3.6/3.7 drop
118+
}
119+
for tags in cibuildwheel_tags.values():
120+
all_tags_to_keep.update(tags)
121+
images = [
122+
*sorted(cibuildwheel_tags.keys()),
123+
"manylinux2014",
124+
"manylinux_2_28",
125+
"manylinux_2_31",
126+
"manylinux_2_34",
127+
"manylinux_2_35",
128+
"manylinux_2_39",
129+
"musllinux_1_2",
130+
]
131+
for image in images:
132+
print(f"checking pypa/{image}")
133+
tags_dict = {}
134+
page = 1
135+
while True:
136+
response = requests_session().get(
137+
f"https://quay.io/api/v1/repository/pypa/{image}/tag/?page={page}&limit=100&onlyActiveTags=true"
138+
)
139+
response.raise_for_status()
140+
repo_info = response.json()
141+
if len(repo_info["tags"]) == 0:
142+
break
143+
tags_dict.update({item["name"]: item for item in repo_info["tags"]})
144+
page += 1
145+
item = tags_dict.pop("latest") # all repositories are guaranteed to have a "latest" tag
146+
manifest_to_keep = {item["manifest_digest"]}
147+
for tag in sorted(all_tags_to_keep):
148+
item = tags_dict.pop(tag, None)
149+
if item is None:
150+
image_tag = f"{image}:{tag}"
151+
if image_tag not in known_missing and tag in cibuildwheel_tags.get(image, set()):
152+
print(f"::warning::image {image_tag} is missing")
153+
continue
154+
manifest_to_keep.add(item["manifest_digest"])
155+
156+
for tag, item in tags_dict.items():
157+
if item["manifest_digest"] in manifest_to_keep:
158+
all_tags_to_keep.add(tag)
159+
continue
160+
match = tag_re.match(tag)
161+
if not match:
162+
print(f"::warning::image {image}:{tag} is invalid")
163+
continue
164+
tag_date = datetime.date(int(match["year"]), int(match["month"]), int(match["day"]))
165+
if tag_date < expiration_date:
166+
images_to_delete_candidates[image].add(tag)
167+
# try to keep things consistent between images
168+
result = []
169+
for image, tags in images_to_delete_candidates.items():
170+
tags_ = tags - all_tags_to_keep
171+
result.extend(f"{image}:{tag}" for tag in tags_)
172+
return sorted(result)
173+
174+
175+
def delete_images(image_list: list[str], *, dry_run: bool = True) -> None:
176+
dry_run_str = " (dry-run)" if dry_run else ""
177+
for image in image_list:
178+
image_url = f"quay.io/pypa/{image}"
179+
print(f"deleting {image_url}{dry_run_str}")
180+
if dry_run:
181+
continue
182+
subprocess.run(
183+
["skopeo", "delete", f"docker://{image_url}"],
184+
check=True,
185+
stdin=subprocess.DEVNULL,
186+
)
187+
188+
189+
def main():
190+
parser = argparse.ArgumentParser()
191+
parser.add_argument("--dry-run", dest="dry_run", action="store_true", help="dry run")
192+
args = parser.parse_args()
193+
expiration_date = datetime.datetime.now(datetime.UTC).date()
194+
if (expiration_date.month, expiration_date.day) == (2, 29):
195+
# This avoids constructing an invalid date when the target year is not a leap year.
196+
# Note: this means images may have a slightly different retention period when the
197+
# script is run on a leap day compared to other days.
198+
expiration_date = expiration_date.replace(day=28)
199+
expiration_date = expiration_date.replace(year=expiration_date.year - 5)
200+
print(f"expiration date: {expiration_date.isoformat()}")
201+
cibuildwheel_tags = get_cibuildwheel_tags()
202+
to_delete = get_images_to_delete(expiration_date, cibuildwheel_tags)
203+
delete_images(to_delete, dry_run=args.dry_run)
204+
205+
206+
if __name__ == "__main__":
207+
main()

0 commit comments

Comments
 (0)