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,1126 @@
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
+ """Unified loading utilities for extracting and parsing framework.yml from containers."""
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import pathlib
22
+ import tarfile
23
+ import tempfile
24
+ from typing import Optional
25
+
26
+ import yaml
27
+ from nemo_evaluator.core.input import get_framework_evaluations
28
+
29
+ from nemo_evaluator_launcher.common.container_metadata.intermediate_repr import (
30
+ HarnessIntermediateRepresentation,
31
+ TaskIntermediateRepresentation,
32
+ )
33
+ from nemo_evaluator_launcher.common.container_metadata.registries import (
34
+ DockerRegistryHandler,
35
+ create_authenticator,
36
+ )
37
+ from nemo_evaluator_launcher.common.container_metadata.utils import (
38
+ parse_container_image,
39
+ )
40
+ from nemo_evaluator_launcher.common.logging_utils import logger
41
+
42
+ # Default max layer size for framework.yml extraction (100KB)
43
+ DEFAULT_MAX_LAYER_SIZE = 100 * 1024
44
+
45
+ # Framework.yml location in containers
46
+ FRAMEWORK_YML_PREFIX = "/opt/metadata"
47
+ FRAMEWORK_YML_FILENAME = "framework.yml"
48
+
49
+ # Cache directory for Docker metadata
50
+ CACHE_DIR = pathlib.Path.home() / ".nemo-evaluator" / "docker-meta"
51
+ MAX_CACHED_DATA = 200 # Maximum number of cache entries
52
+
53
+
54
+ # ============================================================================
55
+ # Cache Management Functions
56
+ # ============================================================================
57
+
58
+
59
+ def _ensure_cache_dir() -> pathlib.Path:
60
+ """Ensure the cache directory exists and return its path.
61
+
62
+ Returns:
63
+ Path to the cache directory
64
+ """
65
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
66
+ return CACHE_DIR
67
+
68
+
69
+ def _get_cache_key(docker_id: str, target_file: str) -> str:
70
+ """Generate a cache key from docker_id and target_file.
71
+
72
+ Args:
73
+ docker_id: Docker image identifier (e.g., 'nvcr.io/nvidia/eval-factory/simple-evals:25.10')
74
+ target_file: Target file path (e.g., '/opt/metadata/framework.yml')
75
+
76
+ Returns:
77
+ Cache key (hash-based filename)
78
+ """
79
+ # Create a unique key from docker_id and target_file
80
+ key_string = f"{docker_id}|{target_file}"
81
+ # Use SHA256 hash to create a filesystem-safe filename
82
+ hash_obj = hashlib.sha256(key_string.encode("utf-8"))
83
+ return hash_obj.hexdigest()
84
+
85
+
86
+ def _get_cache_path(docker_id: str, target_file: str) -> pathlib.Path:
87
+ """Get the cache file path for a given docker_id and target_file.
88
+
89
+ Args:
90
+ docker_id: Docker image identifier
91
+ target_file: Target file path
92
+
93
+ Returns:
94
+ Path to the cache file
95
+ """
96
+ cache_dir = _ensure_cache_dir()
97
+ cache_key = _get_cache_key(docker_id, target_file)
98
+ return cache_dir / f"{cache_key}.json"
99
+
100
+
101
+ def _evict_lru_cache_entries() -> None:
102
+ """Evict least recently used cache entries if cache exceeds MAX_CACHED_DATA.
103
+
104
+ Uses file modification time to determine least recently used entries.
105
+ """
106
+ cache_dir = _ensure_cache_dir()
107
+ cache_files = list(cache_dir.glob("*.json"))
108
+
109
+ if len(cache_files) < MAX_CACHED_DATA:
110
+ return
111
+
112
+ # Sort by modification time (oldest first)
113
+ cache_files.sort(key=lambda p: p.stat().st_mtime)
114
+
115
+ # Delete oldest entries until we're under the limit
116
+ num_to_delete = (
117
+ len(cache_files) - MAX_CACHED_DATA + 1
118
+ ) # +1 to make room for new entry
119
+ for cache_file in cache_files[:num_to_delete]:
120
+ try:
121
+ cache_file.unlink()
122
+ logger.debug("Evicted cache entry", cache_path=str(cache_file))
123
+ except OSError as e:
124
+ logger.warning(
125
+ "Failed to evict cache entry", cache_path=str(cache_file), error=str(e)
126
+ )
127
+
128
+
129
+ def read_from_cache(
130
+ docker_id: str, target_file: str, check_digest: str
131
+ ) -> tuple[Optional[str], Optional[str]]:
132
+ """Read metadata from cache, validating digest.
133
+
134
+ Args:
135
+ docker_id: Docker image identifier
136
+ target_file: Target file path
137
+ check_digest: Manifest digest to validate against stored digest.
138
+ Must match stored digest for cache hit. If doesn't match, returns
139
+ (None, stored_digest) to indicate cache is invalid.
140
+
141
+ Returns:
142
+ Tuple of (cached metadata string if found and valid, stored_digest).
143
+ Returns (None, None) if cache miss, (None, stored_digest) if digest mismatch.
144
+ """
145
+ cache_path = _get_cache_path(docker_id, target_file)
146
+ if not cache_path.exists():
147
+ logger.debug(
148
+ "Cache miss (file not found)",
149
+ docker_id=docker_id,
150
+ target_file=target_file,
151
+ cache_path=str(cache_path),
152
+ )
153
+ return None, None
154
+
155
+ try:
156
+ with open(cache_path, "r", encoding="utf-8") as f:
157
+ cache_data = json.load(f)
158
+ stored_digest = cache_data.get("digest")
159
+ metadata_str = cache_data.get("metadata")
160
+
161
+ # Always check digest - required for cache validity
162
+ if stored_digest is None:
163
+ logger.info(
164
+ "Cache invalidated (no stored digest - old cache entry)",
165
+ docker_id=docker_id,
166
+ target_file=target_file,
167
+ cache_path=str(cache_path),
168
+ )
169
+ return None, None
170
+
171
+ if stored_digest != check_digest:
172
+ logger.info(
173
+ "Cache invalidated (digest mismatch)",
174
+ docker_id=docker_id,
175
+ target_file=target_file,
176
+ stored_digest=stored_digest,
177
+ current_digest=check_digest,
178
+ )
179
+ return None, stored_digest
180
+
181
+ # Digest matches - cache hit!
182
+ # Update file modification time for LRU tracking
183
+ try:
184
+ cache_path.touch()
185
+ except OSError:
186
+ pass # Ignore errors updating mtime
187
+
188
+ logger.info(
189
+ "Cache hit (digest validated)",
190
+ docker_id=docker_id,
191
+ target_file=target_file,
192
+ digest=stored_digest,
193
+ cache_path=str(cache_path),
194
+ )
195
+ return metadata_str, stored_digest
196
+ except (OSError, json.JSONDecodeError, KeyError) as e:
197
+ logger.warning(
198
+ "Failed to read from cache",
199
+ docker_id=docker_id,
200
+ target_file=target_file,
201
+ cache_path=str(cache_path),
202
+ error=str(e),
203
+ )
204
+ return None, None
205
+
206
+
207
+ def write_to_cache(
208
+ docker_id: str,
209
+ target_file: str,
210
+ metadata_str: str,
211
+ digest: str,
212
+ cached_file_path: Optional[str] = None,
213
+ ) -> None:
214
+ """Write metadata to cache with digest.
215
+
216
+ Args:
217
+ docker_id: Docker image identifier
218
+ target_file: Target file path (or pattern for pattern-based searches)
219
+ metadata_str: Metadata content to cache
220
+ digest: Manifest digest of the container image. Required and stored in
221
+ the cache entry for validation on subsequent reads.
222
+ cached_file_path: Optional resolved file path (for pattern-based searches).
223
+ If provided, stored in cache for retrieval on cache hits.
224
+ """
225
+ # Evict old entries if cache is full
226
+ _evict_lru_cache_entries()
227
+
228
+ cache_path = _get_cache_path(docker_id, target_file)
229
+ try:
230
+ cache_data = {
231
+ "docker_id": docker_id,
232
+ "target_file": target_file,
233
+ "metadata": metadata_str,
234
+ "digest": digest, # Always store digest - required for validation
235
+ }
236
+ # Always include cached_file_path if provided (standardized cache structure)
237
+ if cached_file_path is not None:
238
+ cache_data["cached_file_path"] = cached_file_path
239
+
240
+ with open(cache_path, "w", encoding="utf-8") as f:
241
+ json.dump(cache_data, f, indent=2)
242
+
243
+ # Update file modification time for LRU tracking
244
+ try:
245
+ cache_path.touch()
246
+ except OSError:
247
+ pass # Ignore errors updating mtime
248
+
249
+ logger.info(
250
+ "Cached metadata",
251
+ docker_id=docker_id,
252
+ target_file=target_file,
253
+ digest=digest,
254
+ cache_path=str(cache_path),
255
+ cached_file_path=cached_file_path,
256
+ )
257
+ except OSError as e:
258
+ logger.warning(
259
+ "Failed to write to cache",
260
+ docker_id=docker_id,
261
+ target_file=target_file,
262
+ digest=digest,
263
+ cache_path=str(cache_path),
264
+ error=str(e),
265
+ )
266
+
267
+
268
+ # ============================================================================
269
+ # Layer Inspection Functions
270
+ # ============================================================================
271
+
272
+
273
+ class LayerInspector:
274
+ """Utility class for inspecting Docker layers."""
275
+
276
+ @staticmethod
277
+ def extract_file_from_layer(
278
+ layer_content: bytes, target_file: str
279
+ ) -> Optional[str]:
280
+ """Extract a specific file from a layer tar archive.
281
+
282
+ Args:
283
+ layer_content: The layer content as bytes (tar.gz format)
284
+ target_file: The file path to extract
285
+
286
+ Returns:
287
+ The file content as string if found, None otherwise
288
+ """
289
+ try:
290
+ with tempfile.NamedTemporaryFile() as temp_file:
291
+ temp_file.write(layer_content)
292
+ temp_file.flush()
293
+
294
+ with tarfile.open(temp_file.name, "r:gz") as tar:
295
+ logger.debug(
296
+ "Searching for file in layer",
297
+ target_file=target_file,
298
+ files_in_layer=len(tar.getmembers()),
299
+ )
300
+
301
+ # Look for the file in the tar archive
302
+ for member in tar.getmembers():
303
+ if member.name.endswith(
304
+ target_file
305
+ ) or member.name == target_file.lstrip("/"):
306
+ logger.debug("Found file in layer", file_path=member.name)
307
+ file_obj = tar.extractfile(member)
308
+ if file_obj:
309
+ content = file_obj.read().decode("utf-8")
310
+ logger.debug(
311
+ "Extracted file content",
312
+ file_path=member.name,
313
+ content_size_chars=len(content),
314
+ )
315
+ return content
316
+
317
+ logger.debug(
318
+ "File not found in layer",
319
+ target_file=target_file,
320
+ sample_files=[m.name for m in tar.getmembers()[:10]],
321
+ )
322
+
323
+ except Exception as e:
324
+ logger.error(
325
+ "Error extracting file from layer",
326
+ error=str(e),
327
+ target_file=target_file,
328
+ exc_info=True,
329
+ )
330
+
331
+ return None
332
+
333
+ @staticmethod
334
+ def extract_file_matching_pattern(
335
+ layer_content: bytes, prefix: str, filename: str
336
+ ) -> Optional[tuple[str, str]]:
337
+ """Extract a file matching a pattern from a layer tar archive.
338
+
339
+ Searches for files that start with the given prefix and end with the given filename.
340
+ For example, prefix="/opt/metadata/" and filename="framework.yml" will match
341
+ "/opt/metadata/framework.yml" or "/opt/metadata/some_folder/framework.yml".
342
+
343
+ Args:
344
+ layer_content: The layer content as bytes (tar.gz format)
345
+ prefix: The path prefix to match (e.g., "/opt/metadata/")
346
+ filename: The filename to match (e.g., "framework.yml")
347
+
348
+ Returns:
349
+ Tuple of (file_path, file_content) if found, None otherwise
350
+ """
351
+ try:
352
+ with tempfile.NamedTemporaryFile() as temp_file:
353
+ temp_file.write(layer_content)
354
+ temp_file.flush()
355
+
356
+ with tarfile.open(temp_file.name, "r:gz") as tar:
357
+ logger.debug(
358
+ "Searching for file matching pattern in layer",
359
+ prefix=prefix,
360
+ filename=filename,
361
+ files_in_layer=len(tar.getmembers()),
362
+ )
363
+
364
+ # Normalize prefix to ensure it ends with /
365
+ normalized_prefix = prefix.rstrip("/") + "/"
366
+ # Also check without leading /
367
+ normalized_prefix_no_leading = normalized_prefix.lstrip("/")
368
+
369
+ # Look for files matching the pattern
370
+ for member in tar.getmembers():
371
+ # Check if file matches the pattern
372
+ # Match files that start with prefix and end with filename
373
+ member_name = member.name
374
+ member_name_no_leading = member_name.lstrip("/")
375
+
376
+ # Check if file starts with the prefix (with or without leading slash)
377
+ matches_prefix = member_name.startswith(
378
+ normalized_prefix
379
+ ) or member_name_no_leading.startswith(
380
+ normalized_prefix_no_leading
381
+ )
382
+
383
+ if not matches_prefix:
384
+ continue
385
+
386
+ # Check if file ends with the filename (exact match or with path separator)
387
+ # This ensures we match:
388
+ # - /opt/metadata/framework.yml
389
+ # - /opt/metadata/some_folder/framework.yml
390
+ # But not:
391
+ # - /opt/metadata/framework.yml.backup
392
+ # - /opt/metadata/framework_yml
393
+ matches_filename = (
394
+ member_name == normalized_prefix + filename
395
+ or member_name == normalized_prefix_no_leading + filename
396
+ or member_name.endswith(f"/{filename}")
397
+ or member_name_no_leading.endswith(f"/{filename}")
398
+ )
399
+
400
+ if matches_filename:
401
+ logger.debug(
402
+ "Found file matching pattern in layer",
403
+ file_path=member_name,
404
+ prefix=prefix,
405
+ filename=filename,
406
+ )
407
+ file_obj = tar.extractfile(member)
408
+ if file_obj:
409
+ content = file_obj.read().decode("utf-8")
410
+ logger.debug(
411
+ "Extracted file content",
412
+ file_path=member_name,
413
+ content_size_chars=len(content),
414
+ )
415
+ return member_name, content
416
+
417
+ logger.debug(
418
+ "File matching pattern not found in layer",
419
+ prefix=prefix,
420
+ filename=filename,
421
+ sample_files=[m.name for m in tar.getmembers()[:10]],
422
+ )
423
+
424
+ except Exception as e:
425
+ logger.error(
426
+ "Error extracting file matching pattern from layer",
427
+ error=str(e),
428
+ prefix=prefix,
429
+ filename=filename,
430
+ exc_info=True,
431
+ )
432
+
433
+ return None
434
+
435
+
436
+ # ============================================================================
437
+ # File Finding Functions
438
+ # ============================================================================
439
+
440
+
441
+ def find_file_matching_pattern_in_image_layers(
442
+ authenticator: DockerRegistryHandler,
443
+ repository: str,
444
+ reference: str,
445
+ prefix: str,
446
+ filename: str,
447
+ max_layer_size: Optional[int] = None,
448
+ docker_id: Optional[str] = None,
449
+ use_cache: bool = True,
450
+ ) -> Optional[tuple[str, str]]:
451
+ """Find a file matching a pattern in Docker image layers without pulling the entire image.
452
+
453
+ This function searches through image layers (optionally filtered by size)
454
+ to find a file matching the pattern (prefix + filename). Layers are checked
455
+ in reverse order (last to first) to find the most recent version of the file.
456
+
457
+ Args:
458
+ authenticator: Registry authenticator instance (will be authenticated if needed)
459
+ repository: The repository name (e.g., 'agronskiy/idea/poc-for-partial-pull')
460
+ reference: The tag or digest (e.g., 'latest')
461
+ prefix: The path prefix to match (e.g., '/opt/metadata/')
462
+ filename: The filename to match (e.g., 'framework.yml')
463
+ max_layer_size: Optional maximum layer size in bytes. Only layers smaller
464
+ than this size will be checked. If None, all layers are checked.
465
+ docker_id: Optional Docker image identifier for caching. If provided and
466
+ use_cache is True, will check cache before searching and write to cache
467
+ after finding the file.
468
+ use_cache: Whether to use caching. Defaults to True.
469
+
470
+ Returns:
471
+ Tuple of (file_path, file_content) if found, None otherwise
472
+
473
+ Raises:
474
+ ValueError: If authentication fails or manifest cannot be retrieved
475
+ """
476
+ # Authenticate if needed (but don't fail if it returns False - may work for public containers)
477
+ if not getattr(authenticator, "bearer_token", None):
478
+ authenticator.authenticate(repository=repository)
479
+ # Don't fail here - authentication may fail but public containers can still be accessed
480
+
481
+ # Get top-level manifest and digest (tag may resolve to multi-arch index).
482
+ top_manifest, top_digest = authenticator.get_manifest_and_digest(
483
+ repository, reference
484
+ )
485
+ if not top_manifest:
486
+ raise ValueError(f"Failed to get manifest for {repository}:{reference}")
487
+ if not top_digest:
488
+ raise ValueError(f"Failed to get digest for {repository}:{reference}")
489
+
490
+ # Keep top-level digest for caching/validation, but resolve to a platform-specific
491
+ # manifest for layer inspection when the top-level is an index/manifest list.
492
+ manifest = top_manifest
493
+ manifest_digest = top_digest
494
+ if isinstance(top_manifest, dict) and isinstance(
495
+ top_manifest.get("manifests"), list
496
+ ):
497
+ # Prefer registry's default platform resolver by requesting a single-image manifest.
498
+ single_accept = (
499
+ "application/vnd.oci.image.manifest.v1+json, "
500
+ "application/vnd.docker.distribution.manifest.v2+json"
501
+ )
502
+ resolved, _ = authenticator.get_manifest_and_digest(
503
+ repository, reference, accept=single_accept
504
+ )
505
+ if resolved:
506
+ manifest = resolved
507
+
508
+ # If a registry still returns an index (ignoring Accept), fall back to the
509
+ # first digest entry for layer inspection. This does NOT affect recorded digests.
510
+ if isinstance(manifest, dict) and isinstance(manifest.get("manifests"), list):
511
+ for m in manifest.get("manifests") or []:
512
+ if isinstance(m, dict):
513
+ d = m.get("digest")
514
+ if isinstance(d, str) and d.startswith("sha256:"):
515
+ resolved2, _ = authenticator.get_manifest_and_digest(
516
+ repository, d, accept=single_accept
517
+ )
518
+ if resolved2:
519
+ manifest = resolved2
520
+ break
521
+
522
+ # Check cache with digest validation (always validates digest)
523
+ # For pattern searches, use pattern-based cache key (not resolved path)
524
+ # This allows cache hits regardless of where the file is found
525
+ if docker_id and use_cache:
526
+ # Create pattern-based cache key: prefix + filename
527
+ # This ensures same cache key regardless of subdirectory location
528
+ pattern_key = f"{prefix.rstrip('/')}/{filename}"
529
+ logger.debug(
530
+ "Checking cache for pattern",
531
+ docker_id=docker_id,
532
+ pattern=pattern_key,
533
+ current_digest=manifest_digest,
534
+ )
535
+ cached_result, stored_digest = read_from_cache(
536
+ docker_id, pattern_key, check_digest=manifest_digest
537
+ )
538
+ if cached_result is not None:
539
+ # Parse the cached result to extract file path and content
540
+ # The cached metadata should be the file content
541
+ # We need to return (file_path, file_content) but we don't know the path
542
+ # So we'll need to search for it or store the path in cache
543
+ # For now, let's store the path in the cache entry
544
+ logger.info(
545
+ "Using cached metadata (pattern-based, digest validated)",
546
+ docker_id=docker_id,
547
+ pattern=pattern_key,
548
+ digest=manifest_digest,
549
+ )
550
+ # Try to get the file path from cache entry
551
+ cache_path = _get_cache_path(docker_id, pattern_key)
552
+ if cache_path.exists():
553
+ try:
554
+ with open(cache_path, "r", encoding="utf-8") as f:
555
+ cache_data = json.load(f)
556
+ cached_file_path = cache_data.get("cached_file_path")
557
+ if cached_file_path:
558
+ return (cached_file_path, cached_result)
559
+ except (OSError, json.JSONDecodeError, KeyError) as e:
560
+ # Log specific exception types for better debugging
561
+ logger.debug(
562
+ "Failed to read cached_file_path from cache",
563
+ docker_id=docker_id,
564
+ pattern=pattern_key,
565
+ error=str(e),
566
+ error_type=type(e).__name__,
567
+ )
568
+ except Exception as e:
569
+ # Log unexpected exceptions at warning level
570
+ logger.warning(
571
+ "Unexpected error reading cached_file_path from cache",
572
+ docker_id=docker_id,
573
+ pattern=pattern_key,
574
+ error=str(e),
575
+ error_type=type(e).__name__,
576
+ exc_info=True,
577
+ )
578
+ # Fallback: try to infer path from pattern
579
+ # Most common case: file is at prefix/filename
580
+ inferred_path = f"{prefix.rstrip('/')}/{filename}"
581
+ logger.debug(
582
+ "Using inferred file path from pattern (cached_file_path not in cache)",
583
+ docker_id=docker_id,
584
+ pattern=pattern_key,
585
+ inferred_path=inferred_path,
586
+ )
587
+ return (inferred_path, cached_result)
588
+ elif stored_digest is not None:
589
+ # Digest mismatch - cache invalidated
590
+ logger.info(
591
+ "Cache invalidated (digest changed), re-searching",
592
+ docker_id=docker_id,
593
+ pattern=pattern_key,
594
+ stored_digest=stored_digest,
595
+ current_digest=manifest_digest,
596
+ )
597
+ else:
598
+ logger.debug(
599
+ "Cache miss - no cached entry found for pattern",
600
+ docker_id=docker_id,
601
+ pattern=pattern_key,
602
+ )
603
+
604
+ # Get layers from manifest (single-arch image manifest).
605
+ layers = manifest.get("layers", []) if isinstance(manifest, dict) else []
606
+ logger.info(
607
+ "Searching for file matching pattern in image layers",
608
+ repository=repository,
609
+ reference=reference,
610
+ prefix=prefix,
611
+ filename=filename,
612
+ total_layers=len(layers),
613
+ max_layer_size=max_layer_size,
614
+ )
615
+
616
+ # Initialize layer inspector
617
+ inspector = LayerInspector()
618
+
619
+ # Check each layer for files matching the pattern (in reverse order)
620
+ # Reverse order ensures we get the most recent version of the file
621
+ for i, layer in enumerate(reversed(layers)):
622
+ original_index = len(layers) - 1 - i
623
+ layer_digest = layer.get("digest")
624
+ layer_size = layer.get("size", 0)
625
+
626
+ if not layer_digest:
627
+ logger.warning(
628
+ "Layer has no digest, skipping",
629
+ layer_index=original_index,
630
+ )
631
+ continue
632
+
633
+ # Filter by size if max_layer_size is specified
634
+ if max_layer_size is not None and layer_size >= max_layer_size:
635
+ logger.debug(
636
+ "Skipping layer (too large)",
637
+ layer_index=original_index,
638
+ layer_size=layer_size,
639
+ max_layer_size=max_layer_size,
640
+ )
641
+ continue
642
+
643
+ logger.debug(
644
+ "Checking layer for pattern match",
645
+ layer_index=original_index,
646
+ reverse_index=i,
647
+ digest_preview=layer_digest[:20],
648
+ size=layer_size,
649
+ media_type=layer.get("mediaType", "unknown"),
650
+ )
651
+
652
+ # Download the layer
653
+ layer_content = authenticator.get_blob(repository, layer_digest)
654
+ if not layer_content:
655
+ logger.warning(
656
+ "Failed to download layer",
657
+ layer_index=original_index,
658
+ digest_preview=layer_digest[:20],
659
+ )
660
+ continue
661
+
662
+ # Extract files matching the pattern from this layer
663
+ result = inspector.extract_file_matching_pattern(
664
+ layer_content, prefix, filename
665
+ )
666
+ if result:
667
+ file_path, file_content = result
668
+ logger.info(
669
+ "Found file matching pattern in layer",
670
+ file_path=file_path,
671
+ layer_index=original_index,
672
+ digest_preview=layer_digest[:20],
673
+ )
674
+ # Cache the result if docker_id is provided and caching is enabled
675
+ # Always store digest for validation on subsequent reads
676
+ # Use pattern-based cache key (not resolved file path) for consistency
677
+ if docker_id and use_cache:
678
+ pattern_key = f"{prefix.rstrip('/')}/{filename}"
679
+ # Store both the content and the resolved file path in cache
680
+ # Standardized cache structure always includes cached_file_path
681
+ write_to_cache(
682
+ docker_id=docker_id,
683
+ target_file=pattern_key,
684
+ metadata_str=file_content,
685
+ digest=manifest_digest,
686
+ cached_file_path=file_path, # Store resolved path
687
+ )
688
+ logger.info(
689
+ "Cached metadata (pattern-based)",
690
+ docker_id=docker_id,
691
+ pattern=pattern_key,
692
+ resolved_path=file_path,
693
+ digest=manifest_digest,
694
+ )
695
+ return result
696
+ else:
697
+ logger.debug(
698
+ "File matching pattern not found in layer",
699
+ prefix=prefix,
700
+ filename=filename,
701
+ layer_index=original_index,
702
+ )
703
+
704
+ logger.warning(
705
+ "File matching pattern not found in any layer",
706
+ prefix=prefix,
707
+ filename=filename,
708
+ repository=repository,
709
+ reference=reference,
710
+ )
711
+ return None
712
+
713
+
714
+ def get_container_digest(
715
+ authenticator: DockerRegistryHandler, repository: str, reference: str
716
+ ) -> Optional[str]:
717
+ """Get the manifest digest for a container image.
718
+
719
+ Uses the Docker-Content-Digest header from the registry response if available,
720
+ falling back to computing the digest from the manifest JSON if the header is absent.
721
+
722
+ Args:
723
+ authenticator: Registry authenticator instance
724
+ repository: Repository name
725
+ reference: Tag or digest
726
+
727
+ Returns:
728
+ Container digest (sha256:...) or None if failed
729
+ """
730
+ try:
731
+ _, digest = authenticator.get_manifest_and_digest(repository, reference)
732
+ return digest
733
+
734
+ except Exception as e:
735
+ logger.warning(
736
+ "Failed to get container digest",
737
+ repository=repository,
738
+ reference=reference,
739
+ error=str(e),
740
+ )
741
+ return None
742
+
743
+
744
+ def extract_framework_yml(
745
+ container: str,
746
+ max_layer_size: Optional[int] = None,
747
+ use_cache: bool = True,
748
+ ) -> tuple[Optional[str], Optional[str]]:
749
+ """Extract framework.yml from a container using layer inspection.
750
+
751
+ Args:
752
+ container: Container image identifier
753
+ max_layer_size: Optional maximum layer size in bytes
754
+ use_cache: Whether to use caching
755
+
756
+ Returns:
757
+ Tuple of (framework_yml_content, container_digest) or (None, None) if failed
758
+ """
759
+ container_digest = None
760
+ try:
761
+ registry_type, registry_url, repository, tag = parse_container_image(container)
762
+
763
+ logger.info(
764
+ "Extracting frame definition file from the container",
765
+ container=container,
766
+ registry_type=registry_type,
767
+ filename=FRAMEWORK_YML_FILENAME,
768
+ )
769
+
770
+ # Create authenticator and authenticate
771
+ authenticator = create_authenticator(registry_type, registry_url, repository)
772
+ authenticator.authenticate(repository=repository)
773
+
774
+ # Get container digest
775
+ container_digest = get_container_digest(authenticator, repository, tag)
776
+ if not container_digest:
777
+ logger.warning(
778
+ "Could not get container digest, continuing without it",
779
+ container=container,
780
+ )
781
+
782
+ # Search for framework.yml in container layers
783
+ logger.debug(
784
+ "Searching for frame definition file using pattern-based search",
785
+ filename=FRAMEWORK_YML_FILENAME,
786
+ container=container,
787
+ )
788
+ result = find_file_matching_pattern_in_image_layers(
789
+ authenticator=authenticator,
790
+ repository=repository,
791
+ reference=tag,
792
+ prefix=FRAMEWORK_YML_PREFIX,
793
+ filename=FRAMEWORK_YML_FILENAME,
794
+ max_layer_size=max_layer_size,
795
+ docker_id=container,
796
+ use_cache=use_cache,
797
+ )
798
+
799
+ if not result:
800
+ logger.warning(
801
+ "Frame definition file not found in container",
802
+ filename=FRAMEWORK_YML_FILENAME,
803
+ container=container,
804
+ )
805
+ return None, container_digest
806
+
807
+ file_path, framework_yml_content = result
808
+ logger.info(
809
+ "Successfully extracted frame definition file",
810
+ filename=FRAMEWORK_YML_FILENAME,
811
+ container=container,
812
+ file_path=file_path,
813
+ content_size=len(framework_yml_content),
814
+ digest=container_digest,
815
+ )
816
+
817
+ return framework_yml_content, container_digest
818
+
819
+ except Exception as e:
820
+ logger.warning(
821
+ "Failed to extract frame definition file",
822
+ filename=FRAMEWORK_YML_FILENAME,
823
+ container=container,
824
+ error=str(e),
825
+ exc_info=True,
826
+ )
827
+ return None, container_digest
828
+
829
+
830
+ def _extract_task_description(framework_data: dict, task_name: str) -> str:
831
+ """Extract task description from framework.yml data.
832
+
833
+ Args:
834
+ framework_data: Parsed framework.yml dictionary
835
+ task_name: Name of the task
836
+
837
+ Returns:
838
+ Task description string
839
+ """
840
+ for eval_config in framework_data.get("evaluations", []):
841
+ eval_task_name = eval_config.get("defaults", {}).get("config", {}).get("type")
842
+ if eval_task_name == task_name:
843
+ return eval_config.get("description", "")
844
+ return ""
845
+
846
+
847
+ def _create_task_irs(
848
+ evaluations: dict,
849
+ framework_data: dict,
850
+ harness_name: str,
851
+ container_id: str,
852
+ container_digest: Optional[str],
853
+ container_arch: Optional[str] = None,
854
+ ) -> list[TaskIntermediateRepresentation]:
855
+ """Create TaskIntermediateRepresentation objects from evaluations.
856
+
857
+ Args:
858
+ evaluations: Dictionary of evaluation objects from get_framework_evaluations
859
+ framework_data: Parsed framework.yml dictionary
860
+ harness_name: Harness name (original, not normalized)
861
+ container_id: Container image identifier
862
+ container_digest: Container manifest digest
863
+
864
+ Returns:
865
+ List of TaskIntermediateRepresentation objects
866
+ """
867
+ task_irs = []
868
+ for task_name, evaluation in evaluations.items():
869
+ task_description = _extract_task_description(framework_data, task_name)
870
+ evaluation_dict = evaluation.model_dump(exclude_none=True)
871
+
872
+ task_ir = TaskIntermediateRepresentation(
873
+ name=task_name,
874
+ description=task_description,
875
+ harness=harness_name,
876
+ container=container_id,
877
+ container_digest=container_digest,
878
+ container_arch=container_arch,
879
+ defaults=evaluation_dict,
880
+ )
881
+
882
+ task_irs.append(task_ir)
883
+
884
+ logger.debug(
885
+ "Created task IR",
886
+ harness=harness_name,
887
+ task=task_name,
888
+ container=container_id,
889
+ )
890
+
891
+ return task_irs
892
+
893
+
894
+ def parse_framework_to_irs(
895
+ framework_content: str,
896
+ container_id: str,
897
+ container_digest: Optional[str],
898
+ container_arch: Optional[str] = None,
899
+ ) -> tuple[HarnessIntermediateRepresentation, list[TaskIntermediateRepresentation]]:
900
+ """Parse framework.yml content and convert to Intermediate Representations.
901
+
902
+ Args:
903
+ framework_content: Original framework.yml content as string
904
+ container_id: Full container image identifier
905
+ container_digest: Container manifest digest (optional)
906
+
907
+ Returns:
908
+ Tuple of (HarnessIntermediateRepresentation, list[TaskIntermediateRepresentation])
909
+
910
+ Raises:
911
+ ValueError: If framework.yml is empty or missing framework.name
912
+ """
913
+ try:
914
+ framework_data = yaml.safe_load(framework_content)
915
+ if not framework_data:
916
+ raise ValueError("Empty framework.yml content")
917
+
918
+ # Extract harness metadata from framework.yml
919
+ framework_info = framework_data.get("framework", {})
920
+ harness_name = framework_info.get("name")
921
+ if not harness_name:
922
+ raise ValueError(
923
+ "framework.yml missing required 'framework.name' field. "
924
+ "The harness name must be specified in the framework.yml file."
925
+ )
926
+
927
+ if not isinstance(harness_name, str):
928
+ raise ValueError(
929
+ f"framework.name must be a string, got {type(harness_name).__name__}"
930
+ )
931
+
932
+ # Write to temporary file for get_framework_evaluations
933
+ with tempfile.NamedTemporaryFile(
934
+ mode="w", suffix=".yml", delete=False
935
+ ) as temp_file:
936
+ temp_file.write(framework_content)
937
+ temp_file_path = temp_file.name
938
+
939
+ try:
940
+ # Parse evaluations using nemo_evaluator
941
+ (
942
+ parsed_framework_name,
943
+ framework_defaults,
944
+ evaluations,
945
+ ) = get_framework_evaluations(temp_file_path)
946
+
947
+ # Create harness IR
948
+ harness_ir = HarnessIntermediateRepresentation(
949
+ name=harness_name,
950
+ description=framework_info.get("description", ""),
951
+ full_name=framework_info.get("full_name"),
952
+ url=framework_info.get("url"),
953
+ container=container_id,
954
+ container_digest=container_digest,
955
+ )
956
+
957
+ # Create task IRs
958
+ task_irs = _create_task_irs(
959
+ evaluations,
960
+ framework_data,
961
+ harness_name,
962
+ container_id,
963
+ container_digest,
964
+ container_arch,
965
+ )
966
+
967
+ logger.info(
968
+ "Parsed framework to IRs",
969
+ harness=harness_name,
970
+ num_tasks=len(task_irs),
971
+ container=container_id,
972
+ )
973
+
974
+ return harness_ir, task_irs
975
+
976
+ finally:
977
+ try:
978
+ os.unlink(temp_file_path)
979
+ except Exception:
980
+ pass
981
+
982
+ except Exception as e:
983
+ logger.error(
984
+ "Failed to parse frame definition file to IRs",
985
+ filename=FRAMEWORK_YML_FILENAME,
986
+ error=str(e),
987
+ container=container_id,
988
+ exc_info=True,
989
+ )
990
+ raise
991
+
992
+
993
+ def load_tasks_from_container(
994
+ container: str,
995
+ max_layer_size: Optional[int] = None,
996
+ use_cache: bool = True,
997
+ ) -> list[TaskIntermediateRepresentation]:
998
+ """Load tasks from container by extracting and parsing framework.yml.
999
+
1000
+ Args:
1001
+ container: Container image identifier
1002
+ max_layer_size: Optional maximum layer size in bytes for layer inspection
1003
+ use_cache: Whether to use caching for framework.yml extraction
1004
+
1005
+ Returns:
1006
+ List of TaskIntermediateRepresentation objects
1007
+
1008
+ Raises:
1009
+ ValueError: If container filtering results in no tasks
1010
+ """
1011
+ logger.debug("Loading tasks from container", container=container)
1012
+
1013
+ def _normalize_platform_arch(arch: object) -> Optional[str]:
1014
+ if not arch:
1015
+ return None
1016
+ arch_l = str(arch).lower()
1017
+ if arch_l in {"amd64", "x86_64"}:
1018
+ return "amd"
1019
+ if arch_l in {"arm64", "aarch64"}:
1020
+ return "arm"
1021
+ return None
1022
+
1023
+ def _arch_label_from_arch_set(archs: set[str]) -> Optional[str]:
1024
+ if not archs:
1025
+ return None
1026
+ if "amd" in archs and "arm" in archs:
1027
+ return "multiarch"
1028
+ if archs == {"amd"}:
1029
+ return "amd"
1030
+ if archs == {"arm"}:
1031
+ return "arm"
1032
+ return None
1033
+
1034
+ def _get_container_arch(container_ref: str) -> Optional[str]:
1035
+ """Best-effort: derive 'amd'|'arm'|'multiarch' from registry manifest APIs."""
1036
+ try:
1037
+ registry_type, registry_url, repository, tag = parse_container_image(
1038
+ container_ref
1039
+ )
1040
+ authenticator = create_authenticator(
1041
+ registry_type, registry_url, repository
1042
+ )
1043
+ authenticator.authenticate(repository=repository)
1044
+
1045
+ manifest, _ = authenticator.get_manifest_and_digest(repository, tag)
1046
+ if not isinstance(manifest, dict):
1047
+ return None
1048
+
1049
+ manifests = manifest.get("manifests")
1050
+ if isinstance(manifests, list):
1051
+ archs = {
1052
+ _normalize_platform_arch(
1053
+ (m.get("platform") or {}).get("architecture")
1054
+ )
1055
+ for m in manifests
1056
+ if isinstance(m, dict)
1057
+ }
1058
+ archs.discard(None) # type: ignore[arg-type]
1059
+ return _arch_label_from_arch_set(set(archs)) # type: ignore[arg-type]
1060
+
1061
+ cfg = manifest.get("config") or {}
1062
+ cfg_digest = cfg.get("digest") if isinstance(cfg, dict) else None
1063
+ if not (isinstance(cfg_digest, str) and cfg_digest.startswith("sha256:")):
1064
+ return None
1065
+ blob = authenticator.get_blob(repository, cfg_digest)
1066
+ if not blob:
1067
+ return None
1068
+ try:
1069
+ cfg_json = json.loads(blob.decode("utf-8"))
1070
+ except Exception:
1071
+ return None
1072
+ if not isinstance(cfg_json, dict):
1073
+ return None
1074
+ return _normalize_platform_arch(cfg_json.get("architecture"))
1075
+ except Exception:
1076
+ return None
1077
+
1078
+ container_arch = _get_container_arch(container)
1079
+
1080
+ # Extract framework.yml from container
1081
+ framework_content, container_digest = extract_framework_yml(
1082
+ container=container,
1083
+ max_layer_size=max_layer_size or DEFAULT_MAX_LAYER_SIZE,
1084
+ use_cache=use_cache,
1085
+ )
1086
+
1087
+ if not framework_content:
1088
+ logger.error(
1089
+ "Could not extract frame definition file from container",
1090
+ container=container,
1091
+ filename=FRAMEWORK_YML_FILENAME,
1092
+ )
1093
+ return []
1094
+
1095
+ try:
1096
+ # Parse framework.yml to IRs (harness name will be extracted from framework.yml)
1097
+ harness_ir, task_irs = parse_framework_to_irs(
1098
+ framework_content=framework_content,
1099
+ container_id=container,
1100
+ container_digest=container_digest,
1101
+ container_arch=container_arch,
1102
+ )
1103
+
1104
+ logger.info(
1105
+ "Loaded tasks from container",
1106
+ container=container,
1107
+ num_tasks=len(task_irs),
1108
+ )
1109
+
1110
+ if len(task_irs) == 0:
1111
+ logger.warning(
1112
+ "No tasks found in the specified container",
1113
+ container=container,
1114
+ )
1115
+
1116
+ return task_irs
1117
+
1118
+ except Exception as e:
1119
+ logger.error(
1120
+ "Failed to parse frame definition file from container",
1121
+ filename=FRAMEWORK_YML_FILENAME,
1122
+ container=container,
1123
+ error=str(e),
1124
+ exc_info=True,
1125
+ )
1126
+ return []