nemo-evaluator-launcher 0.1.41__py3-none-any.whl → 0.1.67__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 +55 -5
- nemo_evaluator_launcher/api/types.py +21 -14
- nemo_evaluator_launcher/cli/ls_task.py +280 -0
- nemo_evaluator_launcher/cli/ls_tasks.py +208 -55
- nemo_evaluator_launcher/cli/main.py +17 -2
- nemo_evaluator_launcher/cli/run.py +43 -52
- 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 +44 -28
- nemo_evaluator_launcher/common/mapping.py +166 -177
- nemo_evaluator_launcher/common/printing_utils.py +18 -12
- nemo_evaluator_launcher/configs/deployment/nim.yaml +3 -1
- nemo_evaluator_launcher/executors/lepton/executor.py +26 -8
- nemo_evaluator_launcher/executors/local/executor.py +6 -2
- nemo_evaluator_launcher/executors/slurm/executor.py +270 -22
- nemo_evaluator_launcher/package_info.py +1 -1
- nemo_evaluator_launcher/resources/all_tasks_irs.yaml +17016 -0
- nemo_evaluator_launcher/resources/mapping.toml +62 -354
- {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.67.dist-info}/METADATA +2 -1
- {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.67.dist-info}/RECORD +27 -20
- {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.67.dist-info}/WHEEL +0 -0
- {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.67.dist-info}/entry_points.txt +0 -0
- {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.67.dist-info}/licenses/LICENSE +0 -0
- {nemo_evaluator_launcher-0.1.41.dist-info → nemo_evaluator_launcher-0.1.67.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 []
|