From dcf19904cf66f75ef796c791591bbff88d599d4d Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Fri, 6 Feb 2026 10:38:30 +0000 Subject: [PATCH 1/8] Test mantid build on staging --- .github/workflows/build-push.yml | 8 ++++---- LLSP-Worker/Dockerfile | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 9849d05..10f82ef 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -1,9 +1,9 @@ name: Build and Push Docker Images -on: - push: - branches: - - main +on: push + # push: + # branches: + # - main env: REGISTRY: ghcr.io diff --git a/LLSP-Worker/Dockerfile b/LLSP-Worker/Dockerfile index 182e529..2de999f 100644 --- a/LLSP-Worker/Dockerfile +++ b/LLSP-Worker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM ghcr.io/fiaisis/mantid:6.14.0 ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app COPY pyproject.toml pyproject.toml From 94a781b819b21c9b56127c99eb7a478b77da1962 Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Fri, 6 Feb 2026 10:46:42 +0000 Subject: [PATCH 2/8] Reset entrypoint of the mantid container --- LLSP-Worker/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/LLSP-Worker/Dockerfile b/LLSP-Worker/Dockerfile index 2de999f..f8534d8 100644 --- a/LLSP-Worker/Dockerfile +++ b/LLSP-Worker/Dockerfile @@ -4,4 +4,5 @@ WORKDIR /app COPY pyproject.toml pyproject.toml RUN pip install . COPY celery_app.py celery_app.py +ENTRYPOINT [] CMD ["celery", "-A", "celery_app.app", "worker", "--loglevel=INFO", "--concurrency=1"] \ No newline at end of file From d9745b339043da6106c241f935d181351e5e9046 Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Fri, 6 Feb 2026 11:31:37 +0000 Subject: [PATCH 3/8] Revert build push --- .github/workflows/build-push.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 10f82ef..9849d05 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -1,9 +1,9 @@ name: Build and Push Docker Images -on: push - # push: - # branches: - # - main +on: + push: + branches: + - main env: REGISTRY: ghcr.io From 12365b836abb882f3db6d0ca996b46d7fee37d40 Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Thu, 12 Feb 2026 11:20:10 +0000 Subject: [PATCH 4/8] feat: Add API key authentication to endpoints and update tests - Introduced `HTTPBearer` authentication to `/execute` and `/status` endpoints. - Added API key validation support via an environment variable (`LLSP_API_KEY`). - Updated E2E tests to include authorization headers with API key. - Added test cases for unauthorized access to validate API key enforcement. - Updated `docker-compose.test.yml` to include the `LLSP_API_KEY` environment variable. --- LLSP-API/api.py | 29 +++++++++++++++++++++---- docker-compose.test.yml | 2 ++ tests/e2e/test_submission.py | 42 ++++++++++++++++++++++++++++++------ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/LLSP-API/api.py b/LLSP-API/api.py index a102afa..1e31707 100644 --- a/LLSP-API/api.py +++ b/LLSP-API/api.py @@ -7,10 +7,13 @@ import logging import os -from typing import Any +import sys +from typing import Any, Annotated from celery import Celery # type: ignore -from fastapi import FastAPI +from fastapi import FastAPI, Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + from model import ExecIn, Task, TaskState from utils import map_state @@ -19,6 +22,7 @@ BACKEND = os.getenv("CELERY_RESULT_BACKEND", "rpc://") TASK_NAME = os.getenv("EXEC_TASK_NAME", "celery_app.exec_script") +logger = logging.getLogger(__name__) class EndpointFilter(logging.Filter): """Filter out log messages containing /healthz or /ready.""" @@ -36,15 +40,27 @@ def filter(self, record: logging.LogRecord) -> bool: app = FastAPI(title="Exec API") +security = HTTPBearer() + +def verify_api_key(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> None: + """ + Validate the API key from the request credentials. + + :param credentials: The HTTP authorization credentials from the request. + :return: True if the API key matches the environment variable, False otherwise. + """ + if credentials.credentials != os.environ.get("LLSP_API_KEY"): + raise HTTPException(status_code=401, detail="Invalid API key") @app.post("/execute") -def execute(payload: ExecIn) -> Task: +def execute(payload: ExecIn, credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> Task: """ Submit a script for execution. :param payload: The script submission payload. :return: A `Task` object containing the task ID and initial status. """ + verify_api_key(credentials) async_result = celery.send_task( TASK_NAME, args=[payload.script], @@ -53,13 +69,14 @@ def execute(payload: ExecIn) -> Task: @app.get("/status/{task_id}") -def status(task_id: str) -> Task: +def status(task_id: str, credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> Task: """ Check the status of a submitted task. :param task_id: The ID of the task to check. :return: A `Task` object with the current state and result (if ready). """ + verify_api_key(credentials) res = celery.AsyncResult(task_id) body: Any = None try: @@ -96,4 +113,8 @@ def ready() -> dict[str, str]: :return: A dict indicating the service is ready. """ + api_key = os.environ.get("LLSP_API_KEY", None) + if api_key is None: + logger.critical("The LLSP_API_KEY environment variable is not set.") + sys.exit(1) return {"status": "ready"} diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 6907bdf..058d080 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -19,6 +19,7 @@ services: CELERY_BROKER_URL: amqp://llsp:llsp@rabbitmq:5672/llspvhost CELERY_RESULT_BACKEND: rpc:// EXEC_TASK_NAME: celery_app.exec_script + LLSP_API_KEY: "secret-token" depends_on: rabbitmq: condition: service_healthy @@ -50,6 +51,7 @@ services: dockerfile: tests/Dockerfile environment: API_BASE_URL: http://api:8000 + LLSP_API_KEY: "secret-token" depends_on: api: condition: service_healthy diff --git a/tests/e2e/test_submission.py b/tests/e2e/test_submission.py index cf6f407..8988c0b 100644 --- a/tests/e2e/test_submission.py +++ b/tests/e2e/test_submission.py @@ -8,12 +8,12 @@ import time import requests -from tenacity import retry, stop_after_attempt, wait_fixed API_URL = os.getenv("API_BASE_URL", "http://localhost:8000") +API_KEY = os.getenv("LLSP_API_KEY", "secret-token") +HEADERS = {"Authorization": f"Bearer {API_KEY}"} -@retry(stop=stop_after_attempt(5), wait=wait_fixed(2)) def wait_for_api(): """ Wait for the API to become responsive. @@ -45,7 +45,12 @@ def test_workflow(): print('Hello from stdout') print('Hello from stderr', file=sys.stderr) """ - response = requests.post(f"{API_URL}/execute", json={"script": script}, timeout=10) + response = requests.post( + f"{API_URL}/execute", + json={"script": script}, + headers=HEADERS, + timeout=10, + ) response.raise_for_status() data = response.json() assert "task_id" in data @@ -53,7 +58,11 @@ def test_workflow(): # 2. Poll for Status for _ in range(30): - response = requests.get(f"{API_URL}/status/{task_id}", timeout=10) + response = requests.get( + f"{API_URL}/status/{task_id}", + headers=HEADERS, + timeout=10, + ) response.raise_for_status() state = response.json() if state["state"] in ["success", "error"]: @@ -70,5 +79,26 @@ def test_workflow(): assert "Hello from stderr" in result["stderr"] -if __name__ == "__main__": - test_workflow() +def test_unauthorized_access(): + """ + Verify that requests without a valid API key are rejected. + """ + wait_for_api() + + # Case 1: No Authorization header + response = requests.post( + f"{API_URL}/execute", + json={"script": "print('fail')"}, + timeout=10, + ) + assert response.status_code == 403, f"Expected 403, got {response.status_code}" + + # Case 2: Invalid API key + response = requests.post( + f"{API_URL}/execute", + json={"script": "print('fail')"}, + headers={"Authorization": "Bearer invalid-token"}, + timeout=10, + ) + assert response.status_code == 401, f"Expected 401, got {response.status_code}" + From 51692865629bc687a21ca819acebeb9d529bd8fd Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Thu, 12 Feb 2026 11:21:58 +0000 Subject: [PATCH 5/8] ci: Simplify test workflow by triggering only on push events --- .github/workflows/test.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4dbcb1..54f7158 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,6 @@ name: Tests -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] +on: push jobs: api-unit-tests: From 92e83401ff2f5a804605313f9c9c97c89f146088 Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Thu, 12 Feb 2026 11:26:07 +0000 Subject: [PATCH 6/8] ruff fixes --- tests/e2e/test_submission.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/e2e/test_submission.py b/tests/e2e/test_submission.py index 8988c0b..d7b336f 100644 --- a/tests/e2e/test_submission.py +++ b/tests/e2e/test_submission.py @@ -6,6 +6,7 @@ import os import time +from http import HTTPStatus import requests @@ -80,9 +81,7 @@ def test_workflow(): def test_unauthorized_access(): - """ - Verify that requests without a valid API key are rejected. - """ + """Verify that requests without a valid API key are rejected.""" wait_for_api() # Case 1: No Authorization header @@ -91,7 +90,7 @@ def test_unauthorized_access(): json={"script": "print('fail')"}, timeout=10, ) - assert response.status_code == 403, f"Expected 403, got {response.status_code}" + assert response.status_code == HTTPStatus.FORBIDDEN, f"Expected 403, got {response.status_code}" # Case 2: Invalid API key response = requests.post( @@ -100,5 +99,5 @@ def test_unauthorized_access(): headers={"Authorization": "Bearer invalid-token"}, timeout=10, ) - assert response.status_code == 401, f"Expected 401, got {response.status_code}" + assert response.status_code == HTTPStatus.UNAUTHORIZED, f"Expected 401, got {response.status_code}" From 226e226595ed5b2b01aab3789430640794e6e27f Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 12 Feb 2026 11:26:40 +0000 Subject: [PATCH 7/8] Formatting and linting commit --- LLSP-API/api.py | 10 ++++++---- tests/e2e/test_submission.py | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/LLSP-API/api.py b/LLSP-API/api.py index 1e31707..6b3b476 100644 --- a/LLSP-API/api.py +++ b/LLSP-API/api.py @@ -8,12 +8,11 @@ import logging import os import sys -from typing import Any, Annotated +from typing import Annotated, Any from celery import Celery # type: ignore -from fastapi import FastAPI, Depends, HTTPException -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials - +from fastapi import Depends, FastAPI, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from model import ExecIn, Task, TaskState from utils import map_state @@ -24,6 +23,7 @@ logger = logging.getLogger(__name__) + class EndpointFilter(logging.Filter): """Filter out log messages containing /healthz or /ready.""" @@ -42,6 +42,7 @@ def filter(self, record: logging.LogRecord) -> bool: security = HTTPBearer() + def verify_api_key(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> None: """ Validate the API key from the request credentials. @@ -52,6 +53,7 @@ def verify_api_key(credentials: Annotated[HTTPAuthorizationCredentials, Depends( if credentials.credentials != os.environ.get("LLSP_API_KEY"): raise HTTPException(status_code=401, detail="Invalid API key") + @app.post("/execute") def execute(payload: ExecIn, credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> Task: """ diff --git a/tests/e2e/test_submission.py b/tests/e2e/test_submission.py index d7b336f..5065bf6 100644 --- a/tests/e2e/test_submission.py +++ b/tests/e2e/test_submission.py @@ -100,4 +100,3 @@ def test_unauthorized_access(): timeout=10, ) assert response.status_code == HTTPStatus.UNAUTHORIZED, f"Expected 401, got {response.status_code}" - From a927d340178f46a6f35d65e2e1ee9563f6c28ca2 Mon Sep 17 00:00:00 2001 From: Keiran Price Date: Thu, 26 Feb 2026 10:49:36 +0000 Subject: [PATCH 8/8] chore: Add `LLSP_API_KEY` environment variable to `docker-compose.yml` --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 2a984d3..d7b5808 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: CELERY_BROKER_URL: amqp://llsp:llsp@rabbitmq:5672/llspvhost CELERY_RESULT_BACKEND: rpc:// EXEC_TASK_NAME: celery_app.exec_script + LLSP_API_KEY: "secret-token" depends_on: rabbitmq: condition: service_healthy