Skip to content

Commit bc3db30

Browse files
fix(release): pull the infra images' base images from mirror.gcr.io in CI (#275)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 148f655 commit bc3db30

5 files changed

Lines changed: 253 additions & 29 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Pull the Docker official images that Dockerfiles build from on public.ecr.aws from mirror.gcr.io instead, under the
2+
names the Dockerfiles use, so their builds don't pull them from public.ecr.aws.
3+
4+
ECR Public rate-limits anonymous pulls by source IP, and hosted runners share IPs, so its pulls fail for hours at a time.
5+
mirror.gcr.io serves the same images under the same digests, but keeps one only while it's asked for, so a base it
6+
doesn't give is left to its build to pull from public.ecr.aws.
7+
8+
Docker 28 resolves a digest-pinned FROM only from an image under that exact name, which ``docker tag`` refuses, so those
9+
are also named in containerd, whose images Docker reads only on the containerd image store. Switching dockerd to it
10+
restarts dockerd, which stops the containers it runs, so this runs before any start.
11+
12+
``mirror_base_images.py PATH...`` does this for the Dockerfiles at or under each PATH.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
import re
19+
import subprocess
20+
import sys
21+
import time
22+
from pathlib import Path
23+
24+
ECR = "public.ecr.aws/docker/library/"
25+
MIRROR = "mirror.gcr.io/library/"
26+
_FROM = re.compile(rf"^FROM\s+(?:--\S+\s+)*({re.escape(ECR)}\S+)", re.IGNORECASE | re.MULTILINE)
27+
_PULL_WAITS_SECONDS = (10, 20)
28+
_DAEMON_CONFIG = Path("/etc/docker/daemon.json")
29+
_CONTAINERD_STORE = "io.containerd.snapshotter"
30+
31+
32+
def main(argv: list[str]) -> int:
33+
bases = _bases([Path(arg) for arg in argv])
34+
if any("@" in base for base in bases):
35+
_use_containerd_store()
36+
for base in bases:
37+
_pull(base)
38+
return 0
39+
40+
41+
def _bases(paths: list[Path]) -> list[str]:
42+
"""The public.ecr.aws images the Dockerfiles at or under ``paths`` build from."""
43+
dockerfiles = [found for path in paths for found in ([path] if path.is_file() else sorted(path.rglob("Dockerfile*")))]
44+
return sorted({base for dockerfile in dockerfiles if dockerfile.is_file()
45+
for base in _FROM.findall(dockerfile.read_text())})
46+
47+
48+
def _mirrored(base: str) -> str:
49+
"""What mirror.gcr.io serves for ``base``: the image under its digest when it's pinned to one, else under its tag."""
50+
name, _, digest = base.removeprefix(ECR).partition("@")
51+
return f"{MIRROR}{name.split(':')[0]}@{digest}" if digest else f"{MIRROR}{name}"
52+
53+
54+
def _pull(base: str) -> None:
55+
source = _mirrored(base)
56+
for wait in (*_PULL_WAITS_SECONDS, None):
57+
if subprocess.run(["docker", "pull", "--quiet", source]).returncode == 0:
58+
break
59+
if wait is None:
60+
print(f"::warning::couldn't pull {source}, so {base}'s build will pull it", flush=True)
61+
return
62+
time.sleep(wait)
63+
name, _, digest = base.partition("@")
64+
if not digest or ":" in name.rsplit("/", 1)[-1]:
65+
_run(["docker", "tag", source, name])
66+
if digest:
67+
_run(["sudo", "ctr", "--namespace", "moby", "images", "tag", "--force", source, base])
68+
print(f"{base} is {source}", flush=True)
69+
70+
71+
def _use_containerd_store() -> None:
72+
"""Switch dockerd to the containerd image store, which restarts it, unless it's on that store already."""
73+
if _CONTAINERD_STORE in _driver_status():
74+
return
75+
config = json.loads(_DAEMON_CONFIG.read_text().strip() or "{}") if _DAEMON_CONFIG.exists() else {}
76+
config.setdefault("features", {})["containerd-snapshotter"] = True
77+
subprocess.run(["sudo", "tee", str(_DAEMON_CONFIG)], input=json.dumps(config, indent=2), text=True,
78+
stdout=subprocess.DEVNULL, check=True)
79+
_run(["sudo", "systemctl", "restart", "docker"])
80+
if _CONTAINERD_STORE not in _driver_status():
81+
raise SystemExit("dockerd isn't on the containerd image store after restarting")
82+
83+
84+
def _driver_status() -> str:
85+
return subprocess.run(["docker", "info", "--format", "{{json .DriverStatus}}"], capture_output=True, text=True,
86+
check=True).stdout
87+
88+
89+
def _run(argv: list[str]) -> None:
90+
subprocess.run(argv, check=True)
91+
92+
93+
if __name__ == "__main__":
94+
sys.exit(main(sys.argv[1:]))

