daq-queuing-service 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- daq_queuing_service/__init__.py +11 -0
- daq_queuing_service/__main__.py +34 -0
- daq_queuing_service/_version.py +24 -0
- daq_queuing_service/api/__init__.py +0 -0
- daq_queuing_service/api/api.py +180 -0
- daq_queuing_service/api/errors.py +71 -0
- daq_queuing_service/app/__init__.py +0 -0
- daq_queuing_service/app/_config.py +36 -0
- daq_queuing_service/app/app.py +109 -0
- daq_queuing_service/app/authentication.py +103 -0
- daq_queuing_service/app/authorisation.py +31 -0
- daq_queuing_service/blueapi_interaction/__init__.py +0 -0
- daq_queuing_service/blueapi_interaction/blueapi_adapter.py +70 -0
- daq_queuing_service/blueapi_interaction/blueapi_call.py +120 -0
- daq_queuing_service/blueapi_interaction/get_client.py +32 -0
- daq_queuing_service/blueapi_interaction/token_retriever.py +47 -0
- daq_queuing_service/broadcaster.py +62 -0
- daq_queuing_service/log.py +21 -0
- daq_queuing_service/plugins/converter.py +119 -0
- daq_queuing_service/plugins/i15_1/backgrounds.py +24 -0
- daq_queuing_service/plugins/i15_1/i15_1_converter.py +190 -0
- daq_queuing_service/plugins/i15_1/tiled_interaction.py +58 -0
- daq_queuing_service/task_queue/__init__.py +0 -0
- daq_queuing_service/task_queue/queue.py +650 -0
- daq_queuing_service/task_queue/queue_utils.py +26 -0
- daq_queuing_service/task_queue/task.py +106 -0
- daq_queuing_service/worker/__init__.py +0 -0
- daq_queuing_service/worker/worker.py +131 -0
- daq_queuing_service-0.1.13.dist-info/METADATA +265 -0
- daq_queuing_service-0.1.13.dist-info/RECORD +34 -0
- daq_queuing_service-0.1.13.dist-info/WHEEL +5 -0
- daq_queuing_service-0.1.13.dist-info/entry_points.txt +2 -0
- daq_queuing_service-0.1.13.dist-info/licenses/LICENSE +201 -0
- daq_queuing_service-0.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Interface for ``python -m daq_queuing_service``."""
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from daq_queuing_service.app._config import get_default_config_path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
|
|
13
|
+
__all__ = ["main"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(args: Sequence[str] | None = None) -> None:
|
|
17
|
+
"""Argument parser for the CLI."""
|
|
18
|
+
parser = ArgumentParser()
|
|
19
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
20
|
+
parser.add_argument("-p", "--port", type=int, default=8000)
|
|
21
|
+
parser.add_argument("--dev", action="store_true", default=False)
|
|
22
|
+
parser.add_argument("--config", type=Path, default=get_default_config_path())
|
|
23
|
+
|
|
24
|
+
parsed_args = parser.parse_args(args)
|
|
25
|
+
|
|
26
|
+
from daq_queuing_service.app.app import create_app
|
|
27
|
+
|
|
28
|
+
app = create_app(config_path=parsed_args.config, dev=parsed_args.dev)
|
|
29
|
+
|
|
30
|
+
uvicorn.run(app, host="0.0.0.0", port=parsed_args.port, workers=1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.13'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 13)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from blueapi.service.model import TaskRequest
|
|
7
|
+
from fastapi import APIRouter, Depends, Request, Response
|
|
8
|
+
from fastapi.responses import EventSourceResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from daq_queuing_service.app._config import AppConfig
|
|
12
|
+
from daq_queuing_service.app.authentication import User
|
|
13
|
+
from daq_queuing_service.blueapi_interaction.blueapi_call import BlueapiCallResponse
|
|
14
|
+
from daq_queuing_service.broadcaster import Broadcaster
|
|
15
|
+
from daq_queuing_service.plugins.converter import Converter, ValidateError
|
|
16
|
+
from daq_queuing_service.task_queue.queue import (
|
|
17
|
+
QUEUE_EVENTS,
|
|
18
|
+
PauseReason,
|
|
19
|
+
QueueState,
|
|
20
|
+
TaskQueue,
|
|
21
|
+
TaskWithPosition,
|
|
22
|
+
)
|
|
23
|
+
from daq_queuing_service.task_queue.task import Experiment, Status, Task
|
|
24
|
+
|
|
25
|
+
# pyright: reportUnusedFunction=false
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_current_user(request: Request) -> User | None:
|
|
29
|
+
if hasattr(request.state, "user"):
|
|
30
|
+
return request.state.user
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CurrentUser = Annotated[User | None, Depends(get_current_user)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class QueueStateUpdate(BaseModel):
|
|
37
|
+
paused: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskCancelRequest(BaseModel):
|
|
41
|
+
task_ids: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _filter_by_status(
|
|
45
|
+
tasks: list[TaskWithPosition], status: Status | None
|
|
46
|
+
) -> list[TaskWithPosition]:
|
|
47
|
+
if status is None:
|
|
48
|
+
return tasks
|
|
49
|
+
return [task for task in tasks if task.status == status]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def public_routes(queue: TaskQueue) -> APIRouter:
|
|
53
|
+
"""No authentication is required to access these endpoints."""
|
|
54
|
+
router = APIRouter()
|
|
55
|
+
|
|
56
|
+
@router.get("/")
|
|
57
|
+
def read_root(request: Request):
|
|
58
|
+
base_url = str(request.base_url)
|
|
59
|
+
return (
|
|
60
|
+
f"Welcome to the daq queuing service. Visit {base_url}docs for Uvicorn API."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@router.get("/healthz")
|
|
64
|
+
async def healthz():
|
|
65
|
+
return Response()
|
|
66
|
+
|
|
67
|
+
@router.get("/queue/state")
|
|
68
|
+
def get_queue_state() -> QueueState:
|
|
69
|
+
return queue.state
|
|
70
|
+
|
|
71
|
+
return router
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def protected_routes(
|
|
75
|
+
queue: TaskQueue,
|
|
76
|
+
broadcaster: Broadcaster[QUEUE_EVENTS],
|
|
77
|
+
config: AppConfig,
|
|
78
|
+
converter: Converter,
|
|
79
|
+
) -> APIRouter:
|
|
80
|
+
"""Authentication is required to access these endpoints (if turned on in config).
|
|
81
|
+
Additionally, for endpoints that depend on whitelist_check, you must be in the
|
|
82
|
+
whitelist of authorised fedIDs to access them.
|
|
83
|
+
"""
|
|
84
|
+
router = APIRouter()
|
|
85
|
+
|
|
86
|
+
@router.get("/config")
|
|
87
|
+
def get_config() -> AppConfig:
|
|
88
|
+
return config
|
|
89
|
+
|
|
90
|
+
@router.patch("/queue/state")
|
|
91
|
+
async def update_queue_state(payload: QueueStateUpdate) -> QueueState:
|
|
92
|
+
if payload.paused:
|
|
93
|
+
return await queue.pause_queue(PauseReason.USER_REQUESTED)
|
|
94
|
+
else:
|
|
95
|
+
return await queue.resume_queue()
|
|
96
|
+
|
|
97
|
+
@router.get("/queue")
|
|
98
|
+
async def get_queued_tasks(status: Status | None = None) -> list[TaskWithPosition]:
|
|
99
|
+
return _filter_by_status(await queue.get_queue(), status)
|
|
100
|
+
|
|
101
|
+
@router.post("/queue")
|
|
102
|
+
async def add_tasks_to_queue(
|
|
103
|
+
experiments: list[TaskRequest | Experiment],
|
|
104
|
+
user: CurrentUser,
|
|
105
|
+
position: int | None = None,
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
try:
|
|
108
|
+
converter.validate(experiments)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise ValidateError(*e.args) from e
|
|
111
|
+
|
|
112
|
+
tasks = [Task(experiment=experiment, user=user) for experiment in experiments]
|
|
113
|
+
task_ids = [task.id for task in tasks]
|
|
114
|
+
await queue.add_tasks(tasks, position)
|
|
115
|
+
return task_ids
|
|
116
|
+
|
|
117
|
+
@router.delete("/queue")
|
|
118
|
+
async def cancel_all_tasks() -> list[TaskWithPosition]:
|
|
119
|
+
return await queue.cancel_all_tasks()
|
|
120
|
+
|
|
121
|
+
@router.post("/queue/move")
|
|
122
|
+
async def move_task(task_id: str, new_position: int) -> int:
|
|
123
|
+
return await queue.move_task(task_id, new_position)
|
|
124
|
+
|
|
125
|
+
@router.delete("/queue/tasks")
|
|
126
|
+
async def cancel_tasks(payload: TaskCancelRequest) -> list[TaskWithPosition]:
|
|
127
|
+
return await queue.cancel_tasks(payload.task_ids)
|
|
128
|
+
|
|
129
|
+
@router.get("/queue/{position}")
|
|
130
|
+
async def get_task_by_position(position: int) -> TaskWithPosition | None:
|
|
131
|
+
return await queue.get_task_by_position(position)
|
|
132
|
+
|
|
133
|
+
@router.get("/tasks")
|
|
134
|
+
async def get_all_tasks(status: Status | None = None) -> list[TaskWithPosition]:
|
|
135
|
+
return _filter_by_status(await queue.get_tasks(), status)
|
|
136
|
+
|
|
137
|
+
@router.get("/tasks/{task_id}")
|
|
138
|
+
async def get_task_by_id(task_id: str) -> TaskWithPosition:
|
|
139
|
+
return await queue.get_task_by_id(task_id)
|
|
140
|
+
|
|
141
|
+
@router.get("/history")
|
|
142
|
+
async def get_completed_tasks(
|
|
143
|
+
status: Status | None = None,
|
|
144
|
+
) -> list[TaskWithPosition]:
|
|
145
|
+
return _filter_by_status(await queue.get_history(), status)
|
|
146
|
+
|
|
147
|
+
@router.delete("/history")
|
|
148
|
+
async def clear_history():
|
|
149
|
+
return await queue.clear_history()
|
|
150
|
+
|
|
151
|
+
@router.get("/call_queue")
|
|
152
|
+
async def get_call_queue() -> list[BlueapiCallResponse]:
|
|
153
|
+
return await queue.get_call_queue()
|
|
154
|
+
|
|
155
|
+
@router.get("/call_history")
|
|
156
|
+
async def get_call_history() -> list[BlueapiCallResponse]:
|
|
157
|
+
return await queue.get_call_history()
|
|
158
|
+
|
|
159
|
+
@router.get("/events")
|
|
160
|
+
async def stream_events() -> EventSourceResponse:
|
|
161
|
+
subscriber = broadcaster.subscribe()
|
|
162
|
+
|
|
163
|
+
async def event_generator() -> AsyncGenerator[str, None]:
|
|
164
|
+
try:
|
|
165
|
+
while True:
|
|
166
|
+
event = await subscriber.get()
|
|
167
|
+
event_str = (
|
|
168
|
+
f"event: {event['type']}\ndata: {json.dumps(event['data'])}\n\n"
|
|
169
|
+
)
|
|
170
|
+
yield event_str
|
|
171
|
+
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
# Client disconnected
|
|
174
|
+
raise
|
|
175
|
+
finally:
|
|
176
|
+
broadcaster.unsubscribe(subscriber)
|
|
177
|
+
|
|
178
|
+
return EventSourceResponse(event_generator())
|
|
179
|
+
|
|
180
|
+
return router
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
from daq_queuing_service.log import LOGGER
|
|
8
|
+
from daq_queuing_service.plugins.converter import ConverterError, ValidateError
|
|
9
|
+
from daq_queuing_service.task_queue.queue_utils import (
|
|
10
|
+
NegativePositionError,
|
|
11
|
+
QueueError,
|
|
12
|
+
TaskInProgressError,
|
|
13
|
+
TaskNotFoundError,
|
|
14
|
+
TaskNotInQueueError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# pyright: reportUnusedFunction=false
|
|
18
|
+
|
|
19
|
+
E = TypeVar("E", bound=Exception)
|
|
20
|
+
|
|
21
|
+
Handler = Callable[[Request, E], Awaitable[JSONResponse]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_exception_handler(
|
|
25
|
+
status_code: int, error_code: str
|
|
26
|
+
) -> Callable[[Request, Exception], Awaitable[JSONResponse]]:
|
|
27
|
+
async def handler(request: Request, exception: Exception):
|
|
28
|
+
LOGGER.exception("Error while handling request: %s", request)
|
|
29
|
+
return JSONResponse(
|
|
30
|
+
status_code=status_code,
|
|
31
|
+
content={"error": error_code, "message": str(exception)},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return handler
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_exception_handlers(app: FastAPI):
|
|
38
|
+
app.add_exception_handler(
|
|
39
|
+
TaskInProgressError,
|
|
40
|
+
make_exception_handler(409, "task_in_progress"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
app.add_exception_handler(
|
|
44
|
+
TaskNotFoundError,
|
|
45
|
+
make_exception_handler(404, "task_not_found"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
app.add_exception_handler(
|
|
49
|
+
TaskNotInQueueError,
|
|
50
|
+
make_exception_handler(409, "task_not_in_queue"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
app.add_exception_handler(
|
|
54
|
+
NegativePositionError,
|
|
55
|
+
make_exception_handler(400, "negative_position"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
app.add_exception_handler(
|
|
59
|
+
QueueError,
|
|
60
|
+
make_exception_handler(409, "queue_error"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
app.add_exception_handler(
|
|
64
|
+
ValidateError,
|
|
65
|
+
make_exception_handler(422, "validation_error"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
app.add_exception_handler(
|
|
69
|
+
ConverterError,
|
|
70
|
+
make_exception_handler(422, "converter_error"),
|
|
71
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from blueapi.config import OIDCConfig, RestConfig, StompConfig
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
CONFIG_PATH = "/etc/config/config.yaml"
|
|
9
|
+
TEST_CONFIG_PATH = "tests/test_data/test_config.yaml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConverterConfig(BaseModel):
|
|
13
|
+
path: str
|
|
14
|
+
name: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BlueapiConfig(BaseModel):
|
|
18
|
+
stomp: StompConfig = Field(default_factory=StompConfig)
|
|
19
|
+
api: RestConfig = Field(default_factory=RestConfig)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppConfig(BaseModel):
|
|
23
|
+
blueapi: BlueapiConfig
|
|
24
|
+
converter: ConverterConfig
|
|
25
|
+
oidc: OIDCConfig | None = None
|
|
26
|
+
authorisation_whitelist: list[str] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_default_config_path() -> str:
|
|
30
|
+
return CONFIG_PATH if os.path.isfile(CONFIG_PATH) else TEST_CONFIG_PATH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(config_path: Path) -> AppConfig:
|
|
34
|
+
with open(config_path) as f:
|
|
35
|
+
data = yaml.safe_load(f)
|
|
36
|
+
return AppConfig(**data)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
+
from fastapi.param_functions import Depends
|
|
10
|
+
from fastapi.params import Depends as DependsType
|
|
11
|
+
|
|
12
|
+
from daq_queuing_service.api.api import protected_routes, public_routes
|
|
13
|
+
from daq_queuing_service.api.errors import register_exception_handlers
|
|
14
|
+
from daq_queuing_service.app.authentication import (
|
|
15
|
+
build_access_token_check,
|
|
16
|
+
build_get_current_user,
|
|
17
|
+
)
|
|
18
|
+
from daq_queuing_service.app.authorisation import (
|
|
19
|
+
build_ensure_current_user_is_in_whitelist,
|
|
20
|
+
)
|
|
21
|
+
from daq_queuing_service.blueapi_interaction.blueapi_adapter import BlueapiClientAdapter
|
|
22
|
+
from daq_queuing_service.blueapi_interaction.get_client import get_blueapi_client
|
|
23
|
+
from daq_queuing_service.broadcaster import Broadcaster
|
|
24
|
+
from daq_queuing_service.plugins.converter import get_converter
|
|
25
|
+
from daq_queuing_service.task_queue.queue import QUEUE_EVENTS, TaskQueue
|
|
26
|
+
from daq_queuing_service.worker.worker import QueueWorker
|
|
27
|
+
|
|
28
|
+
from ._config import load_config
|
|
29
|
+
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_app(config_path: Path, dev: bool = False) -> FastAPI:
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(app: FastAPI):
|
|
38
|
+
worker_task = asyncio.create_task(app.state.worker.run_loop())
|
|
39
|
+
app.state.worker_task = worker_task
|
|
40
|
+
|
|
41
|
+
def log_task_exception(task: asyncio.Task[NoReturn]):
|
|
42
|
+
try:
|
|
43
|
+
exc = task.exception()
|
|
44
|
+
if exc:
|
|
45
|
+
logging.error("Worker crashed", exc_info=exc)
|
|
46
|
+
except asyncio.CancelledError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
worker_task.add_done_callback(log_task_exception)
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
worker_task.cancel()
|
|
54
|
+
await asyncio.gather(worker_task, return_exceptions=True)
|
|
55
|
+
|
|
56
|
+
config = load_config(config_path)
|
|
57
|
+
|
|
58
|
+
broadcaster: Broadcaster[QUEUE_EVENTS] = Broadcaster()
|
|
59
|
+
|
|
60
|
+
converter_path = config.converter.path
|
|
61
|
+
converter_name = config.converter.name
|
|
62
|
+
converter = get_converter(converter_path, converter_name)
|
|
63
|
+
|
|
64
|
+
app = FastAPI(lifespan=lifespan)
|
|
65
|
+
|
|
66
|
+
dependencies: list[DependsType] = []
|
|
67
|
+
whitelist_check = None
|
|
68
|
+
if config.oidc:
|
|
69
|
+
validate_token = build_access_token_check(config.oidc)
|
|
70
|
+
get_current_user = build_get_current_user(validate_token)
|
|
71
|
+
|
|
72
|
+
app.swagger_ui_init_oauth = {
|
|
73
|
+
"clientId": "NOT_SUPPORTED",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
whitelist_check = build_ensure_current_user_is_in_whitelist(
|
|
77
|
+
config.authorisation_whitelist, get_current_user
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
dependencies.append(Depends(get_current_user))
|
|
81
|
+
dependencies.append(Depends(whitelist_check))
|
|
82
|
+
|
|
83
|
+
if dev: # Allows local client/UI through CORS
|
|
84
|
+
app.add_middleware(
|
|
85
|
+
CORSMiddleware,
|
|
86
|
+
allow_origin_regex=r"http://(localhost|127\.0\.0\.1):\d+",
|
|
87
|
+
allow_credentials=True,
|
|
88
|
+
allow_methods=["*"],
|
|
89
|
+
allow_headers=["*"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
app.state.queue = TaskQueue(converter, broadcaster)
|
|
93
|
+
|
|
94
|
+
blueapi_client = get_blueapi_client(config.blueapi)
|
|
95
|
+
blueapi_client_adapter = BlueapiClientAdapter(blueapi_client)
|
|
96
|
+
|
|
97
|
+
app.state.worker = QueueWorker(
|
|
98
|
+
queue=app.state.queue,
|
|
99
|
+
blueapi_client=blueapi_client_adapter,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
register_exception_handlers(app)
|
|
103
|
+
app.include_router(public_routes(app.state.queue))
|
|
104
|
+
app.include_router(
|
|
105
|
+
protected_routes(app.state.queue, broadcaster, config, converter),
|
|
106
|
+
dependencies=dependencies,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return app
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
from blueapi.config import OIDCConfig
|
|
6
|
+
from fastapi import Depends, HTTPException, Request
|
|
7
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
8
|
+
from jwt.exceptions import DecodeError, ExpiredSignatureError
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
11
|
+
|
|
12
|
+
from daq_queuing_service.log import LOGGER
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class User(BaseModel):
|
|
16
|
+
fedid: str
|
|
17
|
+
email: str | None = None
|
|
18
|
+
name: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Some of the following code was copied from blueapi
|
|
22
|
+
# See https://github.com/DiamondLightSource/blueapi/blob/2108ee0c89b4399d961106f7f23082a58d48a564/src/blueapi/service/authentication.py#L281-L340
|
|
23
|
+
|
|
24
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unchecked_bearer_token(
|
|
28
|
+
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
|
|
29
|
+
) -> str | None:
|
|
30
|
+
if credentials is None:
|
|
31
|
+
return None
|
|
32
|
+
return credentials.credentials
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
UncheckedBearerToken = Annotated[str | None, Depends(unchecked_bearer_token)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_access_token_check(
|
|
39
|
+
config: OIDCConfig,
|
|
40
|
+
) -> Callable[[UncheckedBearerToken], dict[str, Any]]:
|
|
41
|
+
"""
|
|
42
|
+
Create a function to validate the bearer token of requests
|
|
43
|
+
|
|
44
|
+
The returned function should be used via fastAPI's 'Depends' mechanism to
|
|
45
|
+
ensure users are authenticated
|
|
46
|
+
"""
|
|
47
|
+
LOGGER.info(f"JWKS URI: {config.jwks_uri}")
|
|
48
|
+
jwkclient = jwt.PyJWKClient(config.jwks_uri)
|
|
49
|
+
|
|
50
|
+
def validate_bearer_token(token: UncheckedBearerToken):
|
|
51
|
+
"""Check that a bearer token is valid and inject into request state"""
|
|
52
|
+
if not token:
|
|
53
|
+
raise HTTPException(
|
|
54
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
55
|
+
detail="Not authenticated",
|
|
56
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
signing_key = jwkclient.get_signing_key_from_jwt(token)
|
|
61
|
+
except DecodeError as e:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
64
|
+
detail="Cannot decode token",
|
|
65
|
+
) from e
|
|
66
|
+
except ExpiredSignatureError as e:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
69
|
+
detail="Token expired",
|
|
70
|
+
) from e
|
|
71
|
+
|
|
72
|
+
decoded: dict[str, Any] = jwt.decode(
|
|
73
|
+
token,
|
|
74
|
+
signing_key.key,
|
|
75
|
+
algorithms=config.id_token_signing_alg_values_supported,
|
|
76
|
+
verify=True,
|
|
77
|
+
audience=config.client_audience,
|
|
78
|
+
issuer=config.issuer,
|
|
79
|
+
)
|
|
80
|
+
LOGGER.debug(f"Decoded valid token: {decoded}")
|
|
81
|
+
return decoded
|
|
82
|
+
|
|
83
|
+
return validate_bearer_token
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_get_current_user(
|
|
87
|
+
validate_token: Callable[..., dict[str, Any]],
|
|
88
|
+
) -> Callable[[Request, dict[str, Any]], User]:
|
|
89
|
+
def get_current_user(
|
|
90
|
+
request: Request,
|
|
91
|
+
decoded: Annotated[dict[str, Any], Depends(validate_token)],
|
|
92
|
+
) -> User:
|
|
93
|
+
try:
|
|
94
|
+
user = User.model_validate(decoded)
|
|
95
|
+
except ValidationError as e:
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
98
|
+
detail="Invalid token claims",
|
|
99
|
+
) from e
|
|
100
|
+
request.state.user = user
|
|
101
|
+
return user
|
|
102
|
+
|
|
103
|
+
return get_current_user
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, HTTPException
|
|
5
|
+
from starlette.status import HTTP_403_FORBIDDEN
|
|
6
|
+
|
|
7
|
+
from daq_queuing_service.app.authentication import User
|
|
8
|
+
from daq_queuing_service.worker.worker import LOGGER
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_ensure_current_user_is_in_whitelist(
|
|
12
|
+
whitelist: list[str] | None, get_current_user: Callable[..., User]
|
|
13
|
+
) -> Callable[[User], User]:
|
|
14
|
+
def ensure_current_user_is_in_whitelist(
|
|
15
|
+
current_user: Annotated[User, Depends(get_current_user)],
|
|
16
|
+
) -> User:
|
|
17
|
+
LOGGER.debug(f"Got user: {current_user}")
|
|
18
|
+
if whitelist is None:
|
|
19
|
+
LOGGER.debug("No user whitelist. All authenticated users are authorised.")
|
|
20
|
+
return current_user
|
|
21
|
+
elif current_user.fedid in whitelist:
|
|
22
|
+
LOGGER.debug(
|
|
23
|
+
f"FedID {current_user.fedid} found in whitelist, user authorised."
|
|
24
|
+
)
|
|
25
|
+
return current_user
|
|
26
|
+
raise HTTPException(
|
|
27
|
+
status_code=HTTP_403_FORBIDDEN,
|
|
28
|
+
detail="Not authorised. You are not in the whitelist of authorised FedIDs",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return ensure_current_user_is_in_whitelist
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from blueapi.client import BlueapiClient
|
|
6
|
+
from blueapi.client.event_bus import OnAnyEvent
|
|
7
|
+
from blueapi.client.rest import (
|
|
8
|
+
BlueskyRemoteControlError,
|
|
9
|
+
InvalidParametersError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
UnknownPlanError,
|
|
12
|
+
)
|
|
13
|
+
from blueapi.service.model import TaskRequest
|
|
14
|
+
from blueapi.worker import TaskStatus, WorkerState
|
|
15
|
+
|
|
16
|
+
from daq_queuing_service.log import LOGGER
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
E = TypeVar("E", bound=Exception)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class BlueapiResult(Generic[T, E]):
|
|
24
|
+
value: T | None = None
|
|
25
|
+
error: E | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if (self.value is None) == (self.error is None):
|
|
29
|
+
raise ValueError("Exactly one of value or error must be set")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BlueapiClientAdapter:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
client: BlueapiClient,
|
|
36
|
+
):
|
|
37
|
+
self._client = client
|
|
38
|
+
|
|
39
|
+
async def get_state(self) -> BlueapiResult[WorkerState, ServiceUnavailableError]:
|
|
40
|
+
try:
|
|
41
|
+
state = await asyncio.to_thread(self._client.get_state)
|
|
42
|
+
return BlueapiResult(value=state)
|
|
43
|
+
except ServiceUnavailableError as e:
|
|
44
|
+
LOGGER.error(f"Lost connection to blueapi: {e}")
|
|
45
|
+
return BlueapiResult(error=e)
|
|
46
|
+
|
|
47
|
+
async def run_task(
|
|
48
|
+
self,
|
|
49
|
+
task_request: TaskRequest,
|
|
50
|
+
on_event: OnAnyEvent | None = None,
|
|
51
|
+
) -> BlueapiResult[
|
|
52
|
+
TaskStatus,
|
|
53
|
+
BlueskyRemoteControlError
|
|
54
|
+
| InvalidParametersError
|
|
55
|
+
| UnknownPlanError
|
|
56
|
+
| ServiceUnavailableError,
|
|
57
|
+
]:
|
|
58
|
+
try:
|
|
59
|
+
task_status = await asyncio.to_thread(
|
|
60
|
+
self._client.run_task, task_request, on_event=on_event
|
|
61
|
+
)
|
|
62
|
+
return BlueapiResult(value=task_status)
|
|
63
|
+
except (
|
|
64
|
+
BlueskyRemoteControlError,
|
|
65
|
+
InvalidParametersError,
|
|
66
|
+
UnknownPlanError,
|
|
67
|
+
ServiceUnavailableError,
|
|
68
|
+
) as e:
|
|
69
|
+
LOGGER.exception(e)
|
|
70
|
+
return BlueapiResult(error=e)
|