dockerhub-api 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {dockerhub_api-0.1.0/dockerhub_api.egg-info → dockerhub_api-0.2.0}/PKG-INFO +61 -9
  2. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/README.md +56 -4
  3. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/agent_server.py +1 -1
  4. dockerhub_api-0.2.0/dockerhub_api/api/api_client_registry.py +351 -0
  5. dockerhub_api-0.2.0/dockerhub_api/api/api_client_registry_base.py +246 -0
  6. dockerhub_api-0.2.0/dockerhub_api/api/api_client_scout.py +132 -0
  7. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api_client.py +14 -2
  8. dockerhub_api-0.2.0/dockerhub_api/auth.py +497 -0
  9. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/dockerhub_input_models.py +40 -0
  10. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/dockerhub_response_models.py +98 -0
  11. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/__init__.py +20 -0
  12. dockerhub_api-0.2.0/dockerhub_api/mcp/mcp_registry.py +80 -0
  13. dockerhub_api-0.2.0/dockerhub_api/mcp/mcp_scout.py +60 -0
  14. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp_server.py +11 -3
  15. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0/dockerhub_api.egg-info}/PKG-INFO +61 -9
  16. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/SOURCES.txt +7 -0
  17. dockerhub_api-0.2.0/dockerhub_api.egg-info/requires.txt +18 -0
  18. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/pyproject.toml +5 -5
  19. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/requirements.txt +1 -1
  20. dockerhub_api-0.2.0/tests/test_api_registry.py +203 -0
  21. dockerhub_api-0.2.0/tests/test_api_scout.py +53 -0
  22. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_dockerhub_mcp_validation.py +58 -2
  23. dockerhub_api-0.1.0/dockerhub_api/auth.py +0 -252
  24. dockerhub_api-0.1.0/dockerhub_api.egg-info/requires.txt +0 -18
  25. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/LICENSE +0 -0
  26. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/MANIFEST.in +0 -0
  27. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/__init__.py +0 -0
  28. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/__main__.py +0 -0
  29. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/__init__.py +0 -0
  30. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_access_tokens.py +0 -0
  31. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_audit_logs.py +0 -0
  32. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_auth.py +0 -0
  33. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_base.py +0 -0
  34. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_groups.py +0 -0
  35. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_org_access_tokens.py +0 -0
  36. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_orgs.py +0 -0
  37. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_repositories.py +0 -0
  38. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_scim.py +0 -0
  39. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/main_agent.json +0 -0
  40. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_admin.py +0 -0
  41. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_audit.py +0 -0
  42. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_auth.py +0 -0
  43. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_org.py +0 -0
  44. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_repos.py +0 -0
  45. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_scim.py +0 -0
  46. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_teams.py +0 -0
  47. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp_config.json +0 -0
  48. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/dependency_links.txt +0 -0
  49. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/entry_points.txt +0 -0
  50. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/top_level.txt +0 -0
  51. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/setup.cfg +0 -0
  52. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_audit.py +0 -0
  53. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_org.py +0 -0
  54. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_repositories.py +0 -0
  55. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_scim.py +0 -0
  56. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_teams.py +0 -0
  57. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_tokens.py +0 -0
  58. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_wrapper.py +0 -0
  59. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_auth.py +0 -0
  60. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_concept_parity.py +0 -0
  61. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_dockerhub_a2a_validation.py +0 -0
  62. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_init_dynamics.py +0 -0
  63. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_models.py +0 -0
  64. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_rate_limit.py +0 -0
  65. {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dockerhub-api
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Docker Hub API + MCP Server + A2A Server
5
5
  Author-email: Audel Rouhi <knucklessg1@gmail.com>
6
6
  License: MIT
@@ -12,15 +12,15 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: <3.15,>=3.11
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: agent-utilities>=0.47.0
15
+ Requires-Dist: agent-utilities>=0.48.0
16
16
  Requires-Dist: httpx>=0.27.0
17
17
  Requires-Dist: python-dotenv>=1.0.0
18
18
  Provides-Extra: mcp
19
- Requires-Dist: agent-utilities[mcp]>=0.47.0; extra == "mcp"
19
+ Requires-Dist: agent-utilities[mcp]>=0.48.0; extra == "mcp"
20
20
  Provides-Extra: agent
21
- Requires-Dist: agent-utilities[agent,logfire]>=0.47.0; extra == "agent"
21
+ Requires-Dist: agent-utilities[agent,logfire]>=0.48.0; extra == "agent"
22
22
  Provides-Extra: all
23
- Requires-Dist: dockerhub-api[agent,logfire,mcp]>=0.1.0; extra == "all"
23
+ Requires-Dist: dockerhub-api[agent,logfire,mcp]>=0.2.0; extra == "all"
24
24
  Provides-Extra: test
25
25
  Requires-Dist: pytest-xdist>=3.6.0; extra == "test"
26
26
  Requires-Dist: pytest; extra == "test"
@@ -43,7 +43,7 @@ Dynamic: license-file
43
43
  ![PyPI - Wheel](https://img.shields.io/pypi/wheel/dockerhub-api)
44
44
  ![PyPI - Implementation](https://img.shields.io/pypi/implementation/dockerhub-api)
45
45
 
46
- *Version: 0.1.0*
46
+ *Version: 0.2.0*
47
47
 
48
48
  > **Documentation** — Installation, deployment, usage across the API, CLI, and MCP
49
49
  > interfaces, the integrated A2A agent server, and guidance on the backing
@@ -73,15 +73,21 @@ Dynamic: license-file
73
73
  **Dockerhub Api** is a production-grade Agent and Model Context Protocol (MCP) server
74
74
  that wraps the official **Docker Hub API v2** (`https://hub.docker.com`): repositories
75
75
  and tags, immutable tags, personal and organization access tokens, organization
76
- members/settings/invites, teams, audit logs, and SCIM 2.0 provisioning.
76
+ members/settings/invites, teams, audit logs, and SCIM 2.0 provisioning — plus the
77
+ **Registry HTTP API v2** (`registry-1.docker.io`: manifests, blobs, digests,
78
+ multi-arch inspection, OCI referrers, and gated push/delete) and **Docker Scout**
79
+ (`api.scout.docker.com`: CVE/SBOM/policy intelligence).
77
80
 
78
81
  ---
79
82
 
80
83
  ## Key Features
81
84
 
82
- - **Consolidated Action-Routed MCP Tools:** Seven togglable tool modules
85
+ - **Consolidated Action-Routed MCP Tools:** Nine togglable tool modules
83
86
  (`hub_auth`, `hub_repos`, `hub_org`, `hub_teams`, `hub_audit`, `hub_scim`,
84
- `hub_admin`) minimize token overhead in LLM contexts.
87
+ `hub_admin`, `hub_registry`, `hub_scout`) minimize token overhead in LLM contexts.
88
+ - **Three API surfaces, one package:** the Hub *management* API, the *Registry v2*
89
+ image API (its own host + per-repository scoped-token auth), and *Docker Scout*
90
+ — each with the same uniform envelope, redaction, and gating.
85
91
  - **JWT Auth Lifecycle:** Short-lived bearer minted from `POST /v2/auth/token`
86
92
  (password, PAT `dckr_pat_*`, or org access token), cached and refreshed before
87
93
  expiry, with one transparent re-mint on 401.
@@ -153,6 +159,37 @@ Every client method returns a uniform envelope:
153
159
  | `hub_audit` | `AUDITTOOL` | True | Audit trail: `logs`, `actions` |
154
160
  | `hub_scim` | `SCIMTOOL` | True | SCIM 2.0: `service_provider_config`, `resource_types`, `resource_type`, `schemas`, `schema`, `list_users`, `get_user`, `create_user`, `update_user` |
155
161
  | `hub_admin` | `ADMINTOOL` | True | Diagnostics: `rate_limit`, `whoami` (local JWT introspection) |
162
+ | `hub_registry` | `REGISTRYTOOL` | True | Registry v2 (`registry-1.docker.io`): `api_version`, `list_tags`, `get_manifest`, `check_manifest`, `resolve_digest`, `list_platforms`, `get_config`, `inspect`, `get_blob`, `check_blob`, `list_referrers`, `delete_manifest`†, `delete_blob`†, `start_upload`†, `upload_chunk`†, `complete_upload`†, `mount_blob`†, `put_manifest`† |
163
+ | `hub_scout` | `SCOUTTOOL` | True | Docker Scout (`api.scout.docker.com`): `summary`, `cves`, `vulnerabilities`, `sbom`, `compare`, `policies`, `policy_evaluation` |
164
+
165
+ † Gated by `DOCKERHUB_ALLOW_DESTRUCTIVE` (push and delete are destructive).
166
+
167
+ #### Registry v2 vs. the Hub management API
168
+
169
+ `hub_registry` targets a **different host and auth model** than the other tools.
170
+ The Hub management API (`hub.docker.com`) uses one JWT from `/v2/auth/token`; the
171
+ Registry v2 API (`registry-1.docker.io`) authorizes each call with a
172
+ **per-repository, per-action** bearer obtained from a token service via a
173
+ `401 WWW-Authenticate` challenge. Both reuse the same `DOCKER_HUB_USER` /
174
+ `DOCKER_HUB_TOKEN` credentials (anonymous works for public pulls). Single-segment
175
+ repository names (e.g. `nginx`) are normalized to their official `library/` path.
176
+
177
+ `_catalog` (registry-wide repository listing) is intentionally **not** implemented:
178
+ Docker Hub does not issue the registry-scoped token it requires. The chunked push
179
+ buffers each chunk in memory — it is intended for manifests, config, and
180
+ attestation blobs, not as a replacement for `docker push` of large layers.
181
+
182
+ ```python
183
+ from dockerhub_api.auth import get_registry_client, get_scout_client
184
+
185
+ reg = get_registry_client()
186
+ print(reg.inspect("nginx", "latest")["data"]["platforms"]) # multi-arch list
187
+ digest = reg.resolve_digest("nginx", "latest")["data"]["digest"]
188
+ print(reg.list_referrers("nginx", digest)["data"]) # SBOM/attestations
189
+
190
+ scout = get_scout_client()
191
+ print(scout.get_cves("myorg/app", reference="v1")["data"]) # CVE listing
192
+ ```
156
193
 
157
194
  Run the server:
158
195
 
@@ -209,6 +246,21 @@ Technitium DNS guidance.
209
246
 
210
247
  ---
211
248
 
249
+ <!-- BEGIN GENERATED: additional-deployment-options -->
250
+ ### Additional Deployment Options
251
+
252
+ `dockerhub-api` can also run as a **local container** (Docker / Podman / `uv`) or be
253
+ consumed from a **remote deployment**. The
254
+ [Deployment guide](https://knuckles-team.github.io/dockerhub-api/deployment/) has full, copy-paste
255
+ `mcp_config.json` for all four transports — **stdio**, **streamable-http**,
256
+ **local container / uv**, and **remote URL**:
257
+
258
+ - **Local container / uv** — launch the server from `mcp_config.json` via `uvx`,
259
+ `docker run`, or `podman run`, or point at a local streamable-http container by `url`.
260
+ - **Remote URL** — connect to a server deployed behind Caddy at
261
+ `http://dockerhub-mcp.arpa/mcp` using the `"url"` key.
262
+ <!-- END GENERATED: additional-deployment-options -->
263
+
212
264
  ## Safety Model
213
265
 
214
266
  | Operation class | Default | Override |
@@ -13,7 +13,7 @@
13
13
  ![PyPI - Wheel](https://img.shields.io/pypi/wheel/dockerhub-api)
14
14
  ![PyPI - Implementation](https://img.shields.io/pypi/implementation/dockerhub-api)
15
15
 
16
- *Version: 0.1.0*
16
+ *Version: 0.2.0*
17
17
 
18
18
  > **Documentation** — Installation, deployment, usage across the API, CLI, and MCP
19
19
  > interfaces, the integrated A2A agent server, and guidance on the backing
@@ -43,15 +43,21 @@
43
43
  **Dockerhub Api** is a production-grade Agent and Model Context Protocol (MCP) server
44
44
  that wraps the official **Docker Hub API v2** (`https://hub.docker.com`): repositories
45
45
  and tags, immutable tags, personal and organization access tokens, organization
46
- members/settings/invites, teams, audit logs, and SCIM 2.0 provisioning.
46
+ members/settings/invites, teams, audit logs, and SCIM 2.0 provisioning — plus the
47
+ **Registry HTTP API v2** (`registry-1.docker.io`: manifests, blobs, digests,
48
+ multi-arch inspection, OCI referrers, and gated push/delete) and **Docker Scout**
49
+ (`api.scout.docker.com`: CVE/SBOM/policy intelligence).
47
50
 
48
51
  ---
49
52
 
50
53
  ## Key Features
51
54
 
52
- - **Consolidated Action-Routed MCP Tools:** Seven togglable tool modules
55
+ - **Consolidated Action-Routed MCP Tools:** Nine togglable tool modules
53
56
  (`hub_auth`, `hub_repos`, `hub_org`, `hub_teams`, `hub_audit`, `hub_scim`,
54
- `hub_admin`) minimize token overhead in LLM contexts.
57
+ `hub_admin`, `hub_registry`, `hub_scout`) minimize token overhead in LLM contexts.
58
+ - **Three API surfaces, one package:** the Hub *management* API, the *Registry v2*
59
+ image API (its own host + per-repository scoped-token auth), and *Docker Scout*
60
+ — each with the same uniform envelope, redaction, and gating.
55
61
  - **JWT Auth Lifecycle:** Short-lived bearer minted from `POST /v2/auth/token`
56
62
  (password, PAT `dckr_pat_*`, or org access token), cached and refreshed before
57
63
  expiry, with one transparent re-mint on 401.
@@ -123,6 +129,37 @@ Every client method returns a uniform envelope:
123
129
  | `hub_audit` | `AUDITTOOL` | True | Audit trail: `logs`, `actions` |
124
130
  | `hub_scim` | `SCIMTOOL` | True | SCIM 2.0: `service_provider_config`, `resource_types`, `resource_type`, `schemas`, `schema`, `list_users`, `get_user`, `create_user`, `update_user` |
125
131
  | `hub_admin` | `ADMINTOOL` | True | Diagnostics: `rate_limit`, `whoami` (local JWT introspection) |
132
+ | `hub_registry` | `REGISTRYTOOL` | True | Registry v2 (`registry-1.docker.io`): `api_version`, `list_tags`, `get_manifest`, `check_manifest`, `resolve_digest`, `list_platforms`, `get_config`, `inspect`, `get_blob`, `check_blob`, `list_referrers`, `delete_manifest`†, `delete_blob`†, `start_upload`†, `upload_chunk`†, `complete_upload`†, `mount_blob`†, `put_manifest`† |
133
+ | `hub_scout` | `SCOUTTOOL` | True | Docker Scout (`api.scout.docker.com`): `summary`, `cves`, `vulnerabilities`, `sbom`, `compare`, `policies`, `policy_evaluation` |
134
+
135
+ † Gated by `DOCKERHUB_ALLOW_DESTRUCTIVE` (push and delete are destructive).
136
+
137
+ #### Registry v2 vs. the Hub management API
138
+
139
+ `hub_registry` targets a **different host and auth model** than the other tools.
140
+ The Hub management API (`hub.docker.com`) uses one JWT from `/v2/auth/token`; the
141
+ Registry v2 API (`registry-1.docker.io`) authorizes each call with a
142
+ **per-repository, per-action** bearer obtained from a token service via a
143
+ `401 WWW-Authenticate` challenge. Both reuse the same `DOCKER_HUB_USER` /
144
+ `DOCKER_HUB_TOKEN` credentials (anonymous works for public pulls). Single-segment
145
+ repository names (e.g. `nginx`) are normalized to their official `library/` path.
146
+
147
+ `_catalog` (registry-wide repository listing) is intentionally **not** implemented:
148
+ Docker Hub does not issue the registry-scoped token it requires. The chunked push
149
+ buffers each chunk in memory — it is intended for manifests, config, and
150
+ attestation blobs, not as a replacement for `docker push` of large layers.
151
+
152
+ ```python
153
+ from dockerhub_api.auth import get_registry_client, get_scout_client
154
+
155
+ reg = get_registry_client()
156
+ print(reg.inspect("nginx", "latest")["data"]["platforms"]) # multi-arch list
157
+ digest = reg.resolve_digest("nginx", "latest")["data"]["digest"]
158
+ print(reg.list_referrers("nginx", digest)["data"]) # SBOM/attestations
159
+
160
+ scout = get_scout_client()
161
+ print(scout.get_cves("myorg/app", reference="v1")["data"]) # CVE listing
162
+ ```
126
163
 
127
164
  Run the server:
128
165
 
@@ -179,6 +216,21 @@ Technitium DNS guidance.
179
216
 
180
217
  ---
181
218
 
219
+ <!-- BEGIN GENERATED: additional-deployment-options -->
220
+ ### Additional Deployment Options
221
+
222
+ `dockerhub-api` can also run as a **local container** (Docker / Podman / `uv`) or be
223
+ consumed from a **remote deployment**. The
224
+ [Deployment guide](https://knuckles-team.github.io/dockerhub-api/deployment/) has full, copy-paste
225
+ `mcp_config.json` for all four transports — **stdio**, **streamable-http**,
226
+ **local container / uv**, and **remote URL**:
227
+
228
+ - **Local container / uv** — launch the server from `mcp_config.json` via `uvx`,
229
+ `docker run`, or `podman run`, or point at a local streamable-http container by `url`.
230
+ - **Remote URL** — connect to a server deployed behind Caddy at
231
+ `http://dockerhub-mcp.arpa/mcp` using the `"url"` key.
232
+ <!-- END GENERATED: additional-deployment-options -->
233
+
182
234
  ## Safety Model
183
235
 
184
236
  | Operation class | Default | Override |
@@ -11,7 +11,7 @@ import os
11
11
  import sys
12
12
  import warnings
13
13
 
14
- __version__ = "0.1.0"
14
+ __version__ = "0.2.0"
15
15
 
16
16
  logging.basicConfig(
17
17
  level=logging.INFO,
@@ -0,0 +1,351 @@
1
+ """Registry HTTP API v2 endpoints (``registry-1.docker.io``).
2
+
3
+ CONCEPT:HUB-1.7 — Registry v2 client. Image-level operations the Hub
4
+ management API does not cover: tag listing, manifest/blob inspection,
5
+ multi-arch platform resolution, digest resolution, and (gated) deletes.
6
+ CONCEPT:HUB-1.9 — OCI Referrers / attestation discovery.
7
+ CONCEPT:HUB-1.10 — chunked blob push (upload session + manifest put).
8
+
9
+ Pushes, deletes, and blob uploads are destructive and gated by
10
+ ``allow_destructive`` (``DOCKERHUB_ALLOW_DESTRUCTIVE``). ``_catalog`` is
11
+ intentionally omitted: Docker Hub does not support registry-wide catalog
12
+ listing (it requires a registry-scoped token the Hub token service won't issue).
13
+ """
14
+
15
+ import json as _json
16
+ from typing import Any
17
+
18
+ from dockerhub_api.api.api_client_registry_base import (
19
+ ACCEPT_MANIFESTS,
20
+ OCTET_STREAM,
21
+ RegistryApiBase,
22
+ )
23
+ from dockerhub_api.dockerhub_input_models import (
24
+ RegistryReferrersModel,
25
+ RegistryTagsListModel,
26
+ )
27
+ from dockerhub_api.dockerhub_response_models import (
28
+ ImageConfig,
29
+ ReferrerList,
30
+ RegistryManifest,
31
+ RegistryTagList,
32
+ validate_lenient,
33
+ )
34
+
35
+ #: Preferred platform when a multi-arch reference must collapse to one image.
36
+ _PREFERRED_OS = "linux"
37
+ _PREFERRED_ARCH = "amd64"
38
+
39
+
40
+ class RegistryApi(RegistryApiBase):
41
+ """Registry v2: tags, manifests, blobs, referrers, and (gated) push/delete."""
42
+
43
+ # ----------------------------- discovery ----------------------------- #
44
+
45
+ def api_version(self) -> dict[str, Any]:
46
+ """``GET /v2/`` — verify registry support and authentication."""
47
+ return self._registry_request("GET", None, raise_for_status=False)
48
+
49
+ def list_tags(
50
+ self, repo: str, n: int | None = None, last: str | None = None
51
+ ) -> dict[str, Any]:
52
+ """``GET /v2/{repo}/tags/list`` — list a repository's tags."""
53
+ model = RegistryTagsListModel(repo=repo, n=n, last=last)
54
+ envelope = self._registry_request(
55
+ "GET", repo, "/tags/list", params=model.api_parameters
56
+ )
57
+ envelope["data"] = validate_lenient(RegistryTagList, envelope["data"])
58
+ return envelope
59
+
60
+ # ----------------------------- manifests ----------------------------- #
61
+
62
+ def get_manifest(
63
+ self, repo: str, reference: str, accept: str | None = None
64
+ ) -> dict[str, Any]:
65
+ """``GET /v2/{repo}/manifests/{reference}`` (tag or digest)."""
66
+ envelope = self._registry_request(
67
+ "GET",
68
+ repo,
69
+ f"/manifests/{reference}",
70
+ accept=accept or ACCEPT_MANIFESTS,
71
+ )
72
+ envelope["data"] = validate_lenient(RegistryManifest, envelope["data"])
73
+ return envelope
74
+
75
+ def check_manifest(self, repo: str, reference: str) -> dict[str, Any]:
76
+ """``HEAD /v2/{repo}/manifests/{reference}`` — existence + digest."""
77
+ return self._registry_exists(
78
+ repo, f"/manifests/{reference}", accept=ACCEPT_MANIFESTS
79
+ )
80
+
81
+ def resolve_digest(self, repo: str, reference: str) -> dict[str, Any]:
82
+ """Resolve a tag/reference to its content-addressable digest."""
83
+ envelope = self._registry_request(
84
+ "HEAD",
85
+ repo,
86
+ f"/manifests/{reference}",
87
+ accept=ACCEPT_MANIFESTS,
88
+ raise_for_status=False,
89
+ )
90
+ digest = envelope.get("headers", {}).get("Docker-Content-Digest")
91
+ envelope["data"] = {
92
+ "reference": reference,
93
+ "digest": digest,
94
+ "media_type": envelope.get("headers", {}).get("Content-Type"),
95
+ "exists": envelope["status_code"] == 200,
96
+ }
97
+ return envelope
98
+
99
+ def put_manifest(
100
+ self,
101
+ repo: str,
102
+ reference: str,
103
+ manifest: dict | str,
104
+ media_type: str,
105
+ ) -> dict[str, Any]:
106
+ """``PUT /v2/{repo}/manifests/{reference}`` — push a manifest (gated)."""
107
+ self._guard_destructive("put_manifest")
108
+ body = manifest if isinstance(manifest, str) else _json.dumps(manifest)
109
+ return self._registry_request(
110
+ "PUT",
111
+ repo,
112
+ f"/manifests/{reference}",
113
+ scope_actions="pull,push",
114
+ content=body.encode("utf-8"),
115
+ content_type=media_type,
116
+ )
117
+
118
+ def delete_manifest(self, repo: str, reference: str) -> dict[str, Any]:
119
+ """``DELETE /v2/{repo}/manifests/{digest}`` — delete a tag/manifest (gated).
120
+
121
+ Docker Hub requires deletion by digest; pass a tag and it is resolved
122
+ to its digest first.
123
+ """
124
+ self._guard_destructive("delete_manifest")
125
+ target = reference
126
+ if not reference.startswith("sha256:"):
127
+ resolved = self.resolve_digest(repo, reference)
128
+ target = resolved["data"].get("digest") or reference
129
+ return self._registry_request(
130
+ "DELETE", repo, f"/manifests/{target}", scope_actions="pull,push,delete"
131
+ )
132
+
133
+ # ------------------------------- blobs ------------------------------- #
134
+
135
+ def get_blob(self, repo: str, digest: str) -> dict[str, Any]:
136
+ """``GET /v2/{repo}/blobs/{digest}`` — fetch a blob (config/attestation)."""
137
+ return self._registry_request("GET", repo, f"/blobs/{digest}")
138
+
139
+ def check_blob(self, repo: str, digest: str) -> dict[str, Any]:
140
+ """``HEAD /v2/{repo}/blobs/{digest}`` — blob existence check."""
141
+ return self._registry_exists(repo, f"/blobs/{digest}")
142
+
143
+ def delete_blob(self, repo: str, digest: str) -> dict[str, Any]:
144
+ """``DELETE /v2/{repo}/blobs/{digest}`` — delete a blob (gated).
145
+
146
+ Often returns ``405`` on Docker Hub (blob delete disabled); the status
147
+ is surfaced cleanly rather than masked.
148
+ """
149
+ self._guard_destructive("delete_blob")
150
+ return self._registry_request(
151
+ "DELETE",
152
+ repo,
153
+ f"/blobs/{digest}",
154
+ scope_actions="pull,push,delete",
155
+ raise_for_status=False,
156
+ )
157
+
158
+ # ------------------------------ referrers ---------------------------- #
159
+
160
+ def list_referrers(
161
+ self, repo: str, digest: str, artifact_type: str | None = None
162
+ ) -> dict[str, Any]:
163
+ """``GET /v2/{repo}/referrers/{digest}`` — OCI 1.1 referrers.
164
+
165
+ Surfaces SBOM and provenance attestation manifests that reference the
166
+ given image digest as their ``subject``.
167
+ """
168
+ model = RegistryReferrersModel(
169
+ repo=repo, digest=digest, artifact_type=artifact_type
170
+ )
171
+ envelope = self._registry_request(
172
+ "GET",
173
+ repo,
174
+ f"/referrers/{digest}",
175
+ accept="application/vnd.oci.image.index.v1+json",
176
+ params=model.api_parameters,
177
+ )
178
+ envelope["data"] = validate_lenient(ReferrerList, envelope["data"])
179
+ return envelope
180
+
181
+ # ---------------------------- convenience ---------------------------- #
182
+
183
+ def _resolve_image_manifest(
184
+ self, repo: str, reference: str
185
+ ) -> tuple[dict, str | None]:
186
+ """Return a concrete image manifest dict and its digest.
187
+
188
+ Follows one index/manifest-list level, preferring ``linux/amd64``.
189
+ """
190
+ envelope = self.get_manifest(repo, reference)
191
+ manifest = envelope["data"] if isinstance(envelope["data"], dict) else {}
192
+ digest = envelope.get("headers", {}).get("Docker-Content-Digest")
193
+ children = manifest.get("manifests")
194
+ if children:
195
+ chosen = None
196
+ for child in children:
197
+ platform = child.get("platform") or {}
198
+ if (
199
+ platform.get("os") == _PREFERRED_OS
200
+ and platform.get("architecture") == _PREFERRED_ARCH
201
+ ):
202
+ chosen = child
203
+ break
204
+ chosen = chosen or children[0]
205
+ child_digest = chosen.get("digest")
206
+ if child_digest:
207
+ child_env = self.get_manifest(repo, child_digest)
208
+ if isinstance(child_env["data"], dict):
209
+ return child_env["data"], child_digest
210
+ return manifest, digest
211
+
212
+ def list_platforms(self, repo: str, reference: str) -> dict[str, Any]:
213
+ """Enumerate the platforms a reference resolves to (multi-arch aware)."""
214
+ envelope = self.get_manifest(repo, reference)
215
+ manifest = envelope["data"] if isinstance(envelope["data"], dict) else {}
216
+ platforms: list[dict] = []
217
+ children = manifest.get("manifests")
218
+ if children:
219
+ for child in children:
220
+ platform = child.get("platform") or {}
221
+ platforms.append(
222
+ {
223
+ "os": platform.get("os"),
224
+ "architecture": platform.get("architecture"),
225
+ "variant": platform.get("variant"),
226
+ "digest": child.get("digest"),
227
+ "size": child.get("size"),
228
+ "mediaType": child.get("mediaType"),
229
+ }
230
+ )
231
+ else:
232
+ # Single-platform image: derive os/arch from the config blob.
233
+ config = manifest.get("config") or {}
234
+ config_digest = config.get("digest")
235
+ os_name = arch = variant = None
236
+ if config_digest:
237
+ blob = self.get_blob(repo, config_digest)
238
+ if isinstance(blob["data"], dict):
239
+ os_name = blob["data"].get("os")
240
+ arch = blob["data"].get("architecture")
241
+ variant = blob["data"].get("variant")
242
+ platforms.append(
243
+ {
244
+ "os": os_name,
245
+ "architecture": arch,
246
+ "variant": variant,
247
+ "digest": envelope.get("headers", {}).get("Docker-Content-Digest"),
248
+ "size": None,
249
+ "mediaType": manifest.get("mediaType"),
250
+ }
251
+ )
252
+ envelope["data"] = {"platforms": platforms}
253
+ return envelope
254
+
255
+ def get_config(self, repo: str, reference: str) -> dict[str, Any]:
256
+ """Fetch the resolved image's config blob (architecture/os/env/history)."""
257
+ manifest, _digest = self._resolve_image_manifest(repo, reference)
258
+ config = manifest.get("config") or {}
259
+ config_digest = config.get("digest")
260
+ if not config_digest:
261
+ return {
262
+ "status_code": 404,
263
+ "data": {"error": "no config descriptor on the resolved manifest"},
264
+ "rate_limit": dict(self.rate_limit),
265
+ "headers": {},
266
+ }
267
+ envelope = self.get_blob(repo, config_digest)
268
+ envelope["data"] = validate_lenient(ImageConfig, envelope["data"])
269
+ return envelope
270
+
271
+ def inspect(
272
+ self, repo: str, reference: str, include_config: bool = False
273
+ ) -> dict[str, Any]:
274
+ """``docker buildx imagetools inspect``-style summary of a reference."""
275
+ digest_env = self.resolve_digest(repo, reference)
276
+ platforms_env = self.list_platforms(repo, reference)
277
+ data: dict[str, Any] = {
278
+ "repository": self._normalize_repo(repo),
279
+ "reference": reference,
280
+ "digest": digest_env["data"].get("digest"),
281
+ "media_type": digest_env["data"].get("media_type"),
282
+ "platforms": platforms_env["data"].get("platforms", []),
283
+ }
284
+ if include_config:
285
+ config_env = self.get_config(repo, reference)
286
+ data["config"] = config_env["data"]
287
+ return {
288
+ "status_code": 200,
289
+ "data": data,
290
+ "rate_limit": dict(self.rate_limit),
291
+ "headers": digest_env.get("headers", {}),
292
+ }
293
+
294
+ # ------------------------------- push -------------------------------- #
295
+
296
+ def start_upload(self, repo: str) -> dict[str, Any]:
297
+ """``POST /v2/{repo}/blobs/uploads/`` — open a blob upload session (gated)."""
298
+ self._guard_destructive("start_upload")
299
+ return self._registry_request(
300
+ "POST", repo, "/blobs/uploads/", scope_actions="pull,push"
301
+ )
302
+
303
+ def upload_chunk(
304
+ self,
305
+ repo: str,
306
+ location: str,
307
+ chunk: bytes,
308
+ content_range: str | None = None,
309
+ ) -> dict[str, Any]:
310
+ """``PATCH {location}`` — upload one blob chunk (gated)."""
311
+ self._guard_destructive("upload_chunk")
312
+ extra = {"Content-Range": content_range} if content_range else None
313
+ return self._registry_request(
314
+ "PATCH",
315
+ repo,
316
+ scope_actions="pull,push",
317
+ raw_url=location,
318
+ content=chunk,
319
+ content_type=OCTET_STREAM,
320
+ extra_headers=extra,
321
+ )
322
+
323
+ def complete_upload(
324
+ self,
325
+ repo: str,
326
+ location: str,
327
+ digest: str,
328
+ final_chunk: bytes | None = None,
329
+ ) -> dict[str, Any]:
330
+ """``PUT {location}?digest=`` — finalize a blob upload (gated)."""
331
+ self._guard_destructive("complete_upload")
332
+ return self._registry_request(
333
+ "PUT",
334
+ repo,
335
+ scope_actions="pull,push",
336
+ raw_url=location,
337
+ params={"digest": digest},
338
+ content=final_chunk or b"",
339
+ content_type=OCTET_STREAM,
340
+ )
341
+
342
+ def mount_blob(self, repo: str, digest: str, from_repo: str) -> dict[str, Any]:
343
+ """``POST /v2/{repo}/blobs/uploads/?mount=&from=`` — cross-repo mount (gated)."""
344
+ self._guard_destructive("mount_blob")
345
+ return self._registry_request(
346
+ "POST",
347
+ repo,
348
+ "/blobs/uploads/",
349
+ scope_actions="pull,push",
350
+ params={"mount": digest, "from": self._normalize_repo(from_repo)},
351
+ )