From 57f07c568d9c8ea0b673fa5bccc8ace0c395566e Mon Sep 17 00:00:00 2001 From: josmueller <46693545+am9zZWY@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:56:23 +0200 Subject: [PATCH] fix: use head-based manifest version check Assisted By: Claude Sonnet 5 --- ...+head-based-manifest-version-check.feature | 1 + pulp_container/app/registry_api.py | 36 +++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 CHANGES/+head-based-manifest-version-check.feature diff --git a/CHANGES/+head-based-manifest-version-check.feature b/CHANGES/+head-based-manifest-version-check.feature new file mode 100644 index 000000000..8c328aae5 --- /dev/null +++ b/CHANGES/+head-based-manifest-version-check.feature @@ -0,0 +1 @@ +Pull-through manifest resolution now uses HEAD checks, reducing Docker Hub 429s for repeated tag resolutions. \ No newline at end of file diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index 409476842..442c74569 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -1479,14 +1479,43 @@ def fetch_manifest(self, remote, pk): Fetch a manifest from the upstream remote. Returns the local manifest, if it exists in Pulp, and the full response from upstream. Raises response errors if manifest is not found or the download fails. + + A HEAD request is issued first to check the manifest's digest against what is already + stored in Pulp. Per Docker Hub's pull-rate accounting, a HEAD ("version check") does not + count as a pull, unlike a GET. If the manifest is already stored locally, the HEAD + response is returned as-is and the counted GET is skipped entirely. """ relative_url = "/v2/{name}/manifests/{pk}".format( name=remote.namespaced_upstream_name, pk=pk ) tag_url = urljoin(remote.url, relative_url) + + head_response = self._fetch_manifest_response(remote, tag_url, pk, http_method="head") + digest = head_response.headers.get("docker-content-digest") + if digest: + manifest = models.Manifest.objects.filter( + digest=digest, pulp_domain=get_domain() + ).first() + if manifest is not None: + return manifest, head_response + + # The manifest is not stored locally yet, or the upstream did not report a digest on the + # HEAD response; fall back to a full GET, identical to the previous behavior. + response = self._fetch_manifest_response(remote, tag_url, pk, http_method="get") + digest = response.headers.get("docker-content-digest") + return models.Manifest.objects.filter( + digest=digest, pulp_domain=get_domain() + ).first(), response + + def _fetch_manifest_response(self, remote, tag_url, pk, http_method): + """ + Issue a HEAD or GET request for a manifest and map response errors consistently. + """ downloader = remote.get_downloader(url=tag_url) try: - response = downloader.fetch(extra_data={"headers": V2_ACCEPT_HEADERS}) + return downloader.fetch( + extra_data={"headers": V2_ACCEPT_HEADERS, "http_method": http_method} + ) except ClientResponseError as response_error: if response_error.status == 429: # the client could request the manifest outside the docker hub pull limit; @@ -1499,11 +1528,6 @@ def fetch_manifest(self, remote, pk): except (ClientConnectionError, TimeoutException): # The remote server is not available at the moment raise GatewayTimeout() - else: - digest = response.headers.get("docker-content-digest") - return models.Manifest.objects.filter( - digest=digest, pulp_domain=get_domain() - ).first(), response def put(self, request, path, pk=None): """