nemo-evaluator-launcher 0.1.41__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.
Files changed (25) hide show
  1. nemo_evaluator_launcher/api/functional.py +55 -5
  2. nemo_evaluator_launcher/cli/ls_task.py +280 -0
  3. nemo_evaluator_launcher/cli/ls_tasks.py +208 -55
  4. nemo_evaluator_launcher/cli/main.py +17 -2
  5. nemo_evaluator_launcher/cli/run.py +41 -1
  6. nemo_evaluator_launcher/common/container_metadata/__init__.py +61 -0
  7. nemo_evaluator_launcher/common/container_metadata/intermediate_repr.py +530 -0
  8. nemo_evaluator_launcher/common/container_metadata/loading.py +1126 -0
  9. nemo_evaluator_launcher/common/container_metadata/registries.py +824 -0
  10. nemo_evaluator_launcher/common/container_metadata/utils.py +63 -0
  11. nemo_evaluator_launcher/common/helpers.py +44 -28
  12. nemo_evaluator_launcher/common/mapping.py +341 -155
  13. nemo_evaluator_launcher/common/printing_utils.py +18 -12
  14. nemo_evaluator_launcher/executors/lepton/executor.py +26 -8
  15. nemo_evaluator_launcher/executors/local/executor.py +6 -2
  16. nemo_evaluator_launcher/executors/slurm/executor.py +141 -9
  17. nemo_evaluator_launcher/package_info.py +1 -1
  18. nemo_evaluator_launcher/resources/all_tasks_irs.yaml +17016 -0
  19. nemo_evaluator_launcher/resources/mapping.toml +62 -354
  20. {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/METADATA +2 -1
  21. {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/RECORD +25 -18
  22. {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/WHEEL +0 -0
  23. {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/entry_points.txt +0 -0
  24. {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.56.dist-info}/licenses/LICENSE +0 -0
  25. {nemo_evaluator_launcher-0.1.41.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}")