‎.github/workflows/local-backends.yml‎

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -131,39 +131,14 @@ jobs:
131131
python -m pip install --quiet -r .github/ci-requirements.txt
132132
uv sync --locked --extra dev --python "$(command -v python)"
133133
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
134-
# Public ECR caps anonymous pulls by data per source IP, and hosted runners share IPs, so its pulls
135-
# fail here for hours at a time. mirror.gcr.io serves the same Docker official images under the same
136-
# digests: each base image is pulled from there and tagged with the name its Dockerfile uses. Docker
137-
# 28 resolves a digest-pinned FROM only from an image under that exact name, which `docker tag`
138-
# refuses, so those are also named in containerd, whose images Docker reads only on the containerd
139-
# image store. Switching stores restarts dockerd, which would stop a service container, so the
140-
# registry starts afterwards.
141-
- name: Use the containerd image store
142-
run: |
143-
[ -s /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null
144-
jq '.features["containerd-snapshotter"] = true' /etc/docker/daemon.json > "$RUNNER_TEMP/daemon.json"
145-
sudo mv "$RUNNER_TEMP/daemon.json" /etc/docker/daemon.json
146-
sudo systemctl restart docker
147-
docker info --format '{{json .DriverStatus}}' | grep -q io.containerd.snapshotter
134+
# ECR Public's anonymous pulls fail here for hours at a time. This can restart dockerd, so the registry
135+
# starts afterwards.
136+
- name: Pull the base images from mirror.gcr.io
137+
run: python3 .github/scripts/mirror_base_images.py src tst
148138
- name: Start the local registry
149139
run: |
150140
docker run --detach --name registry --publish 5000:5000 mirror.gcr.io/library/registry:2
151141
timeout 30 sh -c 'until curl -sf http://localhost:5000/v2/ >/dev/null; do sleep 1; done'
152-
- name: Pull the base images from mirror.gcr.io
153-
run: |
154-
grep -rhoE '^FROM +public\.ecr\.aws/docker/library/[^ ]+' --include='Dockerfile*' src tst | awk '{print $2}' | sort -u |
155-
while read -r image; do
156-
name=${image#public.ecr.aws/docker/library/}
157-
source=mirror.gcr.io/library/$name
158-
case $name in *@*) source=mirror.gcr.io/library/${name%%[:@]*}@${name#*@} ;; esac
159-
for attempt in 1 2 3; do
160-
docker pull --quiet "$source" && break
161-
if [ "$attempt" = 3 ]; then echo "::warning::couldn't pull $source; $image's build will pull it"; continue 2; fi
162-
sleep $((attempt * 10))
163-
done
164-
case ${name%@*} in *:*) docker tag "$source" "${image%@*}" ;; esac
165-
case $image in *@*) sudo ctr --namespace moby images tag "$source" "$image" >/dev/null ;; esac
166-
done
167142
- name: Slow integration tier on the local defaults
168143
run: |
169144
.venv/bin/python -m pytest -m int_test_slow -rA --timeout=1200 --junitxml=junit-integration-slow.xml \

