|
1 | 1 | """Docker Registry v2 API client implementation.""" |
2 | | -import urllib.request, urllib.parse, urllib.error, json, re, base64 |
| 2 | +import urllib.request, urllib.parse, urllib.error, http.client, json, re, base64 |
3 | 3 | from pathlib import Path |
4 | 4 | from typing import Optional, Dict, Tuple |
5 | 5 |
|
| 6 | +class NoRedirect(urllib.request.HTTPRedirectHandler): |
| 7 | + """HTTP handler that doesn't follow redirects.""" |
| 8 | + def redirect_request(self, req, fp, code, msg, headers, newurl): |
| 9 | + return None |
| 10 | + |
6 | 11 | class RegistryClient: |
7 | 12 | def __init__(self, registry: str, repository: str, auth_token: Optional[str]=None, username: Optional[str]=None, password: Optional[str]=None): |
8 | 13 | self.registry=registry.rstrip('/') |
| 14 | + if self.registry=='docker.io': |
| 15 | + self.registry='registry-1.docker.io' |
9 | 16 | self.repository=repository |
10 | 17 | self.auth_token=auth_token |
11 | 18 | self.username=username |
@@ -73,7 +80,13 @@ def _make_request(self, method: str, url: str, data: Optional[bytes]=None, heade |
73 | 80 | self._bearer_token=self._get_bearer_token(auth_params) |
74 | 81 | if self._bearer_token: |
75 | 82 | return self._make_request(method, url, data, headers, retry_auth=False) |
76 | | - return e.code, e.read(), dict(e.headers) |
| 83 | + body=b'' |
| 84 | + try: |
| 85 | + body=e.read() |
| 86 | + except: pass |
| 87 | + return e.code, body, dict(e.headers) |
| 88 | + except Exception as ex: |
| 89 | + raise RuntimeError(f"Request failed for {url}: {ex}") |
77 | 90 |
|
78 | 91 | def blob_exists(self, digest: str) -> bool: |
79 | 92 | """Check if blob exists in registry via HEAD request.""" |
@@ -136,18 +149,61 @@ def pull_manifest(self, reference: str) -> Tuple[Dict, str]: |
136 | 149 | headers={'Accept':'application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json'} |
137 | 150 | status, body, resp_headers=self._make_request('GET', url, headers=headers) |
138 | 151 | if status!=200: |
139 | | - raise RuntimeError(f"Failed to pull manifest: {status} {body.decode()}") |
| 152 | + body_text=body.decode() if body else '(empty response)' |
| 153 | + raise RuntimeError(f"Failed to pull manifest: {status} {body_text}") |
| 154 | + if not body: |
| 155 | + raise RuntimeError(f"Empty response body from registry for {reference}") |
| 156 | + try: |
| 157 | + manifest=json.loads(body) |
| 158 | + except json.JSONDecodeError as e: |
| 159 | + body_preview=body[:500].decode('utf-8', errors='replace') |
| 160 | + raise RuntimeError(f"Invalid JSON in manifest response: {e}\nBody preview: {body_preview}") |
140 | 161 | digest=resp_headers.get('Docker-Content-Digest') or resp_headers.get('docker-content-digest') |
141 | | - return json.loads(body), digest |
| 162 | + return manifest, digest |
142 | 163 |
|
143 | 164 | def pull_blob(self, digest: str, dest_path: Path) -> bool: |
144 | 165 | """Download blob from registry to local path.""" |
145 | 166 | url=f"{self.base_url}/{self.repository}/blobs/{digest}" |
146 | | - status, body, _=self._make_request('GET', url) |
147 | | - if status!=200: |
148 | | - raise RuntimeError(f"Failed to pull blob {digest}: {status}") |
149 | | - dest_path.write_bytes(body) |
150 | | - return True |
| 167 | + |
| 168 | + req=urllib.request.Request(url) |
| 169 | + token=self._bearer_token or self.auth_token |
| 170 | + if token: |
| 171 | + req.add_header('Authorization', f'Bearer {token}') |
| 172 | + elif self.username and self.password: |
| 173 | + creds=base64.b64encode(f'{self.username}:{self.password}'.encode()).decode() |
| 174 | + req.add_header('Authorization', f'Basic {creds}') |
| 175 | + |
| 176 | + opener=urllib.request.build_opener(NoRedirect) |
| 177 | + try: |
| 178 | + with opener.open(req) as resp: |
| 179 | + if resp.status in (301, 302, 303, 307, 308): |
| 180 | + redirect_url=resp.headers.get('Location') |
| 181 | + if redirect_url: |
| 182 | + redirect_req=urllib.request.Request(redirect_url) |
| 183 | + with urllib.request.urlopen(redirect_req) as redirect_resp: |
| 184 | + body=redirect_resp.read() |
| 185 | + dest_path.write_bytes(body) |
| 186 | + return True |
| 187 | + elif resp.status==200: |
| 188 | + body=resp.read() |
| 189 | + dest_path.write_bytes(body) |
| 190 | + return True |
| 191 | + else: |
| 192 | + body=resp.read() |
| 193 | + error_msg=body.decode('utf-8', errors='replace') if body else '(empty)' |
| 194 | + raise RuntimeError(f"Failed to pull blob {digest}: {resp.status} - {error_msg}") |
| 195 | + except urllib.error.HTTPError as e: |
| 196 | + if e.code in (301, 302, 303, 307, 308): |
| 197 | + redirect_url=e.headers.get('Location') |
| 198 | + if redirect_url: |
| 199 | + redirect_req=urllib.request.Request(redirect_url) |
| 200 | + with urllib.request.urlopen(redirect_req) as redirect_resp: |
| 201 | + body=redirect_resp.read() |
| 202 | + dest_path.write_bytes(body) |
| 203 | + return True |
| 204 | + body=e.read() if hasattr(e, 'read') else b'' |
| 205 | + error_msg=body.decode('utf-8', errors='replace') if body else '(empty)' |
| 206 | + raise RuntimeError(f"Failed to pull blob {digest}: {e.code} - {error_msg}") |
151 | 207 |
|
152 | 208 | def parse_image_reference(ref: str) -> Tuple[str, str, str]: |
153 | 209 | """Parse image reference into (registry, repository, tag). |
|
0 commit comments