nemo-evaluator-launcher 0.1.19__py3-none-any.whl → 0.1.56__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.
- nemo_evaluator_launcher/api/functional.py +159 -5
- nemo_evaluator_launcher/cli/logs.py +102 -0
- nemo_evaluator_launcher/cli/ls_task.py +280 -0
- nemo_evaluator_launcher/cli/ls_tasks.py +208 -55
- nemo_evaluator_launcher/cli/main.py +29 -2
- nemo_evaluator_launcher/cli/run.py +114 -16
- nemo_evaluator_launcher/cli/version.py +26 -23
- nemo_evaluator_launcher/common/container_metadata/__init__.py +61 -0
- nemo_evaluator_launcher/common/container_metadata/intermediate_repr.py +530 -0
- nemo_evaluator_launcher/common/container_metadata/loading.py +1126 -0
- nemo_evaluator_launcher/common/container_metadata/registries.py +824 -0
- nemo_evaluator_launcher/common/container_metadata/utils.py +63 -0
- nemo_evaluator_launcher/common/helpers.py +200 -51
- nemo_evaluator_launcher/common/logging_utils.py +16 -5
- nemo_evaluator_launcher/common/mapping.py +341 -155
- nemo_evaluator_launcher/common/printing_utils.py +25 -12
- nemo_evaluator_launcher/configs/deployment/sglang.yaml +4 -2
- nemo_evaluator_launcher/configs/deployment/trtllm.yaml +2 -3
- nemo_evaluator_launcher/configs/deployment/vllm.yaml +0 -1
- nemo_evaluator_launcher/configs/execution/slurm/default.yaml +14 -0
- nemo_evaluator_launcher/executors/base.py +31 -1
- nemo_evaluator_launcher/executors/lepton/deployment_helpers.py +36 -1
- nemo_evaluator_launcher/executors/lepton/executor.py +107 -9
- nemo_evaluator_launcher/executors/local/executor.py +383 -24
- nemo_evaluator_launcher/executors/local/run.template.sh +54 -2
- nemo_evaluator_launcher/executors/slurm/executor.py +559 -64
- nemo_evaluator_launcher/executors/slurm/proxy.cfg.template +26 -0
- nemo_evaluator_launcher/exporters/utils.py +32 -46
- nemo_evaluator_launcher/package_info.py +1 -1
- nemo_evaluator_launcher/resources/all_tasks_irs.yaml +17016 -0
- nemo_evaluator_launcher/resources/mapping.toml +64 -315
- {nemo_evaluator_launcher-0.1.19.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/METADATA +4 -3
- nemo_evaluator_launcher-0.1.56.dist-info/RECORD +69 -0
- {nemo_evaluator_launcher-0.1.19.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/entry_points.txt +1 -0
- nemo_evaluator_launcher-0.1.19.dist-info/RECORD +0 -60
- {nemo_evaluator_launcher-0.1.19.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/WHEEL +0 -0
- {nemo_evaluator_launcher-0.1.19.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/licenses/LICENSE +0 -0
- {nemo_evaluator_launcher-0.1.19.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
#
|
|
16
|
+
"""Registry authentication and credential management for container registries."""
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from typing import Dict, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from nemo_evaluator_launcher.common.logging_utils import logger
|
|
29
|
+
|
|
30
|
+
# Docker credentials file location for falling back if public and PAT auth failed
|
|
31
|
+
_DOCKER_CONFIG_PATH = pathlib.Path.home() / ".docker" / "config.json"
|
|
32
|
+
|
|
33
|
+
# Docker Registry API v2 manifest Accept header.
|
|
34
|
+
# IMPORTANT: include *manifest list* / *OCI index* types so multi-arch tags return
|
|
35
|
+
# the index by default (otherwise registries may negotiate down to a single-arch
|
|
36
|
+
# manifest, typically amd64).
|
|
37
|
+
_DOCKER_MANIFEST_MEDIA_TYPE = (
|
|
38
|
+
"application/vnd.oci.image.index.v1+json, "
|
|
39
|
+
"application/vnd.docker.distribution.manifest.list.v2+json, "
|
|
40
|
+
"application/vnd.oci.image.manifest.v1+json, "
|
|
41
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_key_variants(registry_url: str) -> list[str]:
|
|
46
|
+
"""Build list of key variants to try when looking up Docker credentials.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
registry_url: Registry URL
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of key variants to try
|
|
53
|
+
"""
|
|
54
|
+
registry_host = registry_url.split(":")[0]
|
|
55
|
+
variants = [
|
|
56
|
+
registry_url,
|
|
57
|
+
registry_host,
|
|
58
|
+
f"https://{registry_url}",
|
|
59
|
+
f"https://{registry_host}",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# For GitLab, also try common ports
|
|
63
|
+
if "gitlab" in registry_host.lower():
|
|
64
|
+
for port in ["5005", "5050"]:
|
|
65
|
+
variants.extend(
|
|
66
|
+
[f"{registry_host}:{port}", f"https://{registry_host}:{port}"]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return variants
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_auth_in_config(
|
|
73
|
+
auths: dict, registry_url: str
|
|
74
|
+
) -> tuple[Optional[dict], Optional[str]]:
|
|
75
|
+
"""Find authentication entry in Docker config auths section.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
auths: Auths dictionary from Docker config
|
|
79
|
+
registry_url: Registry URL to look up
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Tuple of (auth dict, matched key) or (None, None) if not found
|
|
83
|
+
"""
|
|
84
|
+
registry_host = registry_url.split(":")[0]
|
|
85
|
+
key_variants = _build_key_variants(registry_url)
|
|
86
|
+
|
|
87
|
+
# Try exact matches first
|
|
88
|
+
for key in key_variants:
|
|
89
|
+
if key in auths:
|
|
90
|
+
return auths[key], key
|
|
91
|
+
|
|
92
|
+
# Fallback: match by hostname
|
|
93
|
+
for key in auths.keys():
|
|
94
|
+
key_host = key.split("://")[-1].split(":")[0].split("/")[0]
|
|
95
|
+
if key_host == registry_host:
|
|
96
|
+
logger.info(
|
|
97
|
+
"Found credentials using hostname match",
|
|
98
|
+
registry_url=registry_url,
|
|
99
|
+
matched_key=key,
|
|
100
|
+
)
|
|
101
|
+
return auths[key], key
|
|
102
|
+
|
|
103
|
+
return None, None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _decode_auth_string(
|
|
107
|
+
auth_string: str, registry_url: str
|
|
108
|
+
) -> Optional[Tuple[str, str]]:
|
|
109
|
+
"""Decode base64 auth string from Docker config.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
auth_string: Base64 encoded auth string
|
|
113
|
+
registry_url: Registry URL (for logging)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Tuple of (username, password) or None if decoding fails
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
decoded = base64.b64decode(auth_string).decode("utf-8")
|
|
120
|
+
if ":" not in decoded:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"Invalid auth format in Docker config (expected username:password)",
|
|
123
|
+
registry_url=registry_url,
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
return decoded.split(":", 1)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"Failed to decode auth from Docker config",
|
|
130
|
+
registry_url=registry_url,
|
|
131
|
+
error=str(e),
|
|
132
|
+
)
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_docker_credentials(registry_url: str) -> Optional[Tuple[str, str]]:
|
|
137
|
+
"""Read Docker credentials from Docker config file.
|
|
138
|
+
|
|
139
|
+
Docker stores credentials in ~/.docker/config.json with format:
|
|
140
|
+
{
|
|
141
|
+
"auths": {
|
|
142
|
+
"registry-url": {
|
|
143
|
+
"auth": "base64(username:password)"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
registry_url: Registry URL to look up credentials for
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Tuple of (username, password) if found, None otherwise
|
|
153
|
+
"""
|
|
154
|
+
if not _DOCKER_CONFIG_PATH.exists():
|
|
155
|
+
logger.debug(
|
|
156
|
+
"Docker config file not found", config_path=str(_DOCKER_CONFIG_PATH)
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
with open(_DOCKER_CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
162
|
+
config = json.load(f)
|
|
163
|
+
|
|
164
|
+
auths = config.get("auths", {})
|
|
165
|
+
if not auths:
|
|
166
|
+
logger.debug("No auths section in Docker config file")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
logger.debug(
|
|
170
|
+
"Looking up Docker credentials",
|
|
171
|
+
registry_url=registry_url,
|
|
172
|
+
available_keys=list(auths.keys()),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
registry_auth, matched_key = _find_auth_in_config(auths, registry_url)
|
|
176
|
+
if not registry_auth:
|
|
177
|
+
registry_host = registry_url.split(":")[0]
|
|
178
|
+
logger.debug(
|
|
179
|
+
"No credentials found for registry in Docker config",
|
|
180
|
+
registry_url=registry_url,
|
|
181
|
+
registry_host=registry_host,
|
|
182
|
+
available_registries=list(auths.keys()),
|
|
183
|
+
)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
auth_string = registry_auth.get("auth")
|
|
187
|
+
if not auth_string:
|
|
188
|
+
logger.debug(
|
|
189
|
+
"No auth field in Docker config for registry", registry_url=registry_url
|
|
190
|
+
)
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
result = _decode_auth_string(auth_string, registry_url)
|
|
194
|
+
if result:
|
|
195
|
+
username, password = result
|
|
196
|
+
logger.info(
|
|
197
|
+
"Found credentials in Docker config",
|
|
198
|
+
registry_url=registry_url,
|
|
199
|
+
username=username,
|
|
200
|
+
matched_key=matched_key or registry_url,
|
|
201
|
+
)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
except json.JSONDecodeError as e:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"Failed to parse Docker config file",
|
|
207
|
+
config_path=str(_DOCKER_CONFIG_PATH),
|
|
208
|
+
error=str(e),
|
|
209
|
+
)
|
|
210
|
+
return None
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning(
|
|
213
|
+
"Error reading Docker config file",
|
|
214
|
+
config_path=str(_DOCKER_CONFIG_PATH),
|
|
215
|
+
error=str(e),
|
|
216
|
+
)
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _retry_without_auth(
|
|
221
|
+
url: str, stream: bool = False, accept: Optional[str] = None
|
|
222
|
+
) -> Optional[requests.Response]:
|
|
223
|
+
"""Retry HTTP request without authentication headers.
|
|
224
|
+
|
|
225
|
+
Used for accessing public containers that may return 401/403 even for
|
|
226
|
+
anonymous access.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
url: URL to request
|
|
230
|
+
stream: Whether to stream the response
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Response object if successful, None otherwise
|
|
234
|
+
"""
|
|
235
|
+
temp_session = requests.Session()
|
|
236
|
+
temp_session.headers.update({"Accept": accept or _DOCKER_MANIFEST_MEDIA_TYPE})
|
|
237
|
+
response = temp_session.get(url, stream=stream)
|
|
238
|
+
if response.status_code == 200:
|
|
239
|
+
return response
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class DockerRegistryHandler(ABC):
|
|
244
|
+
"""Abstract base class for Docker registry authentication and operations."""
|
|
245
|
+
|
|
246
|
+
@abstractmethod
|
|
247
|
+
def authenticate(self, repository: Optional[str] = None) -> bool:
|
|
248
|
+
"""Authenticate with the registry to obtain JWT token.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
repository: Optional repository name for authentication scope.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True if authentication successful, False otherwise
|
|
255
|
+
"""
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
def get_manifest_and_digest(
|
|
259
|
+
self, repository: str, reference: str, accept: Optional[str] = None
|
|
260
|
+
) -> Tuple[Optional[Dict], Optional[str]]:
|
|
261
|
+
"""Get the manifest and digest for a specific image reference.
|
|
262
|
+
|
|
263
|
+
Default implementation that handles common retry logic for public containers.
|
|
264
|
+
Subclasses can override if they need custom behavior.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
repository: The repository name
|
|
268
|
+
reference: The tag or digest
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Tuple of (manifest dictionary, digest string).
|
|
272
|
+
Returns (None, None) if failed.
|
|
273
|
+
Digest is extracted from Docker-Content-Digest header if available,
|
|
274
|
+
otherwise computed from manifest JSON.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
# Build URL - subclasses should set self.registry_url
|
|
278
|
+
url = f"https://{self.registry_url}/v2/{repository}/manifests/{reference}"
|
|
279
|
+
logger.debug("Fetching manifest", url=url)
|
|
280
|
+
|
|
281
|
+
accept_header = (
|
|
282
|
+
accept
|
|
283
|
+
or self.session.headers.get("Accept")
|
|
284
|
+
or _DOCKER_MANIFEST_MEDIA_TYPE
|
|
285
|
+
)
|
|
286
|
+
response = self.session.get(url, headers={"Accept": accept_header})
|
|
287
|
+
|
|
288
|
+
if response.status_code == 200:
|
|
289
|
+
manifest = response.json()
|
|
290
|
+
headers_dict = dict(response.headers)
|
|
291
|
+
|
|
292
|
+
# Extract digest from Docker-Content-Digest header
|
|
293
|
+
digest = None
|
|
294
|
+
for header_name, header_value in headers_dict.items():
|
|
295
|
+
if header_name.lower() == "docker-content-digest":
|
|
296
|
+
digest = header_value
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
# Fallback: compute digest from manifest JSON
|
|
300
|
+
if not digest:
|
|
301
|
+
manifest_json = json.dumps(
|
|
302
|
+
manifest, sort_keys=True, separators=(",", ":")
|
|
303
|
+
)
|
|
304
|
+
digest = f"sha256:{hashlib.sha256(manifest_json.encode('utf-8')).hexdigest()}"
|
|
305
|
+
logger.debug(
|
|
306
|
+
"Computed digest from manifest JSON",
|
|
307
|
+
repository=repository,
|
|
308
|
+
reference=reference,
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
logger.debug(
|
|
312
|
+
"Using Docker-Content-Digest header from registry",
|
|
313
|
+
repository=repository,
|
|
314
|
+
reference=reference,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
logger.debug(
|
|
318
|
+
"Successfully retrieved manifest",
|
|
319
|
+
schema_version=manifest.get("schemaVersion", "unknown"),
|
|
320
|
+
media_type=manifest.get("mediaType", "unknown"),
|
|
321
|
+
layers_count=len(manifest.get("layers", [])),
|
|
322
|
+
digest=digest,
|
|
323
|
+
)
|
|
324
|
+
return manifest, digest
|
|
325
|
+
|
|
326
|
+
# Retry without authentication for public containers
|
|
327
|
+
if response.status_code in (401, 403):
|
|
328
|
+
logger.debug(
|
|
329
|
+
"Got 401/403, retrying without authentication",
|
|
330
|
+
status_code=response.status_code,
|
|
331
|
+
)
|
|
332
|
+
retry_response = _retry_without_auth(url, accept=accept_header)
|
|
333
|
+
if retry_response:
|
|
334
|
+
manifest = retry_response.json()
|
|
335
|
+
headers_dict = dict(retry_response.headers)
|
|
336
|
+
|
|
337
|
+
# Extract digest from Docker-Content-Digest header
|
|
338
|
+
digest = None
|
|
339
|
+
for header_name, header_value in headers_dict.items():
|
|
340
|
+
if header_name.lower() == "docker-content-digest":
|
|
341
|
+
digest = header_value
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
# Fallback: compute digest from manifest JSON
|
|
345
|
+
if not digest:
|
|
346
|
+
manifest_json = json.dumps(
|
|
347
|
+
manifest, sort_keys=True, separators=(",", ":")
|
|
348
|
+
)
|
|
349
|
+
digest = f"sha256:{hashlib.sha256(manifest_json.encode('utf-8')).hexdigest()}"
|
|
350
|
+
|
|
351
|
+
return manifest, digest
|
|
352
|
+
logger.error("Failed to get manifest", status_code=response.status_code)
|
|
353
|
+
return None, None
|
|
354
|
+
|
|
355
|
+
logger.error(
|
|
356
|
+
"Failed to get manifest",
|
|
357
|
+
status_code=response.status_code,
|
|
358
|
+
response_preview=response.text[:200],
|
|
359
|
+
)
|
|
360
|
+
return None, None
|
|
361
|
+
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.error("Error fetching manifest", error=str(e), exc_info=True)
|
|
364
|
+
return None, None
|
|
365
|
+
|
|
366
|
+
def get_blob(self, repository: str, digest: str) -> Optional[bytes]:
|
|
367
|
+
"""Download a blob (layer) by its digest.
|
|
368
|
+
|
|
369
|
+
Default implementation that handles common retry logic for public containers.
|
|
370
|
+
Subclasses can override if they need custom behavior.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
repository: The repository name
|
|
374
|
+
digest: The blob digest
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
The blob content as bytes, or None if failed
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
url = f"https://{self.registry_url}/v2/{repository}/blobs/{digest}"
|
|
381
|
+
logger.debug("Downloading blob", digest_preview=digest[:20])
|
|
382
|
+
|
|
383
|
+
response = self.session.get(url, stream=True)
|
|
384
|
+
|
|
385
|
+
if response.status_code == 200:
|
|
386
|
+
content = response.content
|
|
387
|
+
logger.debug("Downloaded blob", size_bytes=len(content))
|
|
388
|
+
return content
|
|
389
|
+
|
|
390
|
+
# Retry without authentication for public containers
|
|
391
|
+
if response.status_code in (401, 403):
|
|
392
|
+
logger.debug(
|
|
393
|
+
"Got 401/403, retrying without authentication",
|
|
394
|
+
status_code=response.status_code,
|
|
395
|
+
)
|
|
396
|
+
retry_response = _retry_without_auth(url, stream=True)
|
|
397
|
+
if retry_response:
|
|
398
|
+
return retry_response.content
|
|
399
|
+
logger.error(
|
|
400
|
+
"Failed to download blob", status_code=response.status_code
|
|
401
|
+
)
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
logger.error(
|
|
405
|
+
"Failed to download blob",
|
|
406
|
+
status_code=response.status_code,
|
|
407
|
+
digest_preview=digest[:20],
|
|
408
|
+
)
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.error(
|
|
413
|
+
"Error downloading blob",
|
|
414
|
+
error=str(e),
|
|
415
|
+
digest_preview=digest[:20],
|
|
416
|
+
exc_info=True,
|
|
417
|
+
)
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class GitlabDockerRegistryHandler(DockerRegistryHandler):
|
|
422
|
+
"""GitLab-specific implementation of Docker registry authentication flow."""
|
|
423
|
+
|
|
424
|
+
def __init__(
|
|
425
|
+
self,
|
|
426
|
+
registry_url: str,
|
|
427
|
+
username: Optional[str] = None,
|
|
428
|
+
password: Optional[str] = None,
|
|
429
|
+
repository: Optional[str] = None,
|
|
430
|
+
):
|
|
431
|
+
"""Initialize the authenticator.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
registry_url: The registry URL (e.g., 'gitlab-master.nvidia.com:5005')
|
|
435
|
+
username: Registry username (optional, for authenticated access)
|
|
436
|
+
password: Registry password or token (optional, for authenticated access)
|
|
437
|
+
repository: Optional repository name for JWT scope.
|
|
438
|
+
"""
|
|
439
|
+
self.registry_url = registry_url.rstrip("/")
|
|
440
|
+
self.username = username
|
|
441
|
+
self.password = password
|
|
442
|
+
self.repository = repository
|
|
443
|
+
self.bearer_token: Optional[str] = None
|
|
444
|
+
self.session = requests.Session()
|
|
445
|
+
|
|
446
|
+
def _check_public_access(self) -> bool:
|
|
447
|
+
"""Check if registry allows public access without authentication.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
True if public access is available, False otherwise
|
|
451
|
+
"""
|
|
452
|
+
v2_url = f"https://{self.registry_url}/v2/"
|
|
453
|
+
logger.debug("Checking for public registry access", url=v2_url)
|
|
454
|
+
response = self.session.get(v2_url)
|
|
455
|
+
|
|
456
|
+
if response.status_code == 200:
|
|
457
|
+
logger.debug("Registry is public, no authentication needed")
|
|
458
|
+
self.session.headers.update({"Accept": _DOCKER_MANIFEST_MEDIA_TYPE})
|
|
459
|
+
return True
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
def _request_gitlab_token(self, repository: str) -> Optional[str]:
|
|
463
|
+
"""Request Bearer token from GitLab JWT endpoint.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
repository: Repository name for JWT scope
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Bearer token string or None if failed
|
|
470
|
+
"""
|
|
471
|
+
gitlab_host = self.registry_url.split(":")[0]
|
|
472
|
+
jwt_url = (
|
|
473
|
+
f"https://{gitlab_host}/jwt/auth?"
|
|
474
|
+
f"service=container_registry&scope=repository:{repository}:pull"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if self.username and self.password:
|
|
478
|
+
logger.debug("Requesting Bearer token with credentials", jwt_url=jwt_url)
|
|
479
|
+
token_response = self.session.get(
|
|
480
|
+
jwt_url, auth=(self.username, self.password)
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
logger.debug(
|
|
484
|
+
"Requesting anonymous token for public container", jwt_url=jwt_url
|
|
485
|
+
)
|
|
486
|
+
token_response = self.session.get(jwt_url)
|
|
487
|
+
|
|
488
|
+
if token_response.status_code != 200:
|
|
489
|
+
logger.error(
|
|
490
|
+
"Token request failed",
|
|
491
|
+
status_code=token_response.status_code,
|
|
492
|
+
has_credentials=bool(self.username and self.password),
|
|
493
|
+
)
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
token_data = token_response.json()
|
|
497
|
+
bearer_token = token_data.get("token")
|
|
498
|
+
if not bearer_token:
|
|
499
|
+
logger.error("No token in response", response_keys=list(token_data.keys()))
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
return bearer_token
|
|
503
|
+
|
|
504
|
+
def authenticate(self, repository: Optional[str] = None) -> bool:
|
|
505
|
+
"""Authenticate with GitLab registry using Bearer Token flow.
|
|
506
|
+
|
|
507
|
+
Supports both authenticated and anonymous token requests for public containers.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
repository: Optional repository name for JWT scope.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
True if authentication successful, False otherwise
|
|
514
|
+
"""
|
|
515
|
+
try:
|
|
516
|
+
repo_for_scope = repository or self.repository
|
|
517
|
+
logger.debug(
|
|
518
|
+
"Authenticating with GitLab registry",
|
|
519
|
+
registry_url=self.registry_url,
|
|
520
|
+
repository=repo_for_scope,
|
|
521
|
+
has_credentials=bool(self.username and self.password),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# Check for public access first
|
|
525
|
+
if self._check_public_access():
|
|
526
|
+
return True
|
|
527
|
+
|
|
528
|
+
# Handle unexpected responses
|
|
529
|
+
v2_url = f"https://{self.registry_url}/v2/"
|
|
530
|
+
response = self.session.get(v2_url)
|
|
531
|
+
if response.status_code not in (200, 401):
|
|
532
|
+
logger.error(
|
|
533
|
+
"Unexpected response from registry",
|
|
534
|
+
status_code=response.status_code,
|
|
535
|
+
response_preview=response.text[:200],
|
|
536
|
+
)
|
|
537
|
+
if not (self.username and self.password):
|
|
538
|
+
logger.debug(
|
|
539
|
+
"No credentials available, attempting to proceed without authentication"
|
|
540
|
+
)
|
|
541
|
+
self.session.headers.update({"Accept": _DOCKER_MANIFEST_MEDIA_TYPE})
|
|
542
|
+
return True
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
# Request token
|
|
546
|
+
if not repo_for_scope:
|
|
547
|
+
logger.error(
|
|
548
|
+
"Repository name required for GitLab authentication",
|
|
549
|
+
registry_url=self.registry_url,
|
|
550
|
+
)
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
self.bearer_token = self._request_gitlab_token(repo_for_scope)
|
|
554
|
+
if not self.bearer_token:
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
# Set up session with bearer token
|
|
558
|
+
self.session.headers.update(
|
|
559
|
+
{
|
|
560
|
+
"Authorization": f"Bearer {self.bearer_token}",
|
|
561
|
+
"Accept": _DOCKER_MANIFEST_MEDIA_TYPE,
|
|
562
|
+
}
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return True
|
|
566
|
+
|
|
567
|
+
except Exception as e:
|
|
568
|
+
logger.error("Authentication error", error=str(e))
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
# get_manifest_and_digest and get_blob are inherited from base class
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class NvcrDockerRegistryHandler(DockerRegistryHandler):
|
|
575
|
+
"""NVIDIA Container Registry (nvcr.io) implementation using Docker Registry API v2."""
|
|
576
|
+
|
|
577
|
+
def __init__(
|
|
578
|
+
self,
|
|
579
|
+
registry_url: str,
|
|
580
|
+
username: Optional[str] = None,
|
|
581
|
+
password: Optional[str] = None,
|
|
582
|
+
):
|
|
583
|
+
"""Initialize the authenticator.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
registry_url: The registry URL (e.g., 'nvcr.io')
|
|
587
|
+
username: Registry username. Optional for anonymous access to public containers.
|
|
588
|
+
password: Registry password or API key. Optional for anonymous access to public containers.
|
|
589
|
+
"""
|
|
590
|
+
self.registry_url = registry_url.rstrip("/")
|
|
591
|
+
self.username = username
|
|
592
|
+
self.password = password
|
|
593
|
+
self.bearer_token: Optional[str] = None
|
|
594
|
+
self.session = requests.Session()
|
|
595
|
+
|
|
596
|
+
def _parse_www_authenticate(self, www_authenticate: str) -> Optional[dict]:
|
|
597
|
+
"""Parse WWW-Authenticate header to extract auth parameters.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
www_authenticate: WWW-Authenticate header value
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Dictionary with realm, service, scope or None if parsing fails
|
|
604
|
+
"""
|
|
605
|
+
auth_params = {}
|
|
606
|
+
for part in www_authenticate.replace("Bearer ", "").split(","):
|
|
607
|
+
if "=" in part:
|
|
608
|
+
key, value = part.split("=", 1)
|
|
609
|
+
auth_params[key.strip()] = value.strip('"')
|
|
610
|
+
|
|
611
|
+
realm = auth_params.get("realm")
|
|
612
|
+
if not realm:
|
|
613
|
+
logger.error("No realm in WWW-Authenticate header")
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
"realm": realm,
|
|
618
|
+
"service": auth_params.get("service", ""),
|
|
619
|
+
"scope": auth_params.get("scope", ""),
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
def _request_token(
|
|
623
|
+
self, auth_params: dict, repository: Optional[str]
|
|
624
|
+
) -> Optional[str]:
|
|
625
|
+
"""Request Bearer token from authentication realm.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
auth_params: Authentication parameters (realm, service, scope)
|
|
629
|
+
repository: Optional repository name for scope
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Bearer token string or None if failed
|
|
633
|
+
"""
|
|
634
|
+
token_url = auth_params["realm"]
|
|
635
|
+
params = {"service": auth_params["service"]}
|
|
636
|
+
if auth_params["scope"]:
|
|
637
|
+
params["scope"] = auth_params["scope"]
|
|
638
|
+
elif repository:
|
|
639
|
+
params["scope"] = f"repository:{repository}:pull"
|
|
640
|
+
|
|
641
|
+
if self.username and self.password:
|
|
642
|
+
token_response = self.session.get(
|
|
643
|
+
token_url, params=params, auth=(self.username, self.password)
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
logger.debug("Requesting anonymous token for public container")
|
|
647
|
+
token_response = self.session.get(token_url, params=params)
|
|
648
|
+
|
|
649
|
+
if token_response.status_code != 200:
|
|
650
|
+
logger.error(
|
|
651
|
+
"Token request failed",
|
|
652
|
+
status_code=token_response.status_code,
|
|
653
|
+
has_credentials=bool(self.username and self.password),
|
|
654
|
+
)
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
token_data = token_response.json()
|
|
658
|
+
bearer_token = token_data.get("token") or token_data.get("access_token")
|
|
659
|
+
if not bearer_token:
|
|
660
|
+
logger.error("No token in response")
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
return bearer_token
|
|
664
|
+
|
|
665
|
+
def authenticate(self, repository: Optional[str] = None) -> bool:
|
|
666
|
+
"""Authenticate with nvcr.io using Docker Registry API v2 Bearer token flow.
|
|
667
|
+
|
|
668
|
+
Supports both authenticated and anonymous token requests for public containers.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
repository: Optional repository name for authentication scope.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
True if authentication successful, False otherwise
|
|
675
|
+
"""
|
|
676
|
+
try:
|
|
677
|
+
v2_url = f"https://{self.registry_url}/v2/"
|
|
678
|
+
response = self.session.get(v2_url)
|
|
679
|
+
|
|
680
|
+
if response.status_code == 200:
|
|
681
|
+
return True
|
|
682
|
+
|
|
683
|
+
if response.status_code != 401:
|
|
684
|
+
logger.error(
|
|
685
|
+
"Unexpected response from registry",
|
|
686
|
+
status_code=response.status_code,
|
|
687
|
+
)
|
|
688
|
+
return False
|
|
689
|
+
|
|
690
|
+
www_authenticate = response.headers.get("WWW-Authenticate", "")
|
|
691
|
+
if not www_authenticate:
|
|
692
|
+
logger.error("No WWW-Authenticate header in 401 response")
|
|
693
|
+
return False
|
|
694
|
+
|
|
695
|
+
auth_params = self._parse_www_authenticate(www_authenticate)
|
|
696
|
+
if not auth_params:
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
self.bearer_token = self._request_token(auth_params, repository)
|
|
700
|
+
if not self.bearer_token:
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
self.session.headers.update(
|
|
704
|
+
{
|
|
705
|
+
"Authorization": f"Bearer {self.bearer_token}",
|
|
706
|
+
"Accept": _DOCKER_MANIFEST_MEDIA_TYPE,
|
|
707
|
+
}
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
return True
|
|
711
|
+
|
|
712
|
+
except Exception as e:
|
|
713
|
+
logger.error("Authentication error", error=str(e))
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
# get_manifest_and_digest and get_blob are inherited from base class
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _resolve_gitlab_credentials(
|
|
720
|
+
registry_url: str,
|
|
721
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
722
|
+
"""Resolve GitLab credentials from environment variables and Docker config.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
registry_url: Registry URL
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
Tuple of (username, password)
|
|
729
|
+
"""
|
|
730
|
+
username = os.getenv("DOCKER_USERNAME")
|
|
731
|
+
password = os.getenv("GITLAB_TOKEN")
|
|
732
|
+
|
|
733
|
+
# If password from env but no username, try Docker config for username
|
|
734
|
+
if password and not username:
|
|
735
|
+
docker_creds = _read_docker_credentials(registry_url)
|
|
736
|
+
if docker_creds:
|
|
737
|
+
username, _ = docker_creds
|
|
738
|
+
else:
|
|
739
|
+
username = "gitlab-ci-token"
|
|
740
|
+
|
|
741
|
+
# If no password from env, try Docker config
|
|
742
|
+
if not password:
|
|
743
|
+
docker_creds = _read_docker_credentials(registry_url)
|
|
744
|
+
if docker_creds:
|
|
745
|
+
username, password = docker_creds
|
|
746
|
+
|
|
747
|
+
return username, password
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _resolve_nvcr_credentials(registry_url: str) -> tuple[Optional[str], Optional[str]]:
|
|
751
|
+
"""Resolve NVCR credentials from environment variables and Docker config.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
registry_url: Registry URL
|
|
755
|
+
|
|
756
|
+
Returns:
|
|
757
|
+
Tuple of (username, password)
|
|
758
|
+
"""
|
|
759
|
+
username = os.getenv("NVCR_USERNAME") or os.getenv("DOCKER_USERNAME", "$oauthtoken")
|
|
760
|
+
password = os.getenv("NVCR_PASSWORD") or os.getenv("NVCR_API_KEY")
|
|
761
|
+
|
|
762
|
+
# If no password from env, try Docker config
|
|
763
|
+
if not password:
|
|
764
|
+
docker_creds = _read_docker_credentials(registry_url)
|
|
765
|
+
if docker_creds:
|
|
766
|
+
username, password = docker_creds
|
|
767
|
+
|
|
768
|
+
# Allow None credentials for anonymous access
|
|
769
|
+
if not password:
|
|
770
|
+
username = None
|
|
771
|
+
password = None
|
|
772
|
+
|
|
773
|
+
return username, password
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def create_authenticator(
|
|
777
|
+
registry_type: str, registry_url: str, repository: Optional[str] = None
|
|
778
|
+
) -> DockerRegistryHandler:
|
|
779
|
+
"""Create the appropriate authenticator based on registry type.
|
|
780
|
+
|
|
781
|
+
Unified authenticator creation that supports:
|
|
782
|
+
1. Public containers (anonymous token access)
|
|
783
|
+
2. Environment variable credentials
|
|
784
|
+
3. Docker config file credentials
|
|
785
|
+
4. No credentials (falls back to anonymous access)
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
registry_type: Type of registry ('gitlab' or 'nvcr')
|
|
789
|
+
registry_url: Registry URL
|
|
790
|
+
repository: Optional repository name (required for GitLab)
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
Registry authenticator instance
|
|
794
|
+
"""
|
|
795
|
+
if registry_type == "gitlab":
|
|
796
|
+
username, password = _resolve_gitlab_credentials(registry_url)
|
|
797
|
+
logger.debug(
|
|
798
|
+
"Creating GitLab authenticator",
|
|
799
|
+
registry_url=registry_url,
|
|
800
|
+
repository=repository,
|
|
801
|
+
has_credentials=bool(username and password),
|
|
802
|
+
)
|
|
803
|
+
return GitlabDockerRegistryHandler(
|
|
804
|
+
registry_url=registry_url,
|
|
805
|
+
username=username,
|
|
806
|
+
password=password,
|
|
807
|
+
repository=repository,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
elif registry_type == "nvcr":
|
|
811
|
+
username, password = _resolve_nvcr_credentials(registry_url)
|
|
812
|
+
logger.debug(
|
|
813
|
+
"Creating NVCR authenticator",
|
|
814
|
+
registry_url=registry_url,
|
|
815
|
+
has_credentials=bool(username and password),
|
|
816
|
+
)
|
|
817
|
+
return NvcrDockerRegistryHandler(
|
|
818
|
+
registry_url=registry_url,
|
|
819
|
+
username=username,
|
|
820
|
+
password=password,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
else:
|
|
824
|
+
raise ValueError(f"Unknown registry type: {registry_type}")
|