Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion src/kernelbot/api/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import base64
import datetime
import hmac
import json
import os
import time
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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)],
Expand Down
9 changes: 7 additions & 2 deletions src/libkernelbot/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
65 changes: 63 additions & 2 deletions tests/test_admin_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for admin API endpoints."""

import datetime
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand All @@ -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)


Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading