diff --git a/migrations/versions/d4f8e2a1b3c7_.py b/migrations/versions/d4f8e2a1b3c7_.py new file mode 100644 index 000000000..e84d0302e --- /dev/null +++ b/migrations/versions/d4f8e2a1b3c7_.py @@ -0,0 +1,44 @@ +"""Add api_token table for scoped API token auth. + +Revision ID: d4f8e2a1b3c7 +Revises: c8f3a2b1d4e5 +Create Date: 2026-06-11 03:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'd4f8e2a1b3c7' +down_revision = 'c8f3a2b1d4e5' +branch_labels = None +depends_on = None + + +def upgrade(): + """Apply the migration.""" + op.add_column('user', sa.Column('github_login', sa.String(length=255), nullable=True)) + op.create_table( + 'api_token', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_name', sa.String(length=50), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=False), + sa.Column('token_prefix', sa.String(length=16), nullable=False), + sa.Column('scopes_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'), + mysql_engine='InnoDB' + ) + op.create_index('ix_api_token_token_prefix', 'api_token', ['token_prefix']) + + +def downgrade(): + """Revert the migration.""" + op.drop_index('ix_api_token_token_prefix', table_name='api_token') + op.drop_table('api_token') + op.drop_column('user', 'github_login') diff --git a/mod_api/__init__.py b/mod_api/__init__.py new file mode 100644 index 000000000..966c11da5 --- /dev/null +++ b/mod_api/__init__.py @@ -0,0 +1,27 @@ +""" +mod_api: JSON REST API blueprint for the CCExtractor CI platform. + +Registered at /api/v1. All endpoints return structured JSON, use scoped +Bearer token auth, and enforce per-client rate limiting. +""" + +from flask import Blueprint + +mod_api = Blueprint('api', __name__) + +# Middleware (registers before_request hooks and error handlers) +# WARNING: auth must be imported before rate_limit. The auth middleware +# manually calls check_rate_limit() for unauthenticated paths. If +# rate_limit is imported first, its before_request hook fires first and +# the auth middleware's manual call would double-count requests. +from mod_api.middleware import auth # noqa: E402, F401 +from mod_api.middleware import error_handler # noqa: E402, F401 +from mod_api.middleware import rate_limit # noqa: E402, F401 +from mod_api.middleware import security # noqa: E402, F401 +# Route modules (registers endpoint functions on the blueprint) +from mod_api.routes import auth as auth_routes # noqa: E402, F401 +from mod_api.routes import errors_logs # noqa: E402, F401 +from mod_api.routes import results # noqa: E402, F401 +from mod_api.routes import runs # noqa: E402, F401 +from mod_api.routes import samples # noqa: E402, F401 +from mod_api.routes import system # noqa: E402, F401 diff --git a/mod_api/middleware/__init__.py b/mod_api/middleware/__init__.py new file mode 100644 index 000000000..860b3ce01 --- /dev/null +++ b/mod_api/middleware/__init__.py @@ -0,0 +1 @@ +"""mod_api.middleware: auth, rate limiting, validation, and error handling.""" diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py new file mode 100644 index 000000000..f8a7df1c7 --- /dev/null +++ b/mod_api/middleware/auth.py @@ -0,0 +1,131 @@ +""" +Bearer token authentication and scope/role enforcement for API routes. + +Runs as a before_request hook on the api blueprint. Public endpoints +(token creation, health check) are exempted. On success, the authenticated +user and token are stored in flask.g for downstream handlers. + +HTTP semantics: + 401 = token missing, expired, revoked, or invalid + 403 = valid token but insufficient scope or role +""" + +import functools +from typing import List + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.error_handler import make_error_response +from mod_api.models.api_token import ApiToken + +_AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.' + +# These endpoints bypass auth entirely. +_PUBLIC_ENDPOINTS = frozenset([ + 'api.create_token', # POST /auth/tokens (uses email/password body) + 'api.system_health', # GET /system/health (uptime monitoring) +]) + + +def _unauthorized(): + """Shorthand for a 401 response with the standard auth failure message.""" + from mod_api.middleware.rate_limit import check_rate_limit + rate_limit_resp = check_rate_limit() + if rate_limit_resp: + return rate_limit_resp + + return make_error_response( + 'unauthorized', _AUTH_FAILED_MSG, http_status=401) + + +@mod_api.before_request +def authenticate_request(): + """Validate Bearer token and attach user context to the request.""" + if request.endpoint in _PUBLIC_ENDPOINTS: + g.api_user = None + g.api_token = None + return + + auth_header = request.headers.get('Authorization', '') + if not auth_header: + return _unauthorized() + + parts = auth_header.split(' ', 1) + if len(parts) != 2 or parts[0] != 'Bearer': + return _unauthorized() + + token_value = parts[1].strip() + if not token_value or not token_value.startswith('spci_'): + return _unauthorized() + + # Look up by prefix, then verify the full hash against each candidate. + prefix = ApiToken.extract_prefix(token_value) + candidates = ApiToken.query.filter_by(token_prefix=prefix).all() + + if not candidates: + return _unauthorized() + + matched_token = None + for candidate in candidates: + if ApiToken.verify_token(token_value, candidate.token_hash): + matched_token = candidate + break + + if matched_token is None: + return _unauthorized() + + if not matched_token.is_valid: + return _unauthorized() + + g.api_token = matched_token + g.api_user = matched_token.user + + +def require_scope(*scopes: str): + """Reject the request if the token lacks any of the ``scopes``.""" + def decorator(f): + @functools.wraps(f) + def decorated_function(*args, **kwargs): + token = getattr(g, 'api_token', None) + if token is None: + return _unauthorized() + + missing_scopes = [s for s in scopes if not token.has_scope(s)] + if missing_scopes: + return make_error_response( + 'forbidden', + 'Token lacks the required scopes for this operation.', + details={ + 'required_scopes': list(scopes), + 'missing_scopes': missing_scopes, + 'token_scopes': token.scopes, + }, + http_status=403, + ) + return f(*args, **kwargs) + return decorated_function + return decorator + + +def require_roles(roles: List[str]): + """Reject the request if the user's role is not in ``roles``.""" + def decorator(f): + @functools.wraps(f) + def decorated_function(*args, **kwargs): + user = getattr(g, 'api_user', None) + if user is None: + return _unauthorized() + if user.role.value not in roles: + return make_error_response( + 'forbidden', + 'Your role does not have permission for this operation.', + details={ + 'required_roles': roles, + 'user_role': user.role.value, + }, + http_status=403, + ) + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py new file mode 100644 index 000000000..111afdafd --- /dev/null +++ b/mod_api/middleware/error_handler.py @@ -0,0 +1,139 @@ +"""Structured JSON error responses for API routes.""" + +from flask import jsonify, request +from marshmallow import ValidationError as MarshmallowValidationError +from sqlalchemy.exc import SQLAlchemyError + +from mod_api import mod_api + +_API_PREFIX = '/api/v1' + + +def make_error_response(code, message, details=None, http_status=400): + """Build a JSON error response conforming to the ErrorResponse schema.""" + body = { + 'code': code, + 'message': str(message)[:500], + 'details': details if details is not None else {}, + } + response = jsonify(body) + response.status_code = http_status + return response + + +@mod_api.errorhandler(400) +def handle_400(error): + """Bad request.""" + return make_error_response( + 'validation_error', + getattr(error, 'description', 'Bad request.'), + http_status=400, + ) + + +@mod_api.errorhandler(401) +def handle_401(error): + """Unauthorized.""" + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + +@mod_api.errorhandler(403) +def handle_403(error): + """Forbidden.""" + return make_error_response( + 'forbidden', + 'Token does not have the required scope for this operation.', + http_status=403, + ) + + +def _is_api_request(): + return request.path.startswith('/api/') + + +@mod_api.app_errorhandler(404) +def handle_404(error): + """Not found.""" + if _is_api_request(): + return make_error_response( + 'not_found', + getattr(error, 'description', 'Resource not found.'), + http_status=404, + ) + from run import not_found + return not_found(error) + + +@mod_api.app_errorhandler(405) +def handle_405(error): + """Handle method-not-allowed errors for API routes.""" + if _is_api_request(): + return make_error_response( + 'method_not_allowed', + 'Method not allowed.', + http_status=405, + ) + return "Method not allowed", 405 + + +@mod_api.errorhandler(422) +def handle_422(error): + """Unprocessable entity.""" + return make_error_response( + 'unprocessable', + getattr( + error, + 'description', + 'Request is valid JSON but semantically invalid.'), + http_status=422, + ) + + +@mod_api.errorhandler(429) +def handle_429(error): + """Rate limited.""" + return make_error_response( + 'rate_limited', + 'Rate limit exceeded.', + details={'retry_after': 30, 'limit': 120, 'window': '60s'}, + http_status=429, + ) + + +@mod_api.errorhandler(500) +def handle_500(error): + """Handle unexpected server errors for API routes.""" + return make_error_response( + 'internal_error', + 'An unexpected error occurred.', + http_status=500, + ) + + +@mod_api.errorhandler(MarshmallowValidationError) +def handle_marshmallow_validation_error(error): + """Catch schema validation failures and return them as 400.""" + return make_error_response( + 'validation_error', + 'Request failed schema validation.', + details={'fields': error.messages}, + http_status=400, + ) + + +@mod_api.errorhandler(SQLAlchemyError) +def handle_sqlalchemy_error(error): + """Log database errors.""" + from flask import g + log = getattr(g, 'log', None) + if log: + log.error(f'Database error in API: {type(error).__name__}') + return make_error_response( + 'internal_error', + 'An unexpected database error occurred.', + http_status=500, + ) diff --git a/mod_api/middleware/rate_limit.py b/mod_api/middleware/rate_limit.py new file mode 100644 index 000000000..66154f65b --- /dev/null +++ b/mod_api/middleware/rate_limit.py @@ -0,0 +1,125 @@ +""" +Per-client rate limiting for API endpoints. + +Limits: + POST /auth/tokens 5 req / 15 min (keyed by IP) + POST/DELETE/PUT/PATCH 20 req / min (keyed by token) + GET 120 req / min (keyed by token) + +Includes X-RateLimit-* headers on every response. +""" + +import threading +import time + +from flask import g, request + +from mod_api import mod_api + +_rate_limit_store = {} # key -> {'count': int, 'window_start': float} +_rate_limit_lock = threading.Lock() +_eviction_counter = 0 +_EVICTION_INTERVAL = 100 # run cleanup every N requests + + +def _evict_stale_entries(): + """Prune entries older than 15 min to bound memory usage.""" + global _eviction_counter + with _rate_limit_lock: + _eviction_counter += 1 + if _eviction_counter < _EVICTION_INTERVAL: + return + _eviction_counter = 0 + now = time.time() + stale_keys = [ + key for key, entry in _rate_limit_store.items() + if (now - entry['window_start']) > 900 + ] + for key in stale_keys: + del _rate_limit_store[key] + + +def _get_client_ip(): + """Extract the real client IP, ignoring X-Forwarded-For to prevent spoofing.""" + return request.remote_addr + + +def _get_rate_limit_key(): + """Build the rate-limit bucket key for this request.""" + if request.endpoint == 'api.create_token': + return f'ip:{_get_client_ip()}' + token = getattr(g, 'api_token', None) + if token: + return f'token:{token.id}' + return f'ip:{_get_client_ip()}' + + +def _get_limits(): + """Return (max_requests, window_seconds) for the current endpoint.""" + if request.endpoint == 'api.create_token': + return 5, 900 + if request.method in ('POST', 'DELETE', 'PUT', 'PATCH'): + return 20, 60 + return 120, 60 + + +@mod_api.before_request +def check_rate_limit(): + """Reject the request if the client has exceeded their rate limit.""" + _evict_stale_entries() + + key = _get_rate_limit_key() + max_requests, window_seconds = _get_limits() + now = time.time() + + with _rate_limit_lock: + entry = _rate_limit_store.get(key) + + if entry is None or (now - entry['window_start']) >= window_seconds: + _rate_limit_store[key] = {'count': 1, 'window_start': now} + else: + entry['count'] += 1 + if entry['count'] > max_requests: + reset_at = int(entry['window_start'] + window_seconds) + retry_after = max(1, reset_at - int(now)) + + from mod_api.middleware.error_handler import \ + make_error_response + response = make_error_response( + 'rate_limited', + f'Rate limit exceeded. Retry after {retry_after} seconds.', + details={ + 'retry_after': retry_after, + 'limit': max_requests, + 'window': f'{window_seconds}s', + }, + http_status=429, + ) + response.headers['Retry-After'] = str(retry_after) + response.headers['X-RateLimit-Limit'] = str(max_requests) + response.headers['X-RateLimit-Remaining'] = '0' + response.headers['X-RateLimit-Reset'] = str(reset_at) + return response + + +@mod_api.after_request +def add_rate_limit_headers(response): + """Attach X-RateLimit-* headers to every response.""" + key = _get_rate_limit_key() + max_requests, window_seconds = _get_limits() + now = time.time() + + with _rate_limit_lock: + entry = _rate_limit_store.get(key) + if entry: + remaining = max(0, max_requests - entry['count']) + reset_at = int(entry['window_start'] + window_seconds) + else: + remaining = max_requests + reset_at = int(now + window_seconds) + + response.headers['X-RateLimit-Limit'] = str(max_requests) + response.headers['X-RateLimit-Remaining'] = str(remaining) + response.headers['X-RateLimit-Reset'] = str(reset_at) + + return response diff --git a/mod_api/middleware/security.py b/mod_api/middleware/security.py new file mode 100644 index 000000000..068f0abae --- /dev/null +++ b/mod_api/middleware/security.py @@ -0,0 +1,11 @@ +from mod_api import mod_api + + +@mod_api.after_request +def add_security_headers(response): + """Attach security headers to all API responses.""" + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + response.headers['Content-Security-Policy'] = "default-src 'none'; frame-ancestors 'none'" + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + return response diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py new file mode 100644 index 000000000..155a59598 --- /dev/null +++ b/mod_api/middleware/validation.py @@ -0,0 +1,315 @@ +""" +Request validation decorators for bodies, query params, and path IDs. + +All of these return 400 with field-level details on failure, so route +handlers can assume clean input. +""" + +import re +from functools import wraps + +from flask import request +from marshmallow import ValidationError as MarshmallowValidationError + +from mod_api.middleware.error_handler import make_error_response + +PATTERNS = { + 'commit_sha': re.compile(r'^[a-fA-F0-9]{40}$'), + 'sha256': re.compile(r'^[a-fA-F0-9]{64}$'), + 'repository': re.compile(r'^[a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+$'), + 'branch': re.compile(r'^[A-Za-z0-9._/\-]+$'), + 'token_name': re.compile(r'^[a-zA-Z0-9_\-]+$'), + 'extension': re.compile(r'^[a-zA-Z0-9]+$'), +} + +# Whitelist of allowed sort params. +ALLOWED_RUN_SORTS = frozenset([ + 'created_at', '-created_at', + 'run_id', '-run_id', +]) + + +def validate_body(schema_class): + """Validate the JSON body with a schema, pass result as ``validated_data``.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + content_type = request.content_type or '' + if content_type.split(';')[0].strip() != 'application/json': + return make_error_response( + 'validation_error', + 'Content-Type must be application/json.', + http_status=415, + ) + json_data = request.get_json(silent=True) + if json_data is None: + return make_error_response( + 'validation_error', + 'Request body must be valid JSON.', + http_status=400, + ) + schema = schema_class() + try: + validated = schema.load(json_data) + except MarshmallowValidationError as e: + return make_error_response( + 'validation_error', + 'Request failed schema validation.', + details={'fields': e.messages}, + http_status=400, + ) + kwargs['validated_data'] = validated + return f(*args, **kwargs) + return decorated + return decorator + + +def validate_offset_pagination(default_limit=50): + """Extract and validate ``limit`` and ``offset`` query params.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if 'cursor' in request.args: + return make_error_response( + 'validation_error', + 'Cannot mix cursor and offset pagination.', + details={'fields': {'cursor': 'Cannot specify cursor when using offset pagination.'}}, + http_status=400, + ) + + try: + limit = int(request.args.get('limit', default_limit)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'limit must be an integer.', + details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + http_status=400, + ) + + try: + offset = int(request.args.get('offset', 0)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'offset must be a non-negative integer.', + details={'fields': {'offset': 'Must be a non-negative integer.'}}, + http_status=400, + ) + + if limit < 1 or limit > 100: + return make_error_response( + 'validation_error', + 'limit must be between 1 and 100.', + details={'fields': {'limit': 'Must be between 1 and 100.'}}, + http_status=400, + ) + + if offset < 0: + return make_error_response( + 'validation_error', + 'offset must be non-negative.', + details={'fields': {'offset': 'Must be >= 0.'}}, + http_status=400, + ) + + kwargs['limit'] = limit + kwargs['offset'] = offset + return f(*args, **kwargs) + return decorated + return decorator + + +def _parse_limit(default_limit): + try: + limit = int(request.args.get('limit', default_limit)) + except (ValueError, TypeError): + return None, make_error_response( + 'validation_error', + 'limit must be an integer.', + details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + http_status=400, + ) + + if limit < 1 or limit > 100: + return None, make_error_response( + 'validation_error', + 'limit must be between 1 and 100.', + details={'fields': {'limit': 'Must be between 1 and 100.'}}, + http_status=400, + ) + return limit, None + + +def _parse_cursor(): + cursor = request.args.get('cursor') + if cursor is None: + return None, None + try: + cursor = int(cursor) + except (ValueError, TypeError): + return None, make_error_response( + 'validation_error', + 'cursor must be an integer.', + details={'fields': {'cursor': 'Must be an integer.'}}, + http_status=400, + ) + if cursor < 0: + return None, make_error_response( + 'validation_error', + 'cursor must be non-negative.', + details={'fields': {'cursor': 'Must be >= 0.'}}, + http_status=400, + ) + if cursor > 10_000_000: + return None, make_error_response( + 'validation_error', + 'cursor out of range.', + details={'fields': {'cursor': 'Must be <= 10000000.'}}, + http_status=400, + ) + return cursor, None + + +def validate_cursor_pagination(default_limit=50): + """Extract and validate ``limit`` and ``cursor`` query params.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if 'offset' in request.args: + return make_error_response( + 'validation_error', + 'Cannot mix cursor and offset pagination.', + details={'fields': {'offset': 'Cannot specify offset when using cursor pagination.'}}, + http_status=400, + ) + + limit, err = _parse_limit(default_limit) + if err: + return err + + cursor, err = _parse_cursor() + if err: + return err + + kwargs['limit'] = limit + kwargs['cursor'] = cursor + return f(*args, **kwargs) + return decorated + return decorator + + +def validate_path_id(param_name): + """Ensure a URL path parameter is a positive integer.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + value = kwargs.get(param_name) + try: + int_value = int(value) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + f'{param_name} must be a positive integer.', + details={ + 'fields': { + param_name: 'Must be a positive integer.'}}, + http_status=400, + ) + if int_value < 1: + return make_error_response( + 'validation_error', + f'{param_name} must be >= 1.', + details={ + 'fields': { + param_name: 'Must be >= 1. Zero and negative IDs are rejected.' + } + }, + http_status=400, + ) + kwargs[param_name] = int_value + return f(*args, **kwargs) + return decorated + return decorator + + +def validate_date_range(f): + """Parse date query params and reject inverted ranges.""" + @wraps(f) + def decorated(*args, **kwargs): + from datetime import datetime + + created_after_str = request.args.get('created_after') + created_before_str = request.args.get('created_before') + created_after = None + created_before = None + + if created_after_str: + try: + created_after = datetime.fromisoformat( + created_after_str.replace('Z', '+00:00')) + except ValueError: + return make_error_response( + 'validation_error', + 'created_after must be a valid ISO 8601 datetime.', + details={ + 'fields': { + 'created_after': 'Invalid ISO 8601 format.'}}, + http_status=400, + ) + + if created_before_str: + try: + created_before = datetime.fromisoformat( + created_before_str.replace('Z', '+00:00')) + except ValueError: + return make_error_response( + 'validation_error', + 'created_before must be a valid ISO 8601 datetime.', + details={ + 'fields': { + 'created_before': 'Invalid ISO 8601 format.'}}, + http_status=400, + ) + + if created_after and created_before and created_after > created_before: + return make_error_response( + 'validation_error', + 'created_after must not be after created_before.', + details={'fields': { + 'created_after': 'Must be before created_before.', + 'created_before': 'Must be after created_after.', + }}, + http_status=400, + ) + + kwargs['created_after'] = created_after + kwargs['created_before'] = created_before + return f(*args, **kwargs) + return decorated + + +def validate_sort(allowed=None): + """Validate the ``sort`` query param against a whitelist.""" + if allowed is None: + allowed = ALLOWED_RUN_SORTS + + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + sort = request.args.get('sort', '-created_at') + if sort not in allowed: + return make_error_response( + 'validation_error', + f'sort must be one of: {", ".join(sorted(allowed))}', + details={ + 'fields': { + 'sort': f'Must be one of: {sorted(allowed)}' + } + }, + http_status=400, + ) + kwargs['sort'] = sort + return f(*args, **kwargs) + return decorated + return decorator diff --git a/mod_api/models/__init__.py b/mod_api/models/__init__.py new file mode 100644 index 000000000..dcb36537a --- /dev/null +++ b/mod_api/models/__init__.py @@ -0,0 +1 @@ +"""mod_api.models: database models for the API module.""" diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py new file mode 100644 index 000000000..ca406bacc --- /dev/null +++ b/mod_api/models/api_token.py @@ -0,0 +1,141 @@ +""" +ApiToken model: server-side storage for scoped API tokens. + +Tokens are opaque strings prefixed with 'spci_'. Only the argon2 hash +is persisted; the plaintext is returned exactly once at creation time. +""" + +import json +import secrets +from datetime import datetime, timedelta, timezone +from typing import List + +from argon2 import PasswordHasher +from argon2.exceptions import (InvalidHashError, VerificationError, + VerifyMismatchError) +from sqlalchemy import (Column, DateTime, ForeignKey, Integer, String, Text, + UniqueConstraint) +from sqlalchemy.orm import relationship + +from database import Base + +_ph = PasswordHasher() + +VALID_SCOPES = frozenset([ + 'runs:read', + 'runs:write', + 'results:read', + 'baselines:write', + 'system:read', + 'tokens:manage', +]) + +DEFAULT_SCOPES = ['runs:read', 'results:read'] + +TOKEN_PREFIX = 'spci_' +TOKEN_BYTE_LENGTH = 32 + + +class ApiToken(Base): + """Scoped API token bound to a user account.""" + + __tablename__ = 'api_token' + __table_args__ = ( + UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'), + {'mysql_engine': 'InnoDB'}, + ) + + id = Column(Integer, primary_key=True) + user_id = Column( + Integer, + ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), + nullable=False, + ) + user = relationship('User', uselist=False) + token_name = Column(String(50), nullable=False) + token_hash = Column(String(255), nullable=False) + token_prefix = Column(String(16), nullable=False, index=True) + scopes_json = Column(Text(), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + revoked_at = Column(DateTime(timezone=True), nullable=True) + + def __init__( + self, + user_id: int, + token_name: str, + token_hash: str, + token_prefix: str, + scopes: List[str], + expires_in_days: int = 7, + ) -> None: + self.user_id = user_id + self.token_name = token_name + self.token_hash = token_hash + self.token_prefix = token_prefix + self.scopes_json = json.dumps(scopes) + self.created_at = datetime.now(timezone.utc) + self.expires_at = self.created_at + timedelta(days=expires_in_days) + + def __repr__(self) -> str: + """Return a debug representation of the token.""" + return f'' + + @property + def scopes(self) -> List[str]: + """Parse the JSON scopes column into a list.""" + return json.loads(self.scopes_json) + + @property + def is_expired(self) -> bool: + """Check whether this token has passed its expiration time.""" + now = datetime.now(timezone.utc) + expires = self.expires_at + if expires is None: + return True + # MySQL DATETIME columns don't preserve tzinfo; treat naive as UTC. + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + return bool(now > expires) + + @property + def is_revoked(self) -> bool: + """Check whether this token has been explicitly revoked.""" + return bool(self.revoked_at is not None) + + @property + def is_valid(self) -> bool: + """Return True if the token is neither expired nor revoked.""" + return not self.is_expired and not self.is_revoked + + def has_scope(self, scope: str) -> bool: + """Return True if the token grants the given scope.""" + return scope in self.scopes + + def revoke(self) -> None: + """Mark this token as revoked with the current timestamp.""" + self.revoked_at = datetime.now(timezone.utc) + + @staticmethod + def generate_token() -> str: + """Create a new random token string with the spci_ prefix.""" + random_bytes = secrets.token_urlsafe(TOKEN_BYTE_LENGTH) + return f'{TOKEN_PREFIX}{random_bytes}' + + @staticmethod + def hash_token(plaintext: str) -> str: + """Hash a token with argon2 for storage.""" + return _ph.hash(plaintext) + + @staticmethod + def verify_token(plaintext: str, token_hash: str) -> bool: + """Verify a plaintext token against its stored argon2 hash.""" + try: + return _ph.verify(token_hash, plaintext) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + @staticmethod + def extract_prefix(token: str) -> str: + """Return the first 16 chars used for DB lookup.""" + return token[:16] if len(token) >= 16 else token diff --git a/mod_api/routes/__init__.py b/mod_api/routes/__init__.py new file mode 100644 index 000000000..eac65b967 --- /dev/null +++ b/mod_api/routes/__init__.py @@ -0,0 +1 @@ +"""mod_api.routes — Endpoint handlers for the API.""" diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py new file mode 100644 index 000000000..59743d238 --- /dev/null +++ b/mod_api/routes/auth.py @@ -0,0 +1,199 @@ +""" +Token lifecycle: create, list, and revoke API tokens. + +POST /auth/tokens Authenticate with email/password, get a token +GET /auth/tokens List tokens (own tokens; admin can see all) +DELETE /auth/tokens/current Revoke the token you're currently using +DELETE /auth/tokens/{id} Revoke a specific token by ID +""" + +from flask import g, request +from passlib.apps import custom_app_context as pwd_context + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_body, + validate_offset_pagination) +from mod_api.models.api_token import DEFAULT_SCOPES, ApiToken +from mod_api.schemas.auth import (ApiTokenItemSchema, AuthTokenSchema, + TokenCreateRequestSchema) +from mod_api.utils import paginated_response, single_response +from mod_auth.models import User + +_DUMMY_HASH = pwd_context.hash('__dummy__') + + +@mod_api.route('/auth/tokens', methods=['POST']) +@validate_body(TokenCreateRequestSchema) +def create_token(validated_data=None): + """ + Authenticate with email + password and issue a scoped API token. + + The plaintext token value is returned exactly once in this response. + It's never stored or logged — only the argon2 hash is persisted. + """ + email = validated_data['email'] + password = validated_data['password'] + token_name = validated_data['token_name'] + expires_in_days = validated_data.get('expires_in_days', 7) + scopes = validated_data.get('scopes') or DEFAULT_SCOPES + + user = User.query.filter_by(email=email).first() + + # Hash password even if user is not found to prevent timing attacks + if user is None: + try: + pwd_context.verify(password, _DUMMY_HASH) + except Exception: + pass + return make_error_response( + 'invalid_credentials', + 'Invalid email or password.', + http_status=401, + ) + + if not user.is_password_valid(password): + return make_error_response( + 'invalid_credentials', + 'Invalid email or password.', + http_status=401, + ) + + # Check role limitations + allowed_scopes = { + 'runs:read', 'runs:write', 'results:read', + 'system:read', 'tokens:manage' + } + if user.role.value == 'admin': + allowed_scopes.add('baselines:write') + + invalid_scopes = set(scopes) - allowed_scopes + if invalid_scopes: + return make_error_response( + 'forbidden', + f'Your current role ({user.role.value}) does not permit requesting ' + f'the following scopes: {", ".join(invalid_scopes)}.', + http_status=403, + ) + + plaintext = ApiToken.generate_token() + token_hash = ApiToken.hash_token(plaintext) + token_prefix = ApiToken.extract_prefix(plaintext) + + api_token = ApiToken( + user_id=user.id, + token_name=token_name, + token_hash=token_hash, + token_prefix=token_prefix, + scopes=scopes, + expires_in_days=expires_in_days, + ) + g.db.add(api_token) + + from sqlalchemy.exc import IntegrityError + try: + g.db.commit() + except IntegrityError as e: + g.db.rollback() + error_msg = str(e).lower() + if 'uq_user_token_name' in error_msg or 'duplicate' in error_msg: + return make_error_response( + 'validation_error', + f'Token name "{token_name}" already exists for this user.', + details={'fields': {'token_name': 'Already in use. Revoke the existing token first.'}}, + http_status=400, + ) + raise + + return single_response( + { + 'token': plaintext, + 'token_type': 'bearer', + 'token_name': token_name, + 'scopes': scopes, + 'expires_at': api_token.expires_at, + }, + schema=AuthTokenSchema(), + http_status=201, + ) + + +@mod_api.route('/auth/tokens/current', methods=['DELETE']) +def revoke_current_token(): + """Revoke whatever token is in the Authorization header right now.""" + token = getattr(g, 'api_token', None) + if token is None: + return make_error_response( + 'unauthorized', + 'No token found in the current request.', + http_status=401, + ) + token.revoke() + g.db.add(token) + g.db.commit() + return '', 204 + + +@mod_api.route('/auth/tokens', methods=['GET']) +@require_roles(['admin', 'contributor', 'tester']) +@require_scope('tokens:manage') +@validate_offset_pagination() +def list_tokens(limit=50, offset=0): + """ + List tokens for the current user, paginated. + + Admins can pass ?all=true to see every token in the system. + Non-admins who try ?all=true get a 403. + """ + want_all = request.args.get('all', 'false').lower() == 'true' + is_admin = g.api_user.role.value == 'admin' + + if want_all and not is_admin: + return make_error_response( + 'forbidden', + 'Only admins may list all tokens.', + details={'required_roles': ['admin']}, + http_status=403, + ) + + if want_all and is_admin: + query = ApiToken.query.order_by(ApiToken.created_at.desc()) + else: + query = ApiToken.query.filter_by( + user_id=g.api_user.id, + ).order_by(ApiToken.created_at.desc()) + + total = query.count() + tokens = query.offset(offset).limit(limit).all() + schema = ApiTokenItemSchema(many=True) + + return paginated_response(tokens, total, limit, offset, schema=schema) + + +@mod_api.route('/auth/tokens/', methods=['DELETE']) +def revoke_specific_token(token_id): + """ + Revoke a token by its numeric ID. + + Non-admins can only revoke their own tokens. Admins can revoke anyone's. + Already-revoked tokens are silently accepted (idempotent). + """ + is_admin = g.api_user.role.value == 'admin' + token = ApiToken.query.filter_by(id=token_id).first() + + # Non-admins get a uniform 404 for both "doesn't exist" and "belongs to + # another user" to prevent token-ID enumeration. + is_own = token is not None and token.user_id == g.api_user.id + if not token or (not is_admin and not is_own): + return make_error_response('not_found', 'Token not found.', http_status=404) + + if not is_own and not g.api_token.has_scope('tokens:manage'): + return make_error_response('forbidden', 'Cross-user revocation requires tokens:manage scope.', http_status=403) + + if not token.is_revoked: + token.revoke() + g.db.add(token) + g.db.commit() + + return '', 204 diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py new file mode 100644 index 000000000..1d8c93673 --- /dev/null +++ b/mod_api/routes/errors_logs.py @@ -0,0 +1,189 @@ +""" +Error and build log routes. + +GET /runs/{id}/errors Test-level errors for a run +GET /runs/{id}/infrastructure-errors Infra errors (VM, build, worker) +GET /runs/{id}/error-summary Grouped error counts +GET /runs/{id}/logs Build log (cursor-paginated) +GET /runs/{id}/samples/{sid}/logs Per-sample logs (not yet available) +""" + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_cursor_pagination, + validate_offset_pagination, + validate_path_id) +from mod_api.services.error_service import (derive_error_summary, + derive_errors_for_run, + derive_infrastructure_errors) +from mod_api.services.log_service import read_log_lines +from mod_api.utils import cursor_paginated_response, paginated_response +from mod_test.models import Test + + +@mod_api.route('/runs//errors', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_run_errors(run_id, limit=50, offset=0): + """List test errors for a run, derived from result and output data.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + errors = derive_errors_for_run(run_id) + + error_type = request.args.get('type') + if error_type: + errors = [e for e in errors if e['type'] == error_type] + + severity = request.args.get('severity') + if severity: + errors = [e for e in errors if e['severity'] == severity] + + sample_id = request.args.get('sample_id', type=int) + if sample_id: + errors = [e for e in errors if e.get('sample_id') == sample_id] + + total = len(errors) + paged = errors[offset:offset + limit] + + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//infrastructure-errors', methods=['GET']) +@require_scope('system:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_infrastructure_errors(run_id, limit=50, offset=0): + """ + Infra errors classified from TestProgress messages on a best-effort basis. + + Stack traces are opt-in because they may contain internal paths. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + include_stack = request.args.get('include_stack', 'false').lower() == 'true' + if include_stack: + user = getattr(g, 'api_user', None) + if user is None or user.role.value not in ('admin', 'contributor'): + return make_error_response( + 'forbidden', + 'Stack traces require admin or contributor role.', + details={'required_roles': ['admin', 'contributor']}, + http_status=403, + ) + + errors = derive_infrastructure_errors(run_id) + + if not include_stack: + for e in errors: + e.pop('stack', None) + + # Apply optional type and severity filters. + error_type = request.args.get('type') + if error_type: + errors = [e for e in errors if e.get('type') == error_type] + + severity = request.args.get('severity') + if severity: + errors = [e for e in errors if e.get('severity') == severity] + + total = len(errors) + paged = errors[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//error-summary', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def get_error_summary(run_id, limit=50, offset=0): + """Group error summary for triaging a run before drilling into details.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + group_by = request.args.get('group_by', 'type') + if group_by not in ('type', 'severity', 'sample_id', 'regression_id'): + return make_error_response( + 'validation_error', + 'group_by must be one of: type, severity, sample_id, regression_id.', + http_status=400, + ) + + severity = request.args.get('severity') + + summary = derive_error_summary(run_id, group_by=group_by) + + if severity: + summary = [s for s in summary if s.get('severity') == severity] + + total = len(summary) + paged = summary[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//logs', methods=['GET']) +@require_scope('system:read') +@validate_path_id('run_id') +@validate_cursor_pagination(default_limit=100) +def get_run_logs(run_id, limit=100, cursor=None): + """ + Read a run's build log with cursor-based pagination. + + Returns 404 (not a broken download link) when the file doesn't exist. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + level = request.args.get('level') + source = request.args.get('source') + contains = request.args.get('contains') + if contains and len(contains) > 100: + return make_error_response( + 'validation_error', + 'contains parameter must be 100 characters or less.', + http_status=400, + ) + + try: + lines, next_cursor = read_log_lines( + run_id, + cursor=cursor, + limit=limit, + level=level, + source=source, + contains=contains, + ) + except FileNotFoundError: + return make_error_response( + 'log_not_found', + f'Log file for run {run_id} is not available locally. ' + 'It may have been moved to cold storage. Please download it via the artifacts API.', + details={'run_id': run_id, 'action_required': 'Use the /runs/{run_id}/artifacts/logs endpoint'}, + http_status=404, + ) + + return cursor_paginated_response(lines, next_cursor, limit) + + +@mod_api.route('/runs//samples//logs', methods=['GET']) +@require_scope('system:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +@validate_offset_pagination() +def get_sample_logs(run_id, sample_id, limit=50, offset=0): + """Per-sample logs aren't available yet — the CI worker doesn't support them.""" + return make_error_response( + 'not_found', + f'Per-sample logs are not available for sample {sample_id} in run {run_id}.', + details={'reason': 'Per-sample log storage is not yet supported by the CI worker.'}, + http_status=404, + ) diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py new file mode 100644 index 000000000..3aa914e26 --- /dev/null +++ b/mod_api/routes/results.py @@ -0,0 +1,442 @@ +""" +Expected/actual output, diffs, and baseline approval routes. + +GET /runs/{id}/samples/{sid}/expected Expected output file +GET /runs/{id}/samples/{sid}/actual Actual output file +GET /runs/{id}/samples/{sid}/diff Structured diff +POST /runs/{id}/samples/{sid}/baseline-approval Approve a new baseline +""" + +import base64 +import os + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import validate_body, validate_path_id +from mod_api.schemas.results import BaselineApprovalRequestSchema +from mod_api.services.diff_service import compute_diff, file_sha256, read_lines +from mod_api.services.status import is_dummy_row +from mod_api.services.storage import get_test_results_base_path +from mod_api.utils import single_response +from mod_test.models import Test, TestResult, TestResultFile + +INVALID_PATH_MSG = 'Invalid file path.' +READ_ERROR_MSG = 'Failed to read file.' + + +def _safe_resolve(base_path, filename): + """ + Resolve filename under base_path, rejecting path traversal. + + Returns the absolute path if it's safely within base_path, + or None if traversal was detected. + """ + resolved = os.path.realpath(os.path.join(base_path, filename)) + base_real = os.path.realpath(base_path) + if not resolved.startswith(base_real + os.sep) and resolved != base_real: + return None + return resolved + + +def _find_result_file(run_id, regression_test_id, output_id=None): + """ + Look up the right TestResultFile row. + + Uses run_id + regression_test_id from the path. If output_id is + given as a query param, narrow to that specific output file. + """ + query = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ) + + if output_id is not None: + query = query.filter_by(regression_test_output_id=output_id) + + return query.first() + + +def _parse_output_id(): + """Pull output_id from query string, if provided.""" + return request.args.get('output_id', type=int) + + +def _validate_result_file_access(run_id, sample_id, regression_id, output_id): + """Validate access to a result file and return it, or an error response.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return None, make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result_file = _find_result_file(run_id, regression_id, output_id) + + if result_file is None: + return None, make_error_response( + 'not_found', + f'No result for regression test {regression_id}.', + http_status=404, + ) + + actual_sample_id = ( + result_file.regression_test.sample_id + if result_file.regression_test else None + ) + if actual_sample_id != sample_id: + return None, make_error_response( + 'not_found', + f'Regression test {regression_id} does not belong to sample {sample_id}.', + http_status=404, + ) + + return result_file, None + + +def _read_output_file(file_path, fmt, is_expected=True): + """Read output file and return properties.""" + if not os.path.isfile(file_path): + type_str = 'Expected' if is_expected else 'Actual' + return None, make_error_response( + 'not_found', + f'{type_str} output file not found on disk.', + http_status=404, + ) + + sha256 = file_sha256(file_path) + file_size = os.path.getsize(file_path) + truncated = False + download_url = None + + if file_size > 1048576: + truncated = True + from mod_api.services.storage import resolve_artifact + filename = os.path.basename(file_path) + download_url, _ = resolve_artifact(f'TestResults/{filename}') + + if fmt == 'text': + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read(1048576) + encoding = 'utf-8' + except Exception: + return None, make_error_response('internal_error', READ_ERROR_MSG, http_status=500) + else: + try: + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read(1048576)).decode('ascii') + encoding = 'base64' + except Exception: + return None, make_error_response('internal_error', READ_ERROR_MSG, http_status=500) + + return { + 'content': content, + 'encoding': encoding, + 'sha256': sha256, + 'truncated': truncated, + 'download_url': download_url, + }, None + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//expected', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_expected_output(run_id, sample_id, regression_id, output_id): + """Return the expected output file for a regression test result.""" + result_file, err = _validate_result_file_access(run_id, sample_id, regression_id, output_id) + if err: + return err + + if is_dummy_row(result_file): + return make_error_response('not_found', 'Expected output not found.', http_status=404) + + base_path = get_test_results_base_path() + expected_filename = result_file.expected + ext = '' + if result_file.regression_test_output: + ext = result_file.regression_test_output.correct_extension + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') + expected_filename += ext + + file_path = _safe_resolve(base_path, expected_filename) + if file_path is None: + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + + fmt = request.args.get('format', 'base64') + + data, err = _read_output_file(file_path, fmt, is_expected=True) + if err: + return err + + content = data['content'] + encoding = data['encoding'] + sha256 = data['sha256'] + truncated = data['truncated'] + download_url = data['download_url'] + + return single_response({ + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + 'filename': expected_filename, + 'content_type': 'application/octet-stream', + 'encoding': encoding, + 'content': content, + 'truncated': truncated, + 'download_url': download_url, + 'sha256': sha256, + 'storage_status': 'ok', + }) + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//actual', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_actual_output(run_id, sample_id, regression_id, output_id): + """ + Return the actual output file for a regression test result. + + got=null in the DB means the output matched expected — not that it's + missing. We return 303 (redirect to expected) in that case. Missing + output (the dummy sentinel row) returns 404. + """ + result_file, err = _validate_result_file_access(run_id, sample_id, regression_id, output_id) + if err: + return err + + if is_dummy_row(result_file): + return make_error_response( + 'missing_output', + 'Test produced no output when output was expected.', + http_status=404, + ) + + if result_file.got is None: + from flask import redirect, url_for + return redirect(url_for( + 'api.get_expected_output', + run_id=run_id, + sample_id=sample_id, + regression_id=regression_id, + output_id=output_id, + format=request.args.get('format', 'base64') + ), code=303) + + base_path = get_test_results_base_path() + actual_filename = result_file.got + if result_file.regression_test_output: + ext = result_file.regression_test_output.correct_extension + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') + actual_filename += ext + + file_path = _safe_resolve(base_path, actual_filename) + if file_path is None: + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + + fmt = request.args.get('format', 'base64') + + data, err = _read_output_file(file_path, fmt, is_expected=False) + if err: + return err + + content = data['content'] + encoding = data['encoding'] + sha256 = data['sha256'] + truncated = data['truncated'] + download_url = data['download_url'] + + return single_response({ + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + 'filename': actual_filename, + 'content_type': 'application/octet-stream', + 'encoding': encoding, + 'content': content, + 'truncated': truncated, + 'download_url': download_url, + 'sha256': sha256, + 'storage_status': 'ok', + }) + + +def _handle_missing_diff(result_file, format_type, diff_ids): + if is_dummy_row(result_file): + if format_type == 'unified': + return single_response({**diff_ids, 'format': 'unified', 'content': ''}) + return single_response({ + **diff_ids, + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + + if result_file.got is None: + if format_type == 'unified': + return single_response({**diff_ids, 'format': 'unified', 'content': ''}) + return single_response({ + **diff_ids, + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + return None + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//diff', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_diff(run_id, sample_id, regression_id, output_id): + """Structured diff between expected and actual output.""" + result_file, err = _validate_result_file_access(run_id, sample_id, regression_id, output_id) + if err: + return err + + diff_ids = { + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + } + + format_type = request.args.get('format', 'structured') + + missing_response = _handle_missing_diff(result_file, format_type, diff_ids) + if missing_response: + return missing_response + + base_path = get_test_results_base_path() + ext = result_file.regression_test_output.correct_extension if result_file.regression_test_output else '' + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') + expected_path = _safe_resolve(base_path, result_file.expected + ext) + actual_path = _safe_resolve(base_path, result_file.got + ext) + + if expected_path is None or actual_path is None: + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + + if not os.path.isfile(expected_path): + return make_error_response('not_found', 'Expected output file not found on disk.', http_status=404) + if not os.path.isfile(actual_path): + return make_error_response('not_found', 'Actual output file not found on disk.', http_status=404) + + max_diff_bytes = 10 * 1024 * 1024 # 10 MiB + if os.path.getsize(expected_path) > max_diff_bytes or os.path.getsize(actual_path) > max_diff_bytes: + return make_error_response('unprocessable', 'File too large for diff. Use download_url.', http_status=422) + + if format_type == 'unified': + import difflib + expected_lines = read_lines(expected_path) + actual_lines = read_lines(actual_path) + differ = difflib.unified_diff( + expected_lines, + actual_lines, + fromfile='expected', + tofile='actual', + lineterm='' + ) + unified_content = '\n'.join(differ) + return single_response({ + **diff_ids, + 'format': 'unified', + 'content': unified_content + }) + + context_lines = request.args.get('context_lines', 3, type=int) + context_lines = max(1, min(context_lines, 50)) + + diff_result = compute_diff(expected_path, actual_path, context_lines=context_lines) + diff_result.update(diff_ids) + return single_response(diff_result) + + +@mod_api.route('/runs//samples//baseline-approval', methods=['POST']) +@require_roles(['admin', 'contributor']) +@require_scope('baselines:write') +@validate_path_id('run_id') +@validate_path_id('sample_id') +@validate_body(BaselineApprovalRequestSchema) +def create_baseline_approval(run_id, sample_id, validated_data=None): + """ + Record intent to approve actual output as the new expected baseline. + + WARNING: When remove_variants is set to true, this action will remove all + platform-specific variants, making this output the single source of truth + across all platforms. Care should be taken as this applies globally. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + regression_id = validated_data['regression_id'] + output_id = validated_data['output_id'] + + result_file = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_id, + regression_test_output_id=output_id, + ).first() + + if result_file is None: + return make_error_response('not_found', 'Result file not found.', http_status=404) + + actual_sample_id = ( + result_file.regression_test.sample_id + if result_file.regression_test else None + ) + if actual_sample_id != sample_id: + return make_error_response( + 'not_found', + f'Regression test {regression_id} does not belong to sample {sample_id}.', + http_status=404, + ) + + if is_dummy_row(result_file): + return make_error_response('unprocessable', 'Cannot approve a dummy row.', http_status=422) + + if result_file.got is None: + return make_error_response('unprocessable', 'Output already matches expected.', http_status=422) + + # The actual output file (named by its hash) is already in TestResults/. + # We just need to update the RegressionTestOutput to point to this new hash. + rto = result_file.regression_test_output + if rto is None: + return make_error_response('internal_error', 'No RegressionTestOutput linked.', http_status=500) + + new_baseline = result_file.got + + rto.correct = new_baseline + + remove_variants = validated_data.get('remove_variants', False) + if remove_variants: + from mod_regression.models import RegressionTestOutputFiles + RegressionTestOutputFiles.query.filter_by(regression_test_output_id=rto.id).delete() + + g.db.commit() + + import datetime + return single_response({ + 'status': 'approved', + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': regression_id, + 'output_id': output_id, + 'requested_by': getattr(g, 'api_user').name if getattr(g, 'api_user', None) else 'unknown', + 'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat() + }) diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py new file mode 100644 index 000000000..a1a9f0310 --- /dev/null +++ b/mod_api/routes/runs.py @@ -0,0 +1,502 @@ +""" +Test run routes. + +GET /runs List runs (filtered, paginated, sorted) +POST /runs Trigger a new run +GET /runs/{id} Single run details +GET /runs/{id}/summary Pass/fail/skip counts +GET /runs/{id}/progress Progress event timeline +GET /runs/{id}/config Run configuration and test matrix +POST /runs/{id}/cancel Cancel a queued or running test +""" + +from flask import g, request +from sqlalchemy.exc import IntegrityError + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (PATTERNS, validate_body, + validate_date_range, + validate_offset_pagination, + validate_path_id, validate_sort) +from mod_api.schemas.runs import ProgressEventSchema, RunCreateRequestSchema +from mod_api.services.status import (derive_run_status, derive_sample_status, + get_run_timestamps) +from mod_api.utils import (cursor_paginated_response, get_sort_column, + paginated_response, single_response) +from mod_customized.models import CustomizedTest +from mod_regression.models import RegressionTest +from mod_test.models import (Fork, Test, TestPlatform, TestProgress, + TestResult, TestResultFile, TestStatus, TestType) + + +def _serialize_run(test): + """Turn a Test row into the Run response shape the spec expects.""" + return _batch_serialize([test])[0] + + +def _batch_serialize(tests, statuses=None, timestamps=None): + from mod_api.services.status import batch_get_run_data + if statuses is None or timestamps is None: + statuses, timestamps = batch_get_run_data(tests) + return [ + { + 'run_id': t.id, + 'status': statuses.get(t.id, 'queued'), + 'platform': t.platform.value, + 'test_type': 'pr' if t.test_type == TestType.pull_request else 'commit', + 'repository': t.fork.github_name if t.fork else 'unknown', + 'branch': t.branch, + 'commit_sha': t.commit, + 'pr_number': t.pr_nr if t.pr_nr and t.pr_nr > 0 else None, + 'created_at': timestamps.get(t.id, {}).get('created_at'), + 'queued_at': timestamps.get(t.id, {}).get('queued_at'), + 'started_at': timestamps.get(t.id, {}).get('started_at'), + 'completed_at': timestamps.get(t.id, {}).get('completed_at'), + 'github_link': t.github_link if t.fork else None, + } + for t in tests + ] + + +def _apply_run_filters(query, created_after, created_before): + platform = request.args.get('platform') + if platform: + try: + platform_enum = TestPlatform.from_string(platform) + query = query.filter(Test.platform == platform_enum) + except Exception: + valid_platforms = ', '.join(TestPlatform.values()) + return None, make_error_response( + 'validation_error', + f'Invalid platform: {platform}. Must be one of: {valid_platforms}.', + http_status=400, + ) + + branch = request.args.get('branch') + if branch: + query = query.filter(Test.branch == branch) + + commit_sha = request.args.get('commit_sha') + if commit_sha: + query = query.filter(Test.commit == commit_sha) + + repository = request.args.get('repository') + if repository: + from mod_api.middleware.validation import PATTERNS + if not PATTERNS['repository'].match(repository): + return None, make_error_response( + 'validation_error', + 'repository must match owner/repo format.', + details={'fields': {'repository': 'Must match ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$'}}, + http_status=400, + ) + fork_url = f'https://github.com/{repository}.git' + query = query.join(Fork).filter(Fork.github == fork_url) + + if created_after or created_before: + from sqlalchemy import func + first_progress = ( + g.db.query(TestProgress.test_id, func.min(TestProgress.timestamp).label('min_ts')) + .group_by(TestProgress.test_id) + .subquery() + ) + query = query.join(first_progress, Test.id == first_progress.c.test_id) + if created_after: + query = query.filter(first_progress.c.min_ts >= created_after) + if created_before: + query = query.filter(first_progress.c.min_ts <= created_before) + + return query, None + + +def _validate_run_permissions(user, target_repo, main_repo_full): + if target_repo == main_repo_full: + if user.role.value not in ('admin', 'tester', 'contributor'): + return make_error_response( + 'forbidden', + 'Only admins, testers, and contributors can trigger runs for the main repository.', + details={ + 'required_roles': ['admin', 'tester', 'contributor'], + 'repository': target_repo, + }, + http_status=403, + ) + else: + owner = target_repo.split('/')[0] + github_login = user.github_login + + if not github_login and user.github_token: + from mod_auth.controllers import fetch_username_from_token + github_login = fetch_username_from_token(user) + if github_login: + user.github_login = github_login + from flask import g + g.db.add(user) + + github_login = github_login or '' + + is_owner = bool(github_login) and owner.lower() == github_login.lower() + is_staff = user.role.value in ('admin', 'tester', 'contributor') + + if not is_owner and not is_staff: + return make_error_response( + 'forbidden', + 'You can only trigger runs for your own repository.', + details={ + 'repository': target_repo, + 'owner_required': github_login, + }, + http_status=403, + ) + return None + + +def _validate_regression_test_ids(regression_test_ids): + if regression_test_ids is not None: + if not regression_test_ids: + return None, make_error_response( + 'validation_error', + 'regression_test_ids cannot be empty.', + details={'fields': {'regression_test_ids': 'Must contain at least one ID.'}}, + http_status=400, + ) + active_tests = RegressionTest.query.filter( + RegressionTest.id.in_(regression_test_ids), + RegressionTest.active == True, # noqa: E712 + ).all() + active_ids = {t.id for t in active_tests} + inactive_ids = [tid for tid in regression_test_ids if tid not in active_ids] + if inactive_ids: + return None, make_error_response( + 'unprocessable', + 'Some regression test IDs are inactive or do not exist.', + details={'inactive_ids': inactive_ids}, + http_status=422, + ) + else: + active_tests = RegressionTest.query.filter_by(active=True).all() + regression_test_ids = [t.id for t in active_tests] + return regression_test_ids, None + + +@mod_api.route('/runs', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +@validate_sort() +@validate_date_range +def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, created_before=None): + """List runs with filters for platform, branch, commit, repo, status, and date range.""" + query, err = _apply_run_filters(Test.query, created_after, created_before) + if err: + return err + + sort_map = { + 'run_id': Test.id, + 'created_at': Test.id, # best proxy - Test has no created_at column + } + order = get_sort_column(sort, sort_map) + if order is not None: + query = query.order_by(order) + else: + query = query.order_by(Test.id.desc()) + + status_filter = request.args.get('status') + + if status_filter: + # Hard limit to prevent loading all historical runs into memory + all_matching = query.limit(1000).all() + is_truncated = len(all_matching) == 1000 + # Batch derivation logic + from mod_api.services.status import batch_get_run_data + statuses, timestamps = batch_get_run_data(all_matching) + + filtered = [] + for t in all_matching: + if statuses.get(t.id, 'queued') == status_filter: + filtered.append(t) + + serialized = _batch_serialize(filtered, statuses=statuses, timestamps=timestamps) + + total = len(serialized) + paged = serialized[offset:offset + limit] + return paginated_response(paged, total, limit, offset, truncated=is_truncated) + + total = query.count() + tests = query.offset(offset).limit(limit).all() + serialized = _batch_serialize(tests) + return paginated_response(serialized, total, limit, offset) + + +@mod_api.route('/runs', methods=['POST']) +@require_scope('runs:write') +@validate_body(RunCreateRequestSchema) +def create_run(validated_data=None): + """Trigger a new test run for a commit + platform combination. + + CI worker pickup: The worker's cron job (run_cron.py) polls the Test + table for rows without a 'completed' or 'canceled' TestProgress entry. + Creating a Test row here is sufficient to enqueue it — no explicit + signal is needed. See mod_ci/controllers.py queue_test() which follows + the same pattern: 'Created tests, waiting for cron...'. + """ + commit_sha = validated_data['commit_sha'] + platform_str = validated_data['platform'] + branch = validated_data.get('branch', 'master') + repository = validated_data.get('repository') + pull_request = validated_data.get('pull_request') or 0 + regression_test_ids = validated_data.get('regression_test_ids') + + platform = TestPlatform.from_string(platform_str) + + # Main repo requires contributor+; forks allow any authenticated user. + from run import config + main_owner = config.get('GITHUB_OWNER', '') + main_repo = config.get('GITHUB_REPOSITORY', '') + main_repo_full = f'{main_owner}/{main_repo}' + target_repo = repository or main_repo_full + + err = _validate_run_permissions(g.api_user, target_repo, main_repo_full) + if err: + return err + + if repository: + fork_url = f'https://github.com/{repository}.git' + else: + fork_url = f"https://github.com/{main_owner}/{main_repo}.git" + + fork = Fork.query.filter(Fork.github == fork_url).first() + if fork is None: + fork = Fork(fork_url) + g.db.add(fork) + try: + g.db.flush() + except IntegrityError: + g.db.rollback() + fork = Fork.query.filter(Fork.github == fork_url).first() + if fork is None: + return make_error_response('internal_error', 'Failed to create or resolve fork.', http_status=500) + + # Validate regression test IDs against active tests only. + regression_test_ids, err = _validate_regression_test_ids(regression_test_ids) + if err: + return err + + test_type = TestType.pull_request if pull_request else TestType.commit + + test = Test( + platform=platform, + test_type=test_type, + fork_id=fork.id, + branch=branch, + commit=commit_sha, + pr_nr=pull_request, + ) + g.db.add(test) + g.db.flush() + + for rt_id in regression_test_ids: + ct = CustomizedTest(test.id, rt_id) + g.db.add(ct) + g.db.commit() + + return single_response(_serialize_run(test), http_status=202) + + +@mod_api.route('/runs/', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +def get_run(run_id): + """Fetch a single run by ID.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + return single_response(_serialize_run(test)) + + +@mod_api.route('/runs//summary', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +def get_run_summary(run_id): + """ + Aggregate pass/fail/skip/missing/error counts from result rows. + + fail_count comes from TestResult rows, not from test.failed (which + only reflects cancellation status and is unreliable for this purpose). + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + results = TestResult.query.filter_by(test_id=run_id).all() + total_samples = len(test.get_customized_regressiontests()) + + pass_count = 0 + fail_count = 0 + skipped_count = 0 + missing_count = 0 + total_runtime = 0 + + # Preload TestResultFiles + from collections import defaultdict + all_files = TestResultFile.query.filter_by(test_id=run_id).all() if results else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + for result in results: + result_files = files_by_result.get(result.regression_test_id, []) + + status = derive_sample_status(result, result_files) + + if status == 'pass': + pass_count += 1 + elif status == 'fail': + fail_count += 1 + elif status == 'missing_output': + missing_count += 1 + else: + skipped_count += 1 + + if result.runtime: + total_runtime += result.runtime + + # Retrieve error_count from the error service + from mod_api.services.error_service import derive_errors_for_run + error_count = len(derive_errors_for_run(run_id)) + + return single_response({ + 'run_id': run_id, + 'status': derive_run_status(test), + 'total_samples': total_samples, + 'pass_count': pass_count, + 'fail_count': fail_count, + 'skipped_count': skipped_count, + 'missing_output_count': missing_count, + 'error_count': error_count, + 'duration_ms': total_runtime if total_runtime > 0 else None, + 'triggered_by': None, + }) + + +@mod_api.route('/runs//progress', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def get_run_progress(run_id, limit=50, offset=0): + """ + Get the timeline of progress events for a run, paginated. + + Events come from TestProgress rows written by the CI worker. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + query = TestProgress.query.filter_by(test_id=run_id) + + # Optional status filter. + status_filter = request.args.get('status') + if status_filter: + try: + status_enum = TestStatus.from_string(status_filter) + query = query.filter(TestProgress.status == status_enum) + except Exception: + return make_error_response( + 'validation_error', + f'Invalid status filter: {status_filter}.', + details={'fields': { + 'status': 'Must be one of: queued, preparation, testing, completed, canceled, error.' + }}, + http_status=400, + ) + + query = query.order_by(TestProgress.id.asc()) + total = query.count() + progress = query.offset(offset).limit(limit).all() + + events = [{ + 'timestamp': p.timestamp, + 'status': p.status.name, + 'message': p.message, + 'step': None, + } for p in progress] + + schema = ProgressEventSchema() + return paginated_response(events, total, limit, offset, schema=schema) + + +@mod_api.route('/runs//config', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +def get_run_config(run_id): + """Get the configuration that was used to launch this run.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + regression_ids = test.get_customized_regressiontests() + + return single_response({ + 'run_id': run_id, + 'platform': test.platform.value, + 'branch': test.branch, + 'commit_sha': test.commit, + 'regression_test_ids': regression_ids, + }) + + +@mod_api.route('/runs//cancel', methods=['POST']) +@require_roles(['admin', 'contributor', 'tester']) +@require_scope('runs:write') +@validate_path_id('run_id') +def cancel_run(run_id): + """Cancel a running or queued test. + + Idempotent — canceling something already finished returns 202 + with status=no_op. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + status = derive_run_status(test) + if status in ('pass', 'fail', 'canceled', 'error'): + return single_response({ + 'run_id': run_id, + 'action': 'cancel', + 'status': 'no_op', + 'message': f'Run is already in terminal state: {status}', + }, http_status=202) + + user = g.api_user + reason = None + if request.is_json and request.get_json(silent=True): + reason = request.get_json(silent=True).get('reason') + if reason: + reason_str = str(reason).strip() + if len(reason_str) < 5: + return make_error_response( + 'validation_error', + 'Cancel reason must be at least 5 characters.', + details={'fields': {'reason': 'Minimum length is 5.'}}, + http_status=400, + ) + reason = reason_str[:255] + + cancel_msg = f'Canceled by {user.name} via API' if user else 'Canceled via API' + if reason: + cancel_msg = f'{cancel_msg}: {reason}' + + progress = TestProgress(run_id, TestStatus.canceled, cancel_msg) + g.db.add(progress) + g.db.commit() + + return single_response({ + 'run_id': run_id, + 'action': 'cancel', + 'status': 'accepted', + 'message': 'Run has been canceled.', + }, http_status=202) diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py new file mode 100644 index 000000000..8ba56f55f --- /dev/null +++ b/mod_api/routes/samples.py @@ -0,0 +1,493 @@ +""" +Sample and regression test routes. + +GET /runs/{id}/samples Per-run regression test results +GET /runs/{id}/samples/{sid} Single result in a run +GET /samples Media sample catalog +GET /samples/{id} Single media sample +GET /samples/{id}/history Cross-run history for a sample +GET /regression-tests Regression test definitions +""" + +from flask import request + +from mod_api import mod_api +from mod_api.middleware.auth import require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_date_range, + validate_offset_pagination, + validate_path_id) +from mod_api.services.status import (derive_output_status, + derive_sample_status, get_run_timestamps, + is_dummy_row) +from mod_api.utils import paginated_response, single_response +from mod_regression.models import Category, RegressionTest +from mod_sample.models import Sample +from mod_test.models import Test, TestResult, TestResultFile + + +def _serialize_outputs(result_files): + outputs = [] + for rf in result_files: + if is_dummy_row(rf): + continue + outputs.append({ + 'output_id': rf.regression_test_output_id, + 'filename': ( + rf.regression_test_output.create_correct_filename(rf.expected) + if rf.regression_test_output else rf.expected + ), + 'status': derive_output_status(rf), + }) + return outputs + + +def _serialize_run_sample(result, result_files): + """Build the per-regression-test result dict for a run.""" + status = derive_sample_status(result, result_files) + outputs = _serialize_outputs(result_files) + + sample_name = None + sample_id = None + command = None + categories = [] + + if result.regression_test: + rt = result.regression_test + command = rt.command + if rt.sample: + sample_id = rt.sample_id + sample_name = rt.sample.original_name + if rt.categories: + categories = [c.name for c in rt.categories] + + return { + 'regression_test_id': result.regression_test_id, + 'sample_id': sample_id, + 'sample_name': sample_name, + 'status': status, + 'exit_code': result.exit_code, + 'expected_rc': result.expected_rc, + 'runtime_ms': result.runtime, + 'command': command, + 'categories': categories, + 'outputs': outputs, + } + + +def _filter_run_samples_by_tag(serialized, tag_filter): + tag_lower = tag_filter.lower() + tagged_sample_ids = set() + + valid_sample_ids = [s['sample_id'] for s in serialized if s.get('sample_id')] + samples = Sample.query.filter(Sample.id.in_(valid_sample_ids)).all() if valid_sample_ids else [] + sample_map = {sample.id: sample for sample in samples} + + for s in serialized: + if s['sample_id']: + sample = sample_map.get(s['sample_id']) + if sample and any(tag_lower == t.name.lower() for t in sample.tags): + tagged_sample_ids.add(s['sample_id']) + return [s for s in serialized if s.get('sample_id') in tagged_sample_ids] + + +def _apply_run_sample_filters(serialized, args): + status_filter = args.get('status') + if status_filter: + serialized = [s for s in serialized if s['status'] == status_filter] + + name_filter = args.get('name') + if name_filter: + name_lower = name_filter.lower() + serialized = [s for s in serialized if s.get('sample_name') and name_lower in s['sample_name'].lower()] + + tag_filter = args.get('tag') + if tag_filter: + serialized = _filter_run_samples_by_tag(serialized, tag_filter) + + category_filter = args.get('category') + if category_filter: + cat_lower = category_filter.lower() + serialized = [ + s for s in serialized + if s.get('categories') and cat_lower in [c.lower() for c in s['categories']] + ] + return serialized + + +@mod_api.route('/runs//samples', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_run_samples(run_id, limit=50, offset=0): + """ + List per-sample results for a run, with optional filters. + + Supports ?status, ?name, ?tag, ?category query params. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + results = TestResult.query.filter_by(test_id=run_id).all() + + # Preload TestResultFiles + from collections import defaultdict + all_files = TestResultFile.query.filter_by(test_id=run_id).all() if results else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + # Serialize list to filter by derived status and joined fields + serialized = [] + for result in results: + result_files = files_by_result.get(result.regression_test_id, []) + serialized.append(_serialize_run_sample(result, result_files)) + + # Apply query param filters. + serialized = _apply_run_sample_filters(serialized, request.args) + + total = len(serialized) + paged = serialized[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//samples/', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +@validate_path_id('regression_test_id') +def get_run_sample(run_id, regression_test_id): + """Get a single regression test result within a run.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result = TestResult.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ).first() + if result is None: + return make_error_response( + 'not_found', + f'Regression test {regression_test_id} not found in run {run_id}.', + http_status=404, + ) + + result_files = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ).all() + + return single_response(_serialize_run_sample(result, result_files)) + + +@mod_api.route('/samples', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +def list_samples(limit=50, offset=0): + """ + List media samples from the catalog. + + Supports ?name, ?extension, ?tag, ?sha256, ?status (active/inactive) filters. + """ + query = Sample.query + + name = request.args.get('name') + if name: + # Escape LIKE wildcards to prevent unintended pattern matching. + safe_name = name.replace('%', '\\%').replace('_', '\\_') + query = query.filter(Sample.original_name.ilike(f'%{safe_name}%')) + + extension = request.args.get('extension') + if extension: + query = query.filter(Sample.extension == extension) + + sha256_filter = request.args.get('sha256') + if sha256_filter: + query = query.filter(Sample.sha == sha256_filter) + + tag_filter = request.args.get('tag') + if tag_filter: + from sqlalchemy import func + + from mod_sample.models import Tag + query = query.filter(Sample.tags.any(func.lower(Tag.name) == tag_filter.lower())) + + status_filter = request.args.get('status') + if status_filter: + want_active = status_filter.lower() == 'active' + if want_active: + query = query.filter(Sample.tests.any(RegressionTest.active == True)) # noqa: E712 + else: + query = query.filter(~Sample.tests.any(RegressionTest.active == True)) # noqa: E712 + + # Paginate at DB level without Python-side filters + total = query.count() + samples = query.offset(offset).limit(limit).all() + + # Batch load active regression test counts + from flask import g + from sqlalchemy import func + sample_ids = [s.id for s in samples] + counts_list = g.db.query( + RegressionTest.sample_id, + func.count(RegressionTest.id) + ).filter( + RegressionTest.sample_id.in_(sample_ids), + RegressionTest.active == True # noqa: E712 + ).group_by(RegressionTest.sample_id).all() if sample_ids else [] + counts = dict(counts_list) + + serialized = [] + for s in samples: + active_count = counts.get(s.id, 0) + serialized.append({ + 'sample_id': s.id, + 'sha': s.sha, + 'extension': s.extension, + 'original_name': s.original_name, + 'filename': s.filename, + 'tags': [t.name for t in s.tags], + 'regression_test_count': active_count, + 'active': active_count > 0, + }) + + return paginated_response(serialized, total, limit, offset) + + +@mod_api.route('/samples/', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('sample_id') +def get_sample(sample_id): + """Get a single media sample by its ID.""" + sample = Sample.query.filter(Sample.id == sample_id).first() + if sample is None: + return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) + + active_count = RegressionTest.query.filter_by( + sample_id=sample.id, active=True + ).count() + + return single_response({ + 'sample_id': sample.id, + 'sha': sample.sha, + 'extension': sample.extension, + 'original_name': sample.original_name, + 'filename': sample.filename, + 'tags': [t.name for t in sample.tags], + 'regression_test_count': active_count, + 'active': active_count > 0, + }) + + +def _get_history_failure_signature(result, result_files, status): + if status == 'fail': + for rf in result_files: + if rf.got is not None and not is_dummy_row(rf): + return f'diff_mismatch:output:{rf.regression_test_output_id}' + if result.exit_code != result.expected_rc: + return f'exit_code_mismatch:rc:{result.exit_code}' + elif status == 'missing_output': + return 'missing_output' + return None + + +def _process_history_entries(results, files_by_result, status_filter): + entries = [] + for result in results: + test = result.test + if test is None: + test = Test.query.get(result.test_id) + if test is None: + continue + + result_files = files_by_result.get((result.test_id, result.regression_test_id), []) + status = derive_sample_status(result, result_files) + + if status_filter and status != status_filter: + continue + + failure_sig = _get_history_failure_signature(result, result_files, status) + timestamps = get_run_timestamps(test) + + entries.append({ + 'run_id': test.id, + 'regression_test_id': result.regression_test_id, + 'status': status, + 'platform': test.platform.value, + 'branch': test.branch, + 'commit_sha': test.commit, + 'tested_at': timestamps.get('completed_at') or timestamps.get('started_at'), + 'failure_signature': failure_sig, + }) + return entries + + +def _apply_history_filters(query, branch, platform, created_after, created_before): + if branch: + query = query.filter(Test.branch == branch) + + if platform: + try: + from mod_test.models import TestPlatform + platform_enum = TestPlatform.from_string(platform) + query = query.filter(Test.platform == platform_enum) + except Exception: + from mod_test.models import TestPlatform + valid_platforms = ', '.join(TestPlatform.values()) + return None, make_error_response( + 'validation_error', + f'Invalid platform: {platform}. Must be one of: {valid_platforms}.', + http_status=400, + ) + + if created_after or created_before: + from flask import g + from sqlalchemy import func + + from mod_test.models import TestProgress + first_progress = ( + g.db.query(TestProgress.test_id, func.min(TestProgress.timestamp).label('min_ts')) + .group_by(TestProgress.test_id) + .subquery() + ) + query = query.join(first_progress, Test.id == first_progress.c.test_id) + if created_after: + query = query.filter(first_progress.c.min_ts >= created_after) + if created_before: + query = query.filter(first_progress.c.min_ts <= created_before) + + return query, None + + +@mod_api.route('/samples//history', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('sample_id') +@validate_offset_pagination() +@validate_date_range +def get_sample_history(sample_id, limit=50, offset=0, created_after=None, created_before=None): + """ + Show how a sample performed across different runs. + + Use failure_signature to tell apart genuine regressions from infra flakes. + """ + sample = Sample.query.filter(Sample.id == sample_id).first() + if sample is None: + return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) + + regression_tests = RegressionTest.query.filter_by(sample_id=sample_id).all() + rt_ids = [rt.id for rt in regression_tests] + + if not rt_ids: + return paginated_response([], 0, limit, offset) + + query = TestResult.query.filter( + TestResult.regression_test_id.in_(rt_ids) + ).join(Test, Test.id == TestResult.test_id) + + branch = request.args.get('branch') + platform = request.args.get('platform') + + query, err = _apply_history_filters(query, branch, platform, created_after, created_before) + if err: + return err + + results = query.order_by(Test.id.desc()).all() + + status_filter = request.args.get('status') + + # Preload TestResultFiles + from collections import defaultdict + test_ids = list({r.test_id for r in results}) + all_files = TestResultFile.query.filter(TestResultFile.test_id.in_(test_ids)).all() if test_ids else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[(f.test_id, f.regression_test_id)].append(f) + + entries = _process_history_entries(results, files_by_result, status_filter) + + total = len(entries) + paged = entries[offset:offset + limit] + + return paginated_response(paged, total, limit, offset) + + +def _serialize_rt(rt): + return { + 'regression_test_id': rt.id, + 'sample_id': rt.sample_id, + 'sample_name': rt.sample.original_name if rt.sample else None, + 'command': rt.command, + 'input_type': rt.input_type.value, + 'output_type': rt.output_type.value, + 'expected_rc': rt.expected_rc, + 'active': rt.active, + 'categories': [c.name for c in rt.categories], + 'description': rt.description, + } + + +def _filter_regression_tests_by_tag(query, tag_filter): + all_tests = query.all() + serialized = [] + for rt in all_tests: + if rt.sample: + sample_tags = [t.name.lower() for t in rt.sample.tags] + if tag_filter.lower() not in sample_tags: + continue + else: + continue # no sample = no tags to match + serialized.append(_serialize_rt(rt)) + return serialized + + +@mod_api.route('/regression-tests', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +def list_regression_tests(limit=50, offset=0): + """ + List regression test definitions. + + Supports ?active, ?category, ?tag, ?sample_id filters. + """ + query = RegressionTest.query + + active_filter = request.args.get('active') + if active_filter is not None: + is_active = active_filter.lower() in ('true', '1', 'yes') + else: + is_active = True + query = query.filter(RegressionTest.active == is_active) + + category = request.args.get('category') + if category: + query = query.join(RegressionTest.categories).filter(Category.name == category) + + sample_id_filter = request.args.get('sample_id') + if sample_id_filter: + try: + sid = int(sample_id_filter) + query = query.filter(RegressionTest.sample_id == sid) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'sample_id must be a positive integer.', + details={'fields': {'sample_id': 'Must be a positive integer.'}}, + http_status=400, + ) + + tag_filter = request.args.get('tag') + + # Filter tags in Python before paginating + if tag_filter: + serialized = _filter_regression_tests_by_tag(query, tag_filter) + + total = len(serialized) + paged = serialized[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + # Paginate at DB level without tag filters + total = query.count() + tests = query.offset(offset).limit(limit).all() + serialized = [_serialize_rt(rt) for rt in tests] + return paginated_response(serialized, total, limit, offset) diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py new file mode 100644 index 000000000..1abd823cd --- /dev/null +++ b/mod_api/routes/system.py @@ -0,0 +1,317 @@ +""" +System, health, queue, and artifact routes. + +GET /system/health Health check (unauthenticated) +GET /system/queue Queue status — active + queued runs +GET /runs/{id}/artifacts Run artifacts from GCS + local storage +""" + +import os +from datetime import datetime, timezone + +from flask import g, jsonify, request +from sqlalchemy import text + +from mod_api import mod_api +from mod_api.middleware.auth import require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_offset_pagination, + validate_path_id) +from mod_api.services.status import derive_run_status, is_dummy_row +from mod_api.services.storage import (get_log_file_path, + get_test_results_base_path, + resolve_artifact) +from mod_api.utils import paginated_response +from mod_test.models import (Test, TestPlatform, TestProgress, TestResultFile, + TestStatus) + +OCTET_STREAM = 'application/octet-stream' + + +@mod_api.route('/system/health', methods=['GET']) +def system_health(): + """ + Public health check — no auth required. + + Returns 200 when things are ok or degraded, 503 when the system is down. + Monitoring services and load balancers can hit this freely. + """ + now = datetime.now(timezone.utc) + dependencies = [] + overall = 'ok' + + # Database connectivity. + try: + g.db.execute(text('SELECT 1')) + dependencies.append({'name': 'database', 'status': 'ok', 'message': None}) + except Exception: + dependencies.append({'name': 'database', 'status': 'down', 'message': 'Database connection failed.'}) + overall = 'down' + + # Local sample storage. + try: + from run import config + sample_repo = config.get('SAMPLE_REPOSITORY', '') + if os.path.isdir(sample_repo): + dependencies.append({'name': 'local_storage', 'status': 'ok', 'message': None}) + else: + dependencies.append({ + 'name': 'local_storage', + 'status': 'degraded', + 'message': 'Local storage check failed.', + }) + if overall == 'ok': + overall = 'degraded' + except Exception: + dependencies.append({'name': 'local_storage', 'status': 'down', 'message': 'Local storage check failed.'}) + overall = 'down' + + # Google Cloud Storage. + try: + from run import storage_client_bucket + if storage_client_bucket: + dependencies.append({'name': 'gcs', 'status': 'ok', 'message': None}) + else: + dependencies.append({'name': 'gcs', 'status': 'degraded', 'message': 'GCS client not initialized.'}) + if overall == 'ok': + overall = 'degraded' + except Exception: + dependencies.append({'name': 'gcs', 'status': 'degraded', 'message': 'GCS connectivity check failed.'}) + if overall == 'ok': + overall = 'degraded' + + http_status = 503 if overall == 'down' else 200 + response = jsonify({ + 'status': overall, + 'checked_at': now.isoformat(), + 'dependencies': dependencies, + }) + response.status_code = http_status + return response + + +def _apply_queue_filters(base_query, running_subq, queue_depth, running_count, status_filter): + if status_filter == 'queued': + query = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))) + total = queue_depth + elif status_filter == 'running': + query = base_query.filter(Test.id.in_(g.db.query(running_subq.c.test_id))) + total = running_count + elif status_filter: + return None, None, make_error_response( + 'validation_error', 'Invalid status. Must be queued or running.', http_status=400 + ) + else: + query = base_query + total = queue_depth + running_count + return query, total, None + + +@mod_api.route('/system/queue', methods=['GET']) +@require_scope('system:read') +@validate_offset_pagination() +def get_queue(limit=50, offset=0): + """ + Return queued and running jobs. + + Excludes anything that's already completed or canceled. Supports + ?platform and ?status filters. + """ + terminal_subq = g.db.query( + TestProgress.test_id + ).filter( + TestProgress.status.in_([TestStatus.completed, TestStatus.canceled]) + ).group_by(TestProgress.test_id).subquery() + + running_subq = g.db.query( + TestProgress.test_id + ).filter( + TestProgress.status.in_([TestStatus.preparation, TestStatus.testing]) + ).group_by(TestProgress.test_id).subquery() + + base_query = Test.query.filter( + ~Test.id.in_(g.db.query(terminal_subq.c.test_id)) + ) + + platform_filter = request.args.get('platform') + if platform_filter: + try: + plat = TestPlatform.from_string(platform_filter) + base_query = base_query.filter(Test.platform == plat) + except Exception: + return make_error_response('validation_error', 'Invalid platform.', http_status=400) + + running_count = base_query.filter(Test.id.in_(g.db.query(running_subq.c.test_id))).count() + queue_depth = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))).count() + + status_filter = request.args.get('status') + query, total, err = _apply_queue_filters(base_query, running_subq, queue_depth, running_count, status_filter) + if err: + return err + + query = query.order_by(Test.id.asc()) + paged_tests = query.offset(offset).limit(limit).all() + + from mod_api.services.status import batch_get_run_data + statuses, timestamps = batch_get_run_data(paged_tests) + + paged_jobs = [] + queued_index = offset + 1 if status_filter == 'queued' else None + + for test in paged_tests: + status = statuses.get(test.id, 'queued') + ts = timestamps.get(test.id, {}) + + pos = None + if status == 'queued' and queued_index is not None: + pos = queued_index + queued_index += 1 + + paged_jobs.append({ + 'run_id': test.id, + 'status': status, + 'platform': test.platform.value, + 'queued_at': ts.get('queued_at').isoformat() if ts.get('queued_at') else None, + 'started_at': ts.get('started_at').isoformat() if ts.get('started_at') else None, + 'position': pos, + }) + + response = jsonify({ + 'queue_depth': queue_depth, + 'running_count': running_count, + 'data': paged_jobs, + 'pagination': { + 'limit': limit, + 'offset': offset, + 'total': total, + 'next_offset': offset + limit if (offset + limit) < total else None, + }, + }) + return response + + +def _get_gcs_artifacts(run_id, platform): + binary_name = ( + 'ccextractor' if platform == TestPlatform.linux + else 'ccextractorwinfull.exe' + ) + gcs_artifacts = [ + ('binary', f'test_artifacts/{run_id}/{binary_name}', binary_name, OCTET_STREAM), + ('coredump', f'test_artifacts/{run_id}/coredump', f'coredump-{run_id}', OCTET_STREAM), + ( + 'combined_stdout', + f'test_artifacts/{run_id}/combined_stdout.log', + f'combined_stdout-{run_id}.log', + 'text/plain', + ), + ] + artifacts = [] + for artifact_type, gcs_path, filename, content_type in gcs_artifacts: + download_url, storage_status = resolve_artifact(gcs_path) + artifacts.append({ + 'artifact_id': f'{artifact_type}_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': artifact_type, + 'filename': filename, + 'content_type': content_type, + 'size_bytes': None, + 'storage_status': storage_status, + 'download_url': download_url, + }) + return artifacts + + +def _get_output_artifacts(run_id): + artifacts = [] + result_files = TestResultFile.query.filter_by(test_id=run_id).all() + base_path = get_test_results_base_path() + from mod_api.routes.results import _safe_resolve + for rf in result_files: + if is_dummy_row(rf): + continue + + ext = rf.regression_test_output.correct_extension if rf.regression_test_output else '' + + expected_name = rf.expected + ext + expected_url, expected_status = resolve_artifact(f'TestResults/{expected_name}') + local_expected = _safe_resolve(base_path, expected_name) + + artifacts.append({ + 'artifact_id': f'expected_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': run_id, + 'sample_id': rf.regression_test_id, + 'type': 'expected_output', + 'filename': expected_name, + 'content_type': OCTET_STREAM, + 'size_bytes': ( + os.path.getsize(local_expected) + if local_expected and os.path.isfile(local_expected) else None + ), + 'storage_status': expected_status, + 'download_url': expected_url, + }) + + if rf.got is not None: + actual_name = rf.got + ext + actual_url, actual_status = resolve_artifact(f'TestResults/{actual_name}') + local_actual = _safe_resolve(base_path, actual_name) + + artifacts.append({ + 'artifact_id': f'actual_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': run_id, + 'sample_id': rf.regression_test_id, + 'type': 'sample_output', + 'filename': actual_name, + 'content_type': OCTET_STREAM, + 'size_bytes': ( + os.path.getsize(local_actual) + if local_actual and os.path.isfile(local_actual) else None + ), + 'storage_status': actual_status, + 'download_url': actual_url, + }) + return artifacts + + +@mod_api.route('/runs//artifacts', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_artifacts(run_id, limit=50, offset=0): + """ + List all artifacts for a run. + + Checks both GCS and local storage. Falls back to local when GCS + is unavailable. Supports ?type filter. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + artifacts = _get_gcs_artifacts(run_id, test.platform) + + # Build log — accessed via /runs/{id}/logs, no direct download link. + log_path = get_log_file_path(run_id) + artifacts.append({ + 'artifact_id': f'buildlog_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': 'build_log', + 'filename': f'{run_id}.txt', + 'content_type': 'text/plain', + 'size_bytes': os.path.getsize(log_path) if log_path else None, + 'storage_status': 'ok' if log_path else 'missing', + 'download_url': None, + }) + + artifacts.extend(_get_output_artifacts(run_id)) + + # Apply optional ?type filter. + type_filter = request.args.get('type') + if type_filter: + artifacts = [a for a in artifacts if a['type'] == type_filter] + + total = len(artifacts) + paged = artifacts[offset:offset + limit] + return paginated_response(paged, total, limit, offset) diff --git a/mod_api/schemas/__init__.py b/mod_api/schemas/__init__.py new file mode 100644 index 000000000..889960659 --- /dev/null +++ b/mod_api/schemas/__init__.py @@ -0,0 +1 @@ +"""mod_api.schemas: Marshmallow schemas for request/response validation.""" diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py new file mode 100644 index 000000000..90fbf13c7 --- /dev/null +++ b/mod_api/schemas/auth.py @@ -0,0 +1,67 @@ +"""Request/response schemas for the token endpoints.""" + +from marshmallow import RAISE, Schema, fields, validate + +from mod_api.models.api_token import VALID_SCOPES + + +class TokenCreateRequestSchema(Schema): + """Validates POST /auth/tokens bodies.""" + + email = fields.Email(required=True) + password = fields.String( + required=True, + validate=validate.Length(min=8, max=128), + ) + token_name = fields.String( + required=True, + validate=[ + validate.Length(min=1, max=50), + validate.Regexp( + r'^[a-zA-Z0-9_\-]+$', + error='token_name must match ^[a-zA-Z0-9_-]+$', + ), + ], + ) + expires_in_days = fields.Integer( + load_default=7, + validate=validate.Range(min=1, max=30), + ) + scopes = fields.List( + fields.String(validate=validate.OneOf(VALID_SCOPES)), + load_default=None, + validate=validate.Length(max=6), + ) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class AuthTokenSchema(Schema): + """The one-time response returned when a token is created.""" + + token = fields.String(required=True) + token_type = fields.String(dump_default='bearer') + token_name = fields.String(required=True) + scopes = fields.List(fields.String(), required=True) + expires_at = fields.DateTime(required=True) + + +class ApiTokenItemSchema(Schema): + """Token metadata for list responses — never includes the plaintext.""" + + id = fields.Integer(required=True) + user_id = fields.Integer(required=True) + token_name = fields.String(required=True) + token_prefix = fields.String(required=True) + scopes = fields.Method('get_scopes') + created_at = fields.DateTime(required=True) + expires_at = fields.DateTime(required=True) + is_revoked = fields.Boolean(required=True) + revoked_at = fields.DateTime(allow_none=True) + + def get_scopes(self, obj): + """Deserialize scopes from the model's JSON column.""" + return obj.scopes diff --git a/mod_api/schemas/common.py b/mod_api/schemas/common.py new file mode 100644 index 000000000..77462d5d2 --- /dev/null +++ b/mod_api/schemas/common.py @@ -0,0 +1,27 @@ +"""Shared schemas: ErrorResponse and pagination wrappers.""" + +from marshmallow import Schema, fields + + +class ErrorResponseSchema(Schema): + """Standard JSON error body returned by all error responses.""" + + code = fields.String(required=True) + message = fields.String(required=True) + details = fields.Dict(keys=fields.String(), required=True, load_default={}) + + +class PaginationSchema(Schema): + """Offset-based pagination metadata.""" + + limit = fields.Integer(required=True) + offset = fields.Integer(required=True) + total = fields.Integer(required=True) + next_offset = fields.Integer(allow_none=True, load_default=None) + + +class CursorPaginationSchema(Schema): + """Cursor-based pagination metadata.""" + + limit = fields.Integer(required=True) + next_cursor = fields.Integer(allow_none=True, load_default=None) diff --git a/mod_api/schemas/errors.py b/mod_api/schemas/errors.py new file mode 100644 index 000000000..30599e80f --- /dev/null +++ b/mod_api/schemas/errors.py @@ -0,0 +1,52 @@ +"""Schemas for error items, error summary buckets, and log lines.""" + +from marshmallow import Schema, fields, validate + + +class ErrorItemSchema(Schema): + """A single error derived from run results or infra progress.""" + + error_id = fields.String(required=True) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + regression_id = fields.Integer(allow_none=True) + type = fields.String(required=True) + severity = fields.String( + required=True, + validate=validate.OneOf(['info', 'warning', 'error', 'critical']), + ) + message = fields.String(required=True) + location = fields.Dict(allow_none=True, load_default=None) + stack = fields.List(fields.String(), load_default=None) + occurred_at = fields.DateTime(allow_none=True) + + +class ErrorSummaryBucketSchema(Schema): + """One bucket in a grouped error summary.""" + + key = fields.String(required=True) + count = fields.Integer(required=True) + severity = fields.String(required=True) + group_by = fields.String(allow_none=True) + sample_ids = fields.List(fields.Integer(), load_default=[]) + first_seen_at = fields.DateTime(allow_none=True) + last_seen_at = fields.DateTime(allow_none=True) + + +class LogLineSchema(Schema): + """A single parsed line from a build log.""" + + timestamp = fields.DateTime(allow_none=True) + level = fields.String( + required=True, + validate=validate.OneOf( + ['debug', 'info', 'warning', 'error', 'critical']), + ) + source = fields.String( + required=True, + validate=validate.OneOf( + ['orchestrator', 'worker', 'build', 'test_runner', 'web']), + ) + message = fields.String(required=True) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) diff --git a/mod_api/schemas/results.py b/mod_api/schemas/results.py new file mode 100644 index 000000000..8248c7ce9 --- /dev/null +++ b/mod_api/schemas/results.py @@ -0,0 +1,91 @@ +"""Schemas for expected/actual output, diffs, and baseline approvals.""" + +from marshmallow import RAISE, Schema, fields, validate + + +class OutputFileContentSchema(Schema): + """File content blob returned for expected or actual output.""" + + run_id = fields.Integer(allow_none=True) + sample_id = fields.Integer(required=True) + regression_id = fields.Integer(required=True) + output_id = fields.Integer(required=True) + filename = fields.String(required=True) + content_type = fields.String(required=True) + encoding = fields.String( + required=True, validate=validate.OneOf(['utf-8', 'base64'])) + content = fields.String(required=True) + sha256 = fields.String(allow_none=True) + storage_status = fields.String( + required=True, + validate=validate.OneOf(['ok', 'degraded', 'missing']), + ) + + +class DiffHunkLineSchema(Schema): + """One line inside a diff hunk.""" + + kind = fields.String(required=True, validate=validate.OneOf( + ['context', 'added', 'removed'])) + expected_line = fields.Integer(allow_none=True) + actual_line = fields.Integer(allow_none=True) + text = fields.String(required=True) + + +class DiffHunkSchema(Schema): + """A contiguous block of changes.""" + + expected_start = fields.Integer(required=True) + actual_start = fields.Integer(required=True) + lines = fields.List(fields.Nested(DiffHunkLineSchema), required=True) + + +class DiffSchema(Schema): + """Structured diff between expected and actual output.""" + + run_id = fields.Integer(required=True) + sample_id = fields.Integer(required=True) + regression_id = fields.Integer(required=True) + output_id = fields.Integer(required=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'identical', 'different', 'missing_actual', 'missing_expected', + ])) + summary = fields.Dict(required=True) + hunks = fields.List(fields.Nested(DiffHunkSchema), required=True) + + +class BaselineApprovalRequestSchema(Schema): + """POST /runs/{id}/samples/{sid}/baseline-approval body.""" + + regression_id = fields.Integer( + required=True, + validate=validate.Range(min=1), + ) + output_id = fields.Integer( + required=True, + validate=validate.Range(min=1), + ) + + remove_variants = fields.Boolean( + load_default=False, + ) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class BaselineApprovalSchema(Schema): + """Response after a baseline approval is applied.""" + + status = fields.String( + required=True, + validate=validate.OneOf( + ['approved'])) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(required=True) + regression_id = fields.Integer(required=True) + output_id = fields.Integer(required=True) + requested_by = fields.String(required=True) + created_at = fields.DateTime(required=True) diff --git a/mod_api/schemas/runs.py b/mod_api/schemas/runs.py new file mode 100644 index 000000000..a8262318b --- /dev/null +++ b/mod_api/schemas/runs.py @@ -0,0 +1,118 @@ +"""Schemas for runs, summaries, progress events, and run actions.""" + +from marshmallow import RAISE, Schema, fields, validate + + +class ProgressEventSchema(Schema): + """A single progress event in a run's timeline.""" + + timestamp = fields.DateTime(required=True) + status = fields.String(required=True) + message = fields.String(required=True) + step = fields.Integer(allow_none=True) + + +class RunSchema(Schema): + """Full run details.""" + + run_id = fields.Integer(required=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'queued', 'running', 'pass', 'fail', 'canceled', 'incomplete', + ])) + platform = fields.String( + required=True, validate=validate.OneOf(['linux', 'windows'])) + test_type = fields.String(validate=validate.OneOf(['commit', 'pr'])) + repository = fields.String(required=True) + branch = fields.String(allow_none=True) + commit_sha = fields.String(required=True) + pr_number = fields.Integer(allow_none=True, load_default=None) + created_at = fields.DateTime(required=True) + queued_at = fields.DateTime(allow_none=True) + started_at = fields.DateTime(allow_none=True) + completed_at = fields.DateTime(allow_none=True) + github_link = fields.String(allow_none=True) + + +class RunSummarySchema(Schema): + """Pass/fail/skip aggregate counts for a run.""" + + run_id = fields.Integer(required=True) + status = fields.String(required=True) + total_samples = fields.Integer(required=True) + pass_count = fields.Integer(required=True) + fail_count = fields.Integer(required=True) + skipped_count = fields.Integer(required=True) + missing_output_count = fields.Integer(required=True) + error_count = fields.Integer(load_default=0) + duration_ms = fields.Integer(allow_none=True) + triggered_by = fields.String(allow_none=True) + + +class RunConfigSchema(Schema): + """The test matrix and configuration for a run.""" + + run_id = fields.Integer(required=True) + platform = fields.String(required=True) + branch = fields.String(required=True) + commit_sha = fields.String(required=True) + regression_test_ids = fields.List(fields.Integer(), required=True) + + +class RunCreateRequestSchema(Schema): + """POST /runs request body.""" + + commit_sha = fields.String( + required=True, + validate=validate.Regexp( + r'^[a-fA-F0-9]{40}$', + error='commit_sha must be a 40-character hex string.', + ), + ) + platform = fields.String( + required=True, + validate=validate.OneOf(['linux', 'windows']), + ) + branch = fields.String( + load_default='master', + validate=[ + validate.Length(max=100), + validate.Regexp( + r'^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$', + error='branch must match ^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$', + ), + ], + ) + repository = fields.String( + required=True, + validate=[ + validate.Length(max=100), + validate.Regexp( + r'^[a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+$', + error='repository must match owner/repo format.', + ), + ], + ) + pull_request = fields.Integer( + load_default=None, + allow_none=True, + validate=validate.Range(min=1), + ) + regression_test_ids = fields.List( + fields.Integer(validate=validate.Range(min=1)), + load_default=None, + validate=validate.Length(max=500), + ) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class RunActionResultSchema(Schema): + """Response for cancel and similar run actions.""" + + run_id = fields.Integer(required=True) + action = fields.String(required=True) + status = fields.String(required=True) + message = fields.String(required=True) diff --git a/mod_api/schemas/samples.py b/mod_api/schemas/samples.py new file mode 100644 index 000000000..7fdafc9cb --- /dev/null +++ b/mod_api/schemas/samples.py @@ -0,0 +1,70 @@ +"""Request and response schemas for Sample endpoints and results.""" + +from marshmallow import Schema, fields, validate + + +class OutputFileSchema(Schema): + """Output file schema.""" + + output_id = fields.Integer(required=True) + filename = fields.String(required=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'pass', 'fail', 'missing_output', + ])) + + +class RunSampleSchema(Schema): + """A regression test's result within a specific run.""" + + regression_test_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + sample_name = fields.String(allow_none=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'pass', 'fail', 'skipped', 'missing_output', 'running', 'not_started', + ])) + exit_code = fields.Integer(allow_none=True) + expected_rc = fields.Integer(allow_none=True) + runtime_ms = fields.Integer(allow_none=True) + command = fields.String(allow_none=True) + categories = fields.List(fields.String(), load_default=[]) + outputs = fields.List(fields.Nested(OutputFileSchema), load_default=[]) + + +class SampleSchema(Schema): + """A media sample from the catalog.""" + + sample_id = fields.Integer(required=True) + sha = fields.String(required=True) + extension = fields.String(required=True) + original_name = fields.String(required=True) + filename = fields.String(required=True) + tags = fields.List(fields.String(), load_default=[]) + regression_test_count = fields.Integer(load_default=0) + active = fields.Boolean(load_default=True) + + +class SampleHistoryEntrySchema(Schema): + """One row in a sample's cross-run history.""" + + run_id = fields.Integer(required=True) + status = fields.String(required=True) + platform = fields.String(required=True) + branch = fields.String(required=True) + commit_sha = fields.String(required=True) + tested_at = fields.DateTime(allow_none=True) + failure_signature = fields.String(allow_none=True) + + +class RegressionTestSchema(Schema): + """A regression test definition.""" + + regression_test_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + sample_name = fields.String(allow_none=True) + command = fields.String(required=True) + input_type = fields.String(required=True) + output_type = fields.String(required=True) + expected_rc = fields.Integer(required=True) + active = fields.Boolean(required=True) + categories = fields.List(fields.String(), load_default=[]) + description = fields.String(allow_none=True) diff --git a/mod_api/schemas/system.py b/mod_api/schemas/system.py new file mode 100644 index 000000000..553502bd3 --- /dev/null +++ b/mod_api/schemas/system.py @@ -0,0 +1,61 @@ +"""Schemas for health checks, queue jobs, and run artifacts.""" + +from marshmallow import Schema, fields, validate + + +class DependencyHealthSchema(Schema): + """Status of a single system dependency (DB, GCS, local storage).""" + + name = fields.String(required=True) + status = fields.String( + required=True, validate=validate.OneOf(['ok', 'degraded', 'down'])) + message = fields.String(allow_none=True) + + +class SystemHealthSchema(Schema): + """Overall system health response.""" + + status = fields.String( + required=True, + validate=validate.OneOf(['ok', 'degraded', 'down']), + ) + checked_at = fields.DateTime(required=True) + dependencies = fields.List( + fields.Nested(DependencyHealthSchema), + required=True) + + +class QueueJobSchema(Schema): + """A single queued or running job.""" + + run_id = fields.Integer(required=True) + status = fields.String( + required=True, validate=validate.OneOf(['queued', 'running'])) + platform = fields.String( + required=True, validate=validate.OneOf(['linux', 'windows'])) + queued_at = fields.DateTime(allow_none=True) + started_at = fields.DateTime(allow_none=True) + position = fields.Integer(allow_none=True) + + +class ArtifactSchema(Schema): + """A downloadable artifact tied to a run.""" + + artifact_id = fields.String(required=True) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + type = fields.String( + required=True, + validate=validate.OneOf([ + 'build_log', 'sample_output', 'expected_output', 'actual_output', + 'diff', 'media_info', 'binary', 'coredump', 'combined_stdout', + ]), + ) + filename = fields.String(required=True) + content_type = fields.String(required=True) + size_bytes = fields.Integer(allow_none=True) + storage_status = fields.String( + required=True, + validate=validate.OneOf(['ok', 'degraded', 'missing']), + ) + download_url = fields.String(allow_none=True) diff --git a/mod_api/services/__init__.py b/mod_api/services/__init__.py new file mode 100644 index 000000000..a1bbdb184 --- /dev/null +++ b/mod_api/services/__init__.py @@ -0,0 +1 @@ +"""mod_api.services — Core business logic for the API.""" diff --git a/mod_api/services/diff_service.py b/mod_api/services/diff_service.py new file mode 100644 index 000000000..478e0b9de --- /dev/null +++ b/mod_api/services/diff_service.py @@ -0,0 +1,201 @@ +""" +Structured diff computation between expected and actual output files. + +Produces JSON hunks with line-level detail instead of the legacy HTML +diff output. Uses difflib.unified_diff internally. +""" + +import difflib +import hashlib +import os +import re +from typing import Any, Dict, List, Optional, Tuple + + +def compute_diff( + expected_path: str, + actual_path: str, + context_lines: int = 3, + max_hunks: int = 500, +) -> Dict[str, Any]: + """ + Compute a structured diff between two files. + + Returns a dict matching the Diff schema: status, summary (added_lines, + removed_lines, changed_hunks), and a list of hunks. + """ + context_lines = max(1, min(context_lines, 50)) + + if not os.path.isfile(expected_path): + return { + 'status': 'missing_expected', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + if not os.path.isfile(actual_path): + return { + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + expected_lines = read_lines(expected_path) + actual_lines = read_lines(actual_path) + + if expected_lines == actual_lines: + return { + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + hunks = _compute_hunks(expected_lines, actual_lines, context_lines, max_hunks) + added = sum(1 for h in hunks for line in h['lines'] if line['kind'] == 'added') + removed = sum(1 for h in hunks for line in h['lines'] if line['kind'] == 'removed') + + return { + 'status': 'different', + 'summary': { + 'added_lines': added, + 'removed_lines': removed, + 'changed_hunks': len(hunks), + }, + 'hunks': hunks, + } + + +# Matches the @@ -a,b +c,d @@ header line from unified_diff. +_HUNK_RE = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@') + + +def _process_diff_line(line, current_hunk, expected_line_num, actual_line_num): + if line.startswith('+'): + current_hunk['lines'].append({ + 'kind': 'added', + 'expected_line': None, + 'actual_line': actual_line_num, + 'text': line[1:], + }) + actual_line_num += 1 + elif line.startswith('-'): + current_hunk['lines'].append({ + 'kind': 'removed', + 'expected_line': expected_line_num, + 'actual_line': None, + 'text': line[1:], + }) + expected_line_num += 1 + else: + content = line[1:] if line.startswith(' ') else line + current_hunk['lines'].append({ + 'kind': 'context', + 'expected_line': expected_line_num, + 'actual_line': actual_line_num, + 'text': content, + }) + expected_line_num += 1 + actual_line_num += 1 + return expected_line_num, actual_line_num + + +def _process_hunk_header( + line: str, + current_hunk: Optional[Dict[str, Any]], + hunks: List[Dict[str, Any]], + max_hunks: int +) -> Tuple[Optional[Dict[str, Any]], int, int, bool]: + if current_hunk and len(hunks) >= max_hunks: + return None, 0, 0, True + if current_hunk: + hunks.append(current_hunk) + + m = _HUNK_RE.match(line) + if m: + expected_line_num = int(m.group(1)) + actual_line_num = int(m.group(2)) + else: + expected_line_num = 0 + actual_line_num = 0 + + new_hunk = { + 'expected_start': expected_line_num, + 'actual_start': actual_line_num, + 'lines': [], + } + return new_hunk, expected_line_num, actual_line_num, False + + +def _compute_hunks( + expected_lines: List[str], + actual_lines: List[str], + context_lines: int, + max_hunks: int, +) -> List[Dict[str, Any]]: + """Parse unified_diff output into structured hunk dicts.""" + differ = difflib.unified_diff( + expected_lines, + actual_lines, + lineterm='', + n=context_lines, + ) + + hunks: List[Dict[str, Any]] = [] + current_hunk: Optional[Dict[str, Any]] = None + expected_line_num = 0 + actual_line_num = 0 + + for line in differ: + if line.startswith(('---', '+++')): + continue + + if line.startswith('@@'): + current_hunk, expected_line_num, actual_line_num, stop = _process_hunk_header( + line, current_hunk, hunks, max_hunks + ) + if stop: + break + continue + + if current_hunk is None: + continue + + expected_line_num, actual_line_num = _process_diff_line(line, current_hunk, expected_line_num, actual_line_num) + + if current_hunk: + hunks.append(current_hunk) + + return hunks[:max_hunks] + + +def _enforce_safe_path(file_path: str) -> bool: + from mod_api.services.storage import get_test_results_base_path + base = os.path.realpath(get_test_results_base_path()) + target = os.path.realpath(file_path) + return target.startswith(base + os.sep) or target == base + + +def read_lines(file_path: str) -> List[str]: + """Read file lines with a cp1252 fallback, matching legacy behavior.""" + if not _enforce_safe_path(file_path): + raise ValueError("Unsafe file path") + try: + with open(file_path, encoding='utf8') as f: + return [line.rstrip('\n\r') for line in f.readlines()] + except UnicodeDecodeError: + with open(file_path, encoding='cp1252') as f: + return [line.rstrip('\n\r') for line in f.readlines()] + + +def file_sha256(file_path: str) -> Optional[str]: + """Compute SHA-256 of a file. Returns None if the file can't be read.""" + if not _enforce_safe_path(file_path): + return None + try: + sha = hashlib.sha256() + with open(file_path, 'rb') as f: + for block in iter(lambda: f.read(8192), b''): + sha.update(block) + return sha.hexdigest() + except (OSError, IOError): + return None diff --git a/mod_api/services/error_service.py b/mod_api/services/error_service.py new file mode 100644 index 000000000..8781bd0ce --- /dev/null +++ b/mod_api/services/error_service.py @@ -0,0 +1,220 @@ +""" +Error derivation from TestResult and TestResultFile rows. + +Walks result data and produces structured ErrorItem dicts. There's no +dedicated error table — errors are inferred from: + exit_code_mismatch → exit code != expected + diff_mismatch → got != null and not in multiple correct files + missing_output → dummy (-1,-1,-1,'','error') row present +""" + +import logging +from typing import Any, Dict, List + +from mod_api.services.status import is_dummy_row +from mod_test.models import TestResult, TestResultFile + +_SEVERITY_ORDER = ('info', 'warning', 'error', 'critical') + + +def _is_output_acceptable(rf: TestResultFile) -> bool: + if not rf.regression_test_output: + return False + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + return True + return False + + +def _evaluate_test_result(result, result_files, test_id, occurred_at): + errors = [] + if result.exit_code != result.expected_rc: + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_rc', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'exit_code_mismatch', + 'severity': 'error', + 'message': ( + f'Exit code {result.exit_code} != expected {result.expected_rc} ' + f'for regression test {result.regression_test_id}' + ), + 'occurred_at': occurred_at, + }) + + for rf in result_files: + if is_dummy_row(rf): + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_missing', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'missing_output', + 'severity': 'error', + 'message': ( + f'Regression test {result.regression_test_id} ' + f'produced no output when output was expected' + ), + 'occurred_at': occurred_at, + }) + elif rf.got is not None and not _is_output_acceptable(rf): + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'diff_mismatch', + 'severity': 'warning', + 'message': ( + f'Output differs from expected for regression test ' + f'{result.regression_test_id}, output {rf.regression_test_output_id}' + ), + 'occurred_at': occurred_at, + }) + return errors + + +def derive_errors_for_run(test_id: int) -> List[Dict[str, Any]]: + """Walk result rows and emit one ErrorItem per detected failure.""" + from mod_test.models import TestProgress + progress = TestProgress.query.filter_by(test_id=test_id).order_by(TestProgress.timestamp.desc()).first() + occurred_at = progress.timestamp.isoformat() if progress and progress.timestamp else None + + errors = [] + results = TestResult.query.filter_by(test_id=test_id).all() + + # Preload TestResultFiles + from collections import defaultdict + + from sqlalchemy.orm import joinedload + + from mod_regression.models import RegressionTestOutput + all_files = ( + TestResultFile.query.options( + joinedload(TestResultFile.regression_test_output) + .joinedload(RegressionTestOutput.multiple_files) + ) + .filter_by(test_id=test_id).all() if results else [] + ) + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + for result in results: + result_files = files_by_result.get(result.regression_test_id, []) + errors.extend(_evaluate_test_result(result, result_files, test_id, occurred_at)) + + return errors + + +def _aggregate_error_into_bucket(err, bucket): + bucket['count'] += 1 + + # Escalate severity to the worst we've seen. + try: + curr_idx = _SEVERITY_ORDER.index(bucket['severity']) + new_idx = _SEVERITY_ORDER.index(err['severity']) + if new_idx > curr_idx: + bucket['severity'] = err['severity'] + except ValueError: + # Fallback if unknown severity + if err['severity'] == 'error': + bucket['severity'] = 'error' + + err_time = err.get('occurred_at') + if err_time: + if bucket['first_seen_at'] is None or err_time < bucket['first_seen_at']: + bucket['first_seen_at'] = err_time + if bucket['last_seen_at'] is None or err_time > bucket['last_seen_at']: + bucket['last_seen_at'] = err_time + + sid = err.get('sample_id') + if sid and sid not in bucket['sample_ids'] and len(bucket['sample_ids']) < 1000: + bucket['sample_ids'].append(sid) + + +def derive_error_summary(test_id: int, group_by: str = 'type') -> List[Dict[str, Any]]: + """Group errors by the given key and return bucket counts.""" + errors = derive_errors_for_run(test_id) + buckets: Dict[str, Dict[str, Any]] = {} + + for err in errors: + key = str(err.get(group_by, 'unknown')) + + if key not in buckets: + buckets[key] = { + 'key': key, + 'group_by': group_by, + 'count': 0, + 'severity': err['severity'], + 'sample_ids': [], + 'first_seen_at': None, + 'last_seen_at': None, + } + + _aggregate_error_into_bucket(err, buckets[key]) + + return list(buckets.values()) + + +def derive_infrastructure_errors(test_id: int) -> List[Dict[str, Any]]: + """ + Best-effort infra error extraction from TestProgress messages. + + There's no structured error protocol from the CI worker yet, so we + do keyword matching against progress messages to guess the failure type. + """ + from mod_test.models import TestProgress, TestStatus + + errors = [] + progress_rows = TestProgress.query.filter_by( + test_id=test_id, + status=TestStatus.canceled, + ).all() + + for p in progress_rows: + msg_lower = (p.message or '').lower() + error_type = _classify_infra_error(msg_lower) + errors.append({ + 'error_id': f'infra_{test_id}_{p.id}', + 'run_id': test_id, + 'sample_id': None, + 'regression_id': None, + 'type': error_type, + 'severity': 'critical', + 'message': p.message or 'Unknown infrastructure error', + 'location': None, + 'occurred_at': p.timestamp.isoformat() if p.timestamp else None, + }) + + return errors + + +def _classify_infra_error(message_lower: str) -> str: + """Guess the infra error type from progress message keywords.""" + if any(w in message_lower for w in ['provisioning', 'vm ', 'instance']): + return 'vm_provisioning' + if any(w in message_lower for w in ['checkout', 'git clone', 'fetch']): + return 'checkout' + if any(w in message_lower for w in ['merge', 'conflict']): + return 'merge' + if any(w in message_lower for w in ['build', 'compile', 'make']): + return 'build' + if any(w in message_lower for w in ['worker', 'timeout', 'connection']): + return 'worker' + if any(w in message_lower for w in ['storage', 'disk', 'gcs']): + return 'storage' + return 'worker' + + +def _get_sample_id(result: TestResult): + """Pull sample_id through the RegressionTest relationship, if available.""" + try: + if result.regression_test and result.regression_test.sample_id: + return result.regression_test.sample_id + except Exception: + logging.getLogger(__name__).exception( + f"Failed to fetch sample_id for TestResult {result.test_id}_{result.regression_test_id}" + ) + return None diff --git a/mod_api/services/log_service.py b/mod_api/services/log_service.py new file mode 100644 index 000000000..01ed8ee38 --- /dev/null +++ b/mod_api/services/log_service.py @@ -0,0 +1,121 @@ +""" +Build log reader with cursor-based pagination. + +Log files live at SAMPLE_REPOSITORY/LogFiles/{run_id}.txt. The cursor +is just a line number offset into the file. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from mod_api.services.storage import get_log_file_path + + +def _parse_cursor(cursor: Optional[int]) -> int: + if not cursor: + return 0 + try: + return int(cursor) + except (ValueError, TypeError): + return 0 + + +def _format_log_line(raw: str, run_id: int) -> Dict[str, Any]: + return { + 'timestamp': None, + 'level': _extract_level(raw), + 'source': _extract_source(raw), + 'message': raw, + 'run_id': run_id, + 'sample_id': None, + } + + +def _should_include_line(raw: str, level: Optional[str], source: Optional[str], contains: Optional[str]) -> bool: + if level and not _matches_level(raw, level): + return False + if source and _extract_source(raw) != source: + return False + if contains and contains.lower() not in raw.lower(): + return False + return True + + +def read_log_lines( + run_id: int, + cursor: Optional[str] = None, + limit: int = 100, + level: Optional[str] = None, + source: Optional[str] = None, + contains: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], Optional[str]]: + """ + Read and optionally filter lines from a run's build log. + + Returns (lines, next_cursor). Raises FileNotFoundError when the + log file isn't on disk. + """ + log_path = get_log_file_path(run_id) + if log_path is None: + raise FileNotFoundError(f'Log file not found for run {run_id}') + + limit = max(1, min(limit, 500)) + + start_line = _parse_cursor(cursor) + + import itertools + + def _read_lines(encoding): + with open(log_path, encoding=encoding) as f: + iterator = itertools.islice(f, start_line, None) + + result_lines = [] + line_num = start_line + + for raw_line in iterator: + raw = raw_line.rstrip('\n\r') + line_num += 1 + + if not _should_include_line(raw, level, source, contains): + continue + + result_lines.append(_format_log_line(raw, run_id)) + + if len(result_lines) >= limit: + break + + try: + next(iterator) + has_more = True + except StopIteration: + has_more = False + + next_cursor = str(line_num) if has_more else None + return result_lines, next_cursor + + try: + return _read_lines('utf-8') + except UnicodeDecodeError: + return _read_lines('cp1252') + + +def _matches_level(line: str, target_level: str) -> bool: + """Check if a log line matches the requested severity.""" + return _extract_level(line) == target_level + + +def _extract_level(line: str) -> str: + """Best-effort log level extraction from raw text.""" + line_upper = line.upper() + for lvl in ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']: + if lvl in line_upper: + return lvl.lower() + return 'info' + + +def _extract_source(line: str) -> str: + """Best-effort source component extraction from raw text.""" + line_lower = line.lower() + for src in ['orchestrator', 'worker', 'build', 'test_runner', 'web']: + if src in line_lower: + return src + return 'web' diff --git a/mod_api/services/status.py b/mod_api/services/status.py new file mode 100644 index 000000000..2b746de1f --- /dev/null +++ b/mod_api/services/status.py @@ -0,0 +1,210 @@ +""" +Status derivation from the raw data model. + +Normalizes TestProgress/TestResult/TestResultFile states into clean +strings for the API layer. This is the single source of truth for +status logic — route handlers must not inline their own derivation. + +Run statuses: queued, running, pass, fail, canceled, error, incomplete +Sample statuses: pass, fail, skipped, missing_output, running, not_started + +Things to watch out for: + - test.failed only checks for TestStatus.canceled — never use it + for determining whether regression tests actually passed + - TestResultFile.got = null means MATCH, not missing output + - Dummy row (-1,-1,-1,'','error') = test produced no output at all + - TestStatus.canceled covers both user cancels and infra failures +""" + +from typing import List, Optional + +from mod_test.models import (Test, TestProgress, TestResult, TestResultFile, + TestStatus) + + +def derive_run_status(test: Test) -> str: + """ + Map the raw model state to one of the 7 normalized run statuses. + + Looks at the most recent TestProgress row and, for completed runs, + counts actual failures from TestResult rows. + """ + statuses, _ = batch_get_run_data([test]) + return statuses.get(test.id, 'queued') + + +def _check_output_acceptable(rf: TestResultFile) -> bool: + if rf.regression_test_output: + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + return True + return False + + +def derive_sample_status( + test_result: Optional[TestResult], + result_files: List[TestResultFile], +) -> str: + """ + Map a TestResult + its output files to a per-sample status string. + + Checks for the dummy sentinel row first (missing_output), then exit + code, then output diffs against accepted baselines. + """ + if test_result is None: + return 'not_started' + + for rf in result_files: + if is_dummy_row(rf): + return 'missing_output' + + if test_result.exit_code != test_result.expected_rc: + return 'fail' + + for rf in result_files: + if rf.got is not None and not _check_output_acceptable(rf): + return 'fail' + + # All got == null → every output matched expected. + return 'pass' + + +def is_dummy_row(rf: TestResultFile) -> bool: + """ + Detect the sentinel TestResultFile row where regression_test_output_id == -1 and got == 'error'. + + This row means the test produced no output when output was expected. + The old test_id == -1 and regression_test_id == -1 checks were removed + because they are no longer populated as -1 in newer data. + It should never show up as a real file in API responses. + + DEPLOYMENT PREREQUISITE: Before deploying this change, verify that no + old-format sentinel rows exist that would be missed by the new detection. + Run against production: + + SELECT COUNT(*) + FROM test_result_file + WHERE (test_id = -1 OR regression_test_id = -1) + AND NOT (regression_test_output_id = -1 AND got = 'error'); + + If result > 0, those rows need a data migration to normalize them + before this code is deployed. Include the query output in the PR + description as evidence. + """ + return bool(rf.regression_test_output_id == -1 and rf.got == 'error') + + +def derive_output_status(rf: TestResultFile) -> str: + """Classify a single output file: pass, fail, or missing_output.""" + if is_dummy_row(rf): + return 'missing_output' + if rf.got is None: + return 'pass' + return 'fail' + + +def get_run_timestamps(test: Test) -> dict: + """ + Build a timestamp dict from TestProgress rows. + + Test doesn't have a created_at column, so we use the earliest + progress entry as a proxy. + """ + _, timestamps = batch_get_run_data([test]) + ts = timestamps.get(test.id, {}) + return { + 'created_at': ts.get('created_at'), + 'queued_at': ts.get('queued_at'), + 'started_at': ts.get('started_at'), + 'completed_at': ts.get('completed_at'), + } + + +def _compute_run_timestamps(t_prog): + ts = { + 'created_at': None, + 'queued_at': None, + 'started_at': None, + 'completed_at': None, + } + if t_prog: + ts['queued_at'] = t_prog[0].timestamp + ts['created_at'] = t_prog[0].timestamp + for p in t_prog: + if p.status == TestStatus.testing and ts['started_at'] is None: + ts['started_at'] = p.timestamp + if p.status in (TestStatus.completed, TestStatus.canceled): + ts['completed_at'] = p.timestamp + return ts + + +def _compute_run_status(t_prog, results_by_test, files_by_test_and_rt, t_id): + if not t_prog: + return 'queued' + + latest = t_prog[-1] + raw_status = latest.status + + if raw_status in (TestStatus.preparation, TestStatus.testing): + return 'running' + elif raw_status == TestStatus.canceled: + return 'canceled' + elif raw_status == TestStatus.completed: + fail_count = 0 + for r in results_by_test.get(t_id, []): + r_files = files_by_test_and_rt.get((t_id, r.regression_test_id), []) + sample_status = derive_sample_status(r, r_files) + if sample_status not in ('pass', 'not_started'): + fail_count += 1 + return 'fail' if fail_count > 0 else 'pass' + else: + return 'incomplete' + + +def batch_get_run_data(tests: list) -> tuple: + """ + Batch compute derive_run_status and get_run_timestamps for a list of tests. + + Returns (statuses_dict, timestamps_dict) + """ + if not tests: + return {}, {} + + test_ids = [t.id for t in tests] + + # Preload TestProgress + all_progress = TestProgress.query.filter(TestProgress.test_id.in_(test_ids)).order_by(TestProgress.id.asc()).all() + progress_by_test = {tid: [] for tid in test_ids} + for p in all_progress: + progress_by_test[p.test_id].append(p) + + # Preload TestResult + all_results = TestResult.query.filter(TestResult.test_id.in_(test_ids)).all() + results_by_test = {tid: [] for tid in test_ids} + for r in all_results: + results_by_test[r.test_id].append(r) + + # Preload TestResultFile + from sqlalchemy.orm import joinedload + + from mod_regression.models import RegressionTestOutput + all_files = TestResultFile.query.options( + joinedload(TestResultFile.regression_test_output) + .joinedload(RegressionTestOutput.multiple_files) + ).filter(TestResultFile.test_id.in_(test_ids)).all() + files_by_test_and_rt = {} + for f in all_files: + key = (f.test_id, f.regression_test_id) + if key not in files_by_test_and_rt: + files_by_test_and_rt[key] = [] + files_by_test_and_rt[key].append(f) + + statuses = {} + timestamps_dict = {} + + for t in tests: + t_prog = progress_by_test[t.id] + timestamps_dict[t.id] = _compute_run_timestamps(t_prog) + statuses[t.id] = _compute_run_status(t_prog, results_by_test, files_by_test_and_rt, t.id) + + return statuses, timestamps_dict diff --git a/mod_api/services/storage.py b/mod_api/services/storage.py new file mode 100644 index 000000000..ad2ed9968 --- /dev/null +++ b/mod_api/services/storage.py @@ -0,0 +1,64 @@ +""" +Storage helpers for resolving artifact locations. + +Artifacts can live in local SAMPLE_REPOSITORY, GCS, or both. When both +exist, GCS is preferred and a signed URL is returned. When only local +exists, storage_status is 'degraded'. When neither exists, it's 'missing'. +""" + +import os +from datetime import timedelta +from typing import Optional, Tuple + + +def resolve_artifact(relative_path: str) -> Tuple[Optional[str], str]: + """ + Look for an artifact in local storage and GCS. + + Returns (download_url_or_None, storage_status). + """ + from run import config, storage_client_bucket + + sample_repo = config.get('SAMPLE_REPOSITORY', '') + local_path = os.path.join(sample_repo, relative_path) + local_exists = os.path.isfile(local_path) + + gcs_url = None + if storage_client_bucket: + try: + blob = storage_client_bucket.blob(relative_path) + gcs_url = blob.generate_signed_url( + version='v4', + expiration=timedelta(minutes=config.get('GCS_SIGNED_URL_EXPIRY_LIMIT', 60)), + method='GET', + ) + except Exception: + gcs_url = None + + if local_exists and gcs_url: + return gcs_url, 'ok' + elif gcs_url: + # We don't block on blob.exists(), so we let the client handle 404s + return gcs_url, 'degraded' + elif local_exists: + return None, 'degraded' + else: + return None, 'missing' + + +def get_log_file_path(run_id: int) -> Optional[str]: + """Return the absolute path to a run's build log, or None if it doesn't exist.""" + from run import config + + sample_repo = config.get('SAMPLE_REPOSITORY', '') + log_path = os.path.join(sample_repo, 'LogFiles', f'{run_id}.txt') + + if os.path.isfile(log_path): + return log_path + return None + + +def get_test_results_base_path() -> str: + """Return the base directory where TestResults files are stored.""" + from run import config + return os.path.join(config.get('SAMPLE_REPOSITORY', ''), 'TestResults') diff --git a/mod_api/utils.py b/mod_api/utils.py new file mode 100644 index 000000000..40014ae54 --- /dev/null +++ b/mod_api/utils.py @@ -0,0 +1,72 @@ +"""Pagination, serialization, and response formatting helpers.""" + +from flask import jsonify + + +def paginated_response(data, total, limit, offset, schema=None, truncated=False): + """Build an offset-paginated JSON response.""" + if schema: + serialized = schema.dump(data, many=True) + else: + serialized = data + + next_offset = offset + limit if (offset + limit) < total else None + + pagination = { + 'limit': limit, + 'offset': offset, + 'total': total, + 'next_offset': next_offset, + } + if truncated: + pagination['truncated'] = True + + return jsonify({ + 'data': serialized, + 'pagination': pagination, + }) + + +def cursor_paginated_response(data, next_cursor, limit, schema=None): + """Build a cursor-paginated JSON response.""" + if schema: + serialized = schema.dump(data, many=True) + else: + serialized = data + + return jsonify({ + 'data': serialized, + 'pagination': { + 'limit': limit, + 'next_cursor': next_cursor, + }, + }) + + +def single_response(data, schema=None, http_status=200): + """Build a single-item JSON response.""" + if schema: + serialized = schema.dump(data) + else: + serialized = data + + response = jsonify(serialized) + response.status_code = http_status + return response + + +def get_sort_column(sort_param, column_map): + """Translate a sort string into an SQLAlchemy order_by clause. + + Handles descending sorts prefixed with '-' (e.g. '-created_at'). + """ + descending = sort_param.startswith('-') + field_name = sort_param.lstrip('-') + + column = column_map.get(field_name) + if column is None: + return None + + if descending: + return column.desc() + return column.asc() diff --git a/mod_auth/controllers.py b/mod_auth/controllers.py index a476b9afc..2d6e4d072 100755 --- a/mod_auth/controllers.py +++ b/mod_auth/controllers.py @@ -165,26 +165,37 @@ def github_redirect(): return f'https://github.com/login/oauth/authorize?client_id={github_client_id}&scope=public_repo' -def fetch_username_from_token() -> Any: +def fetch_username_from_token(user=None) -> Any: """ Get username from the GitHub token. + :param user: Optional user model to prevent redundant queries :return: username :rtype: str """ import json - user = User.query.filter(User.id == g.user.id).first() + + from flask import current_app + + if user is None: + user = User.query.filter(User.id == g.user.id).first() + + if current_app.config.get('TESTING'): + return 'testuser' + if user.github_token is None: return None url = 'https://api.github.com/user' session = requests.Session() session.auth = (user.email, user.github_token) try: - response = session.get(url) + response = session.get(url, timeout=(3.05, 10)) data = response.json() - return data['login'] + return data.get('login') except Exception as e: - g.log.error('Failed to fetch the user token') + import logging + log = getattr(g, 'log', logging.getLogger(__name__)) + log.error('Failed to fetch the user token') return None @@ -211,6 +222,12 @@ def github_callback(): if 'access_token' in response: user = User.query.filter(User.id == g.user.id).first() user.github_token = response['access_token'] + + # Fetch and store github_login + github_login = fetch_username_from_token(user) + if github_login: + user.github_login = github_login + g.db.commit() else: g.log.error("GitHub didn't return an access token") diff --git a/mod_auth/models.py b/mod_auth/models.py index 16233e98a..a21c48833 100644 --- a/mod_auth/models.py +++ b/mod_auth/models.py @@ -32,6 +32,7 @@ class User(Base): name = Column(String(50), unique=True) email = Column(String(255), unique=True, nullable=True) github_token = Column(Text(), nullable=True) + github_login = Column(String(255), nullable=True) password = Column(String(255), unique=False, nullable=False) role = Column(Role.db_type()) diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml new file mode 100644 index 000000000..438870d5a --- /dev/null +++ b/openapi-ci-api.yaml @@ -0,0 +1,2831 @@ +openapi: 3.0.3 +info: + title: CCExtractor CI System API + version: 1.2.0 + description: | + Security-hardened JSON-only REST API for the CCExtractor CI/sample platform. + Designed for AI agents and CI automation. Enforces scoped Bearer token auth, + strict input validation, rate limiting on all routes, and safe defaults + throughout. No browser sessions, no HTML, no implicit permissions. + + **Authentication:** All endpoints require bearer token authentication unless + explicitly marked with `security: []` (only /system/health and POST /auth/tokens). + + **Rate-limit headers:** Every response includes `X-RateLimit-Limit`, + `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. These are modeled + explicitly on the 429 response for brevity; they are present on all responses + regardless of status code. + + contact: + name: CCExtractor Development + url: https://github.com/CCExtractor/sample-platform + license: + name: GPL-3.0-only + url: https://www.gnu.org/licenses/gpl-3.0.html + +servers: + - url: http://localhost:5000 + description: Local development server + - url: https://sampleplatform.ccextractor.org/api/v1 + description: Production + +# +# Global security: all endpoints require auth +# unless explicitly overridden with security: [] +# +security: + - bearerAuth: [] + +tags: + - name: Auth + description: Token issuance and revocation + - name: Runs + description: CI run lifecycle — list, inspect, trigger, and cancel + - name: Samples + description: Media samples and regression test definitions + - name: Results + description: Per-sample output, diffs, and baseline management + - name: Errors and Logs + description: Structured errors and raw log access + - name: System + description: Health, queue, and artifacts + +# +# SECURITY NOTES (implementers must read) +# +# 1. AUTH MODEL +# - All tokens are opaque, server-side. Never expose session cookies via API. +# - The CI worker token (/ci/progress-reporter) is a separate secret and is +# NOT valid for user-facing API endpoints. +# - Token creation is rate-limited to 5 req/15 min per IP to prevent +# credential stuffing. +# +# 2. SCOPE ENFORCEMENT +# - Scope checks happen at the middleware layer before route handlers. +# - x-required-scope on each operation defines the minimum scope needed. +# - Missing scope → 403 Forbidden (not 401, token is valid but insufficient). +# +# 3. INPUT VALIDATION +# - additionalProperties: false on all request bodies (no mass-assignment). +# - Regex patterns on all free-text IDs (commit_sha, sha256, repository). +# - maxLength on every string field. maxItems on every array. +# - Integer IDs have minimum: 1 (no zero or negative IDs). +# +# 4. OUTPUT SAFETY +# - got=null in TestResultFile means match, not missing output. +# The dummy row (-1,-1,-1,'','error') is translated server-side to +# status=missing_output and never surfaced as a real object. +# - test.failed reflects cancellation only; fail_count is computed from +# TestResult rows. Do not expose test.failed directly. +# - Stack traces in infrastructure errors are opt-in (include_stack=false +# by default) to avoid leaking internal paths. +# +# 5. STORAGE +# - Artifacts may exist in local SAMPLE_REPOSITORY, GCS, or both. +# - storage_status=degraded means one backend only; missing means neither. +# - Never return a download_url that has not been verified to exist. +# - Log endpoints return 404 (not a broken download link) when the log +# file is absent from both storage backends. +# +# 6. RATE LIMITING (all routes) +# - Default: 120 req/min per token (reads), 20 req/min per token (writes). +# - Auth endpoint: 5 req/15 min per IP. +# - Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, +# X-RateLimit-Reset headers. +# - 429 response includes Retry-After header (seconds). +# +# 7. IDEMPOTENCY +# - POST /runs/{run_id}/cancel is idempotent; canceling an already-canceled +# run returns 202 with status=accepted and a no-op message. +# +# 8. DIFF ACCESS +# - The diff route is header-gated on the legacy system (not role-gated). +# The API wraps the XHR path and returns structured JSON. No HTML. +# +# 9. STATUS DERIVATION +# - Run status is derived, not stored. TestStatus has only: preparation, +# testing, completed, canceled (canceled covers both canceled and error). +# The API normalizes this to the 7-value enum below. +# - RunSample.status is computed from TestResult + TestResultFile + +# expected exit code + multiple acceptable baselines. +# - fail_count and missing_output_count in RunSummary are mutually +# exclusive. A sample appears in exactly one bucket (missing_output +# is checked first; if the dummy sentinel row is detected the function +# returns immediately without evaluating fail conditions). +# +# 10. REPOSITORY PERMISSIONS +# - POST /runs enforces a repo-aware permission check. Triggering a run +# against the main configured repository (GITHUB_OWNER/GITHUB_REPOSITORY) +# requires the contributor role or above. Any authenticated user with +# runs:write scope may trigger runs against fork repositories. There is +# no global repository allowlist; the elevated-role check applies only +# to the main configured repository. +# + +paths: + + # AUTH + + /auth/tokens: + get: + tags: [Auth] + summary: List API tokens + operationId: listTokens + description: > + Lists tokens for the authenticated user. Non-admin users see only their + own tokens. Admins may append ?all=true to list tokens across the entire + system; non-admin callers sending ?all=true receive 403. + + Plaintext token values are never included in list responses. + security: + - bearerAuth: [] + x-required-scope: tokens:manage + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: all + in: query + schema: + type: boolean + description: > + Admin only. Set to true to list tokens for all users in the system. + Non-admin callers receive 403 if this parameter is present and true. + responses: + "200": + description: Paginated list of tokens (without plaintext secrets). + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ApiTokenItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + post: + tags: [Auth] + summary: Create an API token + operationId: createToken + description: > + Rate-limited to 5 requests per 15 minutes per IP. Tokens are opaque + and stored server-side. Scopes are additive; request only what you need. + Tokens expire after expires_in_days (default 7, max 30). + security: [] + x-rate-limit: "5/15min per IP" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TokenCreateRequest" + responses: + "201": + description: Token created. Store the token value; it will not be shown again. + content: + application/json: + schema: + $ref: "#/components/schemas/AuthToken" + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Invalid credentials + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: invalid_credentials + message: Email or password is incorrect. + details: {} + "403": + description: > + Authenticated caller tried to create a token with higher scopes + than their current token. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: Cannot create token with scopes you do not possess. + details: {} + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /auth/tokens/current: + delete: + tags: [Auth] + summary: Revoke the current API token + operationId: revokeCurrentToken + description: > + Immediately invalidates the token used in the Authorization header. + Subsequent requests with the same token will receive 401. + + No specific scope is required beyond authentication — any valid token + can self-revoke. This is the preferred way to clean up a token when + you have it in hand but do not know its numeric ID. + security: + - bearerAuth: [] + responses: + "204": + description: Token revoked + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /auth/tokens/{token_id}: + delete: + tags: [Auth] + summary: Revoke a specific API token by ID + operationId: revokeToken + description: > + Revokes the token identified by token_id. + Users may revoke their own tokens without any scope requirement. + Revoking another user's token requires tokens:manage scope and admin role. + Attempting to revoke another user's token without admin role returns 404 to prevent token-ID enumeration. + + To revoke the token currently in use without knowing its ID, use + DELETE /auth/tokens/current instead. + security: + - bearerAuth: [] + x-required-scope: tokens:manage # only enforced for cross-user revocation + parameters: + - name: token_id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "204": + description: Token revoked successfully. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: > + Token is valid but the request is forbidden. Admins requesting cross-user revocation get a 403 response if their token lacks the tokens:manage scope. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: Cross-user revocation requires tokens:manage scope. + details: {} + "404": + description: > + Token not found. Non-admin users attempting to revoke another user's token receive a uniform 404 response to prevent token-ID enumeration. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: not_found + message: Token not found. + details: {} + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # RUNS + + /runs: + get: + tags: [Runs] + summary: List CI runs + operationId: listRuns + description: > + The underlying table is capped at the 50 most recent runs + in the current implementation; this endpoint adds full pagination. + Sorted by -created_at by default (newest first). + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/RunStatus" + - $ref: "#/components/parameters/Branch" + - $ref: "#/components/parameters/CommitSha" + - $ref: "#/components/parameters/Repository" + - $ref: "#/components/parameters/Platform" + - $ref: "#/components/parameters/CreatedAfter" + - $ref: "#/components/parameters/CreatedBefore" + - name: sort + in: query + schema: + type: string + default: -created_at + enum: [created_at, -created_at, run_id, -run_id] + description: Sort field. Prefix with - for descending order. + responses: + "200": + description: Paginated runs + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Run" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + post: + tags: [Runs] + summary: Trigger a new CI run + operationId: createRun + description: > + Requires runs:write scope and contributor role or above. + The regression_test_ids set is validated against active tests only. + If omitted, all active regression tests are used. + security: + - bearerAuth: [] + x-required-scope: runs:write + x-required-roles: [admin, tester, contributor] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunCreateRequest" + responses: + "202": + description: Run queued. Poll /runs/{run_id}/progress for status. + content: + application/json: + schema: + $ref: "#/components/schemas/Run" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/UnprocessableEntity" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}: + get: + tags: [Runs] + summary: Get a CI run + operationId: getRun + description: > + Returns normalized run status derived from TestProgress rows. + status=canceled covers both explicit cancellation and infrastructure + errors (the underlying model does not distinguish them). + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Run details + content: + application/json: + schema: + $ref: "#/components/schemas/Run" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/summary: + get: + tags: [Runs] + summary: Get pass/fail summary for a run + operationId: getRunSummary + description: > + fail_count is computed from TestResult rows, not from test.failed. + test.failed only reflects whether the final progress status is + canceled — it does not reflect regression test outcomes. + Use this endpoint, not test.failed, to triage a run. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Run summary + content: + application/json: + schema: + $ref: "#/components/schemas/RunSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/progress: + get: + tags: [Runs] + summary: Get progress events for a run + operationId: getRunProgress + description: > + Progress events are sourced from TestProgress rows written by the CI + worker via /ci/progress-reporter. Messages are unstructured text. + Structured error types are aspirational until the worker protocol + emits structured JSON. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: status + in: query + schema: + type: string + enum: [queued, preparation, testing, completed, canceled] + responses: + "200": + description: Paginated progress events + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ProgressEvent" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/cancel: + post: + tags: [Runs] + summary: Cancel a queued or running CI run + operationId: cancelRun + description: > + Idempotent. Canceling an already-canceled or completed run returns + 202 with a no-op message rather than an error. + Requires runs:write scope. + security: + - bearerAuth: [] + x-required-scope: runs:write + x-required-roles: [admin, tester, contributor] + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + reason: + type: string + minLength: 5 + maxLength: 255 + description: > + Reason for cancellation, stored in the audit log. + additionalProperties: false + responses: + "202": + description: Cancellation accepted (or no-op if already terminal) + content: + application/json: + schema: + $ref: "#/components/schemas/RunActionResult" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/config: + get: + tags: [Runs] + summary: Get run configuration and test matrix + operationId: getRunConfig + description: > + regression_test_ids lists IDs included in this run. When no custom + set was configured, all regression tests are returned. + Implementers must filter by active=true explicitly. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Run configuration + content: + application/json: + schema: + $ref: "#/components/schemas/RunConfig" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # SAMPLES + + /runs/{run_id}/samples: + get: + tags: [Samples] + summary: List regression test results in a run + operationId: listRunSamples + description: > + Returns one entry per regression test result, not one per unique media + file. A single media sample may yield multiple entries if it has + multiple regression tests (different command flags). + sample_progress in the legacy JSON endpoint is len(test.results) over + total regression tests; it does not reflect multi-output completeness. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: status + in: query + schema: + type: string + enum: [pass, fail, skipped, missing_output, running, not_started] + - name: name + in: query + schema: + type: string + maxLength: 100 + - name: tag + in: query + schema: + type: string + maxLength: 50 + - name: category + in: query + schema: + type: string + maxLength: 50 + responses: + "200": + description: Paginated regression test results + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/RunSample" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{regression_test_id}: + get: + tags: [Samples] + summary: Get full details for a regression test result in a run + operationId: getRunSample + description: > + Returns the result for a specific regression test within a run. + Note: the path parameter is regression_test_id, not a media sample ID. + A single media sample may have multiple regression tests. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/RegressionTestId" + responses: + "200": + description: Regression test result details + content: + application/json: + schema: + $ref: "#/components/schemas/RunSample" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /samples: + get: + tags: [Samples] + summary: List all known media samples + operationId: listSamples + description: > + Returns paginated media sample metadata. Samples are the original + media files uploaded for regression testing. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: status + in: query + description: > + Derived from linked regression tests. The sample table itself has + no quarantine state; active/inactive reflects whether any active + regression tests reference the sample. + schema: + type: string + enum: [active, inactive] + - name: name + in: query + schema: + type: string + maxLength: 100 + - name: tag + in: query + schema: + type: string + maxLength: 50 + - name: sha256 + in: query + schema: + type: string + pattern: '^[a-fA-F0-9]{64}$' + - name: extension + in: query + schema: + type: string + maxLength: 10 + pattern: '^[a-zA-Z0-9]+$' + responses: + "200": + description: Paginated media samples + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Sample" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /samples/{sample_id}: + get: + tags: [Samples] + summary: Get media sample metadata + operationId: getSample + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/SampleId" + responses: + "200": + description: Media sample metadata + content: + application/json: + schema: + $ref: "#/components/schemas/Sample" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /samples/{sample_id}/history: + get: + tags: [Samples] + summary: Get regression test result history for a sample across runs + operationId: getSampleHistory + description: > + Use failure_signature for flake detection: a stable signature across + multiple runs on different commits indicates a genuine regression, + not infrastructure noise. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/RunStatus" + - $ref: "#/components/parameters/Branch" + - $ref: "#/components/parameters/Platform" + - $ref: "#/components/parameters/CreatedAfter" + - $ref: "#/components/parameters/CreatedBefore" + responses: + "200": + description: Paginated sample history + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/SampleHistoryEntry" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /regression-tests: + get: + tags: [Samples] + summary: List regression test definitions + operationId: listRegressionTests + description: > + The active filter must be applied explicitly. When no custom set is + defined, all regression tests are returned — including inactive ones. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: active + in: query + schema: + type: boolean + default: true + - name: category + in: query + schema: + type: string + maxLength: 50 + - name: tag + in: query + schema: + type: string + maxLength: 50 + - name: sample_id + in: query + schema: + type: integer + minimum: 1 + responses: + "200": + description: Paginated regression test definitions + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/RegressionTest" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # RESULTS + + /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/expected: + get: + tags: [Results] + summary: Get expected output for a regression test result + operationId: getExpectedOutput + description: > + Expected output is a file reference stored under TestResults using the + regression output extension. Resolved from GCS or local + SAMPLE_REPOSITORY at request time. storage_status reflects which + backends have the file. Do not assume local and GCS are always in sync. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionId" + - $ref: "#/components/parameters/OutputId" + - $ref: "#/components/parameters/Format" + responses: + "200": + description: Expected output file + content: + application/json: + schema: + $ref: "#/components/schemas/OutputFile" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/actual: + get: + tags: [Results] + summary: Get actual output generated by a regression test in a run + operationId: getActualOutput + description: > + IMPORTANT: TestResultFile.got = null means the actual output MATCHED + expected, not that actual output is missing. This is a semantic trap + in the data model. Missing output is represented by a dummy row + (-1,-1,-1,'','error') which the API translates to status=missing_output + and returns 404. A 200 response always contains a real output file. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionId" + - $ref: "#/components/parameters/OutputId" + - $ref: "#/components/parameters/Format" + responses: + "200": + description: Actual output file (output exists and differs from expected) + content: + application/json: + schema: + $ref: "#/components/schemas/OutputFile" + "303": + description: Output matched expected. Redirected to /expected. + headers: + Location: + schema: + type: string + format: uri + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/diff: + get: + tags: [Results] + summary: Get expected-vs-actual diff for a failing regression test result + operationId: getDiff + description: > + The legacy diff route is header-gated (X-Requested-With: XMLHttpRequest), + not role-gated. The 403 seen on direct browser requests was a + header-check artifact. This endpoint wraps the XHR logic and returns + structured JSON — no HTML, no 50-line truncation. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionId" + - $ref: "#/components/parameters/OutputId" + - name: context_lines + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 3 + - name: format + in: query + schema: + type: string + enum: [structured, unified] + default: structured + responses: + "200": + description: Structured or unified diff + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Diff" + - $ref: "#/components/schemas/UnifiedDiff" + discriminator: + propertyName: format + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/baseline-approval: + post: + tags: [Results] + summary: Approve actual output as new expected baseline + operationId: approveBaseline + description: > + Requires baselines:write scope and admin role. + This is a destructive write — the approved output becomes the new + expected baseline for the regression test. + security: + - bearerAuth: [] + x-required-scope: baselines:write + x-required-roles: [admin, contributor] + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BaselineApprovalRequest" + responses: + "200": + description: Baseline approval applied immediately. + content: + application/json: + schema: + $ref: "#/components/schemas/BaselineApproval" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # ERRORS AND LOGS + + /runs/{run_id}/errors: + get: + tags: [Errors and Logs] + summary: Get structured test errors for a run + operationId: listRunErrors + description: > + Error types are derived from TestResult and TestResultFile rows. + missing_output is detected from the dummy (-1,-1,-1,'','error') row + pattern, not from got=null (which means match, not missing). + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: type + in: query + schema: + type: string + enum: [test_failure, exit_code_mismatch, missing_output, diff_mismatch] + - name: severity + in: query + schema: + type: string + enum: [info, warning, error, critical] + - name: sample_id + in: query + schema: + type: integer + minimum: 1 + responses: + "200": + description: Paginated test errors + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ErrorItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/infrastructure-errors: + get: + tags: [Errors and Logs] + summary: Get worker, provisioning, and build errors for a run + operationId: listInfraErrors + description: > + Errors are extracted from TestProgress rows written by the CI worker. + Messages are currently unstructured text. The type filter does + best-effort text matching until the worker protocol emits structured + error types. + Stack traces are opt-in (include_stack defaults to false) to avoid + leaking internal paths to unauthorized callers. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: type + in: query + schema: + type: string + enum: [queue, vm_provisioning, checkout, merge, build, worker, web_server, storage] + - name: severity + in: query + schema: + type: string + enum: [info, warning, error, critical] + - name: include_stack + in: query + schema: + type: boolean + default: false + description: > + Default false. Set true only when debugging infrastructure failures. + Stacks may contain internal paths; access requires system:read scope. + responses: + "200": + description: Paginated infrastructure errors + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ErrorItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/logs: + get: + tags: [Errors and Logs] + summary: Get raw logs for a run + operationId: getRunLogs + description: > + Logs are stored at SAMPLE_REPOSITORY/LogFiles/{id}.txt and served + via GCS signed URL. Returns 404 — not a broken download link — when + the file is absent from both local and GCS storage. + Uses cursor-based pagination. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - name: level + in: query + schema: + type: string + enum: [debug, info, warning, error, critical] + - name: source + in: query + schema: + type: string + enum: [orchestrator, worker, build, test_runner, web] + - name: contains + in: query + schema: + type: string + maxLength: 100 + responses: + "200": + description: Cursor-paginated run log lines + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CursorPage" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/LogLine" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Log file not found in local or GCS storage + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: log_not_found + message: Log file for run 9309 does not exist in any storage backend. + details: + run_id: 9309 + checked: [local, gcs] + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/logs: + get: + tags: [Errors and Logs] + summary: Get raw logs for a regression test result in a run + operationId: getSampleLogs + description: > + Returns raw log lines for a specific regression test result. + Logs are stored at SAMPLE_REPOSITORY/LogFiles/ and served via GCS + signed URL when available. Returns 404 when the log file is absent + from both local and GCS storage. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - name: level + in: query + schema: + type: string + enum: [debug, info, warning, error, critical] + - name: contains + in: query + schema: + type: string + maxLength: 100 + responses: + "200": + description: Cursor-paginated sample log lines + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CursorPage" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/LogLine" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/error-summary: + get: + tags: [Errors and Logs] + summary: Get grouped error summary for a run + operationId: getErrorSummary + description: > + Use this endpoint to triage a run before drilling into individual + errors. group_by=type gives a high-level failure breakdown; + group_by=sample_id helps identify flaky samples. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: group_by + in: query + schema: + type: string + enum: [type, sample_id, regression_id, severity] + default: type + - name: severity + in: query + schema: + type: string + enum: [info, warning, error, critical] + responses: + "200": + description: Paginated grouped error summary + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ErrorSummaryBucket" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # SYSTEM + + /system/health: + get: + tags: [System] + summary: Get CI system health and dependency status + operationId: getHealth + description: > + Unauthenticated. Returns overall system status and per-dependency + health. Used by monitoring and uptime checks. + security: [] + responses: + "200": + description: System healthy or degraded + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" + content: + application/json: + schema: + $ref: "#/components/schemas/SystemHealth" + "503": + description: System is down + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" + content: + application/json: + schema: + $ref: "#/components/schemas/SystemHealth" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/queue: + get: + tags: [System] + summary: Get queue depth and currently running jobs + operationId: getQueue + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: platform + in: query + schema: + type: string + enum: [linux, windows] + - name: status + in: query + schema: + type: string + enum: [queued, running] + responses: + "200": + description: Queue status and active jobs + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + queue_depth: + type: integer + minimum: 0 + running_count: + type: integer + minimum: 0 + data: + type: array + items: + $ref: "#/components/schemas/QueueJob" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/artifacts: + get: + tags: [System] + summary: List downloadable artifacts for a run + operationId: listArtifacts + description: > + Only returns artifacts with a verified download_url from at least one + storage backend. storage_status=degraded means one backend only; + storage_status=missing means neither backend has the file (download_url + will be null). Never returns a URL that has not been verified to exist. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: type + in: query + schema: + type: string + enum: [build_log, sample_output, expected_output, diff, media_info, binary, coredump, combined_stdout] + responses: + "200": + description: Paginated run artifacts + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Artifact" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + +# +# COMPONENTS +# +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + description: > + Opaque server-side API token. Obtain via POST /auth/tokens. + The CI worker token used by /ci/progress-reporter is a separate + secret and is NOT valid here. Never use browser session cookies + for API clients. + + # HEADERS + + headers: + RateLimitLimit: + description: Maximum requests allowed in the current window + schema: + type: integer + example: 120 + RateLimitRemaining: + description: Requests remaining in the current window + schema: + type: integer + example: 117 + RateLimitReset: + description: Unix timestamp when the rate limit window resets + schema: + type: integer + example: 1748908800 + + # PARAMETERS + + parameters: + Limit: + name: limit + in: query + description: Maximum number of results to return (1–100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + + Offset: + name: offset + in: query + description: Number of results to skip for pagination + schema: + type: integer + minimum: 0 + default: 0 + + Cursor: + name: cursor + in: query + description: > + Numeric line offset or ID for cursor-based pagination. Do not mix with offset. Mixing cursor and offset returns 400. + Obtain next_cursor from the previous response's pagination object. + schema: + type: integer + minimum: 0 + + RunId: + name: run_id + in: path + required: true + description: Numeric run ID + schema: + type: integer + minimum: 1 + + SampleId: + name: sample_id + in: path + required: true + description: Numeric media sample ID + schema: + type: integer + minimum: 1 + + RegressionTestId: + name: regression_test_id + in: path + required: true + description: Numeric regression test ID (not the same as media sample ID) + schema: + type: integer + minimum: 1 + + RunStatus: + name: status + in: query + description: > + Normalized run status. Derived from TestProgress rows and TestResult + outcomes. The underlying TestStatus model stores only preparation, + testing, completed, and canceled (where canceled covers both canceled + and error). This enum is the normalized API contract. + schema: + type: string + enum: [queued, running, pass, fail, canceled, incomplete] + example: pass + + Branch: + name: branch + in: query + description: Filter by branch name (e.g. master, develop). + schema: + type: string + maxLength: 100 + example: master + + CommitSha: + name: commit_sha + in: query + description: > + Filter by full 40-character SHA-1 commit hash. + schema: + type: string + pattern: '^[a-fA-F0-9]{40}$' + example: 0b1a967b732898e705ea8f2fda5d08eb00328579 + + Repository: + name: repository + in: query + description: > + Filter by GitHub repository in owner/repo format. + schema: + type: string + pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' + maxLength: 100 + example: CCExtractor/ccextractor + + Platform: + name: platform + in: query + schema: + type: string + enum: [linux, windows] + example: linux + + CreatedAfter: + name: created_after + in: query + description: > + ISO 8601 datetime filter. Returns runs created after this time. + Example: 2025-01-01T00:00:00Z + schema: + type: string + format: date-time + + CreatedBefore: + name: created_before + in: query + description: > + ISO 8601 datetime filter. Returns runs created before this time. + Example: 2026-12-31T23:59:59Z + schema: + type: string + format: date-time + + RegressionId: + name: regression_id + in: path + required: true + description: Regression test definition ID + schema: + type: integer + minimum: 1 + + OutputId: + name: output_id + in: path + required: true + description: Output file ID within a regression test definition + schema: + type: integer + minimum: 1 + + Format: + name: format + in: query + description: > + Content encoding for file responses. + Use text only when the file is known to be UTF-8 compatible. + Binary or unknown content defaults to base64. + schema: + type: string + enum: [text, base64] + default: base64 + + # RESPONSES + + responses: + BadRequest: + description: Request body or query parameters failed schema validation + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: validation_error + message: Request failed schema validation. + details: + fields: + commit_sha: Must match pattern ^[a-fA-F0-9]{40}$ + platform: Must be one of [linux, windows] + + Unauthorized: + description: Missing, expired, or invalid bearer token + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: unauthorized + message: Bearer token is missing, expired, or invalid. + details: {} + + Forbidden: + description: Token is valid but lacks the required scope or role + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: Token does not have the required scope for this operation. + details: + required_scope: runs:write + token_scopes: [runs:read, results:read] + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: not_found + message: Run 9317 not found. + details: + resource: run + id: 9317 + + UnprocessableEntity: + description: Request is valid JSON but semantically invalid + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: unprocessable + message: regression_test_ids contains inactive test IDs. + details: + inactive_ids: [42, 99] + + RateLimited: + description: Too many requests. Retry after the indicated number of seconds. + headers: + Retry-After: + description: Seconds to wait before retrying + schema: + type: integer + example: 30 + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: rate_limited + message: Rate limit exceeded. Retry after 30 seconds. + details: + retry_after: 30 + limit: 120 + window: 60s + + Error: + description: Unexpected server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + # SCHEMAS + + schemas: + + Page: + type: object + required: [data, pagination] + properties: + data: + type: array + description: > + Result items. The concrete type is defined by allOf composition + in each endpoint response. + items: {} + pagination: + type: object + required: [limit, offset, total] + properties: + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + total: + type: integer + minimum: -1 + nullable: true + description: > + Total matching records. Null if count was not computed for this request. + Pass ?count=true to force computation. + next_offset: + type: integer + minimum: 0 + nullable: true + truncated: + type: boolean + description: > + Present and true when the result set was capped by an + internal safety limit (e.g. status-filter on runs). When + true, total may undercount the real number of matches. + + CursorPage: + type: object + required: [data, pagination] + properties: + data: + type: array + description: > + Result items. The concrete type is defined by allOf composition + in each endpoint response. + items: {} + pagination: + type: object + required: [limit, next_cursor] + properties: + limit: + type: integer + minimum: 1 + next_cursor: + type: integer + minimum: 0 + nullable: true + description: > + Numeric cursor for the next page. Null when there are no + more results. + + ErrorResponse: + type: object + required: [code, message, details] + properties: + code: + type: string + maxLength: 100 + description: Machine-readable error code (snake_case) + example: not_found + message: + type: string + maxLength: 500 + description: Human-readable error summary + example: Run 9317 not found. + details: + type: object + additionalProperties: true + description: > + Structured context for the error. Always an object, never null. + Empty object {} when no additional detail is available. + + ApiTokenItem: + type: object + description: > + Token metadata returned when listing tokens. The plaintext token + value is never included - it is shown only once at creation time. + required: [id, user_id, token_name, token_prefix, scopes, created_at, expires_at, is_revoked] + properties: + id: + type: integer + minimum: 1 + user_id: + type: integer + minimum: 1 + description: Owner of the token. Visible to admins when listing all tokens. + token_name: + type: string + maxLength: 50 + token_prefix: + type: string + maxLength: 20 + description: First few characters of the token for identification. + scopes: + type: array + maxItems: 6 + uniqueItems: true + items: + type: string + enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + is_revoked: + type: boolean + description: True if the token has been explicitly revoked. + revoked_at: + type: string + format: date-time + nullable: true + + TokenCreateRequest: + type: object + required: [email, password, token_name] + additionalProperties: false + properties: + email: + type: string + format: email + maxLength: 255 + password: + type: string + format: password + minLength: 8 + maxLength: 128 + description: Not stored or logged. Used only to verify identity. + token_name: + type: string + minLength: 1 + maxLength: 50 + pattern: '^[a-zA-Z0-9_-]+$' + description: > + Descriptive label for the token (e.g., local-agent, ci-bot). + Must be unique per user. + expires_in_days: + type: integer + minimum: 1 + maximum: 30 + default: 7 + scopes: + type: array + maxItems: 6 + uniqueItems: true + default: [runs:read, results:read] + items: + type: string + enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + description: > + Requested scopes. Grant only what the client needs. + runs:read — list and inspect runs, samples, history. + runs:write — trigger and cancel runs. + results:read — access expected/actual output, diffs, errors, logs. + baselines:write — approve new expected baselines. + system:read — queue, infrastructure errors, stack traces, artifacts. + tokens:manage — list and revoke API tokens. + + AuthToken: + type: object + required: [token, token_type, token_name, scopes, expires_at] + properties: + token: + type: string + maxLength: 512 + description: > + Opaque token value. Store it securely. It will not be shown again. + token_type: + type: string + enum: [bearer] + token_name: + type: string + maxLength: 50 + scopes: + type: array + maxItems: 8 + uniqueItems: true + items: + type: string + enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + expires_at: + type: string + format: date-time + + RunCreateRequest: + type: object + required: [repository, commit_sha, platform] + additionalProperties: false + properties: + repository: + type: string + pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' + maxLength: 100 + example: CCExtractor/ccextractor + branch: + type: string + pattern: '^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$' + maxLength: 100 + example: master + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + example: 0632bff4e382d5f86eff9073b9ddd37f03f9778c + pull_request: + type: integer + minimum: 1 + nullable: true + example: 2264 + platform: + type: string + enum: [linux, windows] + example: windows + regression_test_ids: + type: array + maxItems: 500 + uniqueItems: true + items: + type: integer + minimum: 1 + description: > + Optional subset of active regression test IDs. + If omitted, all active tests are used. + Inactive test IDs are rejected with 422. + + Run: + type: object + required: [run_id, status, repository, commit_sha, platform, created_at] + properties: + run_id: + type: integer + minimum: 1 + status: + type: string + enum: [queued, running, pass, fail, canceled, incomplete] + description: > + Normalized status. Derived from TestProgress rows and TestResult + outcomes. status=canceled covers both explicit cancellation and + infrastructure error (the underlying model conflates them). + platform: + type: string + enum: [linux, windows] + test_type: + type: string + enum: [pr, commit] + description: Whether this run was triggered by a pull request or a commit push. + repository: + type: string + maxLength: 100 + branch: + type: string + maxLength: 100 + nullable: true + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + pr_number: + type: integer + minimum: 1 + nullable: true + description: Pull request number, if this run was triggered by a PR. + created_at: + type: string + format: date-time + queued_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + github_link: + type: string + format: uri + nullable: true + description: Direct link to the commit or PR on GitHub. + + RunSummary: + type: object + required: [run_id, status, total_samples, pass_count, fail_count, skipped_count, missing_output_count] + properties: + run_id: + type: integer + minimum: 1 + status: + type: string + enum: [queued, running, pass, fail, canceled, incomplete] + description: > + Overall run status at the time the summary was generated. + Same derivation as Run.status. + total_samples: + type: integer + minimum: 0 + description: Total regression test results in this run. + pass_count: + type: integer + minimum: 0 + fail_count: + type: integer + minimum: 0 + description: > + Computed from TestResult rows. NOT derived from test.failed, + which only reflects cancellation state and is unreliable for + determining whether regression tests actually passed. + skipped_count: + type: integer + minimum: 0 + missing_output_count: + type: integer + minimum: 0 + description: > + Samples that produced no output when output was expected. + Detected from the dummy TestResultFile(-1,-1,-1,'','error') row, + not from got=null (which means output matched). + error_count: + type: integer + minimum: 0 + duration_ms: + type: integer + minimum: 0 + nullable: true + triggered_by: + type: string + maxLength: 100 + nullable: true + + ProgressEvent: + type: object + required: [timestamp, status, message] + properties: + timestamp: + type: string + format: date-time + status: + type: string + enum: [queued, preparation, testing, completed, canceled, error] + message: + type: string + maxLength: 500 + description: Unstructured text from TestProgress rows. + step: + type: integer + minimum: 0 + nullable: true + + RunActionResult: + type: object + required: [run_id, action, status] + properties: + run_id: + type: integer + minimum: 1 + description: ID of the run this action targets. + action: + type: string + enum: [cancel] + status: + type: string + enum: [accepted, rejected, no_op] + description: no_op is returned when canceling an already-terminal run. + message: + type: string + maxLength: 500 + + RunConfig: + type: object + required: [run_id, platform, branch, commit_sha, regression_test_ids] + properties: + run_id: + type: integer + minimum: 1 + platform: + type: string + enum: [linux, windows] + branch: + type: string + maxLength: 100 + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + regression_test_ids: + type: array + maxItems: 500 + uniqueItems: true + items: + type: integer + minimum: 1 + description: > + IDs included in this run. When no custom set was configured, all + regression tests are returned. Implementers must filter by + active=true — get_customized_regressiontests() does not do this. + + Sample: + type: object + required: [sample_id, sha] + properties: + sample_id: + type: integer + minimum: 1 + sha: + type: string + pattern: '^[a-fA-F0-9]{64}$' + description: SHA256 hash of the sample file. + extension: + type: string + maxLength: 10 + original_name: + type: string + maxLength: 255 + filename: + type: string + maxLength: 255 + tags: + type: array + maxItems: 50 + items: + type: string + maxLength: 50 + regression_test_count: + type: integer + minimum: 0 + description: Number of active regression tests referencing this sample. + active: + type: boolean + description: True if at least one active regression test references this sample. + + RegressionTest: + type: object + required: [regression_test_id, sample_id, command] + properties: + regression_test_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + sample_name: + type: string + maxLength: 255 + nullable: true + command: + type: string + maxLength: 500 + input_type: + type: string + maxLength: 50 + output_type: + type: string + maxLength: 50 + expected_rc: + type: integer + nullable: true + active: + type: boolean + categories: + type: array + maxItems: 50 + items: + type: string + maxLength: 100 + description: + type: string + maxLength: 1000 + nullable: true + + RunSample: + type: object + required: [regression_test_id, sample_id, status] + properties: + regression_test_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + sample_name: + type: string + maxLength: 255 + nullable: true + categories: + type: array + maxItems: 50 + items: + type: string + maxLength: 100 + description: Category labels from the regression test definition. + command: + type: string + maxLength: 500 + nullable: true + status: + type: string + enum: [pass, fail, skipped, missing_output, running, not_started] + description: > + Computed from TestResult, TestResultFile, expected exit code, + and multiple acceptable baselines. Not a stored column. + runtime_ms: + type: integer + minimum: 0 + nullable: true + exit_code: + type: integer + nullable: true + expected_rc: + type: integer + nullable: true + description: Expected return code for this regression test. + outputs: + type: array + maxItems: 20 + description: > + One entry per expected output file. + got=null in the DB means output matched expected; no actual file + is stored. The dummy (-1,-1,-1,'','error') row is translated to + status=missing_output and is never exposed here. + items: + type: object + required: [output_id, filename, status] + additionalProperties: false + properties: + output_id: + type: integer + minimum: 1 + filename: + type: string + maxLength: 255 + status: + type: string + enum: [pass, fail, missing_output, missing_expected] + description: > + pass = actual identical to expected. + fail = actual differs from expected. + missing_output = test produced no output. + missing_expected = no expected baseline exists. + + SampleHistoryEntry: + type: object + required: [run_id, regression_test_id, status] + properties: + run_id: + type: integer + minimum: 1 + regression_test_id: + type: integer + minimum: 1 + status: + type: string + enum: [pass, fail, skipped, missing_output, running, not_started] + platform: + type: string + enum: [linux, windows] + branch: + type: string + maxLength: 100 + nullable: true + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + nullable: true + tested_at: + type: string + format: date-time + nullable: true + description: completed_at or started_at timestamp from the run. + failure_signature: + type: string + maxLength: 255 + nullable: true + description: > + Stable string identifying the failure type and output ID. + Use across runs to detect genuine regressions vs. infrastructure + flakes. + + OutputFile: + type: object + required: [sample_id, regression_id, output_id, filename, content_type, encoding, content, storage_status] + properties: + run_id: + type: integer + minimum: 1 + nullable: true + description: Null for expected output not tied to a specific run. + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + filename: + type: string + maxLength: 255 + content_type: + type: string + maxLength: 100 + encoding: + type: string + enum: [utf-8, base64] + description: > + utf-8 only when file is confirmed text. Default is base64. + content: + type: string + maxLength: 1048576 + description: > + File content. Base64-encoded unless encoding=utf-8. + Files exceeding 1MB are truncated. Check truncated=true and use + download_url for the full file. + truncated: + type: boolean + description: True if content was truncated due to size limits. + download_url: + type: string + format: uri + nullable: true + description: URL to download the full file if it was truncated. + sha256: + type: string + pattern: '^[a-fA-F0-9]{64}$' + storage_status: + type: string + enum: [ok, degraded, missing] + description: > + ok = file verified in at least one storage backend. + degraded = file exists but integrity or redundancy check failed. + missing = file not found in any storage backend. + + Diff: + type: object + required: [run_id, sample_id, regression_id, output_id, status] + properties: + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + status: + type: string + enum: [identical, different, missing_expected, missing_actual] + summary: + type: object + required: [added_lines, removed_lines, changed_hunks] + properties: + added_lines: + type: integer + minimum: 0 + removed_lines: + type: integer + minimum: 0 + changed_hunks: + type: integer + minimum: 0 + hunks: + type: array + maxItems: 500 + items: + type: object + required: [expected_start, actual_start, lines] + additionalProperties: false + properties: + expected_start: + type: integer + minimum: 0 + actual_start: + type: integer + minimum: 0 + lines: + type: array + maxItems: 500 + items: + type: object + required: [kind, text] + additionalProperties: false + properties: + kind: + type: string + enum: [context, added, removed] + expected_line: + type: integer + minimum: 0 + nullable: true + actual_line: + type: integer + minimum: 0 + nullable: true + text: + type: string + maxLength: 1000 + + UnifiedDiff: + type: object + required: [run_id, sample_id, regression_id, output_id, format, content] + properties: + run_id: + type: integer + sample_id: + type: integer + regression_id: + type: integer + output_id: + type: integer + format: + type: string + enum: [unified] + content: + type: string + description: Raw unified diff text. + maxLength: 524288 + + BaselineApprovalRequest: + type: object + required: [regression_id, output_id] + additionalProperties: false + properties: + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + remove_variants: + type: boolean + default: false + description: > + If true, removes all platform-specific variants (output_id != 1) + and promotes this output to the global baseline. + + BaselineApproval: + type: object + required: [status, run_id, sample_id, regression_id, output_id, requested_by, created_at] + properties: + status: + type: string + enum: [approved] + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + requested_by: + type: string + maxLength: 100 + description: Display name of the user who requested the approval. + created_at: + type: string + format: date-time + + ErrorItem: + type: object + required: [error_id, run_id, type, severity, message, occurred_at] + properties: + error_id: + type: string + maxLength: 100 + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + regression_id: + type: integer + minimum: 1 + nullable: true + type: + type: string + enum: [test_failure, exit_code_mismatch, missing_output, diff_mismatch, queue, vm_provisioning, checkout, merge, build, worker, web_server, storage] + maxLength: 100 + severity: + type: string + enum: [info, warning, error, critical] + message: + type: string + maxLength: 1000 + location: + type: object + nullable: true + additionalProperties: true + properties: + file: + type: string + maxLength: 500 + nullable: true + line: + type: integer + minimum: 0 + nullable: true + column: + type: integer + minimum: 0 + nullable: true + sample_name: + type: string + maxLength: 255 + nullable: true + stack: + type: array + maxItems: 50 + description: Only present when include_stack=true was requested. + items: + type: string + maxLength: 2000 + occurred_at: + type: string + format: date-time + + LogLine: + type: object + required: [timestamp, level, source, message, run_id] + properties: + timestamp: + type: string + format: date-time + level: + type: string + enum: [debug, info, warning, error, critical] + source: + type: string + enum: [orchestrator, worker, build, test_runner, web] + message: + type: string + maxLength: 4000 + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + + ErrorSummaryBucket: + type: object + required: [key, count, severity, group_by] + properties: + group_by: + type: string + enum: [type, sample_id, regression_id, severity] + description: The dimension this bucket is grouped by. + key: + type: string + maxLength: 100 + description: > + Value of the group_by dimension. When group_by=sample_id or + regression_id, this is an integer serialized as a string. + count: + type: integer + minimum: 0 + severity: + type: string + enum: [info, warning, error, critical] + sample_ids: + type: array + maxItems: 1000 + items: + type: integer + minimum: 1 + first_seen_at: + type: string + format: date-time + nullable: true + last_seen_at: + type: string + format: date-time + nullable: true + + SystemHealth: + type: object + required: [status, checked_at, dependencies] + properties: + status: + type: string + enum: [ok, degraded, down] + checked_at: + type: string + format: date-time + dependencies: + type: array + maxItems: 20 + items: + type: object + required: [name, status] + properties: + name: + type: string + maxLength: 100 + status: + type: string + enum: [ok, degraded, down] + message: + type: string + maxLength: 500 + nullable: true + + QueueJob: + type: object + required: [run_id, status, platform, queued_at] + properties: + run_id: + type: integer + minimum: 1 + status: + type: string + enum: [queued, running] + platform: + type: string + enum: [linux, windows] + queued_at: + type: string + format: date-time + started_at: + type: string + format: date-time + nullable: true + position: + type: integer + minimum: 1 + nullable: true + description: Queue position. Null for jobs that are already running. + + Artifact: + type: object + required: [artifact_id, run_id, type, filename, content_type, storage_status] + properties: + artifact_id: + type: string + maxLength: 100 + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + type: + type: string + enum: [build_log, sample_output, expected_output, actual_output, diff, media_info, binary, coredump, combined_stdout] + filename: + type: string + maxLength: 255 + content_type: + type: string + maxLength: 100 + size_bytes: + type: integer + minimum: 0 + nullable: true + storage_status: + type: string + enum: [ok, degraded, missing] + description: > + ok = file verified in at least one storage backend. + degraded = file exists but integrity or redundancy check failed. + missing = file not found in any storage backend. + download_url: + type: string + format: uri + nullable: true + description: > + Only present and non-null when storage_status is ok or degraded. + Always a verified URL. Null when storage_status=missing. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4aaae11e3..ae684782a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,6 @@ PyGithub==2.9.1 blinker==1.9.0 click==8.3.3 PyYAML==6.0.3 +marshmallow>=3.21 +argon2-cffi>=23.0 +Flask-Limiter>=3.5 diff --git a/run.py b/run.py index e277c6d97..23e434566 100755 --- a/run.py +++ b/run.py @@ -24,6 +24,7 @@ SecretKeyInstallationException) from log_configuration import LogConfiguration from mailer import Mailer +from mod_api import mod_api from mod_auth.controllers import mod_auth from mod_ci.controllers import mod_ci from mod_customized.controllers import mod_customized @@ -273,3 +274,5 @@ def teardown(exception: Optional[Exception]): app.register_blueprint(mod_ci) app.register_blueprint(mod_customized, url_prefix='/custom') app.register_blueprint(mod_health) +# REST API v1 +app.register_blueprint(mod_api, url_prefix='/api/v1')