Skip to content

Commit 68e4b86

Browse files
ZohebShaikhclaude
andauthored
feat: update tiled node insertion structure (#1669)
<sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f9af725 commit 68e4b86

14 files changed

Lines changed: 298 additions & 805 deletions

File tree

‎pyproject.toml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ dev = [
7676
"mock",
7777
"jwcrypto",
7878
"deepdiff",
79-
"tiled[minimal-server]>=0.2.4", # For system-test of dls.py
8079
"respx",
8180
"pytest-xdist[psutil]",
8281
"httpx2>=2.5.0",

‎src/blueapi/service/authorization.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async def require_submit_task(self, instrument_session: str, token: str):
6969
self._config.submit_task_check,
7070
{
7171
"token": token,
72-
"proposal": int(match["proposal"]),
72+
"proposal": int(match["proposal_number"]),
7373
"visit": int(match["visit"]),
7474
},
7575
):

‎src/blueapi/service/interface.py‎

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
from collections.abc import Mapping
34
from dataclasses import dataclass
@@ -8,7 +9,9 @@
89
from bluesky.callbacks.tiled_writer import TiledWriter
910
from bluesky_stomp.messaging import StompClient
1011
from bluesky_stomp.models import Broker, DestinationBase, MessageTopic
12+
from fastapi import status
1113
from tiled.client import from_uri
14+
from tiled.client.utils import ClientError
1215

1316
from blueapi.cli.scratch import get_python_environment
1417
from blueapi.config import ApplicationConfig, OIDCConfig, ServiceAccount, StompConfig
@@ -103,13 +106,60 @@ def setup(config: ApplicationConfig) -> None:
103106
stomp_client()
104107

105108

109+
@cache
110+
def service_account_tiled_client():
111+
"""One client for every task: its auth refreshes its own token, so unlike a
112+
client carrying a caller's pass-through headers it can be reused, which saves
113+
the server-info and token requests from_uri makes on every task."""
114+
tiled_config = context().tiled_conf
115+
assert tiled_config is not None and isinstance(
116+
tiled_config.authentication, ServiceAccount
117+
)
118+
return from_uri(
119+
str(tiled_config.url), auth=TiledAuth(tiled_auth=tiled_config.authentication)
120+
)
121+
122+
123+
def _tiled_session_container(tiled_client, instrument: str, instrument_session: str):
124+
"""The beamline/proposal/visit container runs are written under.
125+
126+
It almost always exists already, so it is looked up in one request; only the
127+
first task for a visit walks the levels and creates what is missing.
128+
"""
129+
session_blob = access_blob(instrument_session, instrument)
130+
proposal = session_blob["proposal"]
131+
try:
132+
return tiled_client[f"{instrument}/{proposal}/{instrument_session}"]
133+
except KeyError:
134+
pass
135+
# Each level's access blob is the prefix of the full (beamline, proposal,
136+
# visit) one, matching the beamline/proposal/session tiers the tiled access
137+
# policy expects a container to be tagged with.
138+
level_access_blobs = {
139+
instrument: {"beamline": instrument},
140+
proposal: {"beamline": instrument, "proposal": proposal},
141+
instrument_session: session_blob,
142+
}
143+
for key, blob in level_access_blobs.items():
144+
try:
145+
tiled_client = tiled_client.create_container(
146+
key=key, access_tags=[json.dumps(blob)]
147+
)
148+
except ClientError as e:
149+
if e.response.status_code != status.HTTP_409_CONFLICT:
150+
raise
151+
tiled_client = tiled_client[key] # created already, e.g. by another task
152+
return tiled_client
153+
154+
106155
def teardown() -> None:
107156
worker().stop()
108157
if (stomp_client_ref := stomp_client()) is not None:
109158
stomp_client_ref.disconnect()
110159
context.cache_clear()
111160
worker.cache_clear()
112161
stomp_client.cache_clear()
162+
service_account_tiled_client.cache_clear()
113163

114164

115165
def _publish_event_streams(
@@ -166,7 +216,7 @@ def submit_task(
166216
# We raise an InvalidConfigError on setting tiled_conf if this isn't set
167217
assert md
168218
metadata["tiled_access_tags"] = [
169-
access_blob(task_request.instrument_session, md.instrument)
219+
json.dumps(access_blob(task_request.instrument_session, md.instrument))
170220
]
171221
task = Task(
172222
name=task_request.name,
@@ -191,21 +241,26 @@ def begin_task(
191241
if nt := active_context.numtracker:
192242
nt.set_headers(pass_through_headers or {})
193243

244+
if task.task_id is None:
245+
return task
246+
194247
subscribers = []
195248
if tiled_config := active_context.tiled_conf:
196249
# Tiled queries the root node, so must create an authorized client
197250
if isinstance(tiled_config.authentication, ServiceAccount):
198-
tiled_client = from_uri(
199-
str(tiled_config.url),
200-
auth=TiledAuth(tiled_auth=tiled_config.authentication),
201-
)
251+
tiled_client = service_account_tiled_client()
202252
else:
203253
tiled_client = from_uri(
204254
str(tiled_config.url),
205255
api_key=tiled_config.authentication,
206256
headers=pass_through_headers,
207257
)
208-
258+
if (task_ := get_task_by_id(task_id=task.task_id)) is not None:
259+
tiled_client = _tiled_session_container(
260+
tiled_client,
261+
active_context.run_engine.md["instrument"],
262+
task_.task.metadata["instrument_session"],
263+
)
209264
tiled_writer_token = active_context.run_engine.subscribe(
210265
TiledWriter(tiled_client, batch_size=1)
211266
)
@@ -227,13 +282,12 @@ def remove_callback_when_task_finished(
227282
)
228283
subscribers.append((active_worker.worker_events, remove_callback))
229284

230-
if task.task_id is not None:
231-
try:
232-
active_worker.begin_task(task.task_id)
233-
except:
234-
for channel, token in subscribers:
235-
channel.unsubscribe(token)
236-
raise
285+
try:
286+
active_worker.begin_task(task.task_id)
287+
except Exception:
288+
for channel, token in subscribers:
289+
channel.unsubscribe(token)
290+
raise
237291
return task
238292

239293

‎src/blueapi/utils/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
Args = ParamSpec("Args")
3232
Return = TypeVar("Return")
3333

34-
INSTRUMENT_SESSION_RE = re.compile(r"^[a-z]{2}(?P<proposal>\d+)-(?P<visit>\d+)$")
34+
# e.g. "cm12345-1" -> proposal="cm12345", code="cm", proposal_number="12345", visit="1"
35+
INSTRUMENT_SESSION_RE = re.compile(
36+
r"^(?P<proposal>(?P<code>[a-z]{2})(?P<proposal_number>\d+))-(?P<visit>\d+)$"
37+
)
3538

3639

3740
def report_successful_devices(

‎src/blueapi/utils/serialization.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import json
21
from typing import Any
32

43
from pydantic import BaseModel
@@ -29,16 +28,16 @@ def serialize(obj: Any) -> Any:
2928
return obj
3029

3130

32-
def access_blob(instrument_session: str, beamline: str) -> str:
33-
m = utils.INSTRUMENT_SESSION_RE.match(instrument_session)
34-
if m is None:
31+
def access_blob(instrument_session: str, beamline: str) -> dict[str, Any]:
32+
if (match := utils.INSTRUMENT_SESSION_RE.match(instrument_session)) is None:
3533
raise ValueError(
3634
"Unable to extract proposal and visit from "
3735
f"instrument session {instrument_session}"
3836
)
39-
blob = {
40-
"proposal": int(m["proposal"]),
41-
"visit": int(m["visit"]),
37+
return {
38+
# The full proposal code (e.g. "cm12345"), not just its number - the
39+
# tiled access policy strips the letters itself where it needs them.
40+
"proposal": match["proposal"],
41+
"visit": int(match["visit"]),
4242
"beamline": beamline,
4343
}
44-
return json.dumps(blob)

‎tests/system_tests/compose.yaml‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,9 @@ services:
6565
- label=disable
6666

6767
tiled:
68-
image: ghcr.io/bluesky/tiled:0.2.18
68+
image: ghcr.io/diamondlightsource/dls-tiled:0.5.0
6969
network_mode: host # Port 8407
7070
environment:
71-
- PYTHONPATH=/deploy/
7271
- VITE_LOGIN_REDIRECT=false
7372
volumes:
7473
- ./services/tiled_config:/deploy/config
@@ -80,7 +79,7 @@ services:
8079
- label=disable
8180

8281
opa:
83-
image: openpolicyagent/opa:edge-static-debug
82+
image: openpolicyagent/opa:1.21.2-static-debug # debug: entrypoint.sh needs a shell
8483
network_mode: host # Port 8181
8584
volumes:
8685
- "./services/opa_config:/mnt"

‎tests/system_tests/services/opa_config/config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ services:
55
bundles:
66
diamond-policies:
77
service: ghcr
8-
resource: ghcr.io/diamondlightsource/authz-policy:0.0.24
8+
resource: ghcr.io/diamondlightsource/authz-policy:0.0.25
99
polling:
1010
min_delay_seconds: 30
1111
max_delay_seconds: 120

‎tests/system_tests/services/tiled_config/__init__.py‎

Whitespace-only changes.

‎tests/system_tests/services/tiled_config/config.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ trees:
2020
init_if_not_exists: true
2121

2222
access_control:
23-
access_policy: "config.dls:DiamondOpenPolicyAgentAuthorizationPolicy"
23+
access_policy: "dls_tiled.policy:DiamondOpenPolicyAgentAuthorizationPolicy"
2424
args:
2525
authorization_provider: "http://localhost:8181/v1/data/diamond/policy/"
2626
token_audience: account

‎tests/system_tests/services/tiled_config/dls.py‎

Lines changed: 0 additions & 161 deletions
This file was deleted.

0 commit comments

Comments
 (0)