|
1 | 1 | """ |
2 | | -System, health, and queue routes. |
| 2 | +System, health, queue, and artifact routes. |
3 | 3 |
|
4 | 4 | GET /system/health Health check (unauthenticated) |
5 | 5 | GET /system/queue Queue status — active + queued runs |
| 6 | +GET /runs/{id}/artifacts Run artifacts from GCS + local storage |
6 | 7 | """ |
7 | 8 |
|
8 | 9 | import os |
9 | 10 | from datetime import datetime, timezone |
10 | 11 |
|
11 | 12 | from flask import g, jsonify, request |
12 | 13 | from sqlalchemy import text |
| 14 | +from sqlalchemy.orm import joinedload |
13 | 15 |
|
14 | 16 | from mod_api import mod_api |
15 | 17 | from mod_api.middleware.auth import require_scope |
16 | 18 | from mod_api.middleware.error_handler import make_error_response |
17 | | -from mod_api.middleware.validation import validate_offset_pagination |
| 19 | +from mod_api.middleware.validation import (validate_offset_pagination, |
| 20 | + validate_path_id) |
18 | 21 | from mod_api.schemas.common import DATETIME_FORMAT |
19 | | -from mod_api.services.status import batch_get_run_data |
20 | | -from mod_api.utils import paginated_response |
21 | | -from mod_test.models import Test, TestPlatform, TestProgress, TestStatus |
| 22 | +from mod_api.services.status import batch_get_run_data, is_dummy_row |
| 23 | +from mod_api.services.storage import (get_log_file_path, |
| 24 | + get_test_results_base_path, |
| 25 | + resolve_artifact) |
| 26 | +from mod_api.utils import paginated_response, safe_resolve |
| 27 | +from mod_test.models import (Test, TestPlatform, TestProgress, TestResultFile, |
| 28 | + TestStatus) |
| 29 | + |
| 30 | +OCTET_STREAM = 'application/octet-stream' |
22 | 31 |
|
23 | 32 |
|
24 | 33 | @mod_api.route('/system/health', methods=['GET']) |
@@ -201,3 +210,137 @@ def get_queue(limit=50, offset=0): |
201 | 210 | 'running_count': running_count, |
202 | 211 | } |
203 | 212 | ) |
| 213 | + |
| 214 | + |
| 215 | +def _get_gcs_artifacts(run_id, platform): |
| 216 | + binary_name = ( |
| 217 | + 'ccextractor' if platform == TestPlatform.linux |
| 218 | + else 'ccextractorwinfull.exe' |
| 219 | + ) |
| 220 | + gcs_artifacts = [ |
| 221 | + ('binary', |
| 222 | + f'test_artifacts/{run_id}/{binary_name}', binary_name, OCTET_STREAM), |
| 223 | + ('coredump', f'test_artifacts/{run_id}/coredump', |
| 224 | + f'coredump-{run_id}', OCTET_STREAM), |
| 225 | + ( |
| 226 | + 'combined_stdout', |
| 227 | + f'test_artifacts/{run_id}/combined_stdout.log', |
| 228 | + f'combined_stdout-{run_id}.log', |
| 229 | + 'text/plain', |
| 230 | + ), |
| 231 | + ] |
| 232 | + artifacts = [] |
| 233 | + for artifact_type, gcs_path, filename, content_type in gcs_artifacts: |
| 234 | + download_url, storage_status = resolve_artifact(gcs_path) |
| 235 | + artifacts.append({ |
| 236 | + 'artifact_id': f'{artifact_type}_{run_id}', |
| 237 | + 'run_id': run_id, |
| 238 | + 'sample_id': None, |
| 239 | + 'type': artifact_type, |
| 240 | + 'filename': filename, |
| 241 | + 'content_type': content_type, |
| 242 | + 'size_bytes': None, |
| 243 | + 'storage_status': storage_status, |
| 244 | + 'download_url': download_url, |
| 245 | + }) |
| 246 | + return artifacts |
| 247 | + |
| 248 | + |
| 249 | +def _get_output_artifacts(run_id): |
| 250 | + result_files = TestResultFile.query.options( |
| 251 | + joinedload(TestResultFile.regression_test_output), |
| 252 | + joinedload(TestResultFile.regression_test), |
| 253 | + ).filter_by(test_id=run_id).all() |
| 254 | + for rf in result_files: |
| 255 | + if is_dummy_row(rf): |
| 256 | + continue |
| 257 | + |
| 258 | + ext = rf.regression_test_output.correct_extension if rf.regression_test_output else '' |
| 259 | + sample_id = (rf.regression_test.sample_id |
| 260 | + if rf.regression_test else None) |
| 261 | + |
| 262 | + expected_name = rf.expected + ext |
| 263 | + # NOTE: storage metadata (storage_status, download_url, size_bytes, |
| 264 | + # content_type) is resolved by list_artifacts for paged items only. |
| 265 | + |
| 266 | + yield { |
| 267 | + 'artifact_id': f'expected_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', |
| 268 | + 'run_id': run_id, |
| 269 | + 'sample_id': sample_id, |
| 270 | + 'type': 'expected_output', |
| 271 | + 'filename': expected_name, |
| 272 | + } |
| 273 | + |
| 274 | + if rf.got is not None: |
| 275 | + actual_name = rf.got + ext |
| 276 | + yield { |
| 277 | + 'artifact_id': f'actual_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', |
| 278 | + 'run_id': run_id, |
| 279 | + 'sample_id': sample_id, |
| 280 | + 'type': 'actual_output', |
| 281 | + 'filename': actual_name, |
| 282 | + } |
| 283 | + |
| 284 | + |
| 285 | +@mod_api.route('/runs/<run_id>/artifacts', methods=['GET']) |
| 286 | +@require_scope('results:read') |
| 287 | +@validate_path_id('run_id') |
| 288 | +@validate_offset_pagination() |
| 289 | +def list_artifacts(run_id, limit=50, offset=0): |
| 290 | + """ |
| 291 | + List all artifacts for a run. |
| 292 | +
|
| 293 | + Checks both GCS and local storage. Falls back to local when GCS |
| 294 | + is unavailable. Supports ?type filter. |
| 295 | + """ |
| 296 | + test = Test.query.filter(Test.id == run_id).first() |
| 297 | + if test is None: |
| 298 | + return make_error_response( |
| 299 | + 'not_found', |
| 300 | + f'Run {run_id} not found.', |
| 301 | + http_status=404) |
| 302 | + |
| 303 | + artifacts = _get_gcs_artifacts(run_id, test.platform) |
| 304 | + |
| 305 | + # Build log — accessed via /runs/{id}/logs, no direct download link. |
| 306 | + log_path = get_log_file_path(run_id) |
| 307 | + artifacts.append({ |
| 308 | + 'artifact_id': f'buildlog_{run_id}', |
| 309 | + 'run_id': run_id, |
| 310 | + 'sample_id': None, |
| 311 | + 'type': 'build_log', |
| 312 | + 'filename': f'{run_id}.txt', |
| 313 | + 'content_type': 'text/plain', |
| 314 | + 'size_bytes': os.path.getsize(log_path) if log_path else None, |
| 315 | + 'storage_status': 'ok' if log_path else 'missing', |
| 316 | + 'download_url': None, |
| 317 | + }) |
| 318 | + |
| 319 | + artifacts.extend(list(_get_output_artifacts(run_id))) |
| 320 | + |
| 321 | + # Apply optional ?type filter. |
| 322 | + type_filter = request.args.get('type') |
| 323 | + if type_filter: |
| 324 | + artifacts = [a for a in artifacts if a['type'] == type_filter] |
| 325 | + |
| 326 | + total = len(artifacts) |
| 327 | + paged = artifacts[offset:offset + limit] |
| 328 | + |
| 329 | + # Resolve heavy artifact metadata only for the returned page |
| 330 | + base_path = get_test_results_base_path() |
| 331 | + for a in paged: |
| 332 | + if 'storage_status' not in a: |
| 333 | + # It's an output artifact |
| 334 | + filename = a['filename'] |
| 335 | + url, status = resolve_artifact(f'TestResults/{filename}') |
| 336 | + local = safe_resolve(base_path, filename) |
| 337 | + |
| 338 | + a['content_type'] = OCTET_STREAM |
| 339 | + a['size_bytes'] = ( |
| 340 | + os.path.getsize(local) |
| 341 | + if local and os.path.isfile(local) else None |
| 342 | + ) |
| 343 | + a['storage_status'] = status |
| 344 | + a['download_url'] = url |
| 345 | + |
| 346 | + return paginated_response(paged, total, limit, offset) |
0 commit comments