‎.github/workflows/publish-pypi.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ jobs:
125125
1) echo "build=true" >> "$GITHUB_OUTPUT" ;;
126126
*) exit 1 ;;
127127
esac
128+
- name: Pull its base images from mirror.gcr.io, as public.ecr.aws can rate-limit a runner for hours
129+
if: steps.wanted.outputs.build == 'true'
130+
run: python3 .github/scripts/mirror_base_images.py "$DOCKERFILE"
128131
- name: Build
129132
if: steps.wanted.outputs.build == 'true'
130133
run: |
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""``.github/scripts/mirror_base_images.py``, which pulls the base images Dockerfiles name on public.ecr.aws from
2+
mirror.gcr.io before a workflow builds them, falling back to public.ecr.aws for one the mirror doesn't give."""
3+
4+
from __future__ import annotations
5+
6+
import ast
7+
import json
8+
import subprocess
9+
import sys
10+
11+
import pytest
12+
13+
from agent_env.env import bootstrap
14+
from tst.util.scripts import SCRIPTS, load_script
15+
16+
REPO = SCRIPTS.parents[1]
17+
SCRIPT = SCRIPTS / "mirror_base_images.py"
18+
_DIGEST = "sha256:" + "c" * 64
19+
_PINNED = f"public.ecr.aws/docker/library/python:3.11-slim@{_DIGEST}"
20+
_TAGGED = "public.ecr.aws/docker/library/postgres:16-alpine"
21+
22+
23+
@pytest.fixture
24+
def script():
25+
return load_script(SCRIPT.name)
26+
27+
28+
def test_it_imports_only_the_standard_library():
29+
"""The release's image builds run it in a job that holds a token to push, which installs nothing."""
30+
imported = set()
31+
for node in ast.walk(ast.parse(SCRIPT.read_text())):
32+
if isinstance(node, ast.Import):
33+
imported |= {alias.name.split(".")[0] for alias in node.names}
34+
elif isinstance(node, ast.ImportFrom) and node.level == 0:
35+
imported.add(node.module.split(".")[0])
36+
37+
assert imported - {"__future__"} <= sys.stdlib_module_names, imported - sys.stdlib_module_names
38+
39+
40+
def test_it_finds_each_public_ecr_base_once(script, tmp_path):
41+
(tmp_path / "a").mkdir()
42+
(tmp_path / "a" / "Dockerfile").write_text(f"FROM {_PINNED}\nRUN true\nFROM ghcr.io/astral-sh/uv:latest AS uv\n")
43+
(tmp_path / "a" / "Dockerfile.db-mcp").write_text(f"from --platform=$BUILDPLATFORM {_TAGGED} AS build\n")
44+
(tmp_path / "b").mkdir()
45+
(tmp_path / "b" / "Dockerfile").write_text(f"# FROM {_TAGGED}:old\nFROM {_PINNED}\n")
46+
(tmp_path / "b" / "README.md").write_text("FROM public.ecr.aws/docker/library/node:22\n")
47+
48+
assert script._bases([tmp_path]) == [_TAGGED, _PINNED]
49+
assert script._bases([tmp_path / "a" / "Dockerfile.db-mcp"]) == [_TAGGED]
50+
51+
52+
def test_it_finds_the_bases_of_the_images_agent_env_ships(script):
53+
bases = script._bases([image.dockerfile for image in bootstrap.PUBLISHED_IMAGES])
54+
55+
assert bases and all(base.startswith(script.ECR) for base in bases)
56+
assert any("@sha256:" in base for base in bases)
57+
58+
59+
@pytest.mark.parametrize("base, mirrored", [
60+
(_PINNED, f"mirror.gcr.io/library/python@{_DIGEST}"),
61+
(_TAGGED, "mirror.gcr.io/library/postgres:16-alpine"),
62+
])
63+
def test_a_base_is_pulled_by_its_digest_when_it_has_one(script, base, mirrored):
64+
assert script._mirrored(base) == mirrored
65+
66+
67+
def _docker(monkeypatch, script, *, store="overlay2", pulls_fail=0):
68+
"""Fakes the commands the script runs, recording them; the first ``pulls_fail`` pulls fail."""
69+
ran, failures, driver = [], iter(range(pulls_fail)), [store]
70+
71+
def run(argv, **kwargs):
72+
ran.append(argv)
73+
if argv[:2] == ["docker", "info"]:
74+
return subprocess.CompletedProcess(argv, 0, json.dumps([["driver-type", driver[0]]]), "")
75+
if argv[:2] == ["sudo", "systemctl"]:
76+
driver[0] = "io.containerd.snapshotter.v1"
77+
if argv[:2] == ["sudo", "tee"]:
78+
ran.append(json.loads(kwargs["input"]))
79+
failed = argv[:2] == ["docker", "pull"] and next(failures, None) is not None
80+
return subprocess.CompletedProcess(argv, 1 if failed else 0, "", "")
81+
82+
monkeypatch.setattr(script.subprocess, "run", run)
83+
monkeypatch.setattr(script.time, "sleep", lambda seconds: ran.append(["sleep", seconds]))
84+
return ran
85+
86+
87+
def test_a_pinned_base_is_named_in_containerd_on_its_image_store(script, monkeypatch, tmp_path):
88+
config = tmp_path / "daemon.json"
89+
config.write_text('{"exec-opts": ["native.cgroupdriver=cgroupfs"]}\n')
90+
monkeypatch.setattr(script, "_DAEMON_CONFIG", config)
91+
ran = _docker(monkeypatch, script)
92+
dockerfile = tmp_path / "Dockerfile"
93+
dockerfile.write_text(f"FROM {_PINNED}\n")
94+
95+
assert script.main([str(dockerfile)]) == 0
96+
97+
mirrored = f"mirror.gcr.io/library/python@{_DIGEST}"
98+
assert ran == [
99+
["docker", "info", "--format", "{{json .DriverStatus}}"],
100+
["sudo", "tee", str(config)],
101+
{"exec-opts": ["native.cgroupdriver=cgroupfs"], "features": {"containerd-snapshotter": True}},
102+
["sudo", "systemctl", "restart", "docker"],
103+
["docker", "info", "--format", "{{json .DriverStatus}}"],
104+
["docker", "pull", "--quiet", mirrored],
105+
["docker", "tag", mirrored, "public.ecr.aws/docker/library/python:3.11-slim"],
106+
["sudo", "ctr", "--namespace", "moby", "images", "tag", "--force", mirrored, _PINNED],
107+
]
108+
109+
110+
def test_dockerd_is_left_alone_when_no_base_is_pinned(script, monkeypatch, tmp_path):
111+
ran = _docker(monkeypatch, script)
112+
dockerfile = tmp_path / "Dockerfile"
113+
dockerfile.write_text(f"FROM {_TAGGED}\n")
114+
115+
assert script.main([str(dockerfile)]) == 0
116+
117+
assert ran == [["docker", "pull", "--quiet", "mirror.gcr.io/library/postgres:16-alpine"],
118+
["docker", "tag", "mirror.gcr.io/library/postgres:16-alpine", _TAGGED]]
119+
120+
121+
def test_a_base_the_mirror_doesnt_give_is_left_to_its_build(script, monkeypatch, tmp_path, capsys):
122+
ran = _docker(monkeypatch, script, pulls_fail=3)
123+
dockerfile = tmp_path / "Dockerfile"
124+
dockerfile.write_text(f"FROM {_TAGGED}\n")
125+
126+
assert script.main([str(dockerfile)]) == 0
127+
128+
pull = ["docker", "pull", "--quiet", "mirror.gcr.io/library/postgres:16-alpine"]
129+
assert ran == [pull, ["sleep", 10], pull, ["sleep", 20], pull]
130+
assert f"::warning::couldn't pull mirror.gcr.io/library/postgres:16-alpine, so {_TAGGED}'s build" in capsys.readouterr().out
131+
132+
133+
def test_a_pull_that_fails_once_is_tried_again(script, monkeypatch, tmp_path):
134+
ran = _docker(monkeypatch, script, pulls_fail=1)
135+
dockerfile = tmp_path / "Dockerfile"
136+
dockerfile.write_text(f"FROM {_TAGGED}\n")
137+
138+
assert script.main([str(dockerfile)]) == 0
139+
140+
assert ran[-1] == ["docker", "tag", "mirror.gcr.io/library/postgres:16-alpine", _TAGGED]
141+
assert ["sleep", 10] in ran and ["sleep", 20] not in ran

‎tst/unit/test_release_infra_images.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ def test_a_run_by_hand_goes_ahead_only_on_main_or_a_release_tag(job):
110110
assert "github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')" in JOBS[job]["if"]
111111

112112

113+
def test_a_build_pulls_its_base_images_from_the_mirror_first():
114+
"""public.ecr.aws can rate-limit a hosted runner's anonymous pulls for hours; the build pulls from it only what the
115+
mirror doesn't give."""
116+
(step,) = [step for step in JOBS["infra-build"]["steps"] if "mirror_base_images.py" in step.get("run", "")]
117+
build = "\n".join(_runs("infra-build"))
118+
119+
assert step["run"] == 'python3 .github/scripts/mirror_base_images.py "$DOCKERFILE"'
120+
assert step["if"] == "steps.wanted.outputs.build == 'true'"
121+
assert build.index("mirror_base_images.py") < build.index("docker build")
122+
123+
113124
def test_what_a_registry_can_rate_limit_is_tried_again():
114125
"""A 429 on a base image's pull would fail the build, and with it the release's PyPI jobs."""
115126
build = "\n".join(_runs("infra-build"))

0 commit comments

Comments
 (0)