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.
- {dockerhub_api-0.1.0/dockerhub_api.egg-info → dockerhub_api-0.2.0}/PKG-INFO +61 -9
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/README.md +56 -4
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/agent_server.py +1 -1
- dockerhub_api-0.2.0/dockerhub_api/api/api_client_registry.py +351 -0
- dockerhub_api-0.2.0/dockerhub_api/api/api_client_registry_base.py +246 -0
- dockerhub_api-0.2.0/dockerhub_api/api/api_client_scout.py +132 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api_client.py +14 -2
- dockerhub_api-0.2.0/dockerhub_api/auth.py +497 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/dockerhub_input_models.py +40 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/dockerhub_response_models.py +98 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/__init__.py +20 -0
- dockerhub_api-0.2.0/dockerhub_api/mcp/mcp_registry.py +80 -0
- dockerhub_api-0.2.0/dockerhub_api/mcp/mcp_scout.py +60 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp_server.py +11 -3
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0/dockerhub_api.egg-info}/PKG-INFO +61 -9
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/SOURCES.txt +7 -0
- dockerhub_api-0.2.0/dockerhub_api.egg-info/requires.txt +18 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/pyproject.toml +5 -5
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/requirements.txt +1 -1
- dockerhub_api-0.2.0/tests/test_api_registry.py +203 -0
- dockerhub_api-0.2.0/tests/test_api_scout.py +53 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_dockerhub_mcp_validation.py +58 -2
- dockerhub_api-0.1.0/dockerhub_api/auth.py +0 -252
- dockerhub_api-0.1.0/dockerhub_api.egg-info/requires.txt +0 -18
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/LICENSE +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/MANIFEST.in +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/__init__.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/__main__.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/__init__.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_access_tokens.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_audit_logs.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_auth.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_base.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_groups.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_org_access_tokens.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_orgs.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_repositories.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/api/api_client_scim.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/main_agent.json +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_admin.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_audit.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_auth.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_org.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_repos.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_scim.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp/mcp_teams.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api/mcp_config.json +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/dependency_links.txt +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/entry_points.txt +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/dockerhub_api.egg-info/top_level.txt +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/setup.cfg +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_audit.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_org.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_repositories.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_scim.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_teams.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_tokens.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_api_wrapper.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_auth.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_concept_parity.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_dockerhub_a2a_validation.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_init_dynamics.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_models.py +0 -0
- {dockerhub_api-0.1.0 → dockerhub_api-0.2.0}/tests/test_rate_limit.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
19
|
+
Requires-Dist: agent-utilities[mcp]>=0.48.0; extra == "mcp"
|
|
20
20
|
Provides-Extra: agent
|
|
21
|
-
Requires-Dist: agent-utilities[agent,logfire]>=0.
|
|
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.
|
|
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
|

|
|
44
44
|

|
|
45
45
|
|
|
46
|
-
*Version: 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:**
|
|
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
|

|
|
14
14
|

|
|
15
15
|
|
|
16
|
-
*Version: 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:**
|
|
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 |
|
|
@@ -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
|
+
)
|