dockerhub-api 0.1.0__py3-none-any.whl
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/__init__.py +80 -0
- dockerhub_api/__main__.py +4 -0
- dockerhub_api/agent_server.py +92 -0
- dockerhub_api/api/__init__.py +1 -0
- dockerhub_api/api/api_client_access_tokens.py +77 -0
- dockerhub_api/api/api_client_audit_logs.py +56 -0
- dockerhub_api/api/api_client_auth.py +80 -0
- dockerhub_api/api/api_client_base.py +338 -0
- dockerhub_api/api/api_client_groups.py +158 -0
- dockerhub_api/api/api_client_org_access_tokens.py +106 -0
- dockerhub_api/api/api_client_orgs.py +149 -0
- dockerhub_api/api/api_client_repositories.py +217 -0
- dockerhub_api/api/api_client_scim.py +153 -0
- dockerhub_api/api_client.py +35 -0
- dockerhub_api/auth.py +252 -0
- dockerhub_api/dockerhub_input_models.py +756 -0
- dockerhub_api/dockerhub_response_models.py +344 -0
- dockerhub_api/main_agent.json +14 -0
- dockerhub_api/mcp/__init__.py +120 -0
- dockerhub_api/mcp/mcp_admin.py +45 -0
- dockerhub_api/mcp/mcp_audit.py +44 -0
- dockerhub_api/mcp/mcp_auth.py +66 -0
- dockerhub_api/mcp/mcp_org.py +58 -0
- dockerhub_api/mcp/mcp_repos.py +58 -0
- dockerhub_api/mcp/mcp_scim.py +56 -0
- dockerhub_api/mcp/mcp_teams.py +55 -0
- dockerhub_api/mcp_config.json +3 -0
- dockerhub_api/mcp_server.py +109 -0
- dockerhub_api-0.1.0.dist-info/METADATA +230 -0
- dockerhub_api-0.1.0.dist-info/RECORD +34 -0
- dockerhub_api-0.1.0.dist-info/WHEEL +5 -0
- dockerhub_api-0.1.0.dist-info/entry_points.txt +3 -0
- dockerhub_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- dockerhub_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Base HTTP plumbing for the Docker Hub API client.
|
|
2
|
+
|
|
3
|
+
CONCEPT:HUB-1.0 — core wrapper. Raw ``httpx`` client against
|
|
4
|
+
``https://hub.docker.com`` with a uniform response envelope.
|
|
5
|
+
|
|
6
|
+
CONCEPT:HUB-1.2 — rate-limit telemetry. Every response's
|
|
7
|
+
``X-RateLimit-Limit`` / ``X-RateLimit-Remaining`` / ``X-RateLimit-Reset``
|
|
8
|
+
headers are captured into the result envelope (and kept on the client as
|
|
9
|
+
``rate_limit``), and HTTP 429 responses are retried with a bounded
|
|
10
|
+
``Retry-After`` backoff.
|
|
11
|
+
|
|
12
|
+
CONCEPT:HUB-1.3 — destructive-action gating. Deletes and org-settings writes
|
|
13
|
+
raise :class:`DestructiveOperationError` unless the client was built with
|
|
14
|
+
``allow_destructive=True``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from agent_utilities.base_utilities import get_logger
|
|
23
|
+
from agent_utilities.core.exceptions import (
|
|
24
|
+
ApiError,
|
|
25
|
+
AuthError,
|
|
26
|
+
ParameterError,
|
|
27
|
+
UnauthorizedError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
33
|
+
SCIM_CONTENT_TYPE = "application/scim+json"
|
|
34
|
+
|
|
35
|
+
#: Hard ceiling (seconds) on a single Retry-After sleep so a hostile or
|
|
36
|
+
#: misconfigured server can never stall a caller indefinitely.
|
|
37
|
+
DEFAULT_RETRY_AFTER_CAP = 15.0
|
|
38
|
+
DEFAULT_MAX_RETRIES = 3
|
|
39
|
+
DEFAULT_TIMEOUT = 30.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DestructiveOperationError(PermissionError):
|
|
43
|
+
"""Raised when a destructive operation is attempted while gated off."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DockerHubApiBase:
|
|
47
|
+
"""Shared transport, auth header, retry, and envelope logic."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
url: str | None = None,
|
|
52
|
+
username: str | None = None,
|
|
53
|
+
password: str | None = None,
|
|
54
|
+
token: str | None = None,
|
|
55
|
+
token_manager: Any | None = None,
|
|
56
|
+
verify: bool = True,
|
|
57
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
58
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
59
|
+
retry_after_cap: float = DEFAULT_RETRY_AFTER_CAP,
|
|
60
|
+
allow_destructive: bool = False,
|
|
61
|
+
debug: bool = False,
|
|
62
|
+
transport: httpx.BaseTransport | None = None,
|
|
63
|
+
):
|
|
64
|
+
if debug:
|
|
65
|
+
logger.setLevel(logging.DEBUG)
|
|
66
|
+
logger.debug("Debug mode enabled")
|
|
67
|
+
else:
|
|
68
|
+
logger.setLevel(logging.ERROR)
|
|
69
|
+
|
|
70
|
+
from dockerhub_api.auth import DEFAULT_DOCKERHUB_URL, TokenManager
|
|
71
|
+
|
|
72
|
+
self.url = (url or DEFAULT_DOCKERHUB_URL).rstrip("/")
|
|
73
|
+
self.verify = verify
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.max_retries = max_retries
|
|
76
|
+
self.retry_after_cap = retry_after_cap
|
|
77
|
+
self.allow_destructive = allow_destructive
|
|
78
|
+
self.debug = debug
|
|
79
|
+
#: Last rate-limit snapshot seen on any response.
|
|
80
|
+
self.rate_limit: dict[str, Any] = {}
|
|
81
|
+
|
|
82
|
+
self._static_token: str | None = None
|
|
83
|
+
self._token_manager = None
|
|
84
|
+
if token_manager is not None:
|
|
85
|
+
self._token_manager = token_manager
|
|
86
|
+
elif token:
|
|
87
|
+
self._static_token = token
|
|
88
|
+
elif username and password:
|
|
89
|
+
self._token_manager = TokenManager(
|
|
90
|
+
identifier=username,
|
|
91
|
+
secret=password,
|
|
92
|
+
url=self.url,
|
|
93
|
+
verify=verify,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
transport=transport,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self._client = httpx.Client(
|
|
99
|
+
base_url=self.url,
|
|
100
|
+
verify=verify,
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
transport=transport,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
# Auth & headers
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
|
|
109
|
+
def _bearer_token(self) -> str | None:
|
|
110
|
+
if self._token_manager is not None:
|
|
111
|
+
return self._token_manager.get_token()
|
|
112
|
+
return self._static_token
|
|
113
|
+
|
|
114
|
+
def _build_headers(
|
|
115
|
+
self, content_type: str | None = None, accept: str | None = None
|
|
116
|
+
) -> dict[str, str]:
|
|
117
|
+
headers: dict[str, str] = {
|
|
118
|
+
"Accept": accept or JSON_CONTENT_TYPE,
|
|
119
|
+
"Content-Type": content_type or JSON_CONTENT_TYPE,
|
|
120
|
+
}
|
|
121
|
+
token = self._bearer_token()
|
|
122
|
+
if token:
|
|
123
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
124
|
+
return headers
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
# Rate-limit telemetry
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
|
|
130
|
+
def _capture_rate_limit(self, response: httpx.Response) -> dict[str, Any]:
|
|
131
|
+
snapshot: dict[str, Any] = {}
|
|
132
|
+
for header, key in (
|
|
133
|
+
("X-RateLimit-Limit", "limit"),
|
|
134
|
+
("X-RateLimit-Remaining", "remaining"),
|
|
135
|
+
("X-RateLimit-Reset", "reset"),
|
|
136
|
+
):
|
|
137
|
+
value = response.headers.get(header)
|
|
138
|
+
if value is not None:
|
|
139
|
+
try:
|
|
140
|
+
snapshot[key] = int(value)
|
|
141
|
+
except ValueError:
|
|
142
|
+
snapshot[key] = value
|
|
143
|
+
if snapshot:
|
|
144
|
+
self.rate_limit = snapshot
|
|
145
|
+
return snapshot
|
|
146
|
+
|
|
147
|
+
def get_rate_limit(self) -> dict[str, Any]:
|
|
148
|
+
"""Return the most recent rate-limit snapshot observed by this client."""
|
|
149
|
+
return {
|
|
150
|
+
"status_code": 200,
|
|
151
|
+
"data": dict(self.rate_limit) if self.rate_limit else {},
|
|
152
|
+
"rate_limit": dict(self.rate_limit) if self.rate_limit else {},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def whoami(self) -> dict[str, Any]:
|
|
156
|
+
"""Introspect the active credential locally (decoded JWT claims).
|
|
157
|
+
|
|
158
|
+
No network call is made: the cached JWT payload is decoded. For a
|
|
159
|
+
static token the claims are decoded from the supplied bearer.
|
|
160
|
+
"""
|
|
161
|
+
from dockerhub_api.auth import decode_jwt_claims
|
|
162
|
+
|
|
163
|
+
if self._token_manager is not None:
|
|
164
|
+
claims = self._token_manager.claims()
|
|
165
|
+
identity = getattr(self._token_manager, "identifier", None)
|
|
166
|
+
elif self._static_token:
|
|
167
|
+
claims = decode_jwt_claims(self._static_token)
|
|
168
|
+
identity = claims.get("username") or claims.get("sub")
|
|
169
|
+
else:
|
|
170
|
+
return {
|
|
171
|
+
"status_code": 200,
|
|
172
|
+
"data": {"authenticated": False, "identity": None, "claims": {}},
|
|
173
|
+
"rate_limit": dict(self.rate_limit) if self.rate_limit else {},
|
|
174
|
+
}
|
|
175
|
+
public_claims = {
|
|
176
|
+
key: value
|
|
177
|
+
for key, value in claims.items()
|
|
178
|
+
if key in ("sub", "username", "exp", "iat", "iss", "aud", "scope", "uuid")
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
"status_code": 200,
|
|
182
|
+
"data": {
|
|
183
|
+
"authenticated": True,
|
|
184
|
+
"identity": identity or public_claims.get("username"),
|
|
185
|
+
"claims": public_claims,
|
|
186
|
+
},
|
|
187
|
+
"rate_limit": dict(self.rate_limit) if self.rate_limit else {},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------ #
|
|
191
|
+
# Destructive gating
|
|
192
|
+
# ------------------------------------------------------------------ #
|
|
193
|
+
|
|
194
|
+
def _guard_destructive(self, operation: str) -> None:
|
|
195
|
+
if not self.allow_destructive:
|
|
196
|
+
raise DestructiveOperationError(
|
|
197
|
+
f"Destructive operation '{operation}' is disabled. "
|
|
198
|
+
"Build the client with allow_destructive=True or set "
|
|
199
|
+
"DOCKERHUB_ALLOW_DESTRUCTIVE=True to enable it."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------ #
|
|
203
|
+
# Request engine
|
|
204
|
+
# ------------------------------------------------------------------ #
|
|
205
|
+
|
|
206
|
+
def _request(
|
|
207
|
+
self,
|
|
208
|
+
method: str,
|
|
209
|
+
endpoint: str,
|
|
210
|
+
params: dict | None = None,
|
|
211
|
+
json: Any | None = None,
|
|
212
|
+
content_type: str | None = None,
|
|
213
|
+
accept: str | None = None,
|
|
214
|
+
raise_for_status: bool = True,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
"""Send one API request and return the uniform response envelope.
|
|
217
|
+
|
|
218
|
+
Envelope: ``{"status_code": int, "data": Any, "rate_limit": dict}``.
|
|
219
|
+
Handles 429 with bounded Retry-After backoff and one transparent
|
|
220
|
+
token refresh on 401 when a token manager is present.
|
|
221
|
+
"""
|
|
222
|
+
if params:
|
|
223
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
224
|
+
|
|
225
|
+
attempts = 0
|
|
226
|
+
refreshed_token = False
|
|
227
|
+
while True:
|
|
228
|
+
response = self._client.request(
|
|
229
|
+
method=method,
|
|
230
|
+
url=endpoint,
|
|
231
|
+
params=params or None,
|
|
232
|
+
json=json,
|
|
233
|
+
headers=self._build_headers(content_type=content_type, accept=accept),
|
|
234
|
+
)
|
|
235
|
+
rate_limit = self._capture_rate_limit(response)
|
|
236
|
+
|
|
237
|
+
if response.status_code == 429 and attempts < self.max_retries:
|
|
238
|
+
attempts += 1
|
|
239
|
+
delay = self._retry_after_seconds(response)
|
|
240
|
+
logger.debug(
|
|
241
|
+
"Rate limited; retrying",
|
|
242
|
+
extra={"attempt": attempts, "delay": delay},
|
|
243
|
+
)
|
|
244
|
+
if delay > 0:
|
|
245
|
+
time.sleep(delay)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
response.status_code == 401
|
|
250
|
+
and self._token_manager is not None
|
|
251
|
+
and not refreshed_token
|
|
252
|
+
):
|
|
253
|
+
refreshed_token = True
|
|
254
|
+
self._token_manager.invalidate()
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
data = self._parse_body(response)
|
|
260
|
+
if raise_for_status and response.status_code >= 400:
|
|
261
|
+
self._raise_for_status(response, data)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
"status_code": response.status_code,
|
|
265
|
+
"data": data,
|
|
266
|
+
"rate_limit": rate_limit or dict(self.rate_limit),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def _retry_after_seconds(self, response: httpx.Response) -> float:
|
|
270
|
+
raw = response.headers.get("Retry-After", "1")
|
|
271
|
+
try:
|
|
272
|
+
delay = float(raw)
|
|
273
|
+
except ValueError:
|
|
274
|
+
delay = 1.0
|
|
275
|
+
return max(0.0, min(delay, self.retry_after_cap))
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
279
|
+
if response.status_code == 204 or not response.content:
|
|
280
|
+
return None
|
|
281
|
+
content_type = response.headers.get("Content-Type", "")
|
|
282
|
+
if "json" in content_type:
|
|
283
|
+
try:
|
|
284
|
+
return response.json()
|
|
285
|
+
except ValueError:
|
|
286
|
+
return response.text
|
|
287
|
+
return response.text
|
|
288
|
+
|
|
289
|
+
def _raise_for_status(self, response: httpx.Response, data: Any) -> None:
|
|
290
|
+
detail = ""
|
|
291
|
+
if isinstance(data, dict):
|
|
292
|
+
detail = str(
|
|
293
|
+
data.get("detail") or data.get("message") or data.get("errinfo") or ""
|
|
294
|
+
)
|
|
295
|
+
message = f"HTTP {response.status_code} for {response.request.method} {response.request.url.path}"
|
|
296
|
+
if detail:
|
|
297
|
+
message = f"{message}: {detail}"
|
|
298
|
+
|
|
299
|
+
if response.status_code == 400:
|
|
300
|
+
raise ParameterError(message)
|
|
301
|
+
if response.status_code == 401:
|
|
302
|
+
raise AuthError(message)
|
|
303
|
+
if response.status_code == 403:
|
|
304
|
+
raise UnauthorizedError(message)
|
|
305
|
+
if response.status_code == 404:
|
|
306
|
+
raise ParameterError(message)
|
|
307
|
+
if response.status_code == 429:
|
|
308
|
+
raise ApiError(
|
|
309
|
+
f"{message} — rate limited after {self.max_retries} retries "
|
|
310
|
+
f"(remaining={self.rate_limit.get('remaining')}, "
|
|
311
|
+
f"reset={self.rate_limit.get('reset')})"
|
|
312
|
+
)
|
|
313
|
+
raise ApiError(message)
|
|
314
|
+
|
|
315
|
+
def _exists(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
|
|
316
|
+
"""HEAD helper: existence check without raising on 404."""
|
|
317
|
+
envelope = self._request(
|
|
318
|
+
"HEAD", endpoint, params=params, raise_for_status=False
|
|
319
|
+
)
|
|
320
|
+
status_code = envelope["status_code"]
|
|
321
|
+
if status_code not in (200, 404) and status_code >= 400:
|
|
322
|
+
# Map real errors (401/403/5xx); 404 simply means "does not exist".
|
|
323
|
+
if status_code == 401:
|
|
324
|
+
raise AuthError(f"HTTP {status_code} for HEAD {endpoint}")
|
|
325
|
+
if status_code == 403:
|
|
326
|
+
raise UnauthorizedError(f"HTTP {status_code} for HEAD {endpoint}")
|
|
327
|
+
raise ApiError(f"HTTP {status_code} for HEAD {endpoint}")
|
|
328
|
+
envelope["exists"] = 200 <= status_code < 300
|
|
329
|
+
return envelope
|
|
330
|
+
|
|
331
|
+
def close(self) -> None:
|
|
332
|
+
self._client.close()
|
|
333
|
+
|
|
334
|
+
def __enter__(self) -> "DockerHubApiBase":
|
|
335
|
+
return self
|
|
336
|
+
|
|
337
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
338
|
+
self.close()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Group (team) endpoints (``/v2/orgs/{org}/groups``).
|
|
2
|
+
|
|
3
|
+
CONCEPT:HUB-1.0 — core wrapper.
|
|
4
|
+
CONCEPT:HUB-1.3 — destructive-action gating (group/member deletion).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dockerhub_api.api.api_client_base import DockerHubApiBase
|
|
10
|
+
from dockerhub_api.dockerhub_input_models import (
|
|
11
|
+
GroupCreateModel,
|
|
12
|
+
GroupListModel,
|
|
13
|
+
GroupMemberAddModel,
|
|
14
|
+
GroupMemberListModel,
|
|
15
|
+
GroupMemberModel,
|
|
16
|
+
GroupModel,
|
|
17
|
+
GroupUpdateModel,
|
|
18
|
+
)
|
|
19
|
+
from dockerhub_api.dockerhub_response_models import (
|
|
20
|
+
Group,
|
|
21
|
+
GroupPage,
|
|
22
|
+
OrgMember,
|
|
23
|
+
OrgMemberPage,
|
|
24
|
+
validate_lenient,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DockerHubApiGroups(DockerHubApiBase):
|
|
29
|
+
"""Org teams and their membership."""
|
|
30
|
+
|
|
31
|
+
def get_groups(
|
|
32
|
+
self,
|
|
33
|
+
org: str,
|
|
34
|
+
search: str | None = None,
|
|
35
|
+
page: int | None = None,
|
|
36
|
+
page_size: int | None = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""List an organization's groups (teams)."""
|
|
39
|
+
model = GroupListModel(org=org, search=search, page=page, page_size=page_size)
|
|
40
|
+
envelope = self._request(
|
|
41
|
+
"GET", f"/v2/orgs/{model.org}/groups", params=model.api_parameters
|
|
42
|
+
)
|
|
43
|
+
envelope["data"] = validate_lenient(GroupPage, envelope["data"])
|
|
44
|
+
return envelope
|
|
45
|
+
|
|
46
|
+
def create_group(
|
|
47
|
+
self, org: str, name: str, description: str | None = None
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Create a group (team) in an organization."""
|
|
50
|
+
model = GroupCreateModel(org=org, name=name, description=description)
|
|
51
|
+
envelope = self._request(
|
|
52
|
+
"POST", f"/v2/orgs/{model.org}/groups", json=model.payload
|
|
53
|
+
)
|
|
54
|
+
envelope["data"] = validate_lenient(Group, envelope["data"])
|
|
55
|
+
return envelope
|
|
56
|
+
|
|
57
|
+
def get_group(self, org: str, group_name: str) -> dict[str, Any]:
|
|
58
|
+
"""Get one group."""
|
|
59
|
+
model = GroupModel(org=org, group_name=group_name)
|
|
60
|
+
envelope = self._request(
|
|
61
|
+
"GET", f"/v2/orgs/{model.org}/groups/{model.group_name}"
|
|
62
|
+
)
|
|
63
|
+
envelope["data"] = validate_lenient(Group, envelope["data"])
|
|
64
|
+
return envelope
|
|
65
|
+
|
|
66
|
+
def update_group(
|
|
67
|
+
self,
|
|
68
|
+
org: str,
|
|
69
|
+
group_name: str,
|
|
70
|
+
name: str | None = None,
|
|
71
|
+
description: str | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Replace a group's details (``PUT``)."""
|
|
74
|
+
model = GroupUpdateModel(
|
|
75
|
+
org=org, group_name=group_name, name=name, description=description
|
|
76
|
+
)
|
|
77
|
+
envelope = self._request(
|
|
78
|
+
"PUT", f"/v2/orgs/{model.org}/groups/{model.group_name}", json=model.payload
|
|
79
|
+
)
|
|
80
|
+
envelope["data"] = validate_lenient(Group, envelope["data"])
|
|
81
|
+
return envelope
|
|
82
|
+
|
|
83
|
+
def patch_group(
|
|
84
|
+
self,
|
|
85
|
+
org: str,
|
|
86
|
+
group_name: str,
|
|
87
|
+
name: str | None = None,
|
|
88
|
+
description: str | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Partially update a group (``PATCH``)."""
|
|
91
|
+
model = GroupUpdateModel(
|
|
92
|
+
org=org, group_name=group_name, name=name, description=description
|
|
93
|
+
)
|
|
94
|
+
envelope = self._request(
|
|
95
|
+
"PATCH",
|
|
96
|
+
f"/v2/orgs/{model.org}/groups/{model.group_name}",
|
|
97
|
+
json=model.payload,
|
|
98
|
+
)
|
|
99
|
+
envelope["data"] = validate_lenient(Group, envelope["data"])
|
|
100
|
+
return envelope
|
|
101
|
+
|
|
102
|
+
def delete_group(self, org: str, group_name: str) -> dict[str, Any]:
|
|
103
|
+
"""Delete a group. Destructive — gated."""
|
|
104
|
+
self._guard_destructive("delete_group")
|
|
105
|
+
model = GroupModel(org=org, group_name=group_name)
|
|
106
|
+
return self._request(
|
|
107
|
+
"DELETE", f"/v2/orgs/{model.org}/groups/{model.group_name}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# ------------------------------ members ------------------------------ #
|
|
111
|
+
|
|
112
|
+
def get_group_members(
|
|
113
|
+
self,
|
|
114
|
+
org: str,
|
|
115
|
+
group_name: str,
|
|
116
|
+
search: str | None = None,
|
|
117
|
+
page: int | None = None,
|
|
118
|
+
page_size: int | None = None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""List a group's members."""
|
|
121
|
+
model = GroupMemberListModel(
|
|
122
|
+
org=org,
|
|
123
|
+
group_name=group_name,
|
|
124
|
+
search=search,
|
|
125
|
+
page=page,
|
|
126
|
+
page_size=page_size,
|
|
127
|
+
)
|
|
128
|
+
envelope = self._request(
|
|
129
|
+
"GET",
|
|
130
|
+
f"/v2/orgs/{model.org}/groups/{model.group_name}/members",
|
|
131
|
+
params=model.api_parameters,
|
|
132
|
+
)
|
|
133
|
+
envelope["data"] = validate_lenient(OrgMemberPage, envelope["data"])
|
|
134
|
+
return envelope
|
|
135
|
+
|
|
136
|
+
def add_group_member(
|
|
137
|
+
self, org: str, group_name: str, member: str
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""Add a username to a group."""
|
|
140
|
+
model = GroupMemberAddModel(org=org, group_name=group_name, member=member)
|
|
141
|
+
envelope = self._request(
|
|
142
|
+
"POST",
|
|
143
|
+
f"/v2/orgs/{model.org}/groups/{model.group_name}/members",
|
|
144
|
+
json=model.payload,
|
|
145
|
+
)
|
|
146
|
+
envelope["data"] = validate_lenient(OrgMember, envelope["data"])
|
|
147
|
+
return envelope
|
|
148
|
+
|
|
149
|
+
def remove_group_member(
|
|
150
|
+
self, org: str, group_name: str, username: str
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
"""Remove a username from a group. Destructive — gated."""
|
|
153
|
+
self._guard_destructive("remove_group_member")
|
|
154
|
+
model = GroupMemberModel(org=org, group_name=group_name, username=username)
|
|
155
|
+
return self._request(
|
|
156
|
+
"DELETE",
|
|
157
|
+
f"/v2/orgs/{model.org}/groups/{model.group_name}/members/{model.username}",
|
|
158
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Organization access token (OAT) endpoints (``/v2/orgs/{org}/access-tokens``).
|
|
2
|
+
|
|
3
|
+
CONCEPT:HUB-1.1 — JWT auth lifecycle (OAT management).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from dockerhub_api.api.api_client_base import DockerHubApiBase
|
|
9
|
+
from dockerhub_api.dockerhub_input_models import (
|
|
10
|
+
OrgAccessTokenCreateModel,
|
|
11
|
+
OrgAccessTokenListModel,
|
|
12
|
+
OrgAccessTokenModel,
|
|
13
|
+
OrgAccessTokenPatchModel,
|
|
14
|
+
)
|
|
15
|
+
from dockerhub_api.dockerhub_response_models import (
|
|
16
|
+
OrgAccessToken,
|
|
17
|
+
OrgAccessTokenPage,
|
|
18
|
+
validate_lenient,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DockerHubApiOrgAccessTokens(DockerHubApiBase):
|
|
23
|
+
"""CRUD for organization access tokens."""
|
|
24
|
+
|
|
25
|
+
def get_org_access_tokens(
|
|
26
|
+
self, org: str, page: int | None = None, page_size: int | None = None
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""List an organization's access tokens."""
|
|
29
|
+
model = OrgAccessTokenListModel(org=org, page=page, page_size=page_size)
|
|
30
|
+
envelope = self._request(
|
|
31
|
+
"GET",
|
|
32
|
+
f"/v2/orgs/{model.org}/access-tokens",
|
|
33
|
+
params=model.api_parameters,
|
|
34
|
+
)
|
|
35
|
+
envelope["data"] = validate_lenient(OrgAccessTokenPage, envelope["data"])
|
|
36
|
+
return envelope
|
|
37
|
+
|
|
38
|
+
def create_org_access_token(
|
|
39
|
+
self,
|
|
40
|
+
org: str,
|
|
41
|
+
label: str,
|
|
42
|
+
description: str | None = None,
|
|
43
|
+
expires_at: str | None = None,
|
|
44
|
+
scopes: list[str] | None = None,
|
|
45
|
+
resources: list[dict] | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Create an organization access token.
|
|
48
|
+
|
|
49
|
+
``resources`` entries scope the token to ``TYPE_REPO`` / ``TYPE_ORG``
|
|
50
|
+
resources with path globs and per-resource scopes. The plaintext token
|
|
51
|
+
is only returned once, in this response.
|
|
52
|
+
"""
|
|
53
|
+
model = OrgAccessTokenCreateModel(
|
|
54
|
+
org=org,
|
|
55
|
+
label=label,
|
|
56
|
+
description=description,
|
|
57
|
+
expires_at=expires_at,
|
|
58
|
+
scopes=scopes,
|
|
59
|
+
resources=resources,
|
|
60
|
+
)
|
|
61
|
+
envelope = self._request(
|
|
62
|
+
"POST", f"/v2/orgs/{model.org}/access-tokens", json=model.payload
|
|
63
|
+
)
|
|
64
|
+
envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
|
|
65
|
+
return envelope
|
|
66
|
+
|
|
67
|
+
def get_org_access_token(self, org: str, token_id: str) -> dict[str, Any]:
|
|
68
|
+
"""Get one organization access token by id."""
|
|
69
|
+
model = OrgAccessTokenModel(org=org, token_id=token_id)
|
|
70
|
+
envelope = self._request(
|
|
71
|
+
"GET", f"/v2/orgs/{model.org}/access-tokens/{model.token_id}"
|
|
72
|
+
)
|
|
73
|
+
envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
|
|
74
|
+
return envelope
|
|
75
|
+
|
|
76
|
+
def update_org_access_token(
|
|
77
|
+
self,
|
|
78
|
+
org: str,
|
|
79
|
+
token_id: str,
|
|
80
|
+
label: str | None = None,
|
|
81
|
+
description: str | None = None,
|
|
82
|
+
is_active: bool | None = None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Patch an organization access token."""
|
|
85
|
+
model = OrgAccessTokenPatchModel(
|
|
86
|
+
org=org,
|
|
87
|
+
token_id=token_id,
|
|
88
|
+
label=label,
|
|
89
|
+
description=description,
|
|
90
|
+
is_active=is_active,
|
|
91
|
+
)
|
|
92
|
+
envelope = self._request(
|
|
93
|
+
"PATCH",
|
|
94
|
+
f"/v2/orgs/{model.org}/access-tokens/{model.token_id}",
|
|
95
|
+
json=model.payload,
|
|
96
|
+
)
|
|
97
|
+
envelope["data"] = validate_lenient(OrgAccessToken, envelope["data"])
|
|
98
|
+
return envelope
|
|
99
|
+
|
|
100
|
+
def delete_org_access_token(self, org: str, token_id: str) -> dict[str, Any]:
|
|
101
|
+
"""Delete an organization access token. Destructive — gated."""
|
|
102
|
+
self._guard_destructive("delete_org_access_token")
|
|
103
|
+
model = OrgAccessTokenModel(org=org, token_id=token_id)
|
|
104
|
+
return self._request(
|
|
105
|
+
"DELETE", f"/v2/orgs/{model.org}/access-tokens/{model.token_id}"
|
|
106
|
+
)
|