diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index 1491a6a5..0afb819c 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -1,6 +1,7 @@ import asyncio import base64 import datetime +import hmac import json import os import time @@ -239,7 +240,9 @@ def require_admin( if not authorization: raise HTTPException(status_code=401, detail="Missing Authorization header") expected = f"Bearer {env.ADMIN_TOKEN}" - if authorization != expected: + # Compare on bytes: hmac.compare_digest raises TypeError on non-ASCII str + # (Starlette decodes headers as latin-1), which would surface as a 500 instead of 401. + if not hmac.compare_digest(authorization.encode("utf-8"), expected.encode("utf-8")): raise HTTPException(status_code=401, detail="Invalid admin token") @@ -613,6 +616,69 @@ async def run_submission_async( raise HTTPException(status_code=500, detail="Internal server error") from e +@app.post("/admin/submission/{leaderboard_name}/{gpu_type}/{submission_mode}") +async def admin_run_submission_after_deadline( + leaderboard_name: str, + gpu_type: str, + submission_mode: str, + file: UploadFile, + user_info: Annotated[dict, Depends(validate_user_header)], + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> Any: + """Queue a submission from an authenticated user through an admin-only after-deadline path.""" + try: + await simple_rate_limit() + logger.info( + f"Received admin submission request for {leaderboard_name} {gpu_type} {submission_mode} " + f"user_id={user_info['user_id']} user_name={user_info.get('user_name')} " + f"id_type={user_info.get('id_type')}" + ) + + try: + submission_request, submission_mode_enum = await to_submit_info( + user_info, submission_mode, file, leaderboard_name, gpu_type, db_context + ) + + req = prepare_submission( + submission_request, + backend_instance, + submission_mode_enum, + allow_after_deadline=True, + ) + + except KernelBotError as e: + raise HTTPException(status_code=e.http_code, detail=str(e)) from e + except Exception as e: + raise HTTPException( + status_code=400, detail=f"failed to prepare submission request: {str(e)}" + ) from e + + if not req.gpus or len(req.gpus) != 1: + raise HTTPException(status_code=400, detail="Invalid GPU type") + + sub_id, job_status_id = await enqueue_background_job( + req, submission_mode_enum, backend_instance, background_submission_manager + ) + runner_queue = await get_runner_queue_status(req.gpus[0], req) + + return JSONResponse( + status_code=202, + content={ + "details": {"id": sub_id, "job_status_id": job_status_id}, + "runner_queue": runner_queue, + "status": "accepted", + }, + ) + except HTTPException: + raise + except KernelBotError as e: + raise HTTPException(status_code=getattr(e, "http_code", 400), detail=str(e)) from e + except Exception as e: + logger.error(f"Unexpected error in api submissoin: {e}") + raise HTTPException(status_code=500, detail="Internal server error") from e + + @app.post("/admin/start") async def admin_start( _: Annotated[None, Depends(require_admin)], diff --git a/src/libkernelbot/submission.py b/src/libkernelbot/submission.py index 69b83b24..a1c8df64 100644 --- a/src/libkernelbot/submission.py +++ b/src/libkernelbot/submission.py @@ -42,7 +42,11 @@ class ProcessedSubmissionRequest(SubmissionRequest): def prepare_submission( # noqa: C901 - req: SubmissionRequest, backend: "KernelBackend", mode: SubmissionMode = None + req: SubmissionRequest, + backend: "KernelBackend", + mode: SubmissionMode = None, + *, + allow_after_deadline: bool = False, ) -> ProcessedSubmissionRequest: if not backend.accepts_jobs: raise KernelBotError( @@ -88,7 +92,8 @@ def prepare_submission( # noqa: C901 code=429, ) - check_deadline(leaderboard) + if not allow_after_deadline: + check_deadline(leaderboard) task_gpus = get_avail_gpus(req.leaderboard, backend.db) diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 22fe4309..b8adfba8 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -1,5 +1,6 @@ """Tests for admin API endpoints.""" +import datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -18,12 +19,20 @@ def mock_backend(): @pytest.fixture -def test_client(mock_backend): +def mock_background_manager(): + manager = MagicMock() + manager.enqueue = AsyncMock() + return manager + + +@pytest.fixture +def test_client(mock_backend, mock_background_manager): """Create a test client with mocked backend.""" # Patch env before importing the app with patch.dict('os.environ', {'ADMIN_TOKEN': 'test_token'}): - from kernelbot.api.main import app, init_api + from kernelbot.api.main import app, init_api, init_background_submission_manager init_api(mock_backend) + init_background_submission_manager(mock_background_manager) yield TestClient(app) @@ -169,6 +178,58 @@ def test_admin_stats_with_leaderboard_name(self, test_client, mock_backend): class TestAdminSubmissions: """Test admin submission endpoints.""" + def test_admin_submission_allows_after_deadline( + self, test_client, mock_backend, mock_background_manager + ): + """POST /admin/submission queues an authenticated user submission after deadline.""" + mock_backend.accepts_jobs = True + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.validate_identity = MagicMock(return_value={ + "user_id": "123", + "user_name": "admin_user", + "id_type": "cli", + }) + mock_task = MagicMock() + mock_backend.db.get_leaderboard = MagicMock(return_value={ + "task": mock_task, + "secret_seed": 12345, + "deadline": datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1), + "name": "expired-lb", + "gpu_types": ["B200"], + "visibility": "public", + }) + mock_backend.db.get_leaderboard_gpu_types = MagicMock(return_value=["B200"]) + mock_backend.db.is_user_banned = MagicMock(return_value=False) + mock_backend.db.check_rate_limit = MagicMock(return_value=None) + mock_backend.db.create_submission = MagicMock(return_value=123) + mock_backend.db.upsert_submission_job_status = MagicMock(return_value=456) + mock_backend.get_runner_queue_status = AsyncMock( + return_value=RunnerQueueStatus( + runner="Modal", + gpu="B200", + queued_jobs=0, + available_runners=1, + ) + ) + + response = test_client.post( + "/admin/submission/expired-lb/B200/leaderboard", + headers={ + "Authorization": "Bearer test_token", + "X-Popcorn-Cli-Id": "cli-token", + }, + files={"file": ("submission.py", b"print('ok')", "text/x-python")}, + ) + + assert response.status_code == 202 + assert response.json()["status"] == "accepted" + assert response.json()["details"] == {"id": 123, "job_status_id": 456} + mock_background_manager.enqueue.assert_awaited_once() + queued_req, _, sub_id = mock_background_manager.enqueue.await_args.args + assert queued_req.leaderboard == "expired-lb" + assert sub_id == 123 + def test_list_leaderboard_submissions(self, test_client, mock_backend): """GET /admin/leaderboards/{name}/submissions returns submission IDs.""" mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) diff --git a/tests/test_submission.py b/tests/test_submission.py index f2bced05..781bab59 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -56,6 +56,42 @@ def test_check_deadline(): submission.check_deadline(past_deadline) +def test_prepare_submission_rejects_expired_deadline_by_default(mock_backend): + mock_backend.db.get_leaderboard.return_value["deadline"] = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1) + ) + req = submission.SubmissionRequest( + code="print('hello world')", + file_name="test.py", + user_id=1, + user_name="test_user", + gpus=["A100"], + leaderboard="test_board", + ) + + with pytest.raises(KernelBotError, match="The deadline to submit to test_board has passed"): + submission.prepare_submission(req, mock_backend) + + +def test_prepare_submission_allows_expired_deadline_with_override(mock_backend): + mock_backend.db.get_leaderboard.return_value["deadline"] = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1) + ) + req = submission.SubmissionRequest( + code="print('hello world')", + file_name="test.py", + user_id=1, + user_name="test_user", + gpus=["A100"], + leaderboard="test_board", + ) + + result = submission.prepare_submission(req, mock_backend, allow_after_deadline=True) + + assert result.leaderboard == "test_board" + assert result.gpus == ["A100"] + + def test_get_avail_gpus(mock_backend): db = mock_backend.db # Test with available GPUs