From 5d0b6fe951c0204692754fdd903c653de66ca658 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Tue, 18 Aug 2026 16:01:45 +0100 Subject: [PATCH 01/31] feat(trust): make the PACS connection configurable (#993) FLIP could only ever talk to the mocked Orthanc: the PACS host, AE title and query/retrieve port were literals in configure-xnat.sh, and XNAT's own AE title was hardcoded in three places that have to agree. Parameterise the whole connection, defaulting to today's mocked values so an unconfigured deployment behaves exactly as before. Verified by diffing the emitted XNAT payloads before and after against a stubbed curl: byte-identical with defaults, apart from the PACS id now being resolved rather than assumed. - configure-xnat.sh: PACS_HOST / PACS_AETITLE / PACS_QR_PORT / PACS_LABEL and XNAT_AETITLE, all joining the existing fail-loud guard block, since an empty value interpolated into XNAT's JSON is the silent failure FLIP#822/#862 fixed. - PACS registration now updates in place when host or port drift. It was check-then-create by AE title, so a kit change was silently ignored on redeploy and DQR kept pointing at the old PACS. - Expose the DQR retry settings and the PACS availability schedule. A production PACS may refuse further associations after a certain volume, and the window and thread count are the throttle for that. - Resolve the PACS id by AE title instead of assuming 1, in both the C-FIND and the C-MOVE, so the query and the retrieval cannot address different registrations. Falls back to the configured id when XNAT is unreachable. - Parse the dicomscp and pacs listings with jq rather than grep/sed, and remove XNAT's stock receiver as well as our own so a re-run replaces rather than duplicates. Split XNAT_PORT, which was doing four jobs. It is now the DICOM SCP receiver port only; XNAT_WEB_PORT is the host-published web UI. That is why host 8104 served Tomcat while the DICOM receiver's 8104 was an unpublished container port, so a real PACS could not have completed the C-STORE leg at all. docker-compose-stack.real-pacs.yml publishes the receiver, opt-in via REAL_PACS=true, with a Makefile guard against the two ports colliding. Docs: add "Connecting to a Trust PACS" to the XNAT component page, covering the pull model, the inbound return leg, a worked Sectra example and the mocked Orthanc used in development. Regenerate the XNAT credentials email figure from the current SES template; the previous one predated the rebrand. Signed-off-by: at24_bioeng625-pc --- docs/source/assets/xnat/credentials_email.png | Bin 55317 -> 256565 bytes docs/source/components/component-xnat.rst | 193 ++++++++++++++++- trust/.env.GSTT.development.example | 32 ++- trust/.env.KCH.development.example | 27 ++- trust/.env.example | 34 ++- trust/deploy/compose_trust.development.yml | 6 +- trust/deploy/compose_trust.production.yml | 6 +- trust/imaging-api/imaging_api/config.py | 11 +- .../imaging_api/routers/schemas.py | 5 +- .../imaging_api/services/imaging.py | 49 ++++- .../tests/services/test_imaging.py | 71 ++++++ trust/xnat/Makefile | 24 +++ trust/xnat/docker-compose-stack.real-pacs.yml | 29 +++ trust/xnat/docker-compose-stack.yml | 21 +- trust/xnat/xnat/config/configure-xnat.sh | 204 +++++++++++------- 15 files changed, 623 insertions(+), 89 deletions(-) create mode 100644 trust/xnat/docker-compose-stack.real-pacs.yml diff --git a/docs/source/assets/xnat/credentials_email.png b/docs/source/assets/xnat/credentials_email.png index f6bd72bff9e8f4f6b20e25b39bd49e75ba12d21a..9069493742d0bf42664818e57b8f175a9ae8f7da 100644 GIT binary patch literal 256565 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

pe9R3oa5jdruq>tUI*m_T1)cu*5 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

@DRDYhv*$2~5=&B&o0OLJugPHjoS64qnEWyPO= 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

uT74|{_@D@*X%Qi4xM6mv1)Yn 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 O0aTT=6wB^eJpCWRyA8bn literal 55317 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 diff --git a/docs/source/components/component-xnat.rst b/docs/source/components/component-xnat.rst index 1fa5f9842..bc4cbba5a 100644 --- a/docs/source/components/component-xnat.rst +++ b/docs/source/components/component-xnat.rst @@ -25,7 +25,7 @@ On approval of a FLIP project, any associated users will be granted access to th :width: 500 :align: center - Email sent with XNAT account credentials. + Email sent with XNAT account credentials (password masked). Access ====== @@ -127,6 +127,197 @@ Imaging data will be automatically imported from trust PACS systems on XNAT proj Information on how to download and upload imaging data the XNAT UI can be found `here `_. +**************************** +Connecting to a Trust PACS +**************************** + +XNAT retrieves imaging from the trust's PACS on demand, for the studies belonging to an approved +project cohort. This section describes what has to be configured, and what the trust's PACS and +network teams need to provide. + +How Retrieval Works +=================== + +FLIP **pulls**; the PACS is never configured to push into XNAT. XNAT's +`DICOM Query-Retrieve (DQR) plugin `_ +performs the retrieval, driven over REST by the imaging API: + +1. The cohort query is re-run against the trust's OMOP database and returns **accession numbers + only**. +2. For each accession number, XNAT issues a **C-FIND** to the PACS at STUDY level, matching on + Accession Number ``(0008,0050)``, to resolve the Study Instance UID. +3. XNAT issues a **C-MOVE** to the PACS, naming itself as the move destination. +4. The PACS **C-STOREs** the study back to XNAT's DICOM SCP receiver, where the site-wide + anonymisation script runs on receipt, before the session is archived. + +Only accession numbers belonging to an approved project cohort are ever requested. There is no +standing forward rule and no bulk transfer. + +.. important:: + + Step 4 is a connection **from the PACS to XNAT**. It is easy to overlook when specifying firewall + rules, because every other FLIP connection is outbound. Without it, queries succeed and + retrievals silently time out. + +What the PACS Team Must Register +================================ + +The FLIP XNAT instance must be registered on the PACS as a DICOM node, permitted to: + +* issue **C-FIND** (Study Root query/retrieve) as an SCU; +* act as a **C-MOVE destination**, so retrieved studies can be returned to it; +* issue and answer **C-ECHO**, for verification in both directions. + +The trust must supply, and FLIP must be configured with, the PACS AE title, host and query/retrieve +port. FLIP in turn supplies its own AE title, host and DICOM port. + +.. note:: + + The AE title FLIP presents is one of many configured on a trust PACS, so it should identify the + platform — for example ``FLIPXNAT``. It must match on both sides: the PACS opens the C-STORE + association using the AE title it has registered, and XNAT's SCP receiver rejects an association + addressed to a different AE title. + +Configuration +============= + +.. list-table:: + :widths: 30 45 25 + :header-rows: 1 + + * - Setting + - Description + - Default + * - ``XNAT_AETITLE`` + - XNAT's own AE title, used for the DICOM SCP receiver, the DQR calling AE, and the C-MOVE + destination + - ``XNAT`` + * - ``XNAT_PORT`` + - DICOM SCP receiver port + - ``8104`` + * - ``PACS_AETITLE`` + - AE title of the trust PACS + - ``ORTHANC`` + * - ``PACS_HOST`` + - Hostname or IP of the trust PACS + - ``orthanc`` + * - ``PACS_QR_PORT`` + - Query/retrieve port on the trust PACS + - ``4242`` + +The defaults describe the mocked PACS that ships with FLIP for development, described below. + +Development: the Mocked PACS +============================ + +FLIP ships an `Orthanc `_ DICOM server that stands in for the trust +PACS during development and testing. It is seeded with synthetic DICOM studies whose accession +numbers match the mocked OMOP database, so a cohort query resolves to real studies and the full +retrieval path can be exercised without a hospital PACS. + +Orthanc is a genuine DICOM node, so it answers C-FIND and C-MOVE exactly as a production PACS would +and returns studies by C-STORE. The retrieval path under test is therefore the same one used against +a trust PACS — only the peer differs. It is why the configuration defaults above name ``orthanc``: + +* ``PACS_HOST=orthanc`` — the container name on the FLIP network +* ``PACS_AETITLE=ORTHANC`` — Orthanc's AE title +* ``PACS_QR_PORT=4242`` — Orthanc's DICOM port + +Because both sit on the same container network, Orthanc reaches XNAT's SCP receiver directly and no +host port needs publishing. A real PACS is outside that network, which is the one material +difference between the two setups and the reason the receiver must be explicitly exposed. + +.. note:: + + The mocked PACS is for development and testing only. In a trust deployment the imaging comes from + the trust's own PACS, and Orthanc is either absent or confined to the FLIP node — its DICOM port + is deliberately not published to the wider network. + +.. warning:: + + The DICOM port must be **the same number everywhere** — the port XNAT binds, the port recorded on + its SCP receiver, the port advertised as the C-MOVE destination, and the port the PACS connects + to. DQR matches the C-MOVE destination against a registered SCP receiver by exact AE title and + port, so any translation between these layers causes retrieval to fail. + +Example: Sectra PACS +==================== + +The values below illustrate the shape of the exchange with a Sectra PACS. **They are examples, not +defaults** — each trust supplies its own. + +Provided by the trust's PACS team: + +.. code-block:: text + + Query/Retrieve node (PACS) + AE Title: QR_SCP_EXAMPLE + IP: 10.0.0.10 + Port: 8059 + +Provided by FLIP, to be registered on the PACS as a destination: + +.. code-block:: text + + Destination node (FLIP XNAT SCP receiver) + AE Title: FLIPXNAT + IP: 10.0.0.20 + Port: 8104 + +Firewall rules required, in both directions: + +.. list-table:: + :widths: 40 20 40 + :header-rows: 1 + + * - Connection + - Direction + - Purpose + * - XNAT host → PACS query/retrieve port + - Outbound + - C-ECHO, C-FIND, C-MOVE requests + * - PACS → XNAT host DICOM port + - **Inbound** + - C-STORE of the retrieved studies + +.. important:: + + Where the PACS is vendor-managed, opening the trust's own firewall may not be sufficient. The + vendor may operate a separate firewall that must also whitelist the connection, raised through + the trust's service desk as a request to the PACS supplier. + +Scheduling and Throughput +========================= + +A production PACS may limit how much can be retrieved before it refuses further connections, and +bulk retrieval competes with clinical use. Agree a retrieval schedule with the trust's PACS manager, +then configure XNAT to match: the DQR settings control retry behaviour, and each registered PACS +carries an availability schedule with a per-day window, a thread count and a utilisation percentage. + +Where a trust has a test or pre-production PACS, connecting FLIP to that first is recommended, and +is usually raised as a separate service request. + +Verification +============ + +Work outwards from the network layer: + +1. **C-ECHO in both directions** — from the PACS to the FLIP XNAT AE title and port, and from XNAT + to the PACS. This confirms both firewall directions before any DICOM data moves. +2. **Ping the PACS from XNAT** — ``GET /xapi/pacs/{id}/status`` should report the PACS as reachable + and enabled. +3. **Query a single accession number** — ``POST /xapi/dqr/query/studies`` should return the matching + study. +4. **Import a single study**, and confirm it archives into the expected project with anonymisation + applied. +5. **Run a full project import**, monitoring the import status counts. + +.. note:: + + XNAT must be restarted for changes to its DICOM configuration to take effect. XNAT also holds the + DICOM port while running, so command-line testing with a tool such as ``storescp`` on the same + port requires stopping XNAT first. + **************************** DICOM Anonymization **************************** diff --git a/trust/.env.GSTT.development.example b/trust/.env.GSTT.development.example index 02adfea86..8baa25df9 100644 --- a/trust/.env.GSTT.development.example +++ b/trust/.env.GSTT.development.example @@ -12,7 +12,12 @@ TRUST_REGION=London # ── Host-local profile ──────────────────────────────────────────────────── OMOP_DB_PORT=5434 PACS_UI_PORT=8042 +# XNAT_PORT is the DICOM SCP receiver port; XNAT_WEB_PORT is the host-published web UI. They were +# one variable until FLIP#993, which is why host 8104 served Tomcat while the DICOM receiver's 8104 +# was an unpublished container port. Keep them equal unless connecting a real PACS: publishing the +# receiver (REAL_PACS=true) needs two distinct host ports. XNAT_PORT=8104 +XNAT_WEB_PORT=8104 TRUST_DEBUG_PORT=5682 IMAGING_DEBUG_PORT=5681 DATA_ACCESS_DEBUG_PORT=5680 @@ -71,7 +76,32 @@ TRUST_INTERNAL_SERVICE_KEY_HEADER=X-Trust-Internal-Service-Key GRAFANA_ADMIN_PASSWORD=admin -PACS_DICOM_PORT=4242 +# ── Upstream PACS ───────────────────────────────────────────────────────── +# Defaults describe the mocked Orthanc that ships for development. A real trust points these at its +# PACS; see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". +# +# XNAT_AETITLE is XNAT's own AE title, applied to its DICOM SCP receiver, the DQR calling AE, and the +# C-MOVE destination handed to the PACS. It must match the AE title the PACS has registered for us, +# because the PACS opens the C-STORE association addressed to that title. +# +# PACS_QR_PORT is the port XNAT dials, and must be reachable *from the XNAT container* — not a +# host-published port. Getting that wrong is what the retired PACS_DICOM_PORT variable did +# (FLIP#822 / FLIP#862). (PACS_ID is resolved by AE title at configuration time; not a kit field.) +XNAT_AETITLE=XNAT +PACS_HOST=orthanc +PACS_AETITLE=ORTHANC +PACS_QR_PORT=4242 +PACS_LABEL=Test PACS instance + +# PACS throttle. A production PACS may refuse further associations after a certain volume, and a +# trust may want retrieval confined to out-of-hours. Agree the window with the PACS manager. +PACS_AVAILABILITY_DAYS=MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY +PACS_AVAILABILITY_START=00:00 +PACS_AVAILABILITY_END=24:00 +PACS_THREADS=1 +PACS_UTILIZATION_PERCENT=100 +DQR_MAX_PACS_REQUEST_ATTEMPTS=100 +DQR_RETRY_WAIT_SECONDS=300 POLL_INTERVAL_SECONDS=5 diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index 1c8753d67..7fdb28694 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -71,7 +71,32 @@ TRUST_INTERNAL_SERVICE_KEY_HEADER=X-Trust-Internal-Service-Key GRAFANA_ADMIN_PASSWORD=admin -PACS_DICOM_PORT=4242 +# ── Upstream PACS ───────────────────────────────────────────────────────── +# Defaults describe the mocked Orthanc that ships for development. A real trust points these at its +# PACS; see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". +# +# XNAT_AETITLE is XNAT's own AE title, applied to its DICOM SCP receiver, the DQR calling AE, and the +# C-MOVE destination handed to the PACS. It must match the AE title the PACS has registered for us, +# because the PACS opens the C-STORE association addressed to that title. +# +# PACS_QR_PORT is the port XNAT dials, and must be reachable *from the XNAT container* — not a +# host-published port. Getting that wrong is what the retired PACS_DICOM_PORT variable did +# (FLIP#822 / FLIP#862). (PACS_ID is resolved by AE title at configuration time; not a kit field.) +XNAT_AETITLE=XNAT +PACS_HOST=orthanc +PACS_AETITLE=ORTHANC +PACS_QR_PORT=4242 +PACS_LABEL=Test PACS instance + +# PACS throttle. A production PACS may refuse further associations after a certain volume, and a +# trust may want retrieval confined to out-of-hours. Agree the window with the PACS manager. +PACS_AVAILABILITY_DAYS=MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY +PACS_AVAILABILITY_START=00:00 +PACS_AVAILABILITY_END=24:00 +PACS_THREADS=1 +PACS_UTILIZATION_PERCENT=100 +DQR_MAX_PACS_REQUEST_ATTEMPTS=100 +DQR_RETRY_WAIT_SECONDS=300 POLL_INTERVAL_SECONDS=5 diff --git a/trust/.env.example b/trust/.env.example index 771fedaf1..90511ac73 100644 --- a/trust/.env.example +++ b/trust/.env.example @@ -22,7 +22,12 @@ # ── Host-local profile ──────────────────────────────────────────────────── OMOP_DB_PORT=5434 PACS_UI_PORT=8042 +# XNAT_PORT is the DICOM SCP receiver port; XNAT_WEB_PORT is the host-published web UI. They were +# one variable until FLIP#993, which is why host 8104 served Tomcat while the DICOM receiver's 8104 +# was an unpublished container port. Keep them equal unless connecting a real PACS: publishing the +# receiver (REAL_PACS=true) needs two distinct host ports. XNAT_PORT=8104 +XNAT_WEB_PORT=8104 TRUST_DEBUG_PORT=5682 IMAGING_DEBUG_PORT=5681 DATA_ACCESS_DEBUG_PORT=5680 @@ -101,9 +106,32 @@ TRUST_INTERNAL_SERVICE_KEY_HEADER=X-Trust-Internal-Service-Key # Grafana GRAFANA_ADMIN_PASSWORD=admin -# PACS DICOM listening port. (PACS_ID — the id of the single XNAT-registered -# PACS — is always 1 and now defaults in imaging-api config; not a kit field.) -PACS_DICOM_PORT=4242 +# ── Upstream PACS ───────────────────────────────────────────────────────── +# Defaults describe the mocked Orthanc that ships for development. A real trust points these at its +# PACS; see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". +# +# XNAT_AETITLE is XNAT's own AE title, applied to its DICOM SCP receiver, the DQR calling AE, and the +# C-MOVE destination handed to the PACS. It must match the AE title the PACS has registered for us, +# because the PACS opens the C-STORE association addressed to that title. +# +# PACS_QR_PORT is the port XNAT dials, and must be reachable *from the XNAT container* — not a +# host-published port. Getting that wrong is what the retired PACS_DICOM_PORT variable did +# (FLIP#822 / FLIP#862). (PACS_ID is resolved by AE title at configuration time; not a kit field.) +XNAT_AETITLE=XNAT +PACS_HOST=orthanc +PACS_AETITLE=ORTHANC +PACS_QR_PORT=4242 +PACS_LABEL=Test PACS instance + +# PACS throttle. A production PACS may refuse further associations after a certain volume, and a +# trust may want retrieval confined to out-of-hours. Agree the window with the PACS manager. +PACS_AVAILABILITY_DAYS=MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY +PACS_AVAILABILITY_START=00:00 +PACS_AVAILABILITY_END=24:00 +PACS_THREADS=1 +PACS_UTILIZATION_PERCENT=100 +DQR_MAX_PACS_REQUEST_ATTEMPTS=100 +DQR_RETRY_WAIT_SECONDS=300 # trust-api -> hub poll cadence (seconds). POLL_INTERVAL_SECONDS=5 diff --git a/trust/deploy/compose_trust.development.yml b/trust/deploy/compose_trust.development.yml index dac7ecaa9..c6c2534d0 100644 --- a/trust/deploy/compose_trust.development.yml +++ b/trust/deploy/compose_trust.development.yml @@ -72,7 +72,7 @@ services: ORTHANC__REGISTERED_USERS: | {"${ORTHANC_USERNAME}": "${ORTHANC_PASSWORD}"} ORTHANC__DICOM_MODALITIES: | - {"XNAT": {"AET": "XNAT", "Host": "xnat-web", "Port": "${XNAT_PORT}"}} + {"XNAT": {"AET": "${XNAT_AETITLE:-XNAT}", "Host": "xnat-web", "Port": "${XNAT_PORT}"}} imaging-api: # Pull from GHCR by default; local imaging_api/ bind-mount overlays it for @@ -106,6 +106,10 @@ services: # default in imaging_api/config.py — do not inject them (an empty ${VAR} # from a kit that omits them would override the code default). XNAT_PORT: ${XNAT_PORT} + # Must match what configure-xnat.sh registered: this becomes the C-MOVE + # destination, and the PACS id is resolved from the PACS AE title. + XNAT_AETITLE: ${XNAT_AETITLE:-XNAT} + PACS_AETITLE: ${PACS_AETITLE:-ORTHANC} XNAT_SERVICE_USER: ${XNAT_SERVICE_USER} XNAT_SERVICE_PASSWORD: ${XNAT_SERVICE_PASSWORD} # Minted per-trust XNAT DB password (FLIP-PT-056): imaging_api/config.py diff --git a/trust/deploy/compose_trust.production.yml b/trust/deploy/compose_trust.production.yml index 1a62ac6a8..4bb98eafd 100644 --- a/trust/deploy/compose_trust.production.yml +++ b/trust/deploy/compose_trust.production.yml @@ -71,7 +71,7 @@ services: ORTHANC__REGISTERED_USERS: | {"${ORTHANC_USERNAME}": "${ORTHANC_PASSWORD}"} ORTHANC__DICOM_MODALITIES: | - {"XNAT": {"AET": "XNAT", "Host": "xnat-web", "Port": "${XNAT_PORT}"}} + {"XNAT": {"AET": "${XNAT_AETITLE:-XNAT}", "Host": "xnat-web", "Port": "${XNAT_PORT}"}} imaging-api: image: ghcr.io/londonaicentre/imaging-api:${DOCKER_TAG} @@ -94,6 +94,10 @@ services: # default in imaging_api/config.py — do not inject them (an empty ${VAR} # from a kit that omits them would override the code default). XNAT_PORT: ${XNAT_PORT} + # Must match what configure-xnat.sh registered: this becomes the C-MOVE + # destination, and the PACS id is resolved from the PACS AE title. + XNAT_AETITLE: ${XNAT_AETITLE:-XNAT} + PACS_AETITLE: ${PACS_AETITLE:-ORTHANC} XNAT_SERVICE_USER: ${XNAT_SERVICE_USER} XNAT_SERVICE_PASSWORD: ${XNAT_SERVICE_PASSWORD} # Minted per-trust XNAT DB password (FLIP-PT-056): imaging_api/config.py diff --git a/trust/imaging-api/imaging_api/config.py b/trust/imaging-api/imaging_api/config.py index 3c2d7726f..b81bba409 100644 --- a/trust/imaging-api/imaging_api/config.py +++ b/trust/imaging-api/imaging_api/config.py @@ -40,8 +40,15 @@ def coerce_empty_env(cls, v: str) -> str: # XNAT_PORT: int - # XNAT registers exactly one PACS (configure-xnat.sh), which it assigns id 1, - # so this defaults to 1 rather than being a required, mis-settable kit field. + # XNAT's own AE title. Must match XNAT_AETITLE in configure-xnat.sh, because this value becomes + # the C-MOVE destination handed to the PACS and DQR matches it against a registered SCP receiver + # by exact AE title and port (FLIP#993). + XNAT_AETITLE: str = "XNAT" + # AE title of the upstream PACS. The PACS id is resolved from this at runtime, so a deployment + # whose PACS is not id 1 — an extra registration, or a re-registered entry — still works. + PACS_AETITLE: str = "ORTHANC" + # Fallback used only when the AE-title lookup cannot reach XNAT. Historically XNAT registered + # exactly one PACS and assigned it id 1. PACS_ID: int = 1 # Internal trust-network URLs: docker service name + the service's container diff --git a/trust/imaging-api/imaging_api/routers/schemas.py b/trust/imaging-api/imaging_api/routers/schemas.py index 4b094e407..8e94ecc73 100644 --- a/trust/imaging-api/imaging_api/routers/schemas.py +++ b/trust/imaging-api/imaging_api/routers/schemas.py @@ -20,6 +20,7 @@ PACS_ID = get_settings().PACS_ID XNAT_PORT = get_settings().XNAT_PORT +XNAT_AETITLE = get_settings().XNAT_AETITLE # Blocks the three characters most likely to enable structural XML injection # in the XNAT projectData payload built by imaging_api.services.projects. This @@ -236,7 +237,9 @@ class ImportStudyRequest(BaseModel): """Represents an image import request for DQR.""" pacs_id: int = Field(default=PACS_ID, alias="pacsId") - ae_title: str = Field(default="XNAT", alias="aeTitle") # XNAT + # The C-MOVE destination the PACS is told to send to. DQR matches this against a registered + # SCP receiver by exact AE title and port, so it must equal what configure-xnat.sh registered. + ae_title: str = Field(default=XNAT_AETITLE, alias="aeTitle") port: int = Field(default=XNAT_PORT, alias="port") project_id: str = Field(..., alias="projectId") force_import: bool = Field(default=True, alias="forceImport") diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index f4c1054f2..3c4413834 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -27,8 +27,50 @@ from imaging_api.utils.logger import logger PACS_ID = get_settings().PACS_ID +PACS_AETITLE = get_settings().PACS_AETITLE XNAT_URL = get_settings().XNAT_URL +# Cache for resolve_pacs_id(). XNAT assigns PACS ids at registration time, so the mapping from AE +# title to id is fixed for the life of the registration; re-resolving on every query would add an +# XNAT round-trip per accession number. +_resolved_pacs_id: int | None = None + + +def resolve_pacs_id(headers: dict[str, str], ae_title: str = PACS_AETITLE) -> int: + """ + Resolves the XNAT PACS id for the configured PACS AE title. + + XNAT numbers PACS registrations in creation order. Historically FLIP registered exactly one, so + the id was always 1 and was hardcoded; a trust that has re-registered its PACS, or that carries + the mocked Orthanc alongside a real PACS, breaks that assumption (FLIP#993). Falls back to the + configured ``PACS_ID`` when XNAT cannot be reached or the AE title is not registered, so a + transient XNAT failure degrades to the previous behaviour rather than failing the import. + + Args: + headers (dict[str, str]): XNAT authentication headers. + ae_title (str): AE title to look up. Defaults to the configured PACS AE title. + + Returns: + int: The id of the registered PACS, or the configured ``PACS_ID`` fallback. + """ + global _resolved_pacs_id + if _resolved_pacs_id is not None: + return _resolved_pacs_id + + try: + response = requests.get(f"{XNAT_URL}/xapi/pacs", headers=headers) + response.raise_for_status() + for pacs in response.json(): + if pacs.get("aeTitle") == ae_title: + _resolved_pacs_id = int(pacs["id"]) + logger.info(f"Resolved PACS '{ae_title}' to id {_resolved_pacs_id}") + return _resolved_pacs_id + logger.warning(f"No PACS registered with AE title '{ae_title}'; falling back to id {PACS_ID}") + except Exception as e: + logger.warning(f"Could not resolve PACS id for '{ae_title}' ({e}); falling back to id {PACS_ID}") + + return PACS_ID + def ping_pacs(pacs_id: int, headers: dict[str, str]) -> PacsStatus: """ @@ -100,7 +142,7 @@ def query_by_accession_number(accession_number: str, headers: dict[str, str]) -> Exception: If there is an error during the query request. """ # Construct DQR Query - study_query = StudyQuery(accessionNumber=accession_number, pacsId=PACS_ID) + study_query = StudyQuery(accessionNumber=accession_number, pacsId=resolve_pacs_id(headers)) response = requests.post( f"{XNAT_URL}/xapi/dqr/query/studies", @@ -160,6 +202,11 @@ def queue_image_import_request( # Check if project exists get_project(import_request.project_id, headers=headers) + # Retrieve against the same PACS the C-FIND queried. The model default is the static fallback, + # so resolve here rather than leaving the import pointing at a different registration than the + # query used (FLIP#993). + import_request.pacs_id = resolve_pacs_id(headers) + # Check PACS check_pacs(headers=headers, pacs_id=import_request.pacs_id) diff --git a/trust/imaging-api/tests/services/test_imaging.py b/trust/imaging-api/tests/services/test_imaging.py index 0e8effe8f..9e961c242 100644 --- a/trust/imaging-api/tests/services/test_imaging.py +++ b/trust/imaging-api/tests/services/test_imaging.py @@ -383,3 +383,74 @@ def test_queue_image_import_request_partial_failure(mock_get_project, mock_post, response = queue_image_import_request(import_request, headers) assert response[0].status == "QUEUED" assert response[1].status == "FAILED" + + +# --- PACS id resolution by AE title (FLIP#993) --------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clear_resolved_pacs_id(): + """Resets the module-level PACS id cache so each test resolves independently.""" + import imaging_api.services.imaging as imaging_module + + imaging_module._resolved_pacs_id = None + yield + imaging_module._resolved_pacs_id = None + + +@patch("imaging_api.services.imaging.requests.get") +def test_resolve_pacs_id_matches_ae_title(mock_get, headers): + """The id comes from the registration whose AE title matches, not from the list order.""" + from imaging_api.services.imaging import resolve_pacs_id + + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: [ + {"id": 1, "aeTitle": "ORTHANC"}, + {"id": 7, "aeTitle": "SECTRA_QR"}, + ], + ) + + assert resolve_pacs_id(headers, ae_title="SECTRA_QR") == 7 + + +@patch("imaging_api.services.imaging.requests.get") +def test_resolve_pacs_id_is_cached(mock_get, headers): + """XNAT is queried once; ids are fixed for the life of a registration.""" + from imaging_api.services.imaging import resolve_pacs_id + + mock_get.return_value = MagicMock(status_code=200, json=lambda: [{"id": 4, "aeTitle": "ORTHANC"}]) + + assert resolve_pacs_id(headers, ae_title="ORTHANC") == 4 + assert resolve_pacs_id(headers, ae_title="ORTHANC") == 4 + assert mock_get.call_count == 1 + + +@patch("imaging_api.services.imaging.requests.get") +def test_resolve_pacs_id_falls_back_when_ae_title_absent(mock_get, headers): + """An unregistered AE title degrades to the configured fallback rather than failing the import.""" + from imaging_api.services.imaging import PACS_ID, resolve_pacs_id + + mock_get.return_value = MagicMock(status_code=200, json=lambda: [{"id": 9, "aeTitle": "SOMETHING_ELSE"}]) + + assert resolve_pacs_id(headers, ae_title="ORTHANC") == PACS_ID + + +@patch("imaging_api.services.imaging.requests.get", side_effect=Exception("XNAT unreachable")) +def test_resolve_pacs_id_falls_back_when_xnat_unreachable(mock_get, headers): + """A transient XNAT failure must not take out retrieval; it falls back to the configured id.""" + from imaging_api.services.imaging import PACS_ID, resolve_pacs_id + + assert resolve_pacs_id(headers, ae_title="ORTHANC") == PACS_ID + + +def test_import_request_ae_title_follows_settings(): + """The C-MOVE destination AE title is configuration, not a hardcoded literal.""" + from imaging_api.config import get_settings + + request = ImportStudyRequest( + projectId="project-1", + studies=[{"studyInstanceUid": "1.2.3", "accessionNumber": "ACC1"}], + ) + + assert request.ae_title == get_settings().XNAT_AETITLE diff --git a/trust/xnat/Makefile b/trust/xnat/Makefile index 01843da38..958f7078c 100644 --- a/trust/xnat/Makefile +++ b/trust/xnat/Makefile @@ -87,7 +87,13 @@ XNAT_STACK := xnat$(TRUST_NUM) XNAT_NETWORK := deploy_trust-network-$(TRUST_NUM) # Defaults are the Trust_1 allocation, so a single-trust host (EC2 / on-prem) # needs no XNAT port entries in its kit file. +# XNAT_PORT is the DICOM SCP receiver port: the port XNAT binds, registers on its dicomscp +# receiver, and advertises as the C-MOVE destination. XNAT_WEB_PORT is the host-published web UI +# port. They were historically the same variable, which is why host 8104 serves Tomcat while the +# DICOM receiver's 8104 is an unpublished container port (FLIP#993). The web default is unchanged so +# existing kits, docs and SSM port-forwards keep working. XNAT_PORT_EFFECTIVE := $(or $(XNAT_PORT),8104) +XNAT_WEB_PORT_EFFECTIVE := $(or $(XNAT_WEB_PORT),8104) PACS_UI_PORT_EFFECTIVE := $(or $(PACS_UI_PORT),8042) # Host path that backs this trust's XNAT bind mounts (parent of xnat-data/ @@ -127,6 +133,14 @@ STACK_FILES = -c docker-compose-stack.yml -c docker-compose-stack.development.ym XNAT_PLUGINS_FROM_HOST = 1 endif +# REAL_PACS=true publishes XNAT's DICOM SCP receiver on the host, so a PACS outside the host can +# complete the C-STORE leg of a DQR retrieval. Off by default: the mocked Orthanc reaches +# xnat-web:$(XNAT_PORT_EFFECTIVE) over the container network and needs no published port, and +# publishing one would collide with the web UI while both default to 8104. +ifeq ($(REAL_PACS),true) +STACK_FILES += -c docker-compose-stack.real-pacs.yml +endif + # Guard: a KIT-selected target needs KIT set. define require_kit @if [ -z "$(KIT)" ]; then \ @@ -242,6 +256,8 @@ endif $(MAKE) xnat-reset KIT=$(KIT) XNAT_PATH=${XNAT_PATH} \ XNAT_PORT=$(XNAT_PORT_EFFECTIVE) \ + XNAT_WEB_PORT=$(XNAT_WEB_PORT_EFFECTIVE) \ + XNAT_AETITLE=$(XNAT_AETITLE) \ PACS_UI_PORT=$(PACS_UI_PORT_EFFECTIVE) \ DOCKER_NETWORK_NAME=$(XNAT_NETWORK) \ docker stack deploy --with-registry-auth --detach=false ${STACK_FILES} $(XNAT_STACK) @@ -335,6 +351,14 @@ xnat-reset: @if ! echo "$(XNAT_PORT_EFFECTIVE)" | grep -qE '^[0-9]+$$'; then \ echo "ERROR: XNAT_PORT must be numeric for KIT=$(KIT), got '$(XNAT_PORT_EFFECTIVE)'"; exit 1; \ fi + @if ! echo "$(XNAT_WEB_PORT_EFFECTIVE)" | grep -qE '^[0-9]+$$'; then \ + echo "ERROR: XNAT_WEB_PORT must be numeric for KIT=$(KIT), got '$(XNAT_WEB_PORT_EFFECTIVE)'"; exit 1; \ + fi + @if [ "$(REAL_PACS)" = "true" ] && [ "$(XNAT_PORT_EFFECTIVE)" = "$(XNAT_WEB_PORT_EFFECTIVE)" ]; then \ + echo "ERROR: REAL_PACS=true publishes both the web UI and the DICOM receiver on the host,"; \ + echo " so XNAT_WEB_PORT ($(XNAT_WEB_PORT_EFFECTIVE)) and XNAT_PORT ($(XNAT_PORT_EFFECTIVE)) must differ."; \ + exit 1; \ + fi @echo "Deleting previous XNAT data and creating new directories for $(KIT) ..."; \ if [ "$(PROD)" = "true" ] || [ "$(PROD)" = "stag" ]; then \ echo " 📍 Using prod data dir: $(XNAT_DATA_DIR)/"; \ diff --git a/trust/xnat/docker-compose-stack.real-pacs.yml b/trust/xnat/docker-compose-stack.real-pacs.yml new file mode 100644 index 000000000..a366c4acb --- /dev/null +++ b/trust/xnat/docker-compose-stack.real-pacs.yml @@ -0,0 +1,29 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Opt-in overlay: publish XNAT's DICOM SCP receiver on the host. +# +# Applied by `make up-xnat ... REAL_PACS=true` (trust/xnat/Makefile). Needed only when the PACS is +# outside this host: FLIP retrieves by DQR, so after XNAT issues C-FIND/C-MOVE the PACS opens a +# *new* association back to XNAT to C-STORE the studies. That return leg is inbound, and without a +# published port it never arrives — queries succeed and retrievals silently time out (FLIP#993). +# +# The mocked Orthanc that ships for development does not need this: it reaches xnat-web:${XNAT_PORT} +# over the container network. +# +# XNAT_PORT is used on both sides of the mapping deliberately. DQR matches the C-MOVE destination +# against a registered SCP receiver by exact AE title and port, so the port the PACS connects to must +# be the same number XNAT binds — no translation is possible on this leg. The Makefile refuses to +# deploy if XNAT_WEB_PORT and XNAT_PORT collide. +services: + xnat-web: + ports: + - ${XNAT_PORT}:${XNAT_PORT} diff --git a/trust/xnat/docker-compose-stack.yml b/trust/xnat/docker-compose-stack.yml index 8b7ebe310..b15618817 100644 --- a/trust/xnat/docker-compose-stack.yml +++ b/trust/xnat/docker-compose-stack.yml @@ -17,7 +17,10 @@ services: placement: constraints: [node.role == manager] ports: - - ${XNAT_PORT}:8080 + # Host-published web UI. The DICOM SCP receiver binds ${XNAT_PORT} *inside* the container and + # is published only by docker-compose-stack.real-pacs.yml (make ... REAL_PACS=true), because + # the mocked Orthanc reaches it over the container network (FLIP#993). + - ${XNAT_WEB_PORT}:8080 security_opt: - no-new-privileges:true cap_drop: @@ -35,7 +38,21 @@ services: - XNAT_SERVICE_USER=${XNAT_SERVICE_USER} - XNAT_SERVICE_PASSWORD=${XNAT_SERVICE_PASSWORD} - XNAT_PORT=${XNAT_PORT} - - PACS_DICOM_PORT=${PACS_DICOM_PORT:-4242} + - XNAT_AETITLE=${XNAT_AETITLE:-XNAT} + # Upstream PACS, read by configure-xnat.sh. Defaults are the mocked Orthanc, so an + # unconfigured stack behaves exactly as before; a real trust sets these in its kit file. + - PACS_HOST=${PACS_HOST:-orthanc} + - PACS_AETITLE=${PACS_AETITLE:-ORTHANC} + - PACS_QR_PORT=${PACS_QR_PORT:-4242} + - PACS_LABEL=${PACS_LABEL:-Test PACS instance} + # PACS throttle — a production PACS may refuse further associations after a certain volume. + - PACS_AVAILABILITY_DAYS=${PACS_AVAILABILITY_DAYS:-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY} + - PACS_AVAILABILITY_START=${PACS_AVAILABILITY_START:-00:00} + - PACS_AVAILABILITY_END=${PACS_AVAILABILITY_END:-24:00} + - PACS_THREADS=${PACS_THREADS:-1} + - PACS_UTILIZATION_PERCENT=${PACS_UTILIZATION_PERCENT:-100} + - DQR_MAX_PACS_REQUEST_ATTEMPTS=${DQR_MAX_PACS_REQUEST_ATTEMPTS:-100} + - DQR_RETRY_WAIT_SECONDS=${DQR_RETRY_WAIT_SECONDS:-300} - XNAT_DATASOURCE_DRIVER=${XNAT_DATASOURCE_DRIVER} - XNAT_DATASOURCE_URL=${XNAT_DATASOURCE_URL} - XNAT_DATASOURCE_NAME=${XNAT_DATASOURCE_NAME} diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index ec7699b72..d9a761d43 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -28,10 +28,42 @@ set -euo pipefail : "${XNAT_ADMIN_USER:?}" "${XNAT_ADMIN_INITIAL_PASSWORD:?}" "${XNAT_ADMIN_PASSWORD:?}" : "${XNAT_SERVICE_USER:?}" "${XNAT_SERVICE_PASSWORD:?}" "${XNAT_PORT:?}" -# The below are fixed values for now -XNAT_URL="http://xnat-web:8080" # internal to Docker network -ORTHANC_HOST="orthanc" # name of the service (container) in docker-compose -ORTHANC_AETITLE="ORTHANC" +# XNAT's own identity and the upstream PACS. Defaults reproduce the mocked Orthanc that ships for +# development, so an unconfigured deployment behaves exactly as before; a real trust overrides them +# from its kit file (Compose) or Helm values (Kubernetes). +# +# XNAT_AETITLE is XNAT's AE title in three places that must agree: the DICOM SCP receiver, the DQR +# calling AE, and the C-MOVE destination that imaging-api hands to the PACS. The PACS opens the +# C-STORE association addressed to the AE title it has registered, so a receiver configured under a +# different title rejects it. +XNAT_URL="${XNAT_URL:-http://xnat-web:8080}" # internal to the container network +XNAT_AETITLE="${XNAT_AETITLE:-XNAT}" +PACS_HOST="${PACS_HOST:-orthanc}" # service name in compose / k8s, or a real PACS host +PACS_AETITLE="${PACS_AETITLE:-ORTHANC}" +PACS_QR_PORT="${PACS_QR_PORT:-4242}" +PACS_LABEL="${PACS_LABEL:-Test PACS instance}" + +# DQR retry behaviour and the PACS availability schedule — the throttle for a production PACS, which +# may refuse further associations after a certain volume (FLIP#993). Defaults are today's values. +DQR_MAX_PACS_REQUEST_ATTEMPTS="${DQR_MAX_PACS_REQUEST_ATTEMPTS:-100}" +DQR_RETRY_WAIT_SECONDS="${DQR_RETRY_WAIT_SECONDS:-300}" +PACS_AVAILABILITY_DAYS="${PACS_AVAILABILITY_DAYS:-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY}" +PACS_AVAILABILITY_START="${PACS_AVAILABILITY_START:-00:00}" +PACS_AVAILABILITY_END="${PACS_AVAILABILITY_END:-24:00}" +PACS_THREADS="${PACS_THREADS:-1}" +PACS_UTILIZATION_PERCENT="${PACS_UTILIZATION_PERCENT:-100}" + +# Same fail-loud contract as the credentials above: a default must never resolve to empty, or the +# interpolated JSON is malformed and XNAT rejects it silently (FLIP#822 / FLIP#862). +: "${XNAT_URL:?}" "${XNAT_AETITLE:?}" "${PACS_HOST:?}" "${PACS_AETITLE:?}" "${PACS_QR_PORT:?}" +: "${PACS_LABEL:?}" "${DQR_MAX_PACS_REQUEST_ATTEMPTS:?}" "${DQR_RETRY_WAIT_SECONDS:?}" +: "${PACS_AVAILABILITY_DAYS:?}" "${PACS_AVAILABILITY_START:?}" "${PACS_AVAILABILITY_END:?}" +: "${PACS_THREADS:?}" "${PACS_UTILIZATION_PERCENT:?}" + +# jq parses the /xapi/dicomscp and /xapi/pacs listings below. It ships in the xnat-web image +# (trust/xnat/xnat/Dockerfile), but fail loudly here rather than let a missing binary degrade into a +# silently-empty lookup that would re-register a PACS that already exists. +command -v jq >/dev/null || { echo "ERROR: jq is required by configure-xnat.sh" >&2; exit 1; } # Wait for XNAT to be available (wall-clock bounded, and each probe carries # its own timeout, so a dead or wedged XNAT fails the deploy loudly instead @@ -254,17 +286,17 @@ echo "Configuring DQR plugin..." xnat_curl -X POST "$XNAT_URL/xapi/dqr/settings" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ - -d '{ - "pacsAvailabilityCheckFrequency": "1 minute", - "dqrWaitToRetryRequestInSeconds": "300", - "assumeSameSessionIfArrivedWithin": "30 minutes", - "allowAllUsersToUseDqr": false, - "dqrCallingAe": "XNAT", - "notifyAdminOnImport": false, - "allowAllProjectsToUseDqr": true, - "leavePacsAuditTrail": false, - "dqrMaxPacsRequestAttempts": "100" - }' + -d "{ + \"pacsAvailabilityCheckFrequency\": \"1 minute\", + \"dqrWaitToRetryRequestInSeconds\": \"${DQR_RETRY_WAIT_SECONDS}\", + \"assumeSameSessionIfArrivedWithin\": \"30 minutes\", + \"allowAllUsersToUseDqr\": false, + \"dqrCallingAe\": \"${XNAT_AETITLE}\", + \"notifyAdminOnImport\": false, + \"allowAllProjectsToUseDqr\": true, + \"leavePacsAuditTrail\": false, + \"dqrMaxPacsRequestAttempts\": \"${DQR_MAX_PACS_REQUEST_ATTEMPTS}\" + }" # Configure site-wide anonymization script echo "Configuring site-wide anonymization script..." @@ -280,47 +312,38 @@ xnat_curl -X PUT "$XNAT_URL/xapi/anonymize/site/enabled" \ -H "Content-Type: application/json" \ -d 'true' -# Get SCP receivers +# Remove any pre-existing SCP receiver we are about to replace. Two titles matter: XNAT's stock +# default receiver (always "XNAT", created by the webapp on first boot) and the receiver under our +# configured title, so a re-run with changed settings replaces rather than duplicates. When +# XNAT_AETITLE is the default they are the same entry and the loop deduplicates. response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" "$XNAT_URL/xapi/dicomscp") -# Debug: Print Raw Response -echo "Raw API Response: $response" - -# Check if response is empty or invalid if [[ -z "$response" || "$response" == "[]" ]]; then echo "No SCP receivers found." else - # Extract the SCP receiver ID with "aeTitle": "XNAT" using grep and sed - scp_receiver_data=$(echo "$response" | grep -o '{[^}]*"aeTitle"[^}]*}' | grep '"aeTitle":"XNAT"') - echo "SCP Receiver Data: $scp_receiver_data" - - if [[ -n "$scp_receiver_data" ]]; then - # Extract the "id" field of the SCP receiver - scp_receiver_id=$(echo "$scp_receiver_data" | sed -n 's/.*"id":\([0-9]\+\).*/\1/p') + for ae in $(printf '%s\n' "XNAT" "${XNAT_AETITLE}" | sort -u); do + # `--arg` keeps the AE title as data rather than splicing it into the filter, so a title + # containing jq syntax cannot change what is selected. + scp_receiver_id=$(printf '%s' "$response" | jq -r --arg ae "$ae" \ + 'map(select(.aeTitle == $ae)) | .[0].id // empty') if [[ -n "$scp_receiver_id" ]]; then - echo "Removing SCP Receiver with ID: $scp_receiver_id..." - - # Send DELETE request to remove the SCP receiver - delete_response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" -X DELETE "$XNAT_URL/xapi/dicomscp/$scp_receiver_id") - - echo "Delete Response: $delete_response" - echo "SCP Receiver removed successfully." + echo "Removing SCP receiver '$ae' (id $scp_receiver_id)..." + xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" \ + -X DELETE "$XNAT_URL/xapi/dicomscp/$scp_receiver_id" >/dev/null else - echo "Failed to extract SCP receiver ID." + echo "No SCP receiver with aeTitle='$ae' found." fi - else - echo "No SCP receiver with aeTitle='XNAT' found." - fi + done fi # Configure SCP receiver to have dqrObjectIdentifier as the identifier (the default is not) -echo "Configuring SCP receiver..." +echo "Configuring SCP receiver '${XNAT_AETITLE}' on port ${XNAT_PORT}..." xnat_curl -X POST "$XNAT_URL/xapi/dicomscp" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ -d "{ - \"aeTitle\": \"XNAT\", + \"aeTitle\": \"${XNAT_AETITLE}\", \"port\": ${XNAT_PORT}, \"enabled\": true, \"customProcessing\": true, @@ -342,59 +365,90 @@ xnat_curl -X POST "$XNAT_URL/xapi/siteConfig" \ -H "Content-Type: application/json" \ -d '{"addOhifViewLinkToProjectListingDefaults": true }' -# Register PACS -# queryRetrievePort is the port XNAT dials Orthanc on over the Docker network -# (the "host" below is the orthanc service name), so Orthanc's fixed container -# port 4242 is the only correct value. The old ${PACS_DICOM_PORT} indirection -# was nominally the *host-published* DICOM port — its "${PACS_DICOM_PORT}:4242" -# mappings are commented out in every compose file — so a kit setting it to -# anything but 4242 silently broke registration (FLIP#822 / FLIP#862). -# Check-then-create: a duplicate registration surfaces as an unspecific 500 -# (DB unique-constraint violation), so re-run idempotency is a lookup by -# aeTitle rather than a tolerated status code. -existing_pacs=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_URL/xapi/pacs") -if printf '%s' "$existing_pacs" | grep -q "\"aeTitle\":\"${ORTHANC_AETITLE}\""; then - echo "PACS '${ORTHANC_AETITLE}' already registered — leaving as-is." -else - echo "Registering PACS..." - xnat_curl -X POST "$XNAT_URL/xapi/pacs" \ - -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ - -H "Content-Type: application/json" \ - -d "{ - \"aeTitle\": \"${ORTHANC_AETITLE}\", +# Register the upstream PACS. +# +# queryRetrievePort is the port XNAT dials the PACS on and must be the port that is actually +# reachable from the XNAT container: the mock Orthanc's fixed container port 4242 over the container +# network, or the trust PACS's real query/retrieve port. The retired ${PACS_DICOM_PORT} variable +# meant the *host-published* port, which is not the same thing, so a kit setting it silently broke +# registration (FLIP#822 / FLIP#862) — hence PACS_QR_PORT is guarded non-empty above and documented +# as the reachable port. +# +# A duplicate registration surfaces as an unspecific 500 (DB unique-constraint violation), so +# idempotency is a lookup by aeTitle. Unlike the previous check-then-create, an existing entry whose +# host or port has drifted from the configured values is *updated*: leaving it untouched meant a kit +# change was silently ignored on redeploy, and the operator had no signal that DQR was still +# pointing at the old PACS. +pacs_payload="{ + \"aeTitle\": \"${PACS_AETITLE}\", \"defaultQueryRetrievePacs\": true, \"defaultStoragePacs\": true, - \"host\": \"${ORTHANC_HOST}\", - \"label\": \"Test PACS instance\", + \"host\": \"${PACS_HOST}\", + \"label\": \"${PACS_LABEL}\", \"ormStrategySpringBeanId\": \"dicomOrmStrategy\", - \"queryRetrievePort\": 4242, + \"queryRetrievePort\": ${PACS_QR_PORT}, \"queryable\": true, \"storable\": true, \"supportsExtendedNegotiations\": true }" + +existing_pacs=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_URL/xapi/pacs") +pacs_entry=$(printf '%s' "$existing_pacs" | jq -c --arg ae "${PACS_AETITLE}" \ + 'map(select(.aeTitle == $ae)) | .[0] // empty') + +if [[ -z "$pacs_entry" ]]; then + echo "Registering PACS '${PACS_AETITLE}' at ${PACS_HOST}:${PACS_QR_PORT}..." + xnat_curl -X POST "$XNAT_URL/xapi/pacs" \ + -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d "$pacs_payload" +else + PACS_ID=$(printf '%s' "$pacs_entry" | jq -r '.id') + current_host=$(printf '%s' "$pacs_entry" | jq -r '.host // empty') + current_port=$(printf '%s' "$pacs_entry" | jq -r '.queryRetrievePort // empty') + + if [[ "$current_host" == "${PACS_HOST}" && "$current_port" == "${PACS_QR_PORT}" ]]; then + echo "PACS '${PACS_AETITLE}' already registered at ${PACS_HOST}:${PACS_QR_PORT} — leaving as-is." + else + echo "PACS '${PACS_AETITLE}' registered at ${current_host}:${current_port}," \ + "updating to ${PACS_HOST}:${PACS_QR_PORT}..." + xnat_curl -X PUT "$XNAT_URL/xapi/pacs/${PACS_ID}" \ + -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d "$pacs_payload" + fi fi -# Configure PACS availability schedule (all days). DQR appears to pre-create -# availability intervals when the PACS is registered: on XNAT 1.10 + DQR 3.0.0 -# this POST returns 400 "probable overlap with existing interval" for an -# already-scheduled day, so 400 is treated as "already configured" rather than -# a failure. Anything else non-2xx is a real error and fails the deploy. -for DAY in MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY; do +# The availability schedule below is written against the registered PACS, so resolve its id whether +# it was just created or already existed. +PACS_ID=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_URL/xapi/pacs" \ + | jq -r --arg ae "${PACS_AETITLE}" 'map(select(.aeTitle == $ae)) | .[0].id // empty') +: "${PACS_ID:?PACS '${PACS_AETITLE}' is not registered after configuration}" + +# Configure the PACS availability schedule — the throttle for a production PACS, which may refuse +# further associations after a certain volume, and which a trust may want restricted to out-of-hours +# (FLIP#993). Defaults are all week, all day, one thread. +# +# DQR appears to pre-create availability intervals when the PACS is registered: on XNAT 1.10 + +# DQR 3.0.0 this POST returns 400 "probable overlap with existing interval" for an already-scheduled +# day, so 400 is treated as "already configured" rather than a failure. Anything else non-2xx is a +# real error and fails the deploy. +for DAY in ${PACS_AVAILABILITY_DAYS//,/ }; do echo "Setting PACS availability for $DAY..." avail_body=/tmp/pacs-availability-response.json avail_status=$(curl -s -o "$avail_body" --connect-timeout 10 --max-time 120 -w '%{http_code}' \ - -X POST "$XNAT_URL/xapi/pacs/1/availability" \ + -X POST "$XNAT_URL/xapi/pacs/${PACS_ID}/availability" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ -d "{ - \"availabilityEnd\": \"24:00\", - \"availabilityStart\": \"00:00\", + \"availabilityEnd\": \"${PACS_AVAILABILITY_END}\", + \"availabilityStart\": \"${PACS_AVAILABILITY_START}\", \"availableNow\": true, \"dayOfWeek\": \"$DAY\", \"enabled\": true, - \"pacsId\": 1, - \"threads\": 1, - \"utilizationPercent\": 100 + \"pacsId\": ${PACS_ID}, + \"threads\": ${PACS_THREADS}, + \"utilizationPercent\": ${PACS_UTILIZATION_PERCENT} }") || avail_status="000" if [[ "$avail_status" == 2* ]]; then continue From e0e8129830817bf8a96e93307f23f688d22ac3f5 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Tue, 18 Aug 2026 16:15:26 +0100 Subject: [PATCH 02/31] feat(k8s): expose XNAT's DICOM receiver and unify the XNAT config (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart could not reach a real PACS, by design rather than oversight: the Service was ClusterIP with no way to pin a port, ingress was default-denied with no allowance mechanism at all, and the PACS registration was inlined as literals. Unify first. There were three copies of the XNAT configuration — the script in the xnat-web image, ~390 lines inlined in this Job, and a third in an xnat-scripts ConfigMap — and they had drifted. The inlined copy hardcoded the mocked Orthanc and never read the chart's own orthanc.dicomHost / dicomPort / dicomAet values, which is why setting orthanc.enabled=false with an external host never actually redirected DQR. The init container now runs the same configure-xnat.sh from the same image that ships it; the other two copies are deleted, and the dead values with them. The script is self-contained (it waits for XNAT, then for the DQR plugin routes) and short-circuits an already initialised site, so re-running on helm upgrade is unchanged. Then expose the receiver: - xnat.web.dicomNodePort pins the NodePort, so the PACS has a stable destination. Without pinning, Kubernetes allocates one at random and the C-MOVE destination XNAT advertises would not match what the PACS can reach. - networkPolicies.allowedIngressCIDRsWithPorts allows the C-STORE return leg from the PACS CIDRs only. Both are opt-in: with the defaults the Service stays ClusterIP and no ingress policy renders, so existing deployments are untouched. - New pacs.* values replace the dead orthanc.dicom* ones. Retrieval is a pull, so the PACS opens a *new* association back to XNAT to deliver the studies. That is the one inbound path into a trust, and it is the rule reviewers drop precisely because every other FLIP connection is outbound. security.rst and governance-and-compliance.rst previously said trust systems accept no inbound connections at all; they now state it precisely — nothing from the internet or the hub, one path from the trust's own PACS, scoped to that PACS on the DICOM port. Tests: test_configure_pacs.py runs the script against a stub curl and asserts on the payloads XNAT would receive — defaults still describe the mock, configured values reach all three places the AE title must agree, the throttle is honoured, registration updates in place on drift, and an empty value fails loudly. That last one found a real bug: ${VAR:-default} silently substituted the default for an explicitly empty value, so PACS_HOST= in a kit file would have fallen back to the mocked PACS rather than failing. Now ${VAR-default}, so unset takes the default and empty trips the guard. CI gains a real-PACS render asserting the NodePort and ingress policy, and a companion asserting the default still renders neither. Not yet validated on a cluster: the init-container swap renders and lints, but the K8s bring-up path needs a real deployment before merge. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 34 ++ AGENTS.md | 17 + CLAUDE.md | 17 + deploy/providers/kubernetes/NETWORK-POLICY.md | 1 + deploy/providers/kubernetes/README.md | 4 +- .../providers/kubernetes/TROUBLESHOOTING.md | 2 +- .../kubernetes/templates/network-policy.yaml | 37 ++ .../kubernetes/templates/orthanc.yaml | 2 +- .../kubernetes/templates/xnat-init-job.yaml | 563 ++---------------- .../kubernetes/templates/xnat-web.yaml | 5 + deploy/providers/kubernetes/values.yaml | 50 +- docs/source/governance-and-compliance.rst | 8 +- docs/source/security.rst | 22 +- trust/AGENTS.md | 2 +- trust/CLAUDE.md | 2 +- trust/xnat/tests/test_configure_pacs.py | 229 +++++++ trust/xnat/xnat/config/configure-xnat.sh | 28 +- 17 files changed, 490 insertions(+), 533 deletions(-) create mode 100644 trust/xnat/tests/test_configure_pacs.py diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 055c49150..5442c6c43 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -101,6 +101,40 @@ jobs: --set omopDb.external.host=test.example.com \ > /dev/null + # The real-PACS path is entirely opt-in: with the defaults the chart deploys the mocked + # Orthanc, keeps the Service ClusterIP and leaves ingress default-denied. None of the + # NodePort or NetworkPolicy-ingress bodies render at all unless configured, so without this + # step a broken .Values path in either would merge green and only fail for the first trust + # that connects a real PACS (FLIP#993). + - name: Render template (real PACS) + run: | + helm template trust-release deploy/providers/kubernetes/ \ + --set xnat.web.service.type=NodePort \ + --set xnat.web.dicomNodePort=8104 \ + --set xnat.web.dicomAet=FLIPXNAT \ + --set pacs.host=10.0.0.10 \ + --set pacs.aeTitle=SECTRA_QR \ + --set pacs.qrPort=8059 \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' > /tmp/real-pacs.yaml + for want in "nodePort: 8104" "allow-pacs-ingress" "cidr: \"10.0.0.10/32\"" \ + "value: \"SECTRA_QR\"" "value: \"FLIPXNAT\""; do + if ! grep -q "$want" /tmp/real-pacs.yaml; then + echo "::error::real-PACS render is missing: $want" + exit 1 + fi + done + + # Default-deny must survive: the ingress allowance is the one inbound path into a trust, so a + # chart that opened it without being asked would silently widen every existing deployment. + - name: Render template (default keeps ingress denied) + run: | + helm template trust-release deploy/providers/kubernetes/ > /tmp/default.yaml + if grep -q "allow-pacs-ingress" /tmp/default.yaml; then + echo "::error::the PACS ingress NetworkPolicy rendered without being configured" + exit 1 + fi + # omopDb.vocabLoad.s3Bucket defaults to "" (the licensed bundle has no public # mirror — FLIP#842/843), so every other render in this job skips the # vocab-load Job. Without this step its ~110-line body is never rendered in diff --git a/AGENTS.md b/AGENTS.md index f8466235d..504d5a8c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,6 +373,23 @@ After changes, evaluate if docs need updating: - `FL_PROVISIONED_DIR` — path to the in-tree provisioned FL artifacts, derived per-backend by `deploy/fl_backend.mk` from `FL_BACKEND`: `fl-services/nvflare/provision/workspace-dev` (nvflare startup kits) or `fl-services/flower/provision/creds` (flower per-net TLS certs + SuperNode keys). Both gitignored. Read only by the dev compose overlays for the cert/workspace volume mounts; override at the CLI for a one-off (`make up FL_PROVISIONED_DIR=...`). FL Makefiles are **per-backend** — each `fl-services//Makefile` owns that backend's `build`/`provision`/`up`/`down`/`submit` (flower also `up-secure`); the root Makefile forwards only `build-fl` by `FL_BACKEND`. Each backend's `fl-services//Makefile` also owns its network provisioning (NVFLARE adds `provision`/`provision-2-nets`/`provision-stag`/`provision-prod`/`upload-kits-to-s3`; the project YAMLs, `scripts/`, and gitignored `workspace-{dev,stag,prod}/` output live under `provision/`). Provision with `make -C fl-services/nvflare provision-2-nets` (nvflare) or `make -C fl-services/flower provision NET_NUMBER=` (flower). To run a backend standalone + submit without the full stack: `make -C fl-services/ up` (or `up-secure`) then `make -C fl-services/ submit APP=`. - `FL_APP_BASE_DIR` — Local directory holding the base FL application templates (the repo's `fl-apps/` tree), baked into the flip-api image and bind-mounted in dev. flip-api walks `///` to bundle an application (uploading those files into `FL_APP_DESTINATION_BUCKET/`) and reads each backend's manifest from `//required_files.json`. Default `/app/fl-apps`; override to mount operator-provided templates. Replaces the removed `FL_APP_BASE_BUCKET` S3 dependency (FLIP#724): base templates are no longer published to S3 (the `fl-apps-push-s3-*` sync workflows are gone), so a template hotfix now ships by rebuilding + redeploying the flip-api image rather than syncing S3. `fl-apps/` is baked into the image via a BuildKit named build context (`fl_apps=../fl-apps`) since it sits outside flip-api's build context. For the Flower backend, the template pyprojects also steer Flower's **per-run dependency install** (`uv sync` on every app launch; SuperNodes opt in via `--allow-runtime-dependency-installation` in the composes): `[tool.uv.sources]` pins `flip-utils` to the source kept at `/opt/flip-utils` inside the FL images (never PyPI — FLIP#767; a flip-utils change ships by rebuilding the FL images, `make build-fl FL_BACKEND=flower`) and torch/torchvision to the cu128 index (PyPI's default cu130 wheels need driver >=580). - `FL_KIT_SLOT_NAMES` — JSON list (e.g. `["Trust_1", "Trust_2"]`) of FL kit-slot names for the hub's `fl_kit_slot` pool that `register_trust` claims from; each name must match a provisioned participant kit (in-tree workspace for dev, `s3:///fl-flare-participant-kits//net-/services//` for stag/prod; slot names are global across nets — every net carries a kit per name). The pool is seeded at flip-api boot and **reconciled on demand** when a registration finds it exhausted (`resolve_fl_kit_slot_names`, additive — never deletes or re-assigns rows); only then does `NoFreeKitSlotError` surface. Single source per env: dev = this env var (a `DevSettings`-only field; restart to change, settings load once); stag/prod = the `/flip/fl_kit_slot_names` SSM parameter (Terraform-rendered from this var — the list is plain config, not a secret; deliberately **no env fallback**, so a broken/missing parameter means the pool can't grow, loudly, never masked by stale task-def env). Growing the pool is an env-file edit + `make -C deploy/providers/AWS apply-fl-kit-slots` (targeted plan/apply of just the parameter, plain-text diff) — **no restart, no task-definition change**. One-command workflow: `make -C deploy/providers/AWS add-fl-kits N= PROD=stag|true` (N = "ensure N more live slots": activate spares toward N first, mint only the shortfall on every net → additive S3 upload → env edit → parameter apply); full runbook in `fl-services/nvflare/README.md` ("Onboarding a new client onto an existing network"). NVFLARE-only dynamics — Flower's SuperNode key labelling reads the list at net startup. +- `XNAT_PORT` / `XNAT_WEB_PORT` / `XNAT_AETITLE` — XNAT's DICOM SCP receiver port, its host-published + web-UI port, and its AE title. `XNAT_PORT` was historically one variable doing both jobs, which is + why host 8104 served Tomcat while the DICOM receiver's 8104 was an unpublished container port + (FLIP#993). `XNAT_AETITLE` is applied to the SCP receiver, `dqrCallingAe`, and the C-MOVE + destination in `ImportStudyRequest` — DQR matches that destination against a registered receiver by + exact `AE:port`, so all three must agree and no translation is possible on that leg. Publishing the + receiver for a real PACS is opt-in: `make -C trust/xnat up-xnat KIT= REAL_PACS=true` adds + `docker-compose-stack.real-pacs.yml`, and the Makefile refuses to deploy if the two ports collide. +- `PACS_HOST` / `PACS_AETITLE` / `PACS_QR_PORT` / `PACS_LABEL` — the upstream PACS, defaulting to the + mocked Orthanc (`orthanc` / `ORTHANC` / `4242`). `PACS_QR_PORT` must be reachable *from the XNAT + container*, not a host-published port — conflating the two is what the retired `PACS_DICOM_PORT` + did (FLIP#822/#862). `configure-xnat.sh` updates an existing registration in place when the host or + port drift, and imaging-api resolves the PACS id by AE title rather than assuming 1. +- `PACS_AVAILABILITY_DAYS` / `_START` / `_END` / `PACS_THREADS` / `PACS_UTILIZATION_PERCENT` / + `DQR_MAX_PACS_REQUEST_ATTEMPTS` / `DQR_RETRY_WAIT_SECONDS` — the retrieval throttle. A production + PACS may refuse further associations after a certain volume, so the window and thread count are + agreed with the trust's PACS manager. Defaults are all week, all day, one thread. - `PROD` — `true` (production), `stag` (staging), unset (development) - `AES_KEY_BASE64` — encryption key for trust communication - A remote trust operator only needs their kit file (`trust/.env.`) — no hub `.env.` needed on trust hosts. diff --git a/CLAUDE.md b/CLAUDE.md index 750596df9..a21c12fa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,6 +373,23 @@ After changes, evaluate if docs need updating: - `FL_PROVISIONED_DIR` — path to the in-tree provisioned FL artifacts, derived per-backend by `deploy/fl_backend.mk` from `FL_BACKEND`: `fl-services/nvflare/provision/workspace-dev` (nvflare startup kits) or `fl-services/flower/provision/creds` (flower per-net TLS certs + SuperNode keys). Both gitignored. Read only by the dev compose overlays for the cert/workspace volume mounts; override at the CLI for a one-off (`make up FL_PROVISIONED_DIR=...`). FL Makefiles are **per-backend** — each `fl-services//Makefile` owns that backend's `build`/`provision`/`up`/`down`/`submit` (flower also `up-secure`); the root Makefile forwards only `build-fl` by `FL_BACKEND`. Each backend's `fl-services//Makefile` also owns its network provisioning (NVFLARE adds `provision`/`provision-2-nets`/`provision-stag`/`provision-prod`/`upload-kits-to-s3`; the project YAMLs, `scripts/`, and gitignored `workspace-{dev,stag,prod}/` output live under `provision/`). Provision with `make -C fl-services/nvflare provision-2-nets` (nvflare) or `make -C fl-services/flower provision NET_NUMBER=` (flower). To run a backend standalone + submit without the full stack: `make -C fl-services/ up` (or `up-secure`) then `make -C fl-services/ submit APP=`. - `FL_APP_BASE_DIR` — Local directory holding the base FL application templates (the repo's `fl-apps/` tree), baked into the flip-api image and bind-mounted in dev. flip-api walks `///` to bundle an application (uploading those files into `FL_APP_DESTINATION_BUCKET/`) and reads each backend's manifest from `//required_files.json`. Default `/app/fl-apps`; override to mount operator-provided templates. Replaces the removed `FL_APP_BASE_BUCKET` S3 dependency (FLIP#724): base templates are no longer published to S3 (the `fl-apps-push-s3-*` sync workflows are gone), so a template hotfix now ships by rebuilding + redeploying the flip-api image rather than syncing S3. `fl-apps/` is baked into the image via a BuildKit named build context (`fl_apps=../fl-apps`) since it sits outside flip-api's build context. For the Flower backend, the template pyprojects also steer Flower's **per-run dependency install** (`uv sync` on every app launch; SuperNodes opt in via `--allow-runtime-dependency-installation` in the composes): `[tool.uv.sources]` pins `flip-utils` to the source kept at `/opt/flip-utils` inside the FL images (never PyPI — FLIP#767; a flip-utils change ships by rebuilding the FL images, `make build-fl FL_BACKEND=flower`) and torch/torchvision to the cu128 index (PyPI's default cu130 wheels need driver >=580). - `FL_KIT_SLOT_NAMES` — JSON list (e.g. `["Trust_1", "Trust_2"]`) of FL kit-slot names for the hub's `fl_kit_slot` pool that `register_trust` claims from; each name must match a provisioned participant kit (in-tree workspace for dev, `s3:///fl-flare-participant-kits//net-/services//` for stag/prod; slot names are global across nets — every net carries a kit per name). The pool is seeded at flip-api boot and **reconciled on demand** when a registration finds it exhausted (`resolve_fl_kit_slot_names`, additive — never deletes or re-assigns rows); only then does `NoFreeKitSlotError` surface. Single source per env: dev = this env var (a `DevSettings`-only field; restart to change, settings load once); stag/prod = the `/flip/fl_kit_slot_names` SSM parameter (Terraform-rendered from this var — the list is plain config, not a secret; deliberately **no env fallback**, so a broken/missing parameter means the pool can't grow, loudly, never masked by stale task-def env). Growing the pool is an env-file edit + `make -C deploy/providers/AWS apply-fl-kit-slots` (targeted plan/apply of just the parameter, plain-text diff) — **no restart, no task-definition change**. One-command workflow: `make -C deploy/providers/AWS add-fl-kits N= PROD=stag|true` (N = "ensure N more live slots": activate spares toward N first, mint only the shortfall on every net → additive S3 upload → env edit → parameter apply); full runbook in `fl-services/nvflare/README.md` ("Onboarding a new client onto an existing network"). NVFLARE-only dynamics — Flower's SuperNode key labelling reads the list at net startup. +- `XNAT_PORT` / `XNAT_WEB_PORT` / `XNAT_AETITLE` — XNAT's DICOM SCP receiver port, its host-published + web-UI port, and its AE title. `XNAT_PORT` was historically one variable doing both jobs, which is + why host 8104 served Tomcat while the DICOM receiver's 8104 was an unpublished container port + (FLIP#993). `XNAT_AETITLE` is applied to the SCP receiver, `dqrCallingAe`, and the C-MOVE + destination in `ImportStudyRequest` — DQR matches that destination against a registered receiver by + exact `AE:port`, so all three must agree and no translation is possible on that leg. Publishing the + receiver for a real PACS is opt-in: `make -C trust/xnat up-xnat KIT= REAL_PACS=true` adds + `docker-compose-stack.real-pacs.yml`, and the Makefile refuses to deploy if the two ports collide. +- `PACS_HOST` / `PACS_AETITLE` / `PACS_QR_PORT` / `PACS_LABEL` — the upstream PACS, defaulting to the + mocked Orthanc (`orthanc` / `ORTHANC` / `4242`). `PACS_QR_PORT` must be reachable *from the XNAT + container*, not a host-published port — conflating the two is what the retired `PACS_DICOM_PORT` + did (FLIP#822/#862). `configure-xnat.sh` updates an existing registration in place when the host or + port drift, and imaging-api resolves the PACS id by AE title rather than assuming 1. +- `PACS_AVAILABILITY_DAYS` / `_START` / `_END` / `PACS_THREADS` / `PACS_UTILIZATION_PERCENT` / + `DQR_MAX_PACS_REQUEST_ATTEMPTS` / `DQR_RETRY_WAIT_SECONDS` — the retrieval throttle. A production + PACS may refuse further associations after a certain volume, so the window and thread count are + agreed with the trust's PACS manager. Defaults are all week, all day, one thread. - `PROD` — `true` (production), `stag` (staging), unset (development) - `AES_KEY_BASE64` — encryption key for trust communication - A remote trust operator only needs their kit file (`trust/.env.`) — no hub `.env.` needed on trust hosts. diff --git a/deploy/providers/kubernetes/NETWORK-POLICY.md b/deploy/providers/kubernetes/NETWORK-POLICY.md index fd91c28b6..7f4f3d047 100644 --- a/deploy/providers/kubernetes/NETWORK-POLICY.md +++ b/deploy/providers/kubernetes/NETWORK-POLICY.md @@ -45,6 +45,7 @@ boundary — see [Residual risk](#residual-risk). |---|---|---|---| | `allowedEgressPorts` (default 53/UDP, 53/TCP, 80/TCP, 443/TCP) | **any IP** | DNS resolution; 443 for the hub poll (CloudFront), S3 (kit/results), Cognito, GHCR/ECR image pulls; 80 for redirects/package metadata. `sync-kit` appends `FL_SERVER_PORT` here for the fl-client → fl-server gRPC (#593 pt.3, port-only). | **Primary residual risk: 443/80 to any IP is an exfiltration channel.** A compromised fl-client could POST data anywhere on 443. The added FL-server port widens egress on that one port to any IP — accepted because the FL server is behind an internet-facing NLB with rotating AWS-managed IPs that a `/32` pin cannot track. | | intra-namespace | same namespace | trust-api → imaging/data-access/fl-client, etc. | Low — intra-trust only. | +| `allowedIngressCIDRsWithPorts` (default `[]`) | listed CIDRs, one port, **inbound** to xnat-web | The DICOM C-STORE return leg. FLIP pulls, so after XNAT issues C-MOVE the PACS opens a new association back to XNAT; without this it is dropped and retrievals silently time out (FLIP#993). | Scope to the PACS itself, never the whole trust network. Default-deny is unchanged while the list is empty. | | `allowedEgressCIDRs` (default `[]`) | listed CIDRs, **all ports** | Operator escape hatch to reach an external OMOP/PACS/XNAT on arbitrary ports. | Scoped to listed CIDRs; all-ports is broad — keep the list tight. | | `allowedEgressCIDRsWithPorts` (default `[]`) | listed CIDRs, one port | Operator escape hatch for CIDR+port egress (e.g. an on-prem service on a fixed IP). Not populated by `sync-kit` — the FL-server allowance is port-only (see `allowedEgressPorts`). | Scoped CIDR+port — tightest rule. | | AWS IMDS | `169.254.169.254/32` | EC2 metadata / IAM-role credentials for the fl-client S3 kit sync. | IMDS is a known SSRF/cred-theft target — see hardening note. | diff --git a/deploy/providers/kubernetes/README.md b/deploy/providers/kubernetes/README.md index 4e7fa4d3b..ceaf5691e 100644 --- a/deploy/providers/kubernetes/README.md +++ b/deploy/providers/kubernetes/README.md @@ -435,7 +435,9 @@ old install on the previous chart version. - **NetworkPolicies**: Default-deny-ingress, allow-intra-namespace, allow-egress to Central Hub and FL server only (audit and threat model: [NETWORK-POLICY.md](NETWORK-POLICY.md)) -- **No LoadBalancer or NodePort** for application services (all ClusterIP) +- **No LoadBalancer or NodePort** for application services (all ClusterIP), with one opt-in + exception: `xnat.web.dicomNodePort` with `service.type: NodePort` exposes XNAT's DICOM SCP + receiver so a trust PACS can complete the C-STORE leg of a retrieval. Off by default. - **Secrets**: Separate from ConfigMaps; recommend External Secrets Operator - **FL clients**: No Central Hub credentials; connect outbound to FL server only - **ServiceAccounts**: each stateless service runs under its own ServiceAccount diff --git a/deploy/providers/kubernetes/TROUBLESHOOTING.md b/deploy/providers/kubernetes/TROUBLESHOOTING.md index 6d7b491f3..2b424c9ec 100644 --- a/deploy/providers/kubernetes/TROUBLESHOOTING.md +++ b/deploy/providers/kubernetes/TROUBLESHOOTING.md @@ -459,7 +459,7 @@ The study data is sent to XNAT's prearchive. |---------|---------|------|------|---------| | XNAT SCP | `XNAT` | xnat-web | 8104 | Receives C-STORE from PACS | | Orthanc | `ORTHANC` | orthanc | 4242 | PACS — stores DICOM studies | -| Imaging Worker | `FLIPIMPORT` | (any) | — | C-MOVE source AE | +| Imaging Worker | `XNAT (configurable via `xnat.web.dicomAet`)` | (any) | — | C-MOVE source AE | #### XNAT SCP Receiver Configuration diff --git a/deploy/providers/kubernetes/templates/network-policy.yaml b/deploy/providers/kubernetes/templates/network-policy.yaml index 10d5307c8..7ece88c51 100644 --- a/deploy/providers/kubernetes/templates/network-policy.yaml +++ b/deploy/providers/kubernetes/templates/network-policy.yaml @@ -49,6 +49,43 @@ spec: policyTypes: - Ingress --- +{{- if .Values.networkPolicies.allowedIngressCIDRsWithPorts }} +# Allow inbound DICOM from the trust PACS. +# +# The default-deny above models FLIP's zero-inbound posture, which holds for the internet and for +# the Central Hub: neither can open a connection to a trust. Retrieval from a trust PACS is the one +# exception, and it is inbound by protocol rather than by choice — FLIP pulls, so after XNAT issues +# C-MOVE the PACS opens a *new* association back to XNAT to C-STORE the studies (FLIP#993). +# +# This stays default-deny until an operator lists the PACS CIDRs, and the allowance is scoped to +# those CIDRs on that port only. It does not open anything to the internet or to the hub. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "flip-trust.fullname" . }}-allow-pacs-ingress + namespace: {{ include "flip-trust.namespace" . }} + labels: + {{- include "flip-trust.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "flip-trust.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: xnat-web + ingress: +{{- range .Values.networkPolicies.allowedIngressCIDRsWithPorts }} + - from: + {{- range .cidrs }} + - ipBlock: + cidr: {{ . | quote }} + {{- end }} + ports: + - port: {{ .port }} + protocol: {{ .protocol | default "TCP" }} +{{- end }} + policyTypes: + - Ingress +{{- end }} +--- # Allow egress to specific destinations (Central Hub, FL server, DNS, IMDS) # All other egress is denied apiVersion: networking.k8s.io/v1 diff --git a/deploy/providers/kubernetes/templates/orthanc.yaml b/deploy/providers/kubernetes/templates/orthanc.yaml index 57823ad9c..a31beb8dc 100644 --- a/deploy/providers/kubernetes/templates/orthanc.yaml +++ b/deploy/providers/kubernetes/templates/orthanc.yaml @@ -23,7 +23,7 @@ data: # Orthanc configuration is built from values; users may supply additional # configuration via extra config maps in the future. ORTHANC__DICOM_MODALITIES: | - {"XNAT": {"AET": "XNAT", "Host": "xnat-web", "Port": "8104"}} + {"XNAT": {"AET": {{ .Values.xnat.web.dicomAet | default "XNAT" | quote }}, "Host": "xnat-web", "Port": {{ .Values.xnat.web.dicomPort | default 8104 | quote }}}} --- apiVersion: v1 kind: Service diff --git a/deploy/providers/kubernetes/templates/xnat-init-job.yaml b/deploy/providers/kubernetes/templates/xnat-init-job.yaml index 6f5de8230..7e9bd4071 100644 --- a/deploy/providers/kubernetes/templates/xnat-init-job.yaml +++ b/deploy/providers/kubernetes/templates/xnat-init-job.yaml @@ -40,362 +40,40 @@ spec: # POST /xapi/users instead — see the comment at that call. containers: - name: configure-xnat-web - image: alpine:3.20 + # Runs the same configure-xnat.sh the Compose deployment runs, from the same image that + # ships it (trust/xnat/xnat/Dockerfile ADDs config/ to ${XNAT_ROOT}/config). This used to + # be ~380 lines of shell inlined here plus a third copy in a ConfigMap, and the three had + # drifted: the inlined copy hardcoded the mocked Orthanc and ignored the chart's own + # orthanc.dicomHost / dicomPort / dicomAet values, which is why the external-PACS override + # never actually redirected DQR (FLIP#993). One script, one place, covered by + # trust/xnat/tests/. + # + # configure-xnat.sh is self-contained: it waits for XNAT, waits for the DQR plugin routes + # via wait-for-xnat-plugins.sh, then applies site, user, DQR, SCP receiver and PACS + # configuration. It short-circuits when the site is already initialised, so re-running on + # helm upgrade is safe. + image: "{{ .Values.xnat.web.image.repository }}:{{ .Values.xnat.web.image.tag }}" + imagePullPolicy: {{ .Values.xnat.web.image.pullPolicy }} command: - - /bin/sh + - /bin/bash - -c - | set -euo pipefail - apk add --no-cache curl jq >/dev/null 2>&1 - XNAT_URL="http://xnat-web:8080" - ADMIN_USER="{{ .Values.xnat.web.adminUser }}" - ADMIN_PASS="${XNAT_ADMIN_PASS}" - - # Scrub secrets out of anything echoed on failure: the value - # following -u (credentials) or -d/--data-binary (payloads carry - # the admin and service passwords) must never reach the pod log, - # and neither must a live JSESSIONID. - scrub_args() { - local redact_next="" - local arg - local out="" - for arg in "$@"; do - if [ -n "$redact_next" ]; then - out="$out " - redact_next="" - elif [ "$arg" = "-u" ] || [ "$arg" = "-d" ] || [ "$arg" = "--data-binary" ]; then - out="$out $arg" - redact_next=1 - else - out="$out $arg" - fi - done - printf '%s' "${out# }" | sed 's/JSESSIONID=[^ ]*/JSESSIONID=/g' - } - - # Fail-loud wrapper for XNAT's REST API — the Kubernetes - # counterpart of xnat_curl in trust/xnat/xnat/config/configure-xnat.sh - # (FLIP#862). Bare `curl -s` exits 0 on HTTP errors, so every - # configuration call below used to be swallowed by `|| true` and - # this Job reported success on a half-configured XNAT — the same - # silent-failure class as the unregistered-PACS bug (FLIP#822). - # On 2xx, emits the response body on stdout. On anything else — - # or a curl transport failure — reports the (scrubbed) request and - # the response body on stderr and returns non-zero, which `set -e` - # turns into an abort at the call site. - xnat_curl() { - local response - local status - local body - local curl_exit=0 - response=$(curl -sS --connect-timeout 10 --max-time 120 -w '\n%{http_code}' "$@") || curl_exit=$? - if [ "$curl_exit" -ne 0 ]; then - echo "ERROR: curl transport failure (exit $curl_exit)" >&2 - echo " args: $(scrub_args "$@")" >&2 - return 1 - fi - # Strip CRs so a middlebox emitting \r\n line endings cannot - # make the status guard fail on an invisible character. - status=$(printf '%s' "$response" | tail -n1 | tr -d '\r') - body=$(printf '%s' "$response" | sed '$d' | tr -d '\r') - # Fail closed: anything other than a literal 2xx status line - # (including an empty or non-numeric one) is an error. - case "$status" in - 2[0-9][0-9]) ;; - *) - echo "ERROR: XNAT request failed with HTTP $status" >&2 - echo " args: $(scrub_args "$@")" >&2 - echo " body: $body" >&2 - return 1 - ;; - esac - if [ -n "$body" ]; then - printf '%s\n' "$body" - fi - } - - echo "Waiting for XNAT to be ready..." - XNAT_READY=false - HTTP_CODE=000 - for i in $(seq 1 60); do - # `|| HTTP_CODE=000` is load-bearing: `set -e` aborts on a - # failing command substitution, so without it the first probe - # against a still-booting Tomcat (curl exit 7) kills this - # container and the loop can never wait. - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - --connect-timeout 5 --max-time 10 "${XNAT_URL}/xapi/siteConfig" 2>/dev/null) || HTTP_CODE=000 - if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "401" ]; then - echo "XNAT is ready (HTTP ${HTTP_CODE})!" - XNAT_READY=true - break - fi - sleep 10 - done - if [ "$XNAT_READY" != "true" ]; then - echo "ERROR: XNAT did not become ready within 600s (last HTTP ${HTTP_CODE})" >&2 - exit 1 - fi - - # Login: try configured password first, fall back to XNAT default (admin:admin pre-init) - _xnat_login() { - local PASS="$1" - local RESP - RESP=$(curl -s -D - -X POST "${XNAT_URL}/data/JSESSION" -u "${ADMIN_USER}:${PASS}" 2>/dev/null) - local SID - SID=$(echo "$RESP" | tail -1 | tr -d '[:space:]') - if [ -z "$SID" ] || echo "$SID" | grep -qi '/dev/null) || HTTP_CODE=000 - case "${HTTP_CODE}" in - 2*) - echo "XNAT plugin routes are ready (HTTP ${HTTP_CODE})!" - PLUGINS_READY=true - break - ;; - esac - sleep 10 - done - if [ "$PLUGINS_READY" != "true" ]; then - echo "ERROR: XNAT plugin routes did not register within $(( $(date +%s) - PLUGIN_WAIT_START ))s (last HTTP ${HTTP_CODE})" >&2 - echo " endpoint: ${XNAT_URL}/xapi/dqr/settings" >&2 - exit 1 - fi - - # Activate XNAT site (idempotent — safe to call even if already initialized). - # siteUrl must be non-empty on XNAT >= 1.10.0: the Restlet create paths NPE on a - # null siteUrl while building the response (entity created, request 500s) — see - # trust/xnat/xnat/config/configure-xnat.sh for the full explanation. - echo "Activating XNAT site..." - xnat_curl -X POST "${XNAT_URL}/xapi/siteConfig" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d "{\"initialized\": true, \"siteUrl\": \"${XNAT_URL}\"}" >/dev/null - - # Set admin password to configured value if it differs from default - echo "Setting admin password..." - xnat_curl -X PUT "${XNAT_URL}/xapi/users/${ADMIN_USER}" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d "{\"password\": \"${ADMIN_PASS}\"}" >/dev/null - - # Grant the admin account the ContainerManager role. Mirrors - # trust/xnat/xnat/config/configure-xnat.sh — the sibling - # configure-dcm2niix container authenticates as admin, and since - # Container Service 3.7.0 both /xapi/docker/server and - # /xapi/commands require this role. Without it those calls fail - # with 401/403 and, because they were `|| true`, the Job still - # reported success with dcm2niix silently unregistered. - echo "Assigning role 'ContainerManager' to ${ADMIN_USER}..." - xnat_curl -X PUT "${XNAT_URL}/xapi/users/${ADMIN_USER}/roles/ContainerManager" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "accept: application/json" >/dev/null - - # Assign roles to service account - SERVICE_USER="${XNAT_SERVICE_USER:-flipServiceAccount}" - - # Create the service account through the REST API, never with a raw - # INSERT into xdat_user: XNAT only provisions the account's matching - # xhbm_xdat_user_auth "localdb" record on this path, and without that - # record every login is rejected 401 no matter what - # xdat_user.primary_password holds. XNAT 1.9.3 masked a DB-layer - # insert by back-filling the record on the password PUT below; 1.10.0 - # does not, and since the PUT still answers 200 the job would report - # success on an XNAT whose service account can never authenticate — - # imaging-api then 401s on every call (TROUBLESHOOTING.md §2.3). - echo "Ensuring service account ${SERVICE_USER} exists..." - existing_users=$(xnat_curl "${XNAT_URL}/xapi/users" \ - -H "Cookie: JSESSIONID=${JSESSION}") - if printf '%s' "$existing_users" | jq -e --arg u "${SERVICE_USER}" 'index($u)' >/dev/null; then - echo " ${SERVICE_USER} already exists — leaving as-is." - else - xnat_curl -X POST "${XNAT_URL}/xapi/users" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d "{\"username\": \"${SERVICE_USER}\", - \"password\": \"${SERVICE_PASS}\", - \"firstName\": \"FLIP\", - \"lastName\": \"Service\", - \"email\": \"flip@gstt.nhs.uk\", - \"enabled\": true, - \"verified\": true}" >/dev/null - echo " ${SERVICE_USER} created." - fi - - # Sync service-account password from the chart secret so it always - # matches XNAT_SERVICE_PASSWORD in imaging-api, even after a backup - # restore that carries an older password hash. - echo "Setting ${SERVICE_USER} password..." - xnat_curl -X PUT "${XNAT_URL}/xapi/users/${SERVICE_USER}" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d "{\"password\": \"${SERVICE_PASS}\"}" >/dev/null - echo "Assigning roles to ${SERVICE_USER}..." - xnat_curl -X PUT "${XNAT_URL}/xapi/users/${SERVICE_USER}/groups/" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d '["ALL_DATA_ADMIN"]' >/dev/null - xnat_curl -X PUT "${XNAT_URL}/xapi/users/${SERVICE_USER}/roles/" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d '["ContainerManager","DataManager","SiteUser","Administrator","Dqr","non_expiring"]' >/dev/null - - # Apply + enable the site-wide anonymization script (mirrors - # trust/xnat/xnat/config/configure-xnat.sh in the Compose deploy). - echo "Applying site-wide anonymization script..." - xnat_curl -X PUT "${XNAT_URL}/xapi/anonymize/site" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: text/plain" \ - --data-binary @/cs-config/anon_script.das >/dev/null - xnat_curl -X PUT "${XNAT_URL}/xapi/anonymize/site/enabled" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d 'true' >/dev/null - - # Replace the default DICOM SCP receiver with the DQR-aware one. - # The stock receiver uses identifier=dicomObjectIdentifier with - # customProcessing=false, which leaves DQR-pulled studies in the - # Unassigned prearchive ("Cannot build session. 0 prearchive - # sessions found"). DQR needs identifier=dqrObjectIdentifier + - # customProcessing so received studies are routed to the - # requesting project and the relabel map (Subject UUID / - # Session=accession) is applied. - echo "Replacing DICOM SCP receiver with DQR-aware config..." - scp_list=$(xnat_curl "${XNAT_URL}/xapi/dicomscp" \ - -H "Cookie: JSESSIONID=${JSESSION}") - for SCP_ID in $(printf '%s' "$scp_list" | jq -r '.[] | select(.aeTitle == "XNAT") | .id'); do - echo " Removing existing SCP receiver id=${SCP_ID}..." - xnat_curl -X DELETE "${XNAT_URL}/xapi/dicomscp/${SCP_ID}" \ - -H "Cookie: JSESSIONID=${JSESSION}" >/dev/null - done - xnat_curl -X POST "${XNAT_URL}/xapi/dicomscp" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d '{ - "aeTitle": "XNAT", - "port": 8104, - "enabled": true, - "customProcessing": true, - "directArchive": true, - "identifier": "dqrObjectIdentifier", - "anonymizationEnabled": true, - "whitelistEnabled": false, - "whitelistText": "", - "routingExpressionsEnabled": false, - "projectRoutingExpression": "", - "subjectRoutingExpression": "", - "sessionRoutingExpression": "" - }' >/dev/null - - # Register PACS. Check-then-create by aeTitle: a duplicate - # registration surfaces as an unspecific 500 (DB unique-constraint - # violation), so re-run idempotency has to be a lookup rather than - # a tolerated status code (FLIP#862). - existing_pacs=$(xnat_curl "${XNAT_URL}/xapi/pacs" \ - -H "Cookie: JSESSIONID=${JSESSION}") - if printf '%s' "$existing_pacs" | grep -q '"aeTitle":"ORTHANC"'; then - echo "PACS 'ORTHANC' already registered — leaving as-is." - else - echo "Registering PACS..." - xnat_curl -X POST "${XNAT_URL}/xapi/pacs" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d '{ - "aeTitle": "ORTHANC", - "defaultQueryRetrievePacs": true, - "defaultStoragePacs": true, - "host": "orthanc", - "label": "Orthanc PACS", - "ormStrategySpringBeanId": "dicomOrmStrategy", - "queryRetrievePort": 4242, - "queryable": true, - "storable": true, - "supportsExtendedNegotiations": true - }' >/dev/null - fi - - # Configure DQR settings - echo "Configuring DQR settings..." - xnat_curl -X POST "${XNAT_URL}/xapi/dqr/settings" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d '{ - "pacsAvailabilityCheckFrequency": "1 minute", - "dqrWaitToRetryRequestInSeconds": "300", - "assumeSameSessionIfArrivedWithin": "30 minutes", - "allowAllUsersToUseDqr": false, - "dqrCallingAe": "XNAT", - "notifyAdminOnImport": false, - "allowAllProjectsToUseDqr": true, - "leavePacsAuditTrail": false, - "dqrMaxPacsRequestAttempts": "100" - }' >/dev/null - - # Configure PACS availability for all days. DQR pre-creates - # availability intervals when the PACS is registered, so this POST - # returns 400 "probable overlap with existing interval" for an - # already-scheduled day — treated as "already configured" rather - # than a failure. Anything else non-2xx is a real error. - echo "Configuring PACS availability..." - for DAY in MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY; do - avail_status=$(curl -s -o /tmp/pacs-availability-response.json \ - --connect-timeout 10 --max-time 120 -w '%{http_code}' \ - -X POST "${XNAT_URL}/xapi/pacs/1/availability" \ - -H "Cookie: JSESSIONID=${JSESSION}" \ - -H "Content-Type: application/json" \ - -d "{\"availabilityEnd\":\"23:59\",\"availabilityStart\":\"00:00\",\"availableNow\":true,\"dayOfWeek\":\"$DAY\",\"enabled\":true,\"pacsId\":1,\"threads\":4,\"utilizationPercent\":100}") || avail_status="000" - case "$avail_status" in - 2[0-9][0-9]) ;; - 400) - echo " Availability interval for $DAY already exists (HTTP 400 overlap) — leaving as-is." - ;; - *) - echo "ERROR: setting PACS availability for $DAY failed (HTTP $avail_status)" >&2 - cat /tmp/pacs-availability-response.json >&2 || true - exit 1 - ;; - esac - done - - echo "XNAT web configuration complete." + cd /data/xnat/config + bash configure-xnat.sh env: - - name: XNAT_ADMIN_PASS + - name: XNAT_URL + value: "http://xnat-web:8080" + - name: XNAT_ADMIN_USER + value: {{ .Values.xnat.web.adminUser | quote }} + # Both map to the same secret key, matching the xnat-web deployment: on a fresh install + # XNAT is seeded with this password, and configure-xnat.sh rotates from it to itself. + - name: XNAT_ADMIN_INITIAL_PASSWORD + valueFrom: + secretKeyRef: + name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} + key: xnat-admin-password + - name: XNAT_ADMIN_PASSWORD valueFrom: secretKeyRef: name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} @@ -405,27 +83,48 @@ spec: secretKeyRef: name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} key: xnat-service-user - - name: SERVICE_PASS + - name: XNAT_SERVICE_PASSWORD valueFrom: secretKeyRef: name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} key: xnat-service-password - volumeMounts: - - name: cs-config - mountPath: /cs-config + # DICOM SCP receiver: the port XNAT binds and registers, and the AE title the PACS + # addresses its C-STORE association to. Must match what the PACS has registered. + - name: XNAT_PORT + value: {{ .Values.xnat.web.dicomPort | default 8104 | quote }} + - name: XNAT_AETITLE + value: {{ .Values.xnat.web.dicomAet | default "XNAT" | quote }} + # Upstream PACS. Defaults are the mocked Orthanc; a real trust overrides them. + - name: PACS_HOST + value: {{ .Values.pacs.host | quote }} + - name: PACS_AETITLE + value: {{ .Values.pacs.aeTitle | quote }} + - name: PACS_QR_PORT + value: {{ .Values.pacs.qrPort | quote }} + - name: PACS_LABEL + value: {{ .Values.pacs.label | quote }} + # Throttle: a production PACS may refuse further associations after a certain volume. + - name: PACS_AVAILABILITY_DAYS + value: {{ .Values.pacs.availability.days | quote }} + - name: PACS_AVAILABILITY_START + value: {{ .Values.pacs.availability.start | quote }} + - name: PACS_AVAILABILITY_END + value: {{ .Values.pacs.availability.end | quote }} + - name: PACS_THREADS + value: {{ .Values.pacs.availability.threads | quote }} + - name: PACS_UTILIZATION_PERCENT + value: {{ .Values.pacs.availability.utilizationPercent | quote }} + - name: DQR_MAX_PACS_REQUEST_ATTEMPTS + value: {{ .Values.pacs.dqr.maxRequestAttempts | quote }} + - name: DQR_RETRY_WAIT_SECONDS + value: {{ .Values.pacs.dqr.retryWaitSeconds | quote }} resources: requests: - memory: "64Mi" + memory: "128Mi" cpu: "100m" limits: - memory: "128Mi" + memory: "256Mi" cpu: "200m" - # Configures the XNAT Container Service plugin to use the native - # Kubernetes compute backend (available since container-service 3.2.0), - # registers the dcm2niix command, and enables the Event Service so - # imaging-api can create per-project event subscriptions. Mirrors what - # configure-dcm2niix.sh does in the Compose deployment, but speaks to - # the K8s backend instead of the Docker socket. - name: configure-dcm2niix image: alpine:3.20 command: @@ -844,142 +543,4 @@ data: "generic-resources": {}, "ulimits": {} } ---- -# ConfigMap with helper scripts for XNAT configuration -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "flip-trust.fullname" . }}-xnat-scripts - namespace: {{ include "flip-trust.namespace" . }} - labels: - {{- include "flip-trust.labels" . | nindent 4 }} - app.kubernetes.io/component: xnat-init -data: - configure-xnat.sh: | - #!/bin/sh - # XNAT Configuration Script - # This script configures XNAT after initial deployment. - # It can be run manually if the automated init job is disabled. - set -euo pipefail - - XNAT_URL="${1:-http://xnat-web:8080}" - ADMIN_USER="${2:-admin}" - ADMIN_PASS="${3:-}" - SERVICE_USER="${4:-flipServiceAccount}" - SERVICE_PASS="${5:-}" - ORTHANC_HOST="${6:-orthanc}" - PACS_DICOM_PORT="${7:-4242}" - - if [ -z "$ADMIN_PASS" ] || [ -z "$SERVICE_PASS" ]; then - echo "Usage: configure-xnat.sh [service_user] [service_pass] [orthanc_host] [pacs_port]" - exit 1 - fi - - echo "Configuring XNAT at ${XNAT_URL}..." - - wait_for_xnat() { - echo "Waiting for XNAT to be available..." - for i in $(seq 1 120); do - if wget -q --spider "${XNAT_URL}/app/template/Login.vm" 2>/dev/null; then - echo "XNAT is up!" - return 0 - fi - sleep 5 - done - echo "XNAT did not become available within timeout" - return 1 - } - - wait_for_xnat - - # The session id is written to disk on the way through, so remove it whenever this script - # exits. It is a live XNAT admin session and the file would otherwise outlive its use inside - # the container's filesystem. - trap 'rm -f /tmp/jsession.txt' EXIT - - # Login and get JSESSIONID - login() { - local u="$1" p="$2" - wget -q -O /tmp/jsession.txt --post-data="" \ - --header="Authorization: Basic $(printf '%s:%s' "$u" "$p" | base64)" \ - "${XNAT_URL}/data/JSESSION" 2>/dev/null || true - cat /tmp/jsession.txt 2>/dev/null || echo "" - } - - JSESSION=$(login "${ADMIN_USER}" "${ADMIN_PASS}") - - if [ -z "$JSESSION" ]; then - echo "Trying initial admin password..." - JSESSION=$(login "${ADMIN_USER}" "${ADMIN_PASS}") - if [ -z "$JSESSION" ]; then - echo "Failed to authenticate with XNAT" - exit 1 - fi - fi - - COOKIE="Cookie: JSESSIONID=${JSESSION}" - - # Activate XNAT site. siteUrl must be non-empty on XNAT >= 1.10.0 (Restlet create paths - # NPE on null siteUrl while building the response) — see configure-xnat.sh. - echo "Activating XNAT site..." - wget -q -O /dev/null --header="${COOKIE}" --header="Content-Type: application/json" \ - --post-data="{\"initialized\": true, \"siteUrl\": \"${XNAT_URL}\"}" "${XNAT_URL}/xapi/siteConfig" - - # Register PACS (Orthanc) - echo "Registering PACS..." - wget -q -O /dev/null --header="${COOKIE}" --header="Content-Type: application/json" \ - --post-data="{ - \"aeTitle\": \"ORTHANC\", - \"defaultQueryRetrievePacs\": true, - \"defaultStoragePacs\": true, - \"host\": \"${ORTHANC_HOST}\", - \"label\": \"Orthanc PACS\", - \"ormStrategySpringBeanId\": \"dicomOrmStrategy\", - \"queryRetrievePort\": ${PACS_DICOM_PORT}, - \"queryable\": true, - \"storable\": true, - \"supportsExtendedNegotiations\": true - }" "${XNAT_URL}/xapi/pacs" - - # Assign roles to service account - echo "Assigning roles to service account..." - wget -q -O /dev/null --header="${COOKIE}" --header="Content-Type: application/json" \ - --post-data='["ALL_DATA_ADMIN"]' \ - "${XNAT_URL}/xapi/users/${SERVICE_USER}/groups/" - wget -q -O /dev/null --header="${COOKIE}" --header="Content-Type: application/json" \ - --post-data='["ContainerManager","DataManager","SiteUser","Administrator","Dqr","non_expiring"]' \ - "${XNAT_URL}/xapi/users/${SERVICE_USER}/roles/" - - # Configure DQR settings - echo "Configuring DQR settings..." - wget -q -O /dev/null --header="${COOKIE}" --header="Content-Type: application/json" \ - --post-data='{ - "pacsAvailabilityCheckFrequency": "1 minute", - "dqrWaitToRetryRequestInSeconds": "300", - "assumeSameSessionIfArrivedWithin": "30 minutes", - "allowAllUsersToUseDqr": false, - "dqrCallingAe": "XNAT", - "notifyAdminOnImport": false, - "allowAllProjectsToUseDqr": true, - "leavePacsAuditTrail": false, - "dqrMaxPacsRequestAttempts": "100" - }' "${XNAT_URL}/xapi/dqr/settings" - - # Configure PACS availability for all days - echo "Configuring PACS availability..." - for DAY in MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY; do - wget -q -O /dev/null --header="${COOKIE}" --header="Content-Type: application/json" \ - --post-data="{ - \"availabilityEnd\": \"23:59\", - \"availabilityStart\": \"00:00\", - \"availableNow\": true, - \"dayOfWeek\": \"$DAY\", - \"enabled\": true, - \"pacsId\": 1, - \"threads\": 4, - \"utilizationPercent\": 100 - }" "${XNAT_URL}/xapi/pacs/1/availability" - done - - echo "XNAT configuration complete." {{- end }} diff --git a/deploy/providers/kubernetes/templates/xnat-web.yaml b/deploy/providers/kubernetes/templates/xnat-web.yaml index c2fe3b84c..3648fc509 100644 --- a/deploy/providers/kubernetes/templates/xnat-web.yaml +++ b/deploy/providers/kubernetes/templates/xnat-web.yaml @@ -54,6 +54,11 @@ spec: targetPort: {{ .Values.xnat.web.dicomPort | default 8104 }} protocol: TCP name: dicom-scp + {{- if and .Values.xnat.web.dicomNodePort (eq .Values.xnat.web.service.type "NodePort") }} + # Pinned so the PACS has a stable destination port. Without this Kubernetes allocates one at + # random, and the C-MOVE destination XNAT advertises would not match what the PACS can reach. + nodePort: {{ .Values.xnat.web.dicomNodePort }} + {{- end }} selector: {{- include "flip-trust.selectorLabels" . | nindent 4 }} app.kubernetes.io/component: xnat-web diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index 03bc23ccb..f6ff14ff6 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -484,9 +484,6 @@ omopDb: orthanc: enabled: true host: orthanc - dicomHost: orthanc - dicomPort: 4242 - dicomAet: ORTHANC external: host: "" port: 8042 @@ -546,10 +543,20 @@ xnat: web: enabled: true adminUser: "admin" - # DICOM SCP (Service Class Provider) configuration - # XNAT listens as AE 'XNAT' on this port to receive C-STORE requests + # DICOM SCP (Service Class Provider) configuration. + # XNAT binds this port, registers it on its dicomscp receiver, and advertises it as the C-MOVE + # destination. DQR matches that destination against a registered receiver by exact AE title and + # port, so these must equal what the PACS has registered for us — no translation is possible on + # that leg (FLIP#993). dicomAet: XNAT dicomPort: 8104 + # Fixed NodePort for the DICOM receiver, used with service.type: NodePort. Required when the + # PACS is outside the cluster: after XNAT issues C-MOVE the PACS opens a *new* association back + # to XNAT to C-STORE the studies, so that port must be reachable. Leave empty for the mocked + # Orthanc, which reaches the receiver over the cluster network. + # Must be inside the API server's --service-node-port-range (default 30000-32767), or the range + # widened to admit the DICOM port. Set it equal to dicomPort so one number is true end to end. + dicomNodePort: "" image: repository: ghcr.io/londonaicentre/xnat-web tag: stag @@ -733,12 +740,45 @@ podDisruptionBudget: # --------------------------------------------------------------------------- # Network policies — zero inbound trust model # --------------------------------------------------------------------------- +# Upstream PACS that XNAT retrieves imaging from, via the DQR plugin. +# +# Defaults describe the mocked Orthanc deployed by this chart. A trust points these at its own PACS; +# see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". These replace the +# former orthanc.dicomHost / dicomPort / dicomAet values, which no template ever read — which is why +# setting orthanc.enabled: false with an external.host never actually redirected DQR. +pacs: + host: orthanc + aeTitle: ORTHANC + qrPort: 4242 + label: Test PACS instance + # Throttle. A production PACS may refuse further associations after a certain volume, and a trust + # may want retrieval confined to out-of-hours. Agree the window with the PACS manager. + availability: + days: MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY + start: "00:00" + end: "24:00" + threads: 1 + utilizationPercent: 100 + dqr: + maxRequestAttempts: 100 + retryWaitSeconds: 300 + networkPolicies: enabled: true # When true, allows ingress from kube-system namespace (kubelet health checks, # metrics scraping). Set to true if your CNI requires explicit carve-outs for # kubelet probes or cluster-internal monitoring. allowKubeSystemIngress: false + # CIDRs allowed inbound on a specific port, in addition to the default-deny above. + # The only intended use is the DICOM C-STORE return leg: FLIP retrieves by pull, so after XNAT + # issues C-MOVE the PACS opens a new association back to XNAT. That is inbound, and without an + # allowance here it is dropped — queries succeed and retrievals silently time out (FLIP#993). + # Scope this to the PACS itself, not the whole trust network. + # Each entry: cidrs (list of CIDR strings) + port (int) + optional protocol (default TCP). + allowedIngressCIDRsWithPorts: [] + # - cidrs: + # - 10.0.0.10/32 + # port: 8104 # CIDRs allowed for all-ports egress (in addition to intra-namespace) allowedEgressCIDRs: [] # - 10.0.0.0/8 diff --git a/docs/source/governance-and-compliance.rst b/docs/source/governance-and-compliance.rst index 6359516ac..ef66620fa 100644 --- a/docs/source/governance-and-compliance.rst +++ b/docs/source/governance-and-compliance.rst @@ -75,10 +75,12 @@ Network architecture as a governance guarantee ********************************************** The network design is why the guarantees above are structural rather than procedural. -Trust systems accept no inbound connections: each trust polls the Central Hub outbound, -there are no inbound firewall rules to open, and there is no route from the internet — or +Each trust polls the Central Hub outbound, and there is no route from the internet — or from the hub — into a trust's network. For a trust's own network team, onboarding FLIP -requires no inbound exposure at all. A site-to-site VPN can be provisioned on request +requires no inbound exposure to the outside world at all. The one inbound rule is +internal to the trust: retrieval from the trust's PACS is a pull, so the PACS opens a +connection back to XNAT to deliver the studies it was asked for, scoped to that PACS on +the DICOM port. A site-to-site VPN can be provisioned on request where a trust's policy calls for network-layer separation as well. The practical governance point: a trust does not have to rely on the Central Hub's access diff --git a/docs/source/security.rst b/docs/source/security.rst index 66613cade..472a6e28a 100644 --- a/docs/source/security.rst +++ b/docs/source/security.rst @@ -24,13 +24,21 @@ automated checks that run against every change are publicly inspectable. Network and perimeter ********************* -**Trust systems accept no inbound connections.** Each participating trust runs FLIP -services that reach *out* to the Central Hub to collect work and report results. -Nothing on the internet can open a connection to a trust's FLIP services. This is -enforced in the infrastructure definitions themselves — the security groups permit no -inbound traffic at all — rather than depending on configuration discipline. -Operator access is via AWS Systems Manager Session Manager, so port 22 is never -opened. +**Nothing outside the trust can open a connection to a trust's FLIP services.** Each +participating trust runs FLIP services that reach *out* to the Central Hub to collect +work and report results. Nothing on the internet, and nothing on the Central Hub, can +open a connection inward. This is enforced in the infrastructure definitions themselves — +the security groups permit no inbound traffic at all — rather than depending on +configuration discipline. Operator access is via AWS Systems Manager Session Manager, so +port 22 is never opened. + +Stated precisely, there is one inbound path, and it is inside the trust's own network. +FLIP retrieves imaging by *pull*: XNAT queries the trust PACS and requests a study, and +the PACS then opens a connection back to XNAT to deliver it. That return connection is +inbound by protocol rather than by design, it originates from the trust's own PACS, and +it is restricted to that PACS on the DICOM port alone. It never crosses the boundary +between the trust and the Central Hub, and it does not weaken the guarantee above: no +route exists from the internet, or from the hub, into a trust's network. **Only the Central Hub is internet-facing.** It sits behind CloudFront with modern TLS, HSTS, AWS WAF managed rules, and an internal-only Application Load Balancer. Nothing diff --git a/trust/AGENTS.md b/trust/AGENTS.md index 8540da92b..c6dc7289f 100644 --- a/trust/AGENTS.md +++ b/trust/AGENTS.md @@ -12,7 +12,7 @@ Trust services run at each healthcare institution (cloud EC2 or on-prem). All tr | fl-client | — | FL participant (connects outbound to FL server via NLB) | | omop-db | 5432 | Mocked OMOP patient database (PostgreSQL); dir also holds the image build source + populate tooling (#834, see `omop-db/AGENTS.md`) | | orthanc | 8042 | Mocked DICOM PACS server (UI/REST behind HTTP basic auth — kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`; DICOM port 4242 is internal to the trust network and not bound to the host) | -| xnat | 8104 | Mocked neuroimaging platform | +| xnat | 8104 | Mocked neuroimaging platform. `XNAT_PORT` is the **DICOM SCP receiver** port; `XNAT_WEB_PORT` (also 8104 by default) is the host-published web UI. The receiver is published only with `REAL_PACS=true`, so the two must differ then (FLIP#993) | | observability | 3000/3100 | Grafana + Loki monitoring stack | ## Kit file structure diff --git a/trust/CLAUDE.md b/trust/CLAUDE.md index 78de0b6ee..8042a6b05 100644 --- a/trust/CLAUDE.md +++ b/trust/CLAUDE.md @@ -12,7 +12,7 @@ Trust services run at each healthcare institution (cloud EC2 or on-prem). All tr | fl-client | — | FL participant (connects outbound to FL server via NLB) | | omop-db | 5432 | Mocked OMOP patient database (PostgreSQL); dir also holds the image build source + populate tooling (#834, see `omop-db/CLAUDE.md`) | | orthanc | 8042 | Mocked DICOM PACS server (UI/REST behind HTTP basic auth — kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`; DICOM port 4242 is internal to the trust network and not bound to the host) | -| xnat | 8104 | Mocked neuroimaging platform | +| xnat | 8104 | Mocked neuroimaging platform. `XNAT_PORT` is the **DICOM SCP receiver** port; `XNAT_WEB_PORT` (also 8104 by default) is the host-published web UI. The receiver is published only with `REAL_PACS=true`, so the two must differ then (FLIP#993) | | observability | 3000/3100 | Grafana + Loki monitoring stack | ## Kit file structure diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py new file mode 100644 index 000000000..c185fc249 --- /dev/null +++ b/trust/xnat/tests/test_configure_pacs.py @@ -0,0 +1,229 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Execution tests for the PACS/DQR configuration in configure-xnat.sh (FLIP#993). + +The script is run for real against a stub ``curl`` that records every payload it is asked to POST or +PUT, so these assert what XNAT would actually receive rather than matching strings in the source. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +CONFIG_DIR = Path(__file__).resolve().parents[1] / "xnat" / "config" +SCRIPT = CONFIG_DIR / "configure-xnat.sh" + +# Answers the two listings the script parses. GET /xapi/pacs returns nothing until a POST has been +# seen, so a single run exercises the register-then-resolve path; seeding the marker file up front +# makes the same stub return an already-registered PACS instead. +STUB_CURL = r"""#!/bin/bash +url=""; data=""; method="GET"; status_only=0; outfile="" +prev="" +for a in "$@"; do + case "$prev" in -d) data="$a";; -X) method="$a";; -o|--output) outfile="$a";; esac + case "$a" in http*) url="$a";; '%{http_code}') status_only=1;; esac + prev="$a" +done +if [ -n "$data" ]; then + printf '%s\n' "=== $method $url" >> "$PAYLOADS" + printf '%s\n' "$data" >> "$PAYLOADS" +fi +body='{}' +case "$url" in + *"/xapi/dicomscp"*) body='[{"id":1,"aeTitle":"XNAT","port":8104}]' ;; + *"/xapi/pacs") + if [ -f "$REGISTERED" ]; then + body='[{"id":'"$STUB_PACS_ID"',"aeTitle":"'"$STUB_PACS_AET"'","host":"'"$STUB_PACS_HOST"'","queryRetrievePort":'"$STUB_PACS_PORT"'}]' + else + body='[]' + fi + [ "$method" = "POST" ] && touch "$REGISTERED" + ;; +esac +[ -n "$outfile" ] && [ "$outfile" != "/dev/null" ] && printf '%s' "$body" > "$outfile" +if [ "$status_only" = "1" ]; then printf '200'; else printf '%s\n200' "$body"; fi +exit 0 +""" + +BASE_ENV = { + "XNAT_ADMIN_USER": "admin", + "XNAT_ADMIN_INITIAL_PASSWORD": "initial", + "XNAT_ADMIN_PASSWORD": "rotated", + "XNAT_SERVICE_USER": "flipServiceAccount", + "XNAT_SERVICE_PASSWORD": "service", + "XNAT_PORT": "8104", +} + + +def run_configure(tmp_path, env_overrides=None, pacs_already_registered=False): + """Runs configure-xnat.sh against the stub and returns (exit code, payloads, combined output).""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stub = bin_dir / "curl" + stub.write_text(STUB_CURL) + stub.chmod(0o755) + + payloads = tmp_path / "payloads.txt" + registered = tmp_path / "registered" + if pacs_already_registered: + registered.touch() + + env = { + **os.environ, + **BASE_ENV, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "PAYLOADS": str(payloads), + "REGISTERED": str(registered), + # What the stub reports as registered. Defaults to the mock; when a test configures a + # different PACS the stub echoes that back, mimicking XNAT after the POST succeeded. + "STUB_PACS_ID": "7", + "STUB_PACS_AET": (env_overrides or {}).get("PACS_AETITLE", "ORTHANC"), + "STUB_PACS_HOST": "orthanc" if pacs_already_registered else (env_overrides or {}).get("PACS_HOST", "orthanc"), + "STUB_PACS_PORT": "4242" if pacs_already_registered else (env_overrides or {}).get("PACS_QR_PORT", "4242"), + **(env_overrides or {}), + } + + result = subprocess.run( + ["bash", str(SCRIPT)], cwd=CONFIG_DIR, env=env, capture_output=True, text=True, timeout=120 + ) + body = payloads.read_text() if payloads.exists() else "" + return result.returncode, body, result.stdout + result.stderr + + +def payload_for(payloads: str, endpoint: str) -> dict: + """Returns the last JSON payload sent to ``endpoint``. + + Matched on the URL's path suffix rather than a substring: ``/xapi/pacs`` would otherwise also + match ``/xapi/pacs/7/availability`` and return the wrong payload. + """ + found = None + for block in payloads.split("=== "): + header, _, rest = block.partition("\n") + url = header.split()[-1] if header.split() else "" + matches = url.endswith(endpoint) or ( + endpoint == "/xapi/pacs" and re.search(r"/xapi/pacs/\d+$", url) is not None + ) + if matches and rest.strip().startswith("{"): + found = json.loads(rest.strip()) + assert found is not None, f"no payload sent to {endpoint}" + return found + + +def test_defaults_configure_the_mocked_orthanc(tmp_path): + """An unconfigured deployment must still describe the mock exactly as before.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + + pacs = payload_for(payloads, "/xapi/pacs") + assert pacs["aeTitle"] == "ORTHANC" + assert pacs["host"] == "orthanc" + assert pacs["queryRetrievePort"] == 4242 + + receiver = payload_for(payloads, "/xapi/dicomscp") + assert receiver["aeTitle"] == "XNAT" + assert receiver["port"] == 8104 + + assert payload_for(payloads, "/xapi/dqr/settings")["dqrCallingAe"] == "XNAT" + + +def test_configured_pacs_and_ae_title_reach_xnat(tmp_path): + """Every configured value must appear in what XNAT is actually sent.""" + code, payloads, output = run_configure( + tmp_path, + { + "XNAT_AETITLE": "FLIPXNAT", + "PACS_HOST": "10.0.0.10", + "PACS_AETITLE": "SECTRA_QR", + "PACS_QR_PORT": "8059", + "PACS_LABEL": "GSTT Sectra PACS", + }, + ) + assert code == 0, output + + pacs = payload_for(payloads, "/xapi/pacs") + assert (pacs["aeTitle"], pacs["host"], pacs["queryRetrievePort"]) == ("SECTRA_QR", "10.0.0.10", 8059) + assert pacs["label"] == "GSTT Sectra PACS" + + # The AE title has to reach all three places that must agree, or the C-STORE association the + # PACS opens is addressed to a receiver that does not exist. + assert payload_for(payloads, "/xapi/dicomscp")["aeTitle"] == "FLIPXNAT" + assert payload_for(payloads, "/xapi/dqr/settings")["dqrCallingAe"] == "FLIPXNAT" + + +def test_throttle_settings_are_configurable(tmp_path): + """The availability window and retry behaviour are the throttle for a production PACS.""" + code, payloads, output = run_configure( + tmp_path, + { + "PACS_AVAILABILITY_DAYS": "SATURDAY,SUNDAY", + "PACS_AVAILABILITY_START": "19:00", + "PACS_AVAILABILITY_END": "07:00", + "PACS_THREADS": "2", + "PACS_UTILIZATION_PERCENT": "40", + "DQR_MAX_PACS_REQUEST_ATTEMPTS": "25", + "DQR_RETRY_WAIT_SECONDS": "120", + }, + ) + assert code == 0, output + + dqr = payload_for(payloads, "/xapi/dqr/settings") + assert dqr["dqrMaxPacsRequestAttempts"] == "25" + assert dqr["dqrWaitToRetryRequestInSeconds"] == "120" + + availability = payload_for(payloads, "/availability") + assert availability["availabilityStart"] == "19:00" + assert availability["availabilityEnd"] == "07:00" + assert availability["threads"] == 2 + assert availability["utilizationPercent"] == 40 + + assert output.count("Setting PACS availability for") == 2, "only the configured days should be scheduled" + + +def test_registration_updates_in_place_when_host_or_port_drift(tmp_path): + """A kit change must not be silently ignored on redeploy, leaving DQR on the old PACS.""" + code, payloads, output = run_configure( + tmp_path, + {"PACS_HOST": "10.0.0.10", "PACS_QR_PORT": "8059"}, + pacs_already_registered=True, # stub reports ORTHANC at orthanc:4242 + ) + assert code == 0, output + assert "updating to 10.0.0.10:8059" in output + + pacs = payload_for(payloads, "/xapi/pacs") + assert pacs["host"] == "10.0.0.10" + assert pacs["queryRetrievePort"] == 8059 + + +def test_matching_registration_is_left_alone(tmp_path): + """An unchanged registration must not be rewritten on every redeploy.""" + code, _, output = run_configure(tmp_path, pacs_already_registered=True) + assert code == 0, output + assert "already registered at orthanc:4242 — leaving as-is" in output + + +def test_availability_uses_the_resolved_pacs_id(tmp_path): + """The schedule must be written against the real registration, not a hardcoded id of 1.""" + code, payloads, output = run_configure(tmp_path, pacs_already_registered=True) + assert code == 0, output + assert payload_for(payloads, "/availability")["pacsId"] == 7 + + +@pytest.mark.parametrize("var", ["XNAT_AETITLE", "PACS_HOST", "PACS_AETITLE", "PACS_QR_PORT"]) +def test_empty_values_fail_loudly(tmp_path, var): + """An empty value would produce malformed JSON that XNAT rejects silently (FLIP#822/#862).""" + code, _, output = run_configure(tmp_path, {var: ""}) + assert code != 0, f"empty {var} should abort the run" + assert var in output diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index d9a761d43..c2357d9e7 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -28,6 +28,10 @@ set -euo pipefail : "${XNAT_ADMIN_USER:?}" "${XNAT_ADMIN_INITIAL_PASSWORD:?}" "${XNAT_ADMIN_PASSWORD:?}" : "${XNAT_SERVICE_USER:?}" "${XNAT_SERVICE_PASSWORD:?}" "${XNAT_PORT:?}" +# ${VAR-default} rather than ${VAR:-default} throughout: an *unset* variable takes the default, +# but one set to the empty string stays empty and trips the guard below. An operator who writes +# PACS_HOST= in a kit file must get a loud failure, not a silent fallback to the mocked PACS. +# # XNAT's own identity and the upstream PACS. Defaults reproduce the mocked Orthanc that ships for # development, so an unconfigured deployment behaves exactly as before; a real trust overrides them # from its kit file (Compose) or Helm values (Kubernetes). @@ -37,21 +41,21 @@ set -euo pipefail # C-STORE association addressed to the AE title it has registered, so a receiver configured under a # different title rejects it. XNAT_URL="${XNAT_URL:-http://xnat-web:8080}" # internal to the container network -XNAT_AETITLE="${XNAT_AETITLE:-XNAT}" -PACS_HOST="${PACS_HOST:-orthanc}" # service name in compose / k8s, or a real PACS host -PACS_AETITLE="${PACS_AETITLE:-ORTHANC}" -PACS_QR_PORT="${PACS_QR_PORT:-4242}" -PACS_LABEL="${PACS_LABEL:-Test PACS instance}" +XNAT_AETITLE="${XNAT_AETITLE-XNAT}" +PACS_HOST="${PACS_HOST-orthanc}" # service name in compose / k8s, or a real PACS host +PACS_AETITLE="${PACS_AETITLE-ORTHANC}" +PACS_QR_PORT="${PACS_QR_PORT-4242}" +PACS_LABEL="${PACS_LABEL-Test PACS instance}" # DQR retry behaviour and the PACS availability schedule — the throttle for a production PACS, which # may refuse further associations after a certain volume (FLIP#993). Defaults are today's values. -DQR_MAX_PACS_REQUEST_ATTEMPTS="${DQR_MAX_PACS_REQUEST_ATTEMPTS:-100}" -DQR_RETRY_WAIT_SECONDS="${DQR_RETRY_WAIT_SECONDS:-300}" -PACS_AVAILABILITY_DAYS="${PACS_AVAILABILITY_DAYS:-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY}" -PACS_AVAILABILITY_START="${PACS_AVAILABILITY_START:-00:00}" -PACS_AVAILABILITY_END="${PACS_AVAILABILITY_END:-24:00}" -PACS_THREADS="${PACS_THREADS:-1}" -PACS_UTILIZATION_PERCENT="${PACS_UTILIZATION_PERCENT:-100}" +DQR_MAX_PACS_REQUEST_ATTEMPTS="${DQR_MAX_PACS_REQUEST_ATTEMPTS-100}" +DQR_RETRY_WAIT_SECONDS="${DQR_RETRY_WAIT_SECONDS-300}" +PACS_AVAILABILITY_DAYS="${PACS_AVAILABILITY_DAYS-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY}" +PACS_AVAILABILITY_START="${PACS_AVAILABILITY_START-00:00}" +PACS_AVAILABILITY_END="${PACS_AVAILABILITY_END-24:00}" +PACS_THREADS="${PACS_THREADS-1}" +PACS_UTILIZATION_PERCENT="${PACS_UTILIZATION_PERCENT-100}" # Same fail-loud contract as the credentials above: a default must never resolve to empty, or the # interpolated JSON is malformed and XNAT rejects it silently (FLIP#822 / FLIP#862). From eddb4117e87953664c20ac039efe696122d37b7f Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Tue, 18 Aug 2026 17:13:57 +0100 Subject: [PATCH 03/31] fix(trust): correct the port split found by deploying it (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a dev deployment — one Compose trust (KCH, slot 2) and one Kubernetes trust in an isolated namespace. Three defects only a real deploy surfaced: - XNAT_WEB_PORT defaulted to a literal 8104, so any trust whose XNAT is not on 8104 would have had its web UI moved onto 8104. On a multi-trust host that collides with an already-running stack: KCH is on 8106, and bringing it up next to GSTT failed. It now defaults to XNAT_PORT, so every kit written before this change keeps the exact host port it had. - `docker stack deploy` rejects nested variable defaults, so ${XNAT_WEB_PORT:-${XNAT_PORT}} failed the whole deploy with "services.xnat-web.ports.0 Does not match format 'ports'". The Makefile always supplies the value, so the bare form is enough — the same shape XNAT_PORT already used. - XNAT_AETITLE was exported empty because the Makefile had no default for it, leaving the compose fallback to cover for it. Added XNAT_AETITLE_EFFECTIVE. Also document what the deployment confirmed: the availability schedule only applies when the PACS is first registered. DQR pre-creates the intervals and rejects a later write to a day that already has one, so changing the window on a running instance needs the existing intervals removed first. Docs: add a DICOM networking primer to the XNAT page — AE titles, SCU/SCP, and the four operations with their directions — because the rest of the section assumed all of it. The C-MOVE callout spells out why the return leg is inbound, which is the detail every past integration has lost. Complete the configuration table with XNAT_WEB_PORT and the throttle settings, and add the DICOM terms to the glossary, which had none. Signed-off-by: at24_bioeng625-pc --- docs/source/components/component-xnat.rst | 76 ++++++++++++++++++++++- docs/source/glossary.rst | 15 +++++ trust/.env.KCH.development.example | 4 ++ trust/xnat/Makefile | 6 +- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/docs/source/components/component-xnat.rst b/docs/source/components/component-xnat.rst index bc4cbba5a..c3ad8e874 100644 --- a/docs/source/components/component-xnat.rst +++ b/docs/source/components/component-xnat.rst @@ -135,6 +135,58 @@ XNAT retrieves imaging from the trust's PACS on demand, for the studies belongin project cohort. This section describes what has to be configured, and what the trust's PACS and network teams need to provide. +DICOM Networking in Brief +========================= + +Three ideas are enough to follow the rest of this section. + +**AE Title.** A DICOM system's *name* on the network — like a hostname, but specific to DICOM, and +at most 16 characters. When one system connects to another it announces "I am *X*, calling *Y*". The +receiver checks that *Y* is its own name and that *X* is one it has been told to accept. Names are +separate from addresses: the IP and port are configured alongside the AE title, not derived from it. + +**SCU and SCP.** Client and server. An SCU (Service Class *User*) opens connections; an SCP (Service +Class *Provider*) listens for them. XNAT is both, at different moments — an SCU when it queries the +PACS, an SCP when it receives the images. + +**The four operations**, in the order FLIP uses them: + +.. list-table:: + :widths: 15 55 30 + :header-rows: 1 + + * - Operation + - What it does + - Direction + * - ``C-ECHO`` + - A DICOM ping. Confirms two systems can reach and accept each other + - either way, for testing + * - ``C-FIND`` + - Search — "which study has accession number ABC123?" + - XNAT to PACS + * - ``C-MOVE`` + - "Send that study to the system called ``FLIPXNAT``" + - XNAT to PACS + * - ``C-STORE`` + - The image transfer itself + - **PACS to XNAT** + +.. important:: + + C-MOVE does not return the images on the connection that asked for them. XNAT names a + destination, the PACS looks that name up in its *own* table to find an address, and opens a + **new connection in the opposite direction** to deliver the study. + + Three consequences follow, and each has caused a failed integration in practice: + + * The destination must be registered on the PACS in advance — a name it does not know cannot be + delivered to. + * The AE title and port XNAT advertises must match that registration exactly. XNAT rejects an + association addressed to a different name, and the DQR plugin refuses to issue a C-MOVE whose + destination does not correspond to one of its own configured receivers. + * The return connection needs its own firewall rule. Every other FLIP connection is outbound, so + this is the one reviewers overlook. + How Retrieval Works =================== @@ -202,8 +254,22 @@ Configuration - Hostname or IP of the trust PACS - ``orthanc`` * - ``PACS_QR_PORT`` - - Query/retrieve port on the trust PACS + - Query/retrieve port on the trust PACS. Must be reachable *from the XNAT container* — this is + not a host-published port - ``4242`` + * - ``XNAT_WEB_PORT`` + - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; separate from + ``XNAT_PORT`` so the DICOM receiver can be published independently. Defaults to ``XNAT_PORT`` + - ``XNAT_PORT`` + * - ``PACS_AVAILABILITY_DAYS`` / ``_START`` / ``_END`` + - When retrieval may run, as a comma-separated day list and a daily window + - all week, ``00:00``–``24:00`` + * - ``PACS_THREADS`` / ``PACS_UTILIZATION_PERCENT`` + - How hard to drive the PACS during that window + - ``1`` / ``100`` + * - ``DQR_MAX_PACS_REQUEST_ATTEMPTS`` / ``DQR_RETRY_WAIT_SECONDS`` + - How many times, and how far apart, to retry a study the PACS did not deliver + - ``100`` / ``300`` The defaults describe the mocked PACS that ships with FLIP for development, described below. @@ -297,6 +363,14 @@ carries an availability schedule with a per-day window, a thread count and a uti Where a trust has a test or pre-production PACS, connecting FLIP to that first is recommended, and is usually raised as a separate service request. +.. note:: + + The availability schedule is applied when the PACS is first registered. The DQR plugin pre-creates + the intervals, and rejects a later write to a day that already has one, so changing the window on + an already-configured instance requires deleting the existing intervals through XNAT's + administration UI first. The values above therefore take effect on a fresh deployment; on a + running one, check what is actually configured rather than assuming the setting was applied. + Verification ============ diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 0fb523098..4b4b460fd 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -25,6 +25,21 @@ Glossary **PACS** Picture Archiving and Communication System (PACS), the clinical system used to store and retrieve medical imaging studies (such as DICOM series). + **AE Title** + Application Entity Title. A DICOM system's name on the network, at most 16 characters. When one system connects to another it announces which AE title it is calling and which it is calling from, and the receiver accepts the connection only if the called title is its own. AE titles are names rather than addresses: the IP and port are configured alongside them. See :doc:`components/component-xnat`. + + **SCU / SCP** + Service Class User and Service Class Provider — DICOM's terms for client and server. An SCU opens connections; an SCP listens for them. A system is often both: XNAT acts as an SCU when it queries a PACS, and as an SCP when it receives the resulting images. + + **DIMSE** + DICOM Message Service Element, the classic DICOM network protocol (as opposed to the newer HTTP-based DICOMweb). FLIP retrieves imaging over DIMSE. + + **C-ECHO / C-FIND / C-MOVE / C-STORE** + The DIMSE operations FLIP uses. ``C-ECHO`` is a connectivity check. ``C-FIND`` searches a PACS, in FLIP's case by accession number. ``C-MOVE`` asks the PACS to send a study to a named destination. ``C-STORE`` is the image transfer itself — and because C-MOVE names a destination rather than returning data inline, the C-STORE arrives on a *new* connection opened by the PACS back to that destination. + + **DQR** + DICOM Query-Retrieve, the XNAT plugin that performs the C-FIND and C-MOVE operations against a trust PACS on FLIP's behalf. + **RBAC** Role Based Access Control (RBAC) defines what users are able to access within the FLIP platform. diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index 7fdb28694..60c15d4e2 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -12,7 +12,11 @@ TRUST_REGION=London # ── Host-local profile ──────────────────────────────────────────────────── OMOP_DB_PORT=5436 PACS_UI_PORT=8044 +# XNAT_PORT is the DICOM SCP receiver port; XNAT_WEB_PORT is the host-published web UI. They were +# one variable until FLIP#993. XNAT_WEB_PORT defaults to XNAT_PORT, so it only needs setting when +# connecting a real PACS: publishing the receiver (REAL_PACS=true) needs two distinct host ports. XNAT_PORT=8106 +XNAT_WEB_PORT=8106 TRUST_DEBUG_PORT=5685 IMAGING_DEBUG_PORT=5684 DATA_ACCESS_DEBUG_PORT=5683 diff --git a/trust/xnat/Makefile b/trust/xnat/Makefile index 958f7078c..b7a5a091d 100644 --- a/trust/xnat/Makefile +++ b/trust/xnat/Makefile @@ -93,7 +93,9 @@ XNAT_NETWORK := deploy_trust-network-$(TRUST_NUM) # DICOM receiver's 8104 is an unpublished container port (FLIP#993). The web default is unchanged so # existing kits, docs and SSM port-forwards keep working. XNAT_PORT_EFFECTIVE := $(or $(XNAT_PORT),8104) -XNAT_WEB_PORT_EFFECTIVE := $(or $(XNAT_WEB_PORT),8104) +XNAT_WEB_PORT_EFFECTIVE := $(or $(XNAT_WEB_PORT),$(XNAT_PORT_EFFECTIVE)) +# XNAT's AE title, applied to the SCP receiver, dqrCallingAe and the C-MOVE destination. +XNAT_AETITLE_EFFECTIVE := $(or $(XNAT_AETITLE),XNAT) PACS_UI_PORT_EFFECTIVE := $(or $(PACS_UI_PORT),8042) # Host path that backs this trust's XNAT bind mounts (parent of xnat-data/ @@ -257,7 +259,7 @@ endif XNAT_PATH=${XNAT_PATH} \ XNAT_PORT=$(XNAT_PORT_EFFECTIVE) \ XNAT_WEB_PORT=$(XNAT_WEB_PORT_EFFECTIVE) \ - XNAT_AETITLE=$(XNAT_AETITLE) \ + XNAT_AETITLE=$(XNAT_AETITLE_EFFECTIVE) \ PACS_UI_PORT=$(PACS_UI_PORT_EFFECTIVE) \ DOCKER_NETWORK_NAME=$(XNAT_NETWORK) \ docker stack deploy --with-registry-auth --detach=false ${STACK_FILES} $(XNAT_STACK) From 06cf7093c1c845786fc95a2a8945287a1553491f Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Tue, 18 Aug 2026 17:27:45 +0100 Subject: [PATCH 04/31] refactor(trust): own the PACS registration list, one PACS per XNAT (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trust XNAT retrieves from exactly one PACS, so the configuration script should own that list rather than tolerate several. Two orphan bugs followed from not doing so, and the first was caught by deploying: - Changing XNAT_AETITLE left the previous SCP receiver behind on the same port. Deletion matched on AE title, so a rename created a second receiver and both bound the DICOM port. Reclamation now matches on **the port we bind** as well as the title: whatever else is listening there is ours to remove. - Changing PACS_AETITLE stranded the old PACS registration the same way. Since we set defaultQueryRetrievePacs on ours, a leftover entry claiming the same default leaves DQR's choice of PACS ambiguous. Any registration that is not the configured one is now removed, logged per entry — this deletes configuration an administrator may have added through the XNAT UI, so it must be visible in the deploy output rather than silent. With one PACS guaranteed, imaging-api gets simpler rather than more defensive: PACS_AETITLE is gone from its settings and from both trust compose files, and the id is resolved as "the registered one" instead of matching a title. It still cannot be assumed to be 1 — XNAT numbers registrations in creation order, so an XNAT that carried the mocked Orthanc before the real PACS was configured does not have it at id 1 — and it still falls back to the configured PACS_ID when XNAT is unreachable. More than one registration now logs a warning, since configure-xnat.sh should have prevented it. Verified on the live Compose trust: seeding a foreign PACS alongside the configured one, then re-running, removes it and leaves exactly one registration and one receiver. Signed-off-by: at24_bioeng625-pc --- trust/deploy/compose_trust.development.yml | 5 +- trust/deploy/compose_trust.production.yml | 5 +- trust/imaging-api/imaging_api/config.py | 8 +-- .../imaging_api/services/imaging.py | 37 +++++----- .../tests/services/test_imaging.py | 28 +++----- trust/xnat/tests/test_configure_pacs.py | 39 ++++++++++- trust/xnat/xnat/config/configure-xnat.sh | 67 +++++++++++++------ 7 files changed, 123 insertions(+), 66 deletions(-) diff --git a/trust/deploy/compose_trust.development.yml b/trust/deploy/compose_trust.development.yml index c6c2534d0..4a40fe398 100644 --- a/trust/deploy/compose_trust.development.yml +++ b/trust/deploy/compose_trust.development.yml @@ -106,10 +106,9 @@ services: # default in imaging_api/config.py — do not inject them (an empty ${VAR} # from a kit that omits them would override the code default). XNAT_PORT: ${XNAT_PORT} - # Must match what configure-xnat.sh registered: this becomes the C-MOVE - # destination, and the PACS id is resolved from the PACS AE title. + # Must match what configure-xnat.sh registered: this becomes the C-MOVE destination, + # which DQR matches against a registered SCP receiver by exact AE title and port. XNAT_AETITLE: ${XNAT_AETITLE:-XNAT} - PACS_AETITLE: ${PACS_AETITLE:-ORTHANC} XNAT_SERVICE_USER: ${XNAT_SERVICE_USER} XNAT_SERVICE_PASSWORD: ${XNAT_SERVICE_PASSWORD} # Minted per-trust XNAT DB password (FLIP-PT-056): imaging_api/config.py diff --git a/trust/deploy/compose_trust.production.yml b/trust/deploy/compose_trust.production.yml index 4bb98eafd..e38f62dd2 100644 --- a/trust/deploy/compose_trust.production.yml +++ b/trust/deploy/compose_trust.production.yml @@ -94,10 +94,9 @@ services: # default in imaging_api/config.py — do not inject them (an empty ${VAR} # from a kit that omits them would override the code default). XNAT_PORT: ${XNAT_PORT} - # Must match what configure-xnat.sh registered: this becomes the C-MOVE - # destination, and the PACS id is resolved from the PACS AE title. + # Must match what configure-xnat.sh registered: this becomes the C-MOVE destination, + # which DQR matches against a registered SCP receiver by exact AE title and port. XNAT_AETITLE: ${XNAT_AETITLE:-XNAT} - PACS_AETITLE: ${PACS_AETITLE:-ORTHANC} XNAT_SERVICE_USER: ${XNAT_SERVICE_USER} XNAT_SERVICE_PASSWORD: ${XNAT_SERVICE_PASSWORD} # Minted per-trust XNAT DB password (FLIP-PT-056): imaging_api/config.py diff --git a/trust/imaging-api/imaging_api/config.py b/trust/imaging-api/imaging_api/config.py index b81bba409..c5370f09c 100644 --- a/trust/imaging-api/imaging_api/config.py +++ b/trust/imaging-api/imaging_api/config.py @@ -44,11 +44,9 @@ def coerce_empty_env(cls, v: str) -> str: # the C-MOVE destination handed to the PACS and DQR matches it against a registered SCP receiver # by exact AE title and port (FLIP#993). XNAT_AETITLE: str = "XNAT" - # AE title of the upstream PACS. The PACS id is resolved from this at runtime, so a deployment - # whose PACS is not id 1 — an extra registration, or a re-registered entry — still works. - PACS_AETITLE: str = "ORTHANC" - # Fallback used only when the AE-title lookup cannot reach XNAT. Historically XNAT registered - # exactly one PACS and assigned it id 1. + # A trust XNAT retrieves from exactly one PACS, and configure-xnat.sh enforces that, so the id + # is resolved at runtime as "the registered one" rather than assumed. This value is only the + # fallback for when XNAT cannot be reached: historically XNAT registered one PACS as id 1. PACS_ID: int = 1 # Internal trust-network URLs: docker service name + the service's container diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index 3c4413834..4b4eec816 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -27,7 +27,6 @@ from imaging_api.utils.logger import logger PACS_ID = get_settings().PACS_ID -PACS_AETITLE = get_settings().PACS_AETITLE XNAT_URL = get_settings().XNAT_URL # Cache for resolve_pacs_id(). XNAT assigns PACS ids at registration time, so the mapping from AE @@ -36,19 +35,20 @@ _resolved_pacs_id: int | None = None -def resolve_pacs_id(headers: dict[str, str], ae_title: str = PACS_AETITLE) -> int: +def resolve_pacs_id(headers: dict[str, str]) -> int: """ - Resolves the XNAT PACS id for the configured PACS AE title. + Resolves the id of the PACS registered in XNAT. - XNAT numbers PACS registrations in creation order. Historically FLIP registered exactly one, so - the id was always 1 and was hardcoded; a trust that has re-registered its PACS, or that carries - the mocked Orthanc alongside a real PACS, breaks that assumption (FLIP#993). Falls back to the - configured ``PACS_ID`` when XNAT cannot be reached or the AE title is not registered, so a + A trust XNAT retrieves from exactly one PACS, and ``configure-xnat.sh`` enforces that by removing + any other registration. The id itself cannot be assumed: XNAT numbers registrations in creation + order, so an XNAT that carried the mocked Orthanc before the real PACS was configured does not + have it at id 1 (FLIP#993). + + Falls back to the configured ``PACS_ID`` when XNAT cannot be reached or reports no PACS, so a transient XNAT failure degrades to the previous behaviour rather than failing the import. Args: headers (dict[str, str]): XNAT authentication headers. - ae_title (str): AE title to look up. Defaults to the configured PACS AE title. Returns: int: The id of the registered PACS, or the configured ``PACS_ID`` fallback. @@ -60,14 +60,21 @@ def resolve_pacs_id(headers: dict[str, str], ae_title: str = PACS_AETITLE) -> in try: response = requests.get(f"{XNAT_URL}/xapi/pacs", headers=headers) response.raise_for_status() - for pacs in response.json(): - if pacs.get("aeTitle") == ae_title: - _resolved_pacs_id = int(pacs["id"]) - logger.info(f"Resolved PACS '{ae_title}' to id {_resolved_pacs_id}") - return _resolved_pacs_id - logger.warning(f"No PACS registered with AE title '{ae_title}'; falling back to id {PACS_ID}") + registrations = response.json() + if registrations: + if len(registrations) > 1: + # configure-xnat.sh should have removed the others; if one reappeared, DQR's choice + # of PACS is ambiguous and the operator needs to know. + logger.warning( + f"XNAT has {len(registrations)} PACS registrations; expected one. " + f"Using '{registrations[0].get('aeTitle')}'." + ) + _resolved_pacs_id = int(registrations[0]["id"]) + logger.info(f"Resolved PACS '{registrations[0].get('aeTitle')}' to id {_resolved_pacs_id}") + return _resolved_pacs_id + logger.warning(f"XNAT reports no registered PACS; falling back to id {PACS_ID}") except Exception as e: - logger.warning(f"Could not resolve PACS id for '{ae_title}' ({e}); falling back to id {PACS_ID}") + logger.warning(f"Could not resolve the PACS id ({e}); falling back to id {PACS_ID}") return PACS_ID diff --git a/trust/imaging-api/tests/services/test_imaging.py b/trust/imaging-api/tests/services/test_imaging.py index 9e961c242..8f4713272 100644 --- a/trust/imaging-api/tests/services/test_imaging.py +++ b/trust/imaging-api/tests/services/test_imaging.py @@ -399,19 +399,13 @@ def _clear_resolved_pacs_id(): @patch("imaging_api.services.imaging.requests.get") -def test_resolve_pacs_id_matches_ae_title(mock_get, headers): - """The id comes from the registration whose AE title matches, not from the list order.""" +def test_resolve_pacs_id_uses_the_registered_pacs(mock_get, headers): + """The id is read from XNAT, not assumed to be 1: an XNAT that carried the mock first won't be.""" from imaging_api.services.imaging import resolve_pacs_id - mock_get.return_value = MagicMock( - status_code=200, - json=lambda: [ - {"id": 1, "aeTitle": "ORTHANC"}, - {"id": 7, "aeTitle": "SECTRA_QR"}, - ], - ) + mock_get.return_value = MagicMock(status_code=200, json=lambda: [{"id": 7, "aeTitle": "SECTRA_QR"}]) - assert resolve_pacs_id(headers, ae_title="SECTRA_QR") == 7 + assert resolve_pacs_id(headers) == 7 @patch("imaging_api.services.imaging.requests.get") @@ -421,19 +415,19 @@ def test_resolve_pacs_id_is_cached(mock_get, headers): mock_get.return_value = MagicMock(status_code=200, json=lambda: [{"id": 4, "aeTitle": "ORTHANC"}]) - assert resolve_pacs_id(headers, ae_title="ORTHANC") == 4 - assert resolve_pacs_id(headers, ae_title="ORTHANC") == 4 + assert resolve_pacs_id(headers) == 4 + assert resolve_pacs_id(headers) == 4 assert mock_get.call_count == 1 @patch("imaging_api.services.imaging.requests.get") -def test_resolve_pacs_id_falls_back_when_ae_title_absent(mock_get, headers): - """An unregistered AE title degrades to the configured fallback rather than failing the import.""" +def test_resolve_pacs_id_falls_back_when_none_registered(mock_get, headers): + """No registration degrades to the configured fallback rather than failing the import.""" from imaging_api.services.imaging import PACS_ID, resolve_pacs_id - mock_get.return_value = MagicMock(status_code=200, json=lambda: [{"id": 9, "aeTitle": "SOMETHING_ELSE"}]) + mock_get.return_value = MagicMock(status_code=200, json=lambda: []) - assert resolve_pacs_id(headers, ae_title="ORTHANC") == PACS_ID + assert resolve_pacs_id(headers) == PACS_ID @patch("imaging_api.services.imaging.requests.get", side_effect=Exception("XNAT unreachable")) @@ -441,7 +435,7 @@ def test_resolve_pacs_id_falls_back_when_xnat_unreachable(mock_get, headers): """A transient XNAT failure must not take out retrieval; it falls back to the configured id.""" from imaging_api.services.imaging import PACS_ID, resolve_pacs_id - assert resolve_pacs_id(headers, ae_title="ORTHANC") == PACS_ID + assert resolve_pacs_id(headers) == PACS_ID def test_import_request_ae_title_follows_settings(): diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index c185fc249..3696e853b 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -41,14 +41,15 @@ printf '%s\n' "=== $method $url" >> "$PAYLOADS" printf '%s\n' "$data" >> "$PAYLOADS" fi +[ "$method" = "DELETE" ] && printf '%s\n' "=== DELETE $url" >> "$PAYLOADS" body='{}' case "$url" in - *"/xapi/dicomscp"*) body='[{"id":1,"aeTitle":"XNAT","port":8104}]' ;; + *"/xapi/dicomscp"*) body="${STUB_SCP_JSON}" ;; *"/xapi/pacs") if [ -f "$REGISTERED" ]; then body='[{"id":'"$STUB_PACS_ID"',"aeTitle":"'"$STUB_PACS_AET"'","host":"'"$STUB_PACS_HOST"'","queryRetrievePort":'"$STUB_PACS_PORT"'}]' else - body='[]' + body="${STUB_PACS_JSON:-[]}" fi [ "$method" = "POST" ] && touch "$REGISTERED" ;; @@ -89,6 +90,8 @@ def run_configure(tmp_path, env_overrides=None, pacs_already_registered=False): "REGISTERED": str(registered), # What the stub reports as registered. Defaults to the mock; when a test configures a # different PACS the stub echoes that back, mimicking XNAT after the POST succeeded. + "STUB_SCP_JSON": '[{"id":1,"aeTitle":"XNAT","port":8104}]', + "STUB_PACS_JSON": "[]", "STUB_PACS_ID": "7", "STUB_PACS_AET": (env_overrides or {}).get("PACS_AETITLE", "ORTHANC"), "STUB_PACS_HOST": "orthanc" if pacs_already_registered else (env_overrides or {}).get("PACS_HOST", "orthanc"), @@ -227,3 +230,35 @@ def test_empty_values_fail_loudly(tmp_path, var): code, _, output = run_configure(tmp_path, {var: ""}) assert code != 0, f"empty {var} should abort the run" assert var in output + + +def deletes(payloads: str) -> list[str]: + """URLs the script issued a DELETE against.""" + return [b.partition("\n")[0].split()[-1] for b in payloads.split("=== ") if b.startswith("DELETE ")] + + +def test_receiver_on_our_port_is_reclaimed_whatever_it_is_called(tmp_path): + """Renaming the AE title must not strand the old receiver fighting for the same port.""" + code, payloads, output = run_configure( + tmp_path, + {"STUB_SCP_JSON": '[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]'}, + ) + assert code == 0, output + assert "Removing SCP receiver 'FLIPXNAT' (id 3)" in output + assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)) + + +def test_foreign_pacs_registrations_are_removed(tmp_path): + """A trust XNAT retrieves from one PACS; a stale entry would leave DQR's choice ambiguous.""" + code, payloads, output = run_configure( + tmp_path, + { + "PACS_AETITLE": "SECTRA_QR", + "PACS_HOST": "10.0.0.10", + "PACS_QR_PORT": "8059", + "STUB_PACS_JSON": '[{"id":1,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242}]', + }, + ) + assert code == 0, output + assert "Removing PACS 'ORTHANC' at orthanc:4242 (id 1)" in output + assert any(u.endswith("/xapi/pacs/1") for u in deletes(payloads)) diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index c2357d9e7..ae540f3e8 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -316,29 +316,33 @@ xnat_curl -X PUT "$XNAT_URL/xapi/anonymize/site/enabled" \ -H "Content-Type: application/json" \ -d 'true' -# Remove any pre-existing SCP receiver we are about to replace. Two titles matter: XNAT's stock -# default receiver (always "XNAT", created by the webapp on first boot) and the receiver under our -# configured title, so a re-run with changed settings replaces rather than duplicates. When -# XNAT_AETITLE is the default they are the same entry and the loop deduplicates. +# Remove any pre-existing SCP receiver we are about to replace, matched on **the port we bind** +# rather than only on AE title. The receiver this script owns is defined by that port, so anything +# else listening on it has to go regardless of what it is called — including a receiver left behind +# by an earlier run under a different XNAT_AETITLE. Matching on title alone orphaned the old entry +# on a rename, leaving two receivers fighting over one port. Also removes XNAT's stock default +# receiver (always "XNAT", created by the webapp on first boot) wherever it is bound. +# +# `--arg`/`--argjson` keep the values as data rather than splicing them into the filter, so a title +# containing jq syntax cannot change what is selected. response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" "$XNAT_URL/xapi/dicomscp") if [[ -z "$response" || "$response" == "[]" ]]; then echo "No SCP receivers found." else - for ae in $(printf '%s\n' "XNAT" "${XNAT_AETITLE}" | sort -u); do - # `--arg` keeps the AE title as data rather than splicing it into the filter, so a title - # containing jq syntax cannot change what is selected. - scp_receiver_id=$(printf '%s' "$response" | jq -r --arg ae "$ae" \ - 'map(select(.aeTitle == $ae)) | .[0].id // empty') - - if [[ -n "$scp_receiver_id" ]]; then - echo "Removing SCP receiver '$ae' (id $scp_receiver_id)..." + stale_ids=$(printf '%s' "$response" | jq -r --argjson port "${XNAT_PORT}" --arg ae "${XNAT_AETITLE}" \ + 'map(select(.port == $port or .aeTitle == $ae or .aeTitle == "XNAT")) | .[] | "\(.id):\(.aeTitle)"') + + if [[ -z "$stale_ids" ]]; then + echo "No SCP receiver to replace on port ${XNAT_PORT}." + else + while IFS=: read -r scp_receiver_id scp_receiver_ae; do + [[ -n "$scp_receiver_id" ]] || continue + echo "Removing SCP receiver '${scp_receiver_ae}' (id ${scp_receiver_id})..." xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" \ -X DELETE "$XNAT_URL/xapi/dicomscp/$scp_receiver_id" >/dev/null - else - echo "No SCP receiver with aeTitle='$ae' found." - fi - done + done <<< "$stale_ids" + fi fi # Configure SCP receiver to have dqrObjectIdentifier as the identifier (the default is not) @@ -378,11 +382,17 @@ xnat_curl -X POST "$XNAT_URL/xapi/siteConfig" \ # registration (FLIP#822 / FLIP#862) — hence PACS_QR_PORT is guarded non-empty above and documented # as the reachable port. # -# A duplicate registration surfaces as an unspecific 500 (DB unique-constraint violation), so -# idempotency is a lookup by aeTitle. Unlike the previous check-then-create, an existing entry whose -# host or port has drifted from the configured values is *updated*: leaving it untouched meant a kit -# change was silently ignored on redeploy, and the operator had no signal that DQR was still -# pointing at the old PACS. +# A trust XNAT talks to exactly one PACS, so this script owns the registration list: the configured +# PACS is created or updated in place, and any *other* registration is removed. Two things make that +# the right behaviour rather than merely tidy. A duplicate surfaces as an unspecific 500 (DB +# unique-constraint violation) if we re-POST, and — more importantly — we set +# defaultQueryRetrievePacs on ours, so a leftover entry claiming the same default leaves DQR's +# choice of PACS ambiguous. Changing PACS_AETITLE would otherwise strand the old entry exactly as a +# changed XNAT_AETITLE used to strand the old SCP receiver. +# +# Updating in place rather than delete-and-recreate keeps the PACS id stable, and means a kit change +# actually takes effect: the previous check-then-create silently ignored a drifted host or port on +# redeploy, leaving DQR pointing at the old PACS with no signal to the operator. pacs_payload="{ \"aeTitle\": \"${PACS_AETITLE}\", \"defaultQueryRetrievePacs\": true, @@ -400,6 +410,21 @@ existing_pacs=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_ pacs_entry=$(printf '%s' "$existing_pacs" | jq -c --arg ae "${PACS_AETITLE}" \ 'map(select(.aeTitle == $ae)) | .[0] // empty') +# Remove every registration that is not the configured one, so exactly one survives. Logged per +# entry: this deletes configuration an administrator may have added through the XNAT UI, and that +# should be visible in the deploy output rather than silent. +foreign_pacs=$(printf '%s' "$existing_pacs" | jq -r --arg ae "${PACS_AETITLE}" \ + 'map(select(.aeTitle != $ae)) | .[] | "\(.id):\(.aeTitle):\(.host):\(.queryRetrievePort)"') +if [[ -n "$foreign_pacs" ]]; then + while IFS=: read -r stale_id stale_ae stale_host stale_port; do + [[ -n "$stale_id" ]] || continue + echo "Removing PACS '${stale_ae}' at ${stale_host}:${stale_port} (id ${stale_id}):" \ + "a trust XNAT retrieves from one PACS, and '${PACS_AETITLE}' is the configured one." + xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ + -X DELETE "$XNAT_URL/xapi/pacs/${stale_id}" >/dev/null + done <<< "$foreign_pacs" +fi + if [[ -z "$pacs_entry" ]]; then echo "Registering PACS '${PACS_AETITLE}' at ${PACS_HOST}:${PACS_QR_PORT}..." xnat_curl -X POST "$XNAT_URL/xapi/pacs" \ From d6c234c473523ef4946f49410185b12b9dc55562 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Tue, 18 Aug 2026 18:58:10 +0100 Subject: [PATCH 05/31] docs: scope the inbound-connections claim to the internet and the hub (#993) The earlier wording explained the PACS return leg at length, which buried the part that matters. The claim people rely on is about the internet and the Central Hub, so say that in the heading and keep the exception to two sentences. No change of substance: still no route inward from the internet or the hub, still one inbound connection inside the trust's own network, on the DICOM port. Signed-off-by: at24_bioeng625-pc --- docs/source/governance-and-compliance.rst | 7 +++---- docs/source/security.rst | 25 +++++++++-------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/docs/source/governance-and-compliance.rst b/docs/source/governance-and-compliance.rst index ef66620fa..87f824742 100644 --- a/docs/source/governance-and-compliance.rst +++ b/docs/source/governance-and-compliance.rst @@ -77,10 +77,9 @@ Network architecture as a governance guarantee The network design is why the guarantees above are structural rather than procedural. Each trust polls the Central Hub outbound, and there is no route from the internet — or from the hub — into a trust's network. For a trust's own network team, onboarding FLIP -requires no inbound exposure to the outside world at all. The one inbound rule is -internal to the trust: retrieval from the trust's PACS is a pull, so the PACS opens a -connection back to XNAT to deliver the studies it was asked for, scoped to that PACS on -the DICOM port. A site-to-site VPN can be provisioned on request +requires no inbound exposure to the outside world. The one inbound rule is internal to +the trust: FLIP asks the trust's PACS for a study, and the PACS opens a connection back +to XNAT to deliver it, on the DICOM port alone. A site-to-site VPN can be provisioned on request where a trust's policy calls for network-layer separation as well. The practical governance point: a trust does not have to rely on the Central Hub's access diff --git a/docs/source/security.rst b/docs/source/security.rst index 472a6e28a..0ec520703 100644 --- a/docs/source/security.rst +++ b/docs/source/security.rst @@ -24,21 +24,16 @@ automated checks that run against every change are publicly inspectable. Network and perimeter ********************* -**Nothing outside the trust can open a connection to a trust's FLIP services.** Each -participating trust runs FLIP services that reach *out* to the Central Hub to collect -work and report results. Nothing on the internet, and nothing on the Central Hub, can -open a connection inward. This is enforced in the infrastructure definitions themselves — -the security groups permit no inbound traffic at all — rather than depending on -configuration discipline. Operator access is via AWS Systems Manager Session Manager, so -port 22 is never opened. - -Stated precisely, there is one inbound path, and it is inside the trust's own network. -FLIP retrieves imaging by *pull*: XNAT queries the trust PACS and requests a study, and -the PACS then opens a connection back to XNAT to deliver it. That return connection is -inbound by protocol rather than by design, it originates from the trust's own PACS, and -it is restricted to that PACS on the DICOM port alone. It never crosses the boundary -between the trust and the Central Hub, and it does not weaken the guarantee above: no -route exists from the internet, or from the hub, into a trust's network. +**Trust systems accept no inbound connections from the internet or the Central Hub.** +Each participating trust runs FLIP services that reach *out* to the Central Hub to +collect work and report results. This is enforced in the infrastructure definitions +themselves — the security groups permit no inbound traffic at all — rather than +depending on configuration discipline. Operator access is via AWS Systems Manager +Session Manager, so port 22 is never opened. + +The one inbound connection in the design is internal to the trust. FLIP asks the trust's +PACS for a study, and the PACS opens a connection back to XNAT to deliver it, on the +DICOM port alone. It stays inside the trust's own network. **Only the Central Hub is internet-facing.** It sits behind CloudFront with modern TLS, HSTS, AWS WAF managed rules, and an internal-only Application Load Balancer. Nothing From 89a4cf2422718c4dd873fe4a59abb90a0eed8d8b Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 11:10:37 +0100 Subject: [PATCH 06/31] docs: give PACS its own component page (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PACS connection was documented inside the XNAT page, but it is a trust system in its own right rather than an aspect of XNAT: it is the trust's clinical imaging store, configured by the trust's PACS and network teams, and it is what the request to a PACS supplier is about. Burying it under XNAT also made it hard to point anyone at. Move it to components/component-pacs.rst — the DICOM primer, how retrieval works, what the PACS team registers, the configuration reference, the mocked Orthanc used in development, the worked Sectra example, scheduling and verification — and register it in the components toctree ahead of XNAT, matching the direction imaging flows. XNAT keeps a short section explaining that imaging arrives by DQR retrieval and pointing at the new page, and its two other references to PACS import now link there as well. The PACS page links back to XNAT's anonymisation section, which gains a label for the purpose. Signed-off-by: at24_bioeng625-pc --- docs/source/components.rst | 1 + docs/source/components/component-pacs.rst | 271 +++++++++++++++++++++ docs/source/components/component-xnat.rst | 274 +--------------------- 3 files changed, 285 insertions(+), 261 deletions(-) create mode 100644 docs/source/components/component-pacs.rst diff --git a/docs/source/components.rst b/docs/source/components.rst index 6b57edf11..fff4b4e7f 100644 --- a/docs/source/components.rst +++ b/docs/source/components.rst @@ -8,6 +8,7 @@ FLIP components components/architecture-overview components/component-fl-nodes components/component-omop-database + components/component-pacs components/component-xnat components/component-logging-stack diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst new file mode 100644 index 000000000..050c2b84d --- /dev/null +++ b/docs/source/components/component-pacs.rst @@ -0,0 +1,271 @@ +.. _flip-pacs: + +#### +PACS +#### + +The Picture Archiving and Communication System (PACS) is the clinical system that stores a trust's +imaging. FLIP does not hold a copy of it: imaging is retrieved from the trust's own PACS, on demand, +for the studies belonging to an approved project cohort, and lands in that trust's +:doc:`XNAT ` instance. + +This page describes how that retrieval works, what a trust's PACS and network teams need to +configure, and how to verify the connection. + +DICOM Networking in Brief +========================= + +Three ideas are enough to follow the rest of this section. + +**AE Title.** A DICOM system's *name* on the network — like a hostname, but specific to DICOM, and +at most 16 characters. When one system connects to another it announces "I am *X*, calling *Y*". The +receiver checks that *Y* is its own name and that *X* is one it has been told to accept. Names are +separate from addresses: the IP and port are configured alongside the AE title, not derived from it. + +**SCU and SCP.** Client and server. An SCU (Service Class *User*) opens connections; an SCP (Service +Class *Provider*) listens for them. XNAT is both, at different moments — an SCU when it queries the +PACS, an SCP when it receives the images. + +**The four operations**, in the order FLIP uses them: + +.. list-table:: + :widths: 15 55 30 + :header-rows: 1 + + * - Operation + - What it does + - Direction + * - ``C-ECHO`` + - A DICOM ping. Confirms two systems can reach and accept each other + - either way, for testing + * - ``C-FIND`` + - Search — "which study has accession number ABC123?" + - XNAT to PACS + * - ``C-MOVE`` + - "Send that study to the system called ``FLIPXNAT``" + - XNAT to PACS + * - ``C-STORE`` + - The image transfer itself + - **PACS to XNAT** + +.. important:: + + C-MOVE does not return the images on the connection that asked for them. XNAT names a + destination, the PACS looks that name up in its *own* table to find an address, and opens a + **new connection in the opposite direction** to deliver the study. + + Three consequences follow, and each has caused a failed integration in practice: + + * The destination must be registered on the PACS in advance — a name it does not know cannot be + delivered to. + * The AE title and port XNAT advertises must match that registration exactly. XNAT rejects an + association addressed to a different name, and the DQR plugin refuses to issue a C-MOVE whose + destination does not correspond to one of its own configured receivers. + * The return connection needs its own firewall rule. Every other FLIP connection is outbound, so + this is the one reviewers overlook. + +How Retrieval Works +=================== + +FLIP **pulls**; the PACS is never configured to push into XNAT. XNAT's +`DICOM Query-Retrieve (DQR) plugin `_ +performs the retrieval, driven over REST by the imaging API: + +1. The cohort query is re-run against the trust's OMOP database and returns **accession numbers + only**. +2. For each accession number, XNAT issues a **C-FIND** to the PACS at STUDY level, matching on + Accession Number ``(0008,0050)``, to resolve the Study Instance UID. +3. XNAT issues a **C-MOVE** to the PACS, naming itself as the move destination. +4. The PACS **C-STOREs** the study back to XNAT's DICOM SCP receiver, where the site-wide + anonymisation script runs on receipt, before the session is archived — see + :ref:`DICOM Anonymization `. + +Only accession numbers belonging to an approved project cohort are ever requested. There is no +standing forward rule and no bulk transfer. + +.. important:: + + Step 4 is a connection **from the PACS to XNAT**. It is easy to overlook when specifying firewall + rules, because every other FLIP connection is outbound. Without it, queries succeed and + retrievals silently time out. + +What the PACS Team Must Register +================================ + +The FLIP XNAT instance must be registered on the PACS as a DICOM node, permitted to: + +* issue **C-FIND** (Study Root query/retrieve) as an SCU; +* act as a **C-MOVE destination**, so retrieved studies can be returned to it; +* issue and answer **C-ECHO**, for verification in both directions. + +The trust must supply, and FLIP must be configured with, the PACS AE title, host and query/retrieve +port. FLIP in turn supplies its own AE title, host and DICOM port. + +.. note:: + + The AE title FLIP presents is one of many configured on a trust PACS, so it should identify the + platform — for example ``FLIPXNAT``. It must match on both sides: the PACS opens the C-STORE + association using the AE title it has registered, and XNAT's SCP receiver rejects an association + addressed to a different AE title. + +Configuration +============= + +.. list-table:: + :widths: 30 45 25 + :header-rows: 1 + + * - Setting + - Description + - Default + * - ``XNAT_AETITLE`` + - XNAT's own AE title, used for the DICOM SCP receiver, the DQR calling AE, and the C-MOVE + destination + - ``XNAT`` + * - ``XNAT_PORT`` + - DICOM SCP receiver port + - ``8104`` + * - ``PACS_AETITLE`` + - AE title of the trust PACS + - ``ORTHANC`` + * - ``PACS_HOST`` + - Hostname or IP of the trust PACS + - ``orthanc`` + * - ``PACS_QR_PORT`` + - Query/retrieve port on the trust PACS. Must be reachable *from the XNAT container* — this is + not a host-published port + - ``4242`` + * - ``XNAT_WEB_PORT`` + - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; separate from + ``XNAT_PORT`` so the DICOM receiver can be published independently. Defaults to ``XNAT_PORT`` + - ``XNAT_PORT`` + * - ``PACS_AVAILABILITY_DAYS`` / ``_START`` / ``_END`` + - When retrieval may run, as a comma-separated day list and a daily window + - all week, ``00:00``–``24:00`` + * - ``PACS_THREADS`` / ``PACS_UTILIZATION_PERCENT`` + - How hard to drive the PACS during that window + - ``1`` / ``100`` + * - ``DQR_MAX_PACS_REQUEST_ATTEMPTS`` / ``DQR_RETRY_WAIT_SECONDS`` + - How many times, and how far apart, to retry a study the PACS did not deliver + - ``100`` / ``300`` + +The defaults describe the mocked PACS that ships with FLIP for development, described below. + +Development: the Mocked PACS +============================ + +FLIP ships an `Orthanc `_ DICOM server that stands in for the trust +PACS during development and testing. It is seeded with synthetic DICOM studies whose accession +numbers match the mocked OMOP database, so a cohort query resolves to real studies and the full +retrieval path can be exercised without a hospital PACS. + +Orthanc is a genuine DICOM node, so it answers C-FIND and C-MOVE exactly as a production PACS would +and returns studies by C-STORE. The retrieval path under test is therefore the same one used against +a trust PACS — only the peer differs. It is why the configuration defaults above name ``orthanc``: + +* ``PACS_HOST=orthanc`` — the container name on the FLIP network +* ``PACS_AETITLE=ORTHANC`` — Orthanc's AE title +* ``PACS_QR_PORT=4242`` — Orthanc's DICOM port + +Because both sit on the same container network, Orthanc reaches XNAT's SCP receiver directly and no +host port needs publishing. A real PACS is outside that network, which is the one material +difference between the two setups and the reason the receiver must be explicitly exposed. + +.. note:: + + The mocked PACS is for development and testing only. In a trust deployment the imaging comes from + the trust's own PACS, and Orthanc is either absent or confined to the FLIP node — its DICOM port + is deliberately not published to the wider network. + +.. warning:: + + The DICOM port must be **the same number everywhere** — the port XNAT binds, the port recorded on + its SCP receiver, the port advertised as the C-MOVE destination, and the port the PACS connects + to. DQR matches the C-MOVE destination against a registered SCP receiver by exact AE title and + port, so any translation between these layers causes retrieval to fail. + +Example: Sectra PACS +==================== + +The values below illustrate the shape of the exchange with a Sectra PACS. **They are examples, not +defaults** — each trust supplies its own. + +Provided by the trust's PACS team: + +.. code-block:: text + + Query/Retrieve node (PACS) + AE Title: QR_SCP_EXAMPLE + IP: 10.0.0.10 + Port: 8059 + +Provided by FLIP, to be registered on the PACS as a destination: + +.. code-block:: text + + Destination node (FLIP XNAT SCP receiver) + AE Title: FLIPXNAT + IP: 10.0.0.20 + Port: 8104 + +Firewall rules required, in both directions: + +.. list-table:: + :widths: 40 20 40 + :header-rows: 1 + + * - Connection + - Direction + - Purpose + * - XNAT host → PACS query/retrieve port + - Outbound + - C-ECHO, C-FIND, C-MOVE requests + * - PACS → XNAT host DICOM port + - **Inbound** + - C-STORE of the retrieved studies + +.. important:: + + Where the PACS is vendor-managed, opening the trust's own firewall may not be sufficient. The + vendor may operate a separate firewall that must also whitelist the connection, raised through + the trust's service desk as a request to the PACS supplier. + +Scheduling and Throughput +========================= + +A production PACS may limit how much can be retrieved before it refuses further connections, and +bulk retrieval competes with clinical use. Agree a retrieval schedule with the trust's PACS manager, +then configure XNAT to match: the DQR settings control retry behaviour, and each registered PACS +carries an availability schedule with a per-day window, a thread count and a utilisation percentage. + +Where a trust has a test or pre-production PACS, connecting FLIP to that first is recommended, and +is usually raised as a separate service request. + +.. note:: + + The availability schedule is applied when the PACS is first registered. The DQR plugin pre-creates + the intervals, and rejects a later write to a day that already has one, so changing the window on + an already-configured instance requires deleting the existing intervals through XNAT's + administration UI first. The values above therefore take effect on a fresh deployment; on a + running one, check what is actually configured rather than assuming the setting was applied. + +Verification +============ + +Work outwards from the network layer: + +1. **C-ECHO in both directions** — from the PACS to the FLIP XNAT AE title and port, and from XNAT + to the PACS. This confirms both firewall directions before any DICOM data moves. +2. **Ping the PACS from XNAT** — ``GET /xapi/pacs/{id}/status`` should report the PACS as reachable + and enabled. +3. **Query a single accession number** — ``POST /xapi/dqr/query/studies`` should return the matching + study. +4. **Import a single study**, and confirm it archives into the expected project with anonymisation + applied. +5. **Run a full project import**, monitoring the import status counts. + +.. note:: + + XNAT must be restarted for changes to its DICOM configuration to take effect. XNAT also holds the + DICOM port while running, so command-line testing with a tool such as ``storescp`` on the same + port requires stopping XNAT first. diff --git a/docs/source/components/component-xnat.rst b/docs/source/components/component-xnat.rst index c3ad8e874..a46fdbc69 100644 --- a/docs/source/components/component-xnat.rst +++ b/docs/source/components/component-xnat.rst @@ -8,7 +8,7 @@ This page provides a quick-reference guide to both interactions with the XNAT UI `XNAT `_ is an open-source imaging informatics software platform dedicated to imaging-based research. XNAT's core functions manage importing, archiving, processing and securely distributing imaging and related study data. Detailed documentation on how to use XNAT is `provided on their wiki `_. -Upon FLIP project approval, XNAT project creation tasks are queued for each trust. Trusts poll for these tasks and create the XNAT projects locally. Relevant imaging data is then imported from trust PACS systems. Model developers are granted access to the XNAT project at each trust in order to perform any data preparation and enrichment activities which may be necessary for the running & training of AI models. +Upon FLIP project approval, XNAT project creation tasks are queued for each trust. Trusts poll for these tasks and create the XNAT projects locally. Relevant imaging data is then retrieved from the trust's PACS (see :doc:`PACS `). Model developers are granted access to the XNAT project at each trust in order to perform any data preparation and enrichment activities which may be necessary for the running & training of AI models. ******* XNAT UI @@ -123,274 +123,26 @@ A CT session is derived from an imported PACS DICOM Study. The CT session page c Downloading and Uploading Imaging Data ======================================= -Imaging data will be automatically imported from trust PACS systems on XNAT project generation. Model developers may wish to download this data or upload new or amended imaging data to support model development. +Imaging data is retrieved automatically from the trust's :doc:`PACS ` when the XNAT project is generated. Model developers may wish to download this data or upload new or amended imaging data to support model development. Information on how to download and upload imaging data the XNAT UI can be found `here `_. -**************************** -Connecting to a Trust PACS -**************************** - -XNAT retrieves imaging from the trust's PACS on demand, for the studies belonging to an approved -project cohort. This section describes what has to be configured, and what the trust's PACS and -network teams need to provide. - -DICOM Networking in Brief -========================= - -Three ideas are enough to follow the rest of this section. - -**AE Title.** A DICOM system's *name* on the network — like a hostname, but specific to DICOM, and -at most 16 characters. When one system connects to another it announces "I am *X*, calling *Y*". The -receiver checks that *Y* is its own name and that *X* is one it has been told to accept. Names are -separate from addresses: the IP and port are configured alongside the AE title, not derived from it. - -**SCU and SCP.** Client and server. An SCU (Service Class *User*) opens connections; an SCP (Service -Class *Provider*) listens for them. XNAT is both, at different moments — an SCU when it queries the -PACS, an SCP when it receives the images. - -**The four operations**, in the order FLIP uses them: - -.. list-table:: - :widths: 15 55 30 - :header-rows: 1 - - * - Operation - - What it does - - Direction - * - ``C-ECHO`` - - A DICOM ping. Confirms two systems can reach and accept each other - - either way, for testing - * - ``C-FIND`` - - Search — "which study has accession number ABC123?" - - XNAT to PACS - * - ``C-MOVE`` - - "Send that study to the system called ``FLIPXNAT``" - - XNAT to PACS - * - ``C-STORE`` - - The image transfer itself - - **PACS to XNAT** - -.. important:: - - C-MOVE does not return the images on the connection that asked for them. XNAT names a - destination, the PACS looks that name up in its *own* table to find an address, and opens a - **new connection in the opposite direction** to deliver the study. - - Three consequences follow, and each has caused a failed integration in practice: - - * The destination must be registered on the PACS in advance — a name it does not know cannot be - delivered to. - * The AE title and port XNAT advertises must match that registration exactly. XNAT rejects an - association addressed to a different name, and the DQR plugin refuses to issue a C-MOVE whose - destination does not correspond to one of its own configured receivers. - * The return connection needs its own firewall rule. Every other FLIP connection is outbound, so - this is the one reviewers overlook. - -How Retrieval Works -=================== - -FLIP **pulls**; the PACS is never configured to push into XNAT. XNAT's -`DICOM Query-Retrieve (DQR) plugin `_ -performs the retrieval, driven over REST by the imaging API: - -1. The cohort query is re-run against the trust's OMOP database and returns **accession numbers - only**. -2. For each accession number, XNAT issues a **C-FIND** to the PACS at STUDY level, matching on - Accession Number ``(0008,0050)``, to resolve the Study Instance UID. -3. XNAT issues a **C-MOVE** to the PACS, naming itself as the move destination. -4. The PACS **C-STOREs** the study back to XNAT's DICOM SCP receiver, where the site-wide - anonymisation script runs on receipt, before the session is archived. - -Only accession numbers belonging to an approved project cohort are ever requested. There is no -standing forward rule and no bulk transfer. - -.. important:: - - Step 4 is a connection **from the PACS to XNAT**. It is easy to overlook when specifying firewall - rules, because every other FLIP connection is outbound. Without it, queries succeed and - retrievals silently time out. - -What the PACS Team Must Register -================================ - -The FLIP XNAT instance must be registered on the PACS as a DICOM node, permitted to: - -* issue **C-FIND** (Study Root query/retrieve) as an SCU; -* act as a **C-MOVE destination**, so retrieved studies can be returned to it; -* issue and answer **C-ECHO**, for verification in both directions. - -The trust must supply, and FLIP must be configured with, the PACS AE title, host and query/retrieve -port. FLIP in turn supplies its own AE title, host and DICOM port. - -.. note:: - - The AE title FLIP presents is one of many configured on a trust PACS, so it should identify the - platform — for example ``FLIPXNAT``. It must match on both sides: the PACS opens the C-STORE - association using the AE title it has registered, and XNAT's SCP receiver rejects an association - addressed to a different AE title. - -Configuration -============= - -.. list-table:: - :widths: 30 45 25 - :header-rows: 1 - - * - Setting - - Description - - Default - * - ``XNAT_AETITLE`` - - XNAT's own AE title, used for the DICOM SCP receiver, the DQR calling AE, and the C-MOVE - destination - - ``XNAT`` - * - ``XNAT_PORT`` - - DICOM SCP receiver port - - ``8104`` - * - ``PACS_AETITLE`` - - AE title of the trust PACS - - ``ORTHANC`` - * - ``PACS_HOST`` - - Hostname or IP of the trust PACS - - ``orthanc`` - * - ``PACS_QR_PORT`` - - Query/retrieve port on the trust PACS. Must be reachable *from the XNAT container* — this is - not a host-published port - - ``4242`` - * - ``XNAT_WEB_PORT`` - - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; separate from - ``XNAT_PORT`` so the DICOM receiver can be published independently. Defaults to ``XNAT_PORT`` - - ``XNAT_PORT`` - * - ``PACS_AVAILABILITY_DAYS`` / ``_START`` / ``_END`` - - When retrieval may run, as a comma-separated day list and a daily window - - all week, ``00:00``–``24:00`` - * - ``PACS_THREADS`` / ``PACS_UTILIZATION_PERCENT`` - - How hard to drive the PACS during that window - - ``1`` / ``100`` - * - ``DQR_MAX_PACS_REQUEST_ATTEMPTS`` / ``DQR_RETRY_WAIT_SECONDS`` - - How many times, and how far apart, to retry a study the PACS did not deliver - - ``100`` / ``300`` - -The defaults describe the mocked PACS that ships with FLIP for development, described below. - -Development: the Mocked PACS -============================ - -FLIP ships an `Orthanc `_ DICOM server that stands in for the trust -PACS during development and testing. It is seeded with synthetic DICOM studies whose accession -numbers match the mocked OMOP database, so a cohort query resolves to real studies and the full -retrieval path can be exercised without a hospital PACS. - -Orthanc is a genuine DICOM node, so it answers C-FIND and C-MOVE exactly as a production PACS would -and returns studies by C-STORE. The retrieval path under test is therefore the same one used against -a trust PACS — only the peer differs. It is why the configuration defaults above name ``orthanc``: - -* ``PACS_HOST=orthanc`` — the container name on the FLIP network -* ``PACS_AETITLE=ORTHANC`` — Orthanc's AE title -* ``PACS_QR_PORT=4242`` — Orthanc's DICOM port - -Because both sit on the same container network, Orthanc reaches XNAT's SCP receiver directly and no -host port needs publishing. A real PACS is outside that network, which is the one material -difference between the two setups and the reason the receiver must be explicitly exposed. - -.. note:: - - The mocked PACS is for development and testing only. In a trust deployment the imaging comes from - the trust's own PACS, and Orthanc is either absent or confined to the FLIP node — its DICOM port - is deliberately not published to the wider network. - -.. warning:: - - The DICOM port must be **the same number everywhere** — the port XNAT binds, the port recorded on - its SCP receiver, the port advertised as the C-MOVE destination, and the port the PACS connects - to. DQR matches the C-MOVE destination against a registered SCP receiver by exact AE title and - port, so any translation between these layers causes retrieval to fail. - -Example: Sectra PACS -==================== - -The values below illustrate the shape of the exchange with a Sectra PACS. **They are examples, not -defaults** — each trust supplies its own. - -Provided by the trust's PACS team: - -.. code-block:: text - - Query/Retrieve node (PACS) - AE Title: QR_SCP_EXAMPLE - IP: 10.0.0.10 - Port: 8059 - -Provided by FLIP, to be registered on the PACS as a destination: - -.. code-block:: text - - Destination node (FLIP XNAT SCP receiver) - AE Title: FLIPXNAT - IP: 10.0.0.20 - Port: 8104 - -Firewall rules required, in both directions: - -.. list-table:: - :widths: 40 20 40 - :header-rows: 1 - - * - Connection - - Direction - - Purpose - * - XNAT host → PACS query/retrieve port - - Outbound - - C-ECHO, C-FIND, C-MOVE requests - * - PACS → XNAT host DICOM port - - **Inbound** - - C-STORE of the retrieved studies - -.. important:: - - Where the PACS is vendor-managed, opening the trust's own firewall may not be sufficient. The - vendor may operate a separate firewall that must also whitelist the connection, raised through - the trust's service desk as a request to the PACS supplier. - -Scheduling and Throughput -========================= - -A production PACS may limit how much can be retrieved before it refuses further connections, and -bulk retrieval competes with clinical use. Agree a retrieval schedule with the trust's PACS manager, -then configure XNAT to match: the DQR settings control retry behaviour, and each registered PACS -carries an availability schedule with a per-day window, a thread count and a utilisation percentage. - -Where a trust has a test or pre-production PACS, connecting FLIP to that first is recommended, and -is usually raised as a separate service request. - -.. note:: - - The availability schedule is applied when the PACS is first registered. The DQR plugin pre-creates - the intervals, and rejects a later write to a day that already has one, so changing the window on - an already-configured instance requires deleting the existing intervals through XNAT's - administration UI first. The values above therefore take effect on a fresh deployment; on a - running one, check what is actually configured rather than assuming the setting was applied. +***************************** +Retrieval from the Trust PACS +***************************** -Verification -============ +Imaging reaches XNAT by retrieval from the trust's PACS, performed by XNAT's DICOM Query-Retrieve +(DQR) plugin: XNAT queries the PACS for the studies in an approved cohort, and the PACS returns them +for XNAT to archive. -Work outwards from the network layer: +That connection — what the trust's PACS and network teams must configure, the AE titles and ports +involved, and how to verify it — is described on its own page. -1. **C-ECHO in both directions** — from the PACS to the FLIP XNAT AE title and port, and from XNAT - to the PACS. This confirms both firewall directions before any DICOM data moves. -2. **Ping the PACS from XNAT** — ``GET /xapi/pacs/{id}/status`` should report the PACS as reachable - and enabled. -3. **Query a single accession number** — ``POST /xapi/dqr/query/studies`` should return the matching - study. -4. **Import a single study**, and confirm it archives into the expected project with anonymisation - applied. -5. **Run a full project import**, monitoring the import status counts. +.. seealso:: -.. note:: + :doc:`PACS ` — connecting XNAT to a trust PACS. - XNAT must be restarted for changes to its DICOM configuration to take effect. XNAT also holds the - DICOM port while running, so command-line testing with a tool such as ``storescp`` on the same - port requires stopping XNAT first. +.. _dicom-anonymization: **************************** DICOM Anonymization From 2ead0707a3bd74809471a0284575358c28765e24 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 11:14:53 +0100 Subject: [PATCH 07/31] docs: list PACS after XNAT in the components index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XNAT is the component a reader meets first — it is where imaging lands and where model developers work. PACS is the upstream system it retrieves from, so it reads better as the follow-on page rather than the preamble. Signed-off-by: at24_bioeng625-pc --- docs/source/components.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/components.rst b/docs/source/components.rst index fff4b4e7f..9df770746 100644 --- a/docs/source/components.rst +++ b/docs/source/components.rst @@ -8,8 +8,8 @@ FLIP components components/architecture-overview components/component-fl-nodes components/component-omop-database - components/component-pacs components/component-xnat + components/component-pacs components/component-logging-stack .. note:: From fd320cd57b036e4b1e4f6c4e646b20d63c657901 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 11:20:05 +0100 Subject: [PATCH 08/31] docs: stop the PACS defaults reading as instructions to the trust (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings table sat under a bare "Configuration" heading with a "Default" column, and the note explaining those defaults describe the bundled mock came after it. A reader from a trust's PACS team meets orthanc / ORTHANC / 4242 before the caveat, and can reasonably take them as the values to enter. Label them where the misreading happens: the column is now "Development default", the caveat is an admonition above the table rather than a sentence below it, and the heading is "Configuring FLIP" — pairing with "What the PACS Team Must Register" so the split between what the trust supplies and what FLIP is set to is visible from the contents. Also says where the settings are applied, which was missing: the kit file on Compose, Helm values on Kubernetes. Signed-off-by: at24_bioeng625-pc --- docs/source/components/component-pacs.rst | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index 050c2b84d..fa4decd20 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -108,8 +108,18 @@ port. FLIP in turn supplies its own AE title, host and DICOM port. association using the AE title it has registered, and XNAT's SCP receiver rejects an association addressed to a different AE title. -Configuration -============= +Configuring FLIP +================ + +These are FLIP's own settings, applied by the operator deploying the trust node — in the trust's kit +file for a Compose deployment, or in the Helm values for Kubernetes. They are not something the +trust's PACS team supplies; what they supply is covered above. + +.. important:: + + The defaults below describe the **mocked PACS that ships with FLIP for development**, not values + a trust should use. Every one of them is replaced with the real details when connecting to a + trust PACS. .. list-table:: :widths: 30 45 25 @@ -117,7 +127,7 @@ Configuration * - Setting - Description - - Default + - Development default * - ``XNAT_AETITLE`` - XNAT's own AE title, used for the DICOM SCP receiver, the DQR calling AE, and the C-MOVE destination @@ -149,7 +159,7 @@ Configuration - How many times, and how far apart, to retry a study the PACS did not deliver - ``100`` / ``300`` -The defaults describe the mocked PACS that ships with FLIP for development, described below. +The mocked PACS those defaults describe is covered below. Development: the Mocked PACS ============================ From 4ef0de183e68cc1c608a8fc2095021f9c2ff4c33 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 11:25:34 +0100 Subject: [PATCH 09/31] docs: complete the list of what to ask a trust's PACS team (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the section against the two GSTT email threads and the AI4VBH onboarding and handover documents. The connection details were there, but every question that has actually delayed or broken a connection in the past was missing: - Whether C-MOVE is served by a different AE to the query service. Sectra's own sheet lists the Q/R node and the destination separately, so assuming one AE covers both is a guess. - Confirmation of STUDY-level matching on Accession Number. It is the only key FLIP queries by, and nothing resolves without it. - Whether relational queries / extended negotiation are supported — FLIP registers the PACS with supportsExtendedNegotiations enabled. - Transfer syntax, so what arrives is readable once archived. - Any per-connection retrieval limit. A trust PACS previously refused further connections after a certain volume, which read as an application fault. - Whether DICOM TLS is required, which FLIP cannot currently do on the DIMSE connection — better established before a design is committed to. - Which port they want for bulk retrieval. One already used for teleradiology may be set up for single studies rather than sustained retrieval. Also records two things learned the hard way: the PACS team often cannot register a node without a call to the supplier, which sets the lead time; and previous FLIP node entries should be removed at the same time, since a retrieval addressed to a decommissioned host silently goes nowhere. Signed-off-by: at24_bioeng625-pc --- docs/source/components/component-pacs.rst | 42 +++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index fa4decd20..563fd8993 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -98,8 +98,36 @@ The FLIP XNAT instance must be registered on the PACS as a DICOM node, permitted * act as a **C-MOVE destination**, so retrieved studies can be returned to it; * issue and answer **C-ECHO**, for verification in both directions. -The trust must supply, and FLIP must be configured with, the PACS AE title, host and query/retrieve -port. FLIP in turn supplies its own AE title, host and DICOM port. +FLIP supplies its own AE title, host and DICOM port for that registration. In return, the trust +needs to confirm the following — the first three are required to configure anything at all, the rest +are the questions that most often turn out to matter: + +.. list-table:: + :widths: 32 68 + :header-rows: 1 + + * - What to ask for + - Why it matters + * - AE title, host/IP and query/retrieve port of the PACS + - The details XNAT dials. The port must be reachable from the XNAT container + * - Whether C-MOVE is served by a **different AE** to the query service + - Some PACS separate the query and retrieve roles; if so, both must be known + * - Confirmation that STUDY-level matching on Accession Number ``(0008,0050)`` is supported + - This is the only key FLIP queries by. Without it, nothing resolves + * - Whether relational queries / extended negotiation are supported + - FLIP registers the PACS with ``supportsExtendedNegotiations`` enabled; a PACS that does not + support it needs that turned off + * - The transfer syntax studies will be sent in + - Compressed or transcoded data still has to be readable by XNAT once archived + * - Any per-connection or per-session limit on how much may be retrieved + - A production PACS may refuse further associations after a certain volume; see + `Scheduling and Throughput`_ + * - Whether DICOM TLS is required + - FLIP does not currently support TLS on the DIMSE connection, so this needs to be established + before committing to a design + * - Which port they want used for bulk retrieval + - A port already used for teleradiology may be configured for single studies rather than the + sustained retrieval FLIP performs .. note:: @@ -108,6 +136,16 @@ port. FLIP in turn supplies its own AE title, host and DICOM port. association using the AE title it has registered, and XNAT's SCP receiver rejects an association addressed to a different AE title. +.. note:: + + Registering a new node is often not something the trust's PACS team can do unaided. Depending on + how the PACS is managed, they may need to raise a call with the supplier to have the connection + enabled and any credentials issued — worth starting early, as it tends to set the lead time. + + Where FLIP has been connected to this PACS before, ask for any previous FLIP or XNAT node entries + to be removed at the same time. Stale registrations pointing at decommissioned hosts are + confusing at best, and a retrieval addressed to one silently goes nowhere. + Configuring FLIP ================ From 010237da7db6fd78134be85383172489a061eded Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 11:26:27 +0100 Subject: [PATCH 10/31] docs: stop XNAT_WEB_PORT's default reading as a literal value (#993) Every other row in the settings table has a literal in the default column, so a code-formatted ``XNAT_PORT`` there scanned as the value rather than as a reference to the setting above it. Say it in words instead, and drop the "Defaults to ``XNAT_PORT``" from the description, which was only there because the column could not carry it. Signed-off-by: at24_bioeng625-pc --- docs/source/components/component-pacs.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index 563fd8993..e4fb9cc45 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -185,8 +185,8 @@ trust's PACS team supplies; what they supply is covered above. - ``4242`` * - ``XNAT_WEB_PORT`` - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; separate from - ``XNAT_PORT`` so the DICOM receiver can be published independently. Defaults to ``XNAT_PORT`` - - ``XNAT_PORT`` + ``XNAT_PORT`` so the DICOM receiver can be published independently + - whatever ``XNAT_PORT`` is set to * - ``PACS_AVAILABILITY_DAYS`` / ``_START`` / ``_END`` - When retrieval may run, as a comma-separated day list and a daily window - all week, ``00:00``–``24:00`` From 426180ff326a04658d843d2bfbcb0c1b74f7c649 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 12:25:26 +0100 Subject: [PATCH 11/31] fix: close five silent-failure bugs found in review (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-agent review of this PR found that it reintroduced, in new places, the class of bug it exists to remove. Three of the five had passing tests that asserted the opposite. **imaging-api never received the AE title on Kubernetes.** Compose was wired, Helm was not, so a trust setting xnat.web.dicomAet got a receiver under the new title and import requests addressed to the old one — DQR matches the C-MOVE destination by exact AE and port, so retrieval queued and never arrived. The same file carried a second, independent XNAT_PORT literal: one number with two definitions, which is the defect this PR set out to remove. Both now come from xnat.web.*, and the CI render asserts they reach imaging-api's ConfigMap specifically — grepping the whole document passed on the init job's own copy, which is how this shipped broken. **The fail-loud guard was cancelled by the layer in front of it.** The script used ${VAR-default} so an empty value trips the guard; the compose file passed every one through ${VAR:-default}, which substitutes on empty. PACS_QR_PORT= in a kit file silently became Orthanc's 4242. The test passed because it set the env directly, bypassing compose — the one layer where the bug lived. Fixed in compose, in the Makefile ($(or) treats empty as false) and in Helm (`default` substitutes on empty too). **Payloads were string-interpolated.** The jq *selectors* took --arg with a comment explaining why; the payloads did not. A PACS_HOST of `x", "defaultQueryRetrievePacs": false, "z": "y` produced valid JSON whose duplicate key won, silently disabling that flag on the registration this script exists to make authoritative. All four payloads are now built with jq -n, which also validates the numeric fields at construction. **Receiver reclamation still orphaned.** Matching "our port or our title" missed the case where both moved in one change, leaving the old receiver enabled and bound. It now removes every existing receiver: XNAT carries exactly one FLIP-owned receiver and this script owns it, so filtering was a guess about which were ours. **Foreign-PACS deletion could delete a real registration.** pacs.aeTitle defaults to ORTHANC, so an operator with a hand-registered PACS running an unrelated helm upgrade would have had it replaced by the mock. It now refuses, listing what it found, while the configuration is still the shipped default. Also: the availability 400 tolerance now matches "overlap" in the response body rather than the bare status, so a rejected schedule is no longer reported as applied — the payload is operator-supplied now, so a bad day name or window returns 400 too, and a trust would have been told its out-of-hours window was in force while retrieval ran around the clock. xnat-web's XNAT_PORT no longer says 8080 under a key that now means DICOM, and the retired PACS_DICOM_PORT is gone. Verified: with defaults, every payload the script sends is semantically identical to before apart from the intended pacsId resolution. Four regression tests reproduce the reviewers' exact cases. trust/xnat 196 passed, imaging-api 284 passed, helm lint and four renders clean. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 19 +- .../kubernetes/templates/imaging-api.yaml | 7 +- .../kubernetes/templates/xnat-init-job.yaml | 7 +- .../kubernetes/templates/xnat-web.yaml | 7 +- deploy/providers/kubernetes/values.yaml | 9 +- trust/xnat/Makefile | 4 +- trust/xnat/docker-compose-stack.yml | 28 +-- trust/xnat/tests/test_configure_pacs.py | 49 +++++- trust/xnat/xnat/config/configure-xnat.sh | 166 +++++++++++------- 9 files changed, 209 insertions(+), 87 deletions(-) diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 5442c6c43..c7dbf160a 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -118,12 +118,29 @@ jobs: --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' > /tmp/real-pacs.yaml for want in "nodePort: 8104" "allow-pacs-ingress" "cidr: \"10.0.0.10/32\"" \ - "value: \"SECTRA_QR\"" "value: \"FLIPXNAT\""; do + "value: \"SECTRA_QR\"" "value: \"10.0.0.10\"" "value: \"8059\"" \ + "value: \"FLIPXNAT\""; do if ! grep -q "$want" /tmp/real-pacs.yaml; then echo "::error::real-PACS render is missing: $want" exit 1 fi done + # imaging-api builds the C-MOVE destination from these, and DQR matches it against a + # registered receiver by exact AE title and port. Grepping the whole document is not + # enough — the init job's own copy satisfies that while imaging-api silently falls back + # to its code default, which is how this shipped broken once (FLIP#993). + python3 - <<'PY' + import re, sys, pathlib + doc = pathlib.Path("/tmp/real-pacs.yaml").read_text() + cm = next((d for d in doc.split("---") + if "kind: ConfigMap" in d and "component: imaging-api" in d), None) + if cm is None: + sys.exit("::error::imaging-api ConfigMap not found in the real-PACS render") + for key, val in (("XNAT_AETITLE", "FLIPXNAT"), ("XNAT_PORT", "8104")): + if not re.search(rf'^\s*{key}:\s*"{val}"\s*$', cm, re.M): + sys.exit(f'::error::imaging-api ConfigMap missing {key}: "{val}" ' + f'— the C-MOVE destination will not match the registered receiver') + PY # Default-deny must survive: the ingress allowance is the one inbound path into a trust, so a # chart that opened it without being asked would silently widen every existing deployment. diff --git a/deploy/providers/kubernetes/templates/imaging-api.yaml b/deploy/providers/kubernetes/templates/imaging-api.yaml index 460a015c2..b51627abe 100644 --- a/deploy/providers/kubernetes/templates/imaging-api.yaml +++ b/deploy/providers/kubernetes/templates/imaging-api.yaml @@ -30,7 +30,12 @@ metadata: app.kubernetes.io/component: imaging-api data: XNAT_URL: {{ .Values.imagingApi.env.XNAT_URL | quote }} - XNAT_PORT: {{ .Values.imagingApi.env.XNAT_PORT | quote }} + # Both of these describe XNAT's DICOM SCP receiver, and imaging-api uses them to build the C-MOVE + # destination it hands to the PACS. DQR matches that destination against a registered receiver by + # exact AE title and port, so they must equal what xnat-init-job.yaml registered — they are + # sourced from the same xnat.web.* values it uses rather than restated here (FLIP#993). + XNAT_PORT: {{ .Values.xnat.web.dicomPort | quote }} + XNAT_AETITLE: {{ .Values.xnat.web.dicomAet | quote }} PACS_ID: {{ .Values.imagingApi.env.PACS_ID | quote }} XNAT_DATABASE_URL: {{ .Values.imagingApi.env.XNAT_DATABASE_URL | quote }} DATA_ACCESS_API_URL: {{ .Values.imagingApi.env.DATA_ACCESS_API_URL | quote }} diff --git a/deploy/providers/kubernetes/templates/xnat-init-job.yaml b/deploy/providers/kubernetes/templates/xnat-init-job.yaml index 7e9bd4071..82257aebb 100644 --- a/deploy/providers/kubernetes/templates/xnat-init-job.yaml +++ b/deploy/providers/kubernetes/templates/xnat-init-job.yaml @@ -90,10 +90,13 @@ spec: key: xnat-service-password # DICOM SCP receiver: the port XNAT binds and registers, and the AE title the PACS # addresses its C-STORE association to. Must match what the PACS has registered. + # No `| default` here: Helm substitutes a default for an empty string too, which would + # hand the script a value it considers configured and cancel its fail-loud guard. The + # chart's own defaults live in values.yaml; an operator who blanks one gets an error. - name: XNAT_PORT - value: {{ .Values.xnat.web.dicomPort | default 8104 | quote }} + value: {{ .Values.xnat.web.dicomPort | quote }} - name: XNAT_AETITLE - value: {{ .Values.xnat.web.dicomAet | default "XNAT" | quote }} + value: {{ .Values.xnat.web.dicomAet | quote }} # Upstream PACS. Defaults are the mocked Orthanc; a real trust overrides them. - name: PACS_HOST value: {{ .Values.pacs.host | quote }} diff --git a/deploy/providers/kubernetes/templates/xnat-web.yaml b/deploy/providers/kubernetes/templates/xnat-web.yaml index 3648fc509..c87ec13bf 100644 --- a/deploy/providers/kubernetes/templates/xnat-web.yaml +++ b/deploy/providers/kubernetes/templates/xnat-web.yaml @@ -22,7 +22,11 @@ metadata: data: XNAT_HOME: /data/xnat/home XNAT_ADMIN_USER: {{ .Values.xnat.web.adminUser | quote }} - XNAT_PORT: "8080" + # The DICOM SCP receiver port. Sourced from the same value the init job registers the receiver + # with, so a hand-run of configure-xnat.sh inside this pod (initJob.enabled: false) configures the + # same port rather than a stale literal. It read "8080" — Tomcat's port — while XNAT_PORT was + # still ambiguous; it now unambiguously means DICOM (FLIP#993). + XNAT_PORT: {{ .Values.xnat.web.dicomPort | quote }} XNAT_DATASOURCE_DRIVER: "org.postgresql.Driver" XNAT_DATASOURCE_NAME: "xnat" XNAT_DATASOURCE_USERNAME: {{ .Values.xnat.db.datasourceUsername | quote }} @@ -33,7 +37,6 @@ data: XNAT_MIN_HEAP: {{ .Values.xnat.web.env.XNAT_MIN_HEAP | quote }} XNAT_MAX_HEAP: {{ .Values.xnat.web.env.XNAT_MAX_HEAP | quote }} CATALINA_OPTS: "-Xms{{ .Values.xnat.web.env.XNAT_MIN_HEAP }} -Xmx{{ .Values.xnat.web.env.XNAT_MAX_HEAP }} -Dxnat.home=/data/xnat/home -XX:+UseG1GC -Djava.awt.headless=true" - PACS_DICOM_PORT: "4242" --- apiVersion: v1 kind: Service diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index f6ff14ff6..ccba73241 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -206,11 +206,10 @@ imagingApi: topologyKey: kubernetes.io/hostname env: XNAT_URL: "http://xnat-web:8080" - # DICOM SCP receiver port — NOT the web port. DQR import requests are - # queued with destination AE "XNAT:{XNAT_PORT}"; XNAT only dequeues them - # if a DICOM SCP receiver with that exact AE:port exists (8104, matching - # the xnat-web Service dicom-scp port and the Orthanc modality config). - XNAT_PORT: "8104" + # XNAT_PORT and XNAT_AETITLE are deliberately absent here. They describe XNAT's DICOM SCP + # receiver, so they come from xnat.web.dicomPort / xnat.web.dicomAet — the same values the init + # job registers the receiver with. Restating them here made two sources for one number, which is + # the defect FLIP#993 set out to remove. PACS_ID: "1" # Topology only — no password here (FLIP-PT-056): imaging-api splices in the # xnat-datasource-password secret at startup (see templates/imaging-api.yaml). diff --git a/trust/xnat/Makefile b/trust/xnat/Makefile index b7a5a091d..df7685207 100644 --- a/trust/xnat/Makefile +++ b/trust/xnat/Makefile @@ -95,7 +95,9 @@ XNAT_NETWORK := deploy_trust-network-$(TRUST_NUM) XNAT_PORT_EFFECTIVE := $(or $(XNAT_PORT),8104) XNAT_WEB_PORT_EFFECTIVE := $(or $(XNAT_WEB_PORT),$(XNAT_PORT_EFFECTIVE)) # XNAT's AE title, applied to the SCP receiver, dqrCallingAe and the C-MOVE destination. -XNAT_AETITLE_EFFECTIVE := $(or $(XNAT_AETITLE),XNAT) +# $(if $(filter undefined,...)) rather than $(or): $(or) treats an empty value as false and +# would substitute the default, cancelling configure-xnat.sh's guard on an empty kit value. +XNAT_AETITLE_EFFECTIVE := $(if $(filter undefined,$(origin XNAT_AETITLE)),XNAT,$(XNAT_AETITLE)) PACS_UI_PORT_EFFECTIVE := $(or $(PACS_UI_PORT),8042) # Host path that backs this trust's XNAT bind mounts (parent of xnat-data/ diff --git a/trust/xnat/docker-compose-stack.yml b/trust/xnat/docker-compose-stack.yml index b15618817..aa13365bc 100644 --- a/trust/xnat/docker-compose-stack.yml +++ b/trust/xnat/docker-compose-stack.yml @@ -38,21 +38,25 @@ services: - XNAT_SERVICE_USER=${XNAT_SERVICE_USER} - XNAT_SERVICE_PASSWORD=${XNAT_SERVICE_PASSWORD} - XNAT_PORT=${XNAT_PORT} - - XNAT_AETITLE=${XNAT_AETITLE:-XNAT} + - XNAT_AETITLE=${XNAT_AETITLE-XNAT} # Upstream PACS, read by configure-xnat.sh. Defaults are the mocked Orthanc, so an # unconfigured stack behaves exactly as before; a real trust sets these in its kit file. - - PACS_HOST=${PACS_HOST:-orthanc} - - PACS_AETITLE=${PACS_AETITLE:-ORTHANC} - - PACS_QR_PORT=${PACS_QR_PORT:-4242} - - PACS_LABEL=${PACS_LABEL:-Test PACS instance} + # ${VAR-default}, not ${VAR:-default}: compose substitutes the default for a set-but-empty + # variable, which would hand the script a value it considers configured and cancel its + # fail-loud guard. An operator who writes PACS_HOST= in a kit file must get an error, not + # a silent fallback to the mocked PACS (FLIP#993). + - PACS_HOST=${PACS_HOST-orthanc} + - PACS_AETITLE=${PACS_AETITLE-ORTHANC} + - PACS_QR_PORT=${PACS_QR_PORT-4242} + - PACS_LABEL=${PACS_LABEL-Test PACS instance} # PACS throttle — a production PACS may refuse further associations after a certain volume. - - PACS_AVAILABILITY_DAYS=${PACS_AVAILABILITY_DAYS:-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY} - - PACS_AVAILABILITY_START=${PACS_AVAILABILITY_START:-00:00} - - PACS_AVAILABILITY_END=${PACS_AVAILABILITY_END:-24:00} - - PACS_THREADS=${PACS_THREADS:-1} - - PACS_UTILIZATION_PERCENT=${PACS_UTILIZATION_PERCENT:-100} - - DQR_MAX_PACS_REQUEST_ATTEMPTS=${DQR_MAX_PACS_REQUEST_ATTEMPTS:-100} - - DQR_RETRY_WAIT_SECONDS=${DQR_RETRY_WAIT_SECONDS:-300} + - PACS_AVAILABILITY_DAYS=${PACS_AVAILABILITY_DAYS-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY} + - PACS_AVAILABILITY_START=${PACS_AVAILABILITY_START-00:00} + - PACS_AVAILABILITY_END=${PACS_AVAILABILITY_END-24:00} + - PACS_THREADS=${PACS_THREADS-1} + - PACS_UTILIZATION_PERCENT=${PACS_UTILIZATION_PERCENT-100} + - DQR_MAX_PACS_REQUEST_ATTEMPTS=${DQR_MAX_PACS_REQUEST_ATTEMPTS-100} + - DQR_RETRY_WAIT_SECONDS=${DQR_RETRY_WAIT_SECONDS-300} - XNAT_DATASOURCE_DRIVER=${XNAT_DATASOURCE_DRIVER} - XNAT_DATASOURCE_URL=${XNAT_DATASOURCE_URL} - XNAT_DATASOURCE_NAME=${XNAT_DATASOURCE_NAME} diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index 3696e853b..6626c74aa 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -244,7 +244,7 @@ def test_receiver_on_our_port_is_reclaimed_whatever_it_is_called(tmp_path): {"STUB_SCP_JSON": '[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]'}, ) assert code == 0, output - assert "Removing SCP receiver 'FLIPXNAT' (id 3)" in output + assert "(id 3)" in output and "FLIPXNAT" in output assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)) @@ -260,5 +260,50 @@ def test_foreign_pacs_registrations_are_removed(tmp_path): }, ) assert code == 0, output - assert "Removing PACS 'ORTHANC' at orthanc:4242 (id 1)" in output + assert "Removing PACS ORTHANC at orthanc:4242 (id 1)" in output assert any(u.endswith("/xapi/pacs/1") for u in deletes(payloads)) + + +def test_receiver_is_reclaimed_when_port_and_title_both_change(tmp_path): + """Matching on our port *or* our AE title left an orphan when both moved in one change.""" + code, payloads, output = run_configure( + tmp_path, + { + "XNAT_PORT": "11112", + "XNAT_AETITLE": "FLIPXNAT2", + "STUB_SCP_JSON": '[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]', + }, + ) + assert code == 0, output + assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)), ( + "the old receiver was left enabled and bound to its port" + ) + + +def test_refuses_to_delete_a_foreign_pacs_while_still_on_mock_defaults(tmp_path): + """An unrelated redeploy must not delete a PACS the operator registered by hand.""" + code, payloads, output = run_configure( + tmp_path, + {"STUB_PACS_JSON": '[{"id":1,"aeTitle":"SECTRA_QR","host":"10.0.0.10","queryRetrievePort":8059}]'}, + ) + assert code != 0, "should refuse rather than delete a registration it may not own" + assert "SECTRA_QR at 10.0.0.10:8059" in output + assert not any("/xapi/pacs/1" in u for u in deletes(payloads)), "deleted it anyway" + + +def test_injected_json_in_a_pacs_value_cannot_change_other_fields(tmp_path): + """Values are data, not JSON fragments: a crafted host must not flip defaultQueryRetrievePacs.""" + code, payloads, output = run_configure( + tmp_path, + {"PACS_HOST": 'x", "defaultQueryRetrievePacs": false, "z": "y'}, + ) + assert code == 0, output + pacs = payload_for(payloads, "/xapi/pacs") + assert pacs["defaultQueryRetrievePacs"] is True, "injected key won" + assert pacs["host"] == 'x", "defaultQueryRetrievePacs": false, "z": "y' + + +def test_non_numeric_port_fails_naming_the_variable(tmp_path): + """A bad port must fail here, not reach XNAT as an opaque 400.""" + code, _, output = run_configure(tmp_path, {"PACS_QR_PORT": "8059abc"}) + assert code != 0, "a non-numeric port was accepted" diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index ae540f3e8..0e4205642 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -290,17 +290,18 @@ echo "Configuring DQR plugin..." xnat_curl -X POST "$XNAT_URL/xapi/dqr/settings" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ - -d "{ - \"pacsAvailabilityCheckFrequency\": \"1 minute\", - \"dqrWaitToRetryRequestInSeconds\": \"${DQR_RETRY_WAIT_SECONDS}\", - \"assumeSameSessionIfArrivedWithin\": \"30 minutes\", - \"allowAllUsersToUseDqr\": false, - \"dqrCallingAe\": \"${XNAT_AETITLE}\", - \"notifyAdminOnImport\": false, - \"allowAllProjectsToUseDqr\": true, - \"leavePacsAuditTrail\": false, - \"dqrMaxPacsRequestAttempts\": \"${DQR_MAX_PACS_REQUEST_ATTEMPTS}\" - }" + -d "$(jq -n --arg ae "${XNAT_AETITLE}" \ + --argjson retry "${DQR_RETRY_WAIT_SECONDS}" --argjson attempts "${DQR_MAX_PACS_REQUEST_ATTEMPTS}" '{ + pacsAvailabilityCheckFrequency: "1 minute", + dqrWaitToRetryRequestInSeconds: ($retry | tostring), + assumeSameSessionIfArrivedWithin: "30 minutes", + allowAllUsersToUseDqr: false, + dqrCallingAe: $ae, + notifyAdminOnImport: false, + allowAllProjectsToUseDqr: true, + leavePacsAuditTrail: false, + dqrMaxPacsRequestAttempts: ($attempts | tostring) + }')" # Configure site-wide anonymization script echo "Configuring site-wide anonymization script..." @@ -330,15 +331,19 @@ response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" "$XNAT_URL/xapi/ if [[ -z "$response" || "$response" == "[]" ]]; then echo "No SCP receivers found." else - stale_ids=$(printf '%s' "$response" | jq -r --argjson port "${XNAT_PORT}" --arg ae "${XNAT_AETITLE}" \ - 'map(select(.port == $port or .aeTitle == $ae or .aeTitle == "XNAT")) | .[] | "\(.id):\(.aeTitle)"') + # Every receiver, not a filtered subset. Matching on "our port or our AE title" left an orphan + # whenever both moved in one change — an existing FLIPXNAT:8104 with new config + # XNAT_AETITLE=FLIPXNAT2 XNAT_PORT=11112 matched neither, so the old receiver stayed enabled and + # bound. XNAT carries exactly one FLIP-owned receiver and this script owns it, so the honest + # filter is all of them (FLIP#993). + stale_ids=$(printf '%s' "$response" | jq -r '.[] | "\(.id):\(.aeTitle):\(.port)"') if [[ -z "$stale_ids" ]]; then - echo "No SCP receiver to replace on port ${XNAT_PORT}." + echo "No existing SCP receiver to replace." else - while IFS=: read -r scp_receiver_id scp_receiver_ae; do + while IFS=: read -r scp_receiver_id scp_receiver_ae scp_receiver_port; do [[ -n "$scp_receiver_id" ]] || continue - echo "Removing SCP receiver '${scp_receiver_ae}' (id ${scp_receiver_id})..." + echo "Removing SCP receiver '${scp_receiver_ae}:${scp_receiver_port}' (id ${scp_receiver_id})..." xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" \ -X DELETE "$XNAT_URL/xapi/dicomscp/$scp_receiver_id" >/dev/null done <<< "$stale_ids" @@ -350,21 +355,21 @@ echo "Configuring SCP receiver '${XNAT_AETITLE}' on port ${XNAT_PORT}..." xnat_curl -X POST "$XNAT_URL/xapi/dicomscp" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ - -d "{ - \"aeTitle\": \"${XNAT_AETITLE}\", - \"port\": ${XNAT_PORT}, - \"enabled\": true, - \"customProcessing\": true, - \"directArchive\": true, - \"identifier\": \"dqrObjectIdentifier\", - \"anonymizationEnabled\": true, - \"whitelistEnabled\": false, - \"whitelistText\": \"\", - \"routingExpressionsEnabled\": false, - \"projectRoutingExpression\": \"\", - \"subjectRoutingExpression\": \"\", - \"sessionRoutingExpression\": \"\" - }" + -d "$(jq -n --arg ae "${XNAT_AETITLE}" --argjson port "${XNAT_PORT}" '{ + aeTitle: $ae, + port: $port, + enabled: true, + customProcessing: true, + directArchive: true, + identifier: "dqrObjectIdentifier", + anonymizationEnabled: true, + whitelistEnabled: false, + whitelistText: "", + routingExpressionsEnabled: false, + projectRoutingExpression: "", + subjectRoutingExpression: "", + sessionRoutingExpression: "" + }')" # Configure OHIF viewer echo "Configuring OHIF viewer..." @@ -393,18 +398,30 @@ xnat_curl -X POST "$XNAT_URL/xapi/siteConfig" \ # Updating in place rather than delete-and-recreate keeps the PACS id stable, and means a kit change # actually takes effect: the previous check-then-create silently ignored a drifted host or port on # redeploy, leaving DQR pointing at the old PACS with no signal to the operator. -pacs_payload="{ - \"aeTitle\": \"${PACS_AETITLE}\", - \"defaultQueryRetrievePacs\": true, - \"defaultStoragePacs\": true, - \"host\": \"${PACS_HOST}\", - \"label\": \"${PACS_LABEL}\", - \"ormStrategySpringBeanId\": \"dicomOrmStrategy\", - \"queryRetrievePort\": ${PACS_QR_PORT}, - \"queryable\": true, - \"storable\": true, - \"supportsExtendedNegotiations\": true - }" +# Built with jq rather than string interpolation. Splicing operator-supplied values straight into +# JSON is an injection point: a PACS_HOST of `x", "defaultQueryRetrievePacs": false, "z": "y` +# produces *valid* JSON whose duplicate key wins, silently disabling the flag on the registration +# this script exists to make authoritative; a label containing a quote produces malformed JSON that +# XNAT rejects with an opaque 400 mid-run. --arg/--argjson keep them data. --argjson also parses the +# numeric fields, so a non-numeric port fails here naming the variable, rather than as an +# unexplained 400 (FLIP#993). +pacs_payload=$(jq -n \ + --arg ae "${PACS_AETITLE}" \ + --arg host "${PACS_HOST}" \ + --arg label "${PACS_LABEL}" \ + --argjson port "${PACS_QR_PORT}" \ + '{ + aeTitle: $ae, + defaultQueryRetrievePacs: true, + defaultStoragePacs: true, + host: $host, + label: $label, + ormStrategySpringBeanId: "dicomOrmStrategy", + queryRetrievePort: $port, + queryable: true, + storable: true, + supportsExtendedNegotiations: true + }') existing_pacs=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_URL/xapi/pacs") pacs_entry=$(printf '%s' "$existing_pacs" | jq -c --arg ae "${PACS_AETITLE}" \ @@ -413,12 +430,33 @@ pacs_entry=$(printf '%s' "$existing_pacs" | jq -c --arg ae "${PACS_AETITLE}" \ # Remove every registration that is not the configured one, so exactly one survives. Logged per # entry: this deletes configuration an administrator may have added through the XNAT UI, and that # should be visible in the deploy output rather than silent. -foreign_pacs=$(printf '%s' "$existing_pacs" | jq -r --arg ae "${PACS_AETITLE}" \ - 'map(select(.aeTitle != $ae)) | .[] | "\(.id):\(.aeTitle):\(.host):\(.queryRetrievePort)"') +# +# Refuse rather than delete when the configuration is still the shipped default. An operator whose +# XNAT already carries a hand-registered real PACS, running an unrelated upgrade without having set +# PACS_AETITLE, would otherwise have that registration deleted and replaced with the mock — a +# retrieval outage caused by a deploy that changed nothing else. Deleting is right only once the +# operator has said which PACS is theirs (FLIP#993). +foreign_pacs=$(printf '%s' "$existing_pacs" \ + | jq -r --arg ae "${PACS_AETITLE}" 'map(select(.aeTitle != $ae)) | .[] | @base64') if [[ -n "$foreign_pacs" ]]; then - while IFS=: read -r stale_id stale_ae stale_host stale_port; do - [[ -n "$stale_id" ]] || continue - echo "Removing PACS '${stale_ae}' at ${stale_host}:${stale_port} (id ${stale_id}):" \ + if [[ "${PACS_AETITLE}" == "ORTHANC" && "${PACS_HOST}" == "orthanc" ]]; then + echo "ERROR: XNAT has PACS registrations other than the configured one, but PACS_AETITLE and" >&2 + echo " PACS_HOST are still the mocked-Orthanc defaults. Refusing to delete a registration" >&2 + echo " this deployment may not own. Set PACS_AETITLE/PACS_HOST to the trust's PACS, or" >&2 + echo " remove the unwanted registration in XNAT's admin UI. Found:" >&2 + printf '%s\n' "$foreign_pacs" | while read -r entry; do + [[ -n "$entry" ]] || continue + printf ' %s\n' "$(printf '%s' "$entry" | base64 -d \ + | jq -r '"\(.aeTitle) at \(.host):\(.queryRetrievePort) (id \(.id))"')" >&2 + done + exit 1 + fi + while read -r entry; do + [[ -n "$entry" ]] || continue + # base64 per record: an IPv6 host contains colons, which a ':'-delimited read would mis-split. + stale=$(printf '%s' "$entry" | base64 -d) + stale_id=$(printf '%s' "$stale" | jq -r '.id') + echo "Removing PACS $(printf '%s' "$stale" | jq -r '"\(.aeTitle) at \(.host):\(.queryRetrievePort) (id \(.id))"'):" \ "a trust XNAT retrieves from one PACS, and '${PACS_AETITLE}' is the configured one." xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -X DELETE "$XNAT_URL/xapi/pacs/${stale_id}" >/dev/null @@ -460,8 +498,12 @@ PACS_ID=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_URL/xa # # DQR appears to pre-create availability intervals when the PACS is registered: on XNAT 1.10 + # DQR 3.0.0 this POST returns 400 "probable overlap with existing interval" for an already-scheduled -# day, so 400 is treated as "already configured" rather than a failure. Anything else non-2xx is a -# real error and fails the deploy. +# day, so that specific 400 is treated as "already configured" rather than a failure. +# +# The overlap text is matched, not the bare status: the payload is now built from operator-supplied +# values, so a bad day name or window also returns 400. Treating every 400 as an overlap reported a +# rejected schedule as applied, and a trust that had negotiated an out-of-hours window would have +# been told it was in force while DQR retrieved around the clock (FLIP#993). for DAY in ${PACS_AVAILABILITY_DAYS//,/ }; do echo "Setting PACS availability for $DAY..." avail_body=/tmp/pacs-availability-response.json @@ -469,19 +511,21 @@ for DAY in ${PACS_AVAILABILITY_DAYS//,/ }; do -X POST "$XNAT_URL/xapi/pacs/${PACS_ID}/availability" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ - -d "{ - \"availabilityEnd\": \"${PACS_AVAILABILITY_END}\", - \"availabilityStart\": \"${PACS_AVAILABILITY_START}\", - \"availableNow\": true, - \"dayOfWeek\": \"$DAY\", - \"enabled\": true, - \"pacsId\": ${PACS_ID}, - \"threads\": ${PACS_THREADS}, - \"utilizationPercent\": ${PACS_UTILIZATION_PERCENT} - }") || avail_status="000" + -d "$(jq -n --arg start "${PACS_AVAILABILITY_START}" --arg end "${PACS_AVAILABILITY_END}" \ + --arg day "$DAY" --argjson pacs "${PACS_ID}" --argjson threads "${PACS_THREADS}" \ + --argjson util "${PACS_UTILIZATION_PERCENT}" '{ + availabilityEnd: $end, + availabilityStart: $start, + availableNow: true, + dayOfWeek: $day, + enabled: true, + pacsId: $pacs, + threads: $threads, + utilizationPercent: $util + }')") || avail_status="000" if [[ "$avail_status" == 2* ]]; then continue - elif [[ "$avail_status" == "400" ]]; then + elif [[ "$avail_status" == "400" ]] && grep -qi 'overlap' "$avail_body"; then echo " Availability interval for $DAY already exists (HTTP 400 overlap) — leaving as-is." else echo "ERROR: setting PACS availability for $DAY failed (HTTP $avail_status)" >&2 From fa870150a64bcb0f1a1da0f7d1f02e54d77f1a35 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 12:30:56 +0100 Subject: [PATCH 12/31] fix: address the important-tier review findings (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Networking, on the Kubernetes path the PR targets first: - The Service now sets externalTrafficPolicy: Local when the DICOM NodePort is pinned. Under the default Cluster policy kube-proxy rewrites the PACS source address to the node's before the pod sees it, so the ingress rule's CIDR would never have matched and the C-STORE would have been dropped by default-deny — the chart would have looked correctly configured and retrieval would still have timed out. - An ingress entry with no `port` now fails the render. Kubernetes reads an absent port as *all* ports, so a forgotten line silently widened the one inbound path into a trust from DICOM to everything, and both helm lint and the render step stayed green. - A configured real PACS with no egress rule now fails the render. Egress defaults are DNS/80/443, so a PACS on 8059 was unreachable and even the C-FIND never left the cluster. The CI's own real-PACS reference configuration was exactly this, rendered and asserted as correct; it now configures egress, and a companion step asserts the refusal. resolve_pacs_id was more defensive than useful: - It caught bare Exception and fell back to the id the function exists to distrust, at warning level. On a brownfield trust that fallback is the known-wrong answer and the symptom — "no study found" for every accession — reads as a data problem rather than a misconfiguration. Now logged at error, naming the consequence, and the fallback is never cached so a transient failure cannot pin it. - No timeout on a call that sits on the retrieval path; a wedged XNAT hung the import rather than falling back. Bounded at 30s. - It took registrations[0]. configure-xnat.sh sets defaultQueryRetrievePacs on the one it owns, so prefer that flag over list order. - The cache never invalidated. Re-registering under a new AE title gives the PACS a new id, and nothing restarts imaging-api when XNAT is reconfigured, so every import went to a deleted registration until the container restarted. It is now cleared when XNAT reports the PACS missing. Documentation, where the review found claims that were simply wrong: - "Resolves the PACS id by AE title" appeared in five places. The code reads the sole registration; it never compares AE titles. Corrected, including the timing — runtime, not configuration time. - The availability note asserted the configured window "takes effect on a fresh deployment". Testing on a live XNAT showed the write is rejected wherever intervals already exist, including after a re-registration, and that XNAT normalises 24:00 to 00:00. Since the window is usually something a PACS manager has agreed to, the caveat now tells operators to verify what is in force rather than assume. - security.rst claimed "no inbound traffic at all" two paragraphs above "one inbound connection". Scoped to the AWS security groups, which is what is actually true. The three pages it points readers at still asserted the retired claim — including the firewall table in admin-platform-support.rst, whose DICOM row had an empty Inbound column. That is the table an operator uses to raise the request, so it now carries the C-STORE return leg as its own row. - The PACS page insisted the receiver must be exposed without ever saying how. Adds "Exposing the DICOM Receiver" covering REAL_PACS, dicomNodePort and both NetworkPolicy CIDR lists. - Corrected the SCU/SCP glossary entry, which defined the roles by who opens the connection — contradicting the C-MOVE entry directly below it, where the PACS is the SCP and opens the C-STORE. Fixed four stale cross-references to a section this PR moved. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 15 +++- AGENTS.md | 3 +- CLAUDE.md | 3 +- .../kubernetes/templates/network-policy.yaml | 16 ++++ .../kubernetes/templates/xnat-web.yaml | 9 ++ deploy/providers/kubernetes/values.yaml | 2 +- docs/source/components/component-pacs.rst | 63 ++++++++++++-- .../deploy-flip/deploy-flip-node-on-prem.rst | 7 +- docs/source/glossary.rst | 4 +- docs/source/security.rst | 4 +- .../sys-admin/admin-platform-support.rst | 10 ++- trust/.env.GSTT.development.example | 5 +- trust/.env.KCH.development.example | 5 +- trust/.env.example | 5 +- .../imaging_api/services/imaging.py | 84 ++++++++++++++----- .../tests/services/test_imaging.py | 2 +- 16 files changed, 192 insertions(+), 45 deletions(-) diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index c7dbf160a..03f72ce8a 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -116,7 +116,9 @@ jobs: --set pacs.aeTitle=SECTRA_QR \ --set pacs.qrPort=8059 \ --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ - --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' > /tmp/real-pacs.yaml + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].port=8059' > /tmp/real-pacs.yaml for want in "nodePort: 8104" "allow-pacs-ingress" "cidr: \"10.0.0.10/32\"" \ "value: \"SECTRA_QR\"" "value: \"10.0.0.10\"" "value: \"8059\"" \ "value: \"FLIPXNAT\""; do @@ -142,6 +144,17 @@ jobs: f'— the C-MOVE destination will not match the registered receiver') PY + # A real PACS with no egress rule cannot be queried at all — C-FIND never leaves the cluster. + # The chart refuses to render that, so assert the refusal: this configuration was previously + # rendered and asserted as correct (FLIP#993). + - name: Render template (real PACS without egress is refused) + run: | + if helm template trust-release deploy/providers/kubernetes/ \ + --set pacs.host=10.0.0.10 --set pacs.qrPort=8059 > /dev/null 2>&1; then + echo "::error::a PACS with no egress rule rendered successfully" + exit 1 + fi + # Default-deny must survive: the ingress allowance is the one inbound path into a trust, so a # chart that opened it without being asked would silently widen every existing deployment. - name: Render template (default keeps ingress denied) diff --git a/AGENTS.md b/AGENTS.md index 504d5a8c5..3f4f97642 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -385,7 +385,8 @@ After changes, evaluate if docs need updating: mocked Orthanc (`orthanc` / `ORTHANC` / `4242`). `PACS_QR_PORT` must be reachable *from the XNAT container*, not a host-published port — conflating the two is what the retired `PACS_DICOM_PORT` did (FLIP#822/#862). `configure-xnat.sh` updates an existing registration in place when the host or - port drift, and imaging-api resolves the PACS id by AE title rather than assuming 1. + port drift, and imaging-api reads the PACS id from XNAT at runtime rather than assuming 1 — + `configure-xnat.sh` keeps exactly one registration, so it is the sole one XNAT reports. - `PACS_AVAILABILITY_DAYS` / `_START` / `_END` / `PACS_THREADS` / `PACS_UTILIZATION_PERCENT` / `DQR_MAX_PACS_REQUEST_ATTEMPTS` / `DQR_RETRY_WAIT_SECONDS` — the retrieval throttle. A production PACS may refuse further associations after a certain volume, so the window and thread count are diff --git a/CLAUDE.md b/CLAUDE.md index a21c12fa3..b6999cab8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,7 +385,8 @@ After changes, evaluate if docs need updating: mocked Orthanc (`orthanc` / `ORTHANC` / `4242`). `PACS_QR_PORT` must be reachable *from the XNAT container*, not a host-published port — conflating the two is what the retired `PACS_DICOM_PORT` did (FLIP#822/#862). `configure-xnat.sh` updates an existing registration in place when the host or - port drift, and imaging-api resolves the PACS id by AE title rather than assuming 1. + port drift, and imaging-api reads the PACS id from XNAT at runtime rather than assuming 1 — + `configure-xnat.sh` keeps exactly one registration, so it is the sole one XNAT reports. - `PACS_AVAILABILITY_DAYS` / `_START` / `_END` / `PACS_THREADS` / `PACS_UTILIZATION_PERCENT` / `DQR_MAX_PACS_REQUEST_ATTEMPTS` / `DQR_RETRY_WAIT_SECONDS` — the retrieval throttle. A production PACS may refuse further associations after a certain volume, so the window and thread count are diff --git a/deploy/providers/kubernetes/templates/network-policy.yaml b/deploy/providers/kubernetes/templates/network-policy.yaml index 7ece88c51..230587b9c 100644 --- a/deploy/providers/kubernetes/templates/network-policy.yaml +++ b/deploy/providers/kubernetes/templates/network-policy.yaml @@ -49,6 +49,19 @@ spec: policyTypes: - Ingress --- +{{- if and .Values.networkPolicies.enabled (ne .Values.pacs.host "orthanc") }} +{{- $pacsPort := .Values.pacs.qrPort | int }} +{{- $egressOk := false }} +{{- range .Values.networkPolicies.allowedEgressCIDRsWithPorts }} + {{- if eq (.port | int) $pacsPort }}{{ $egressOk = true }}{{ end }} +{{- end }} +{{- range .Values.networkPolicies.allowedEgressPorts }} + {{- if eq (.port | int) $pacsPort }}{{ $egressOk = true }}{{ end }} +{{- end }} +{{- if and (not $egressOk) (not .Values.networkPolicies.allowedEgressCIDRs) }} +{{- fail (printf "pacs.host is %s but no egress rule reaches its query/retrieve port %v. XNAT could not issue C-FIND or C-MOVE, so retrieval would fail before it began. Add the PACS to networkPolicies.allowedEgressCIDRsWithPorts." .Values.pacs.host .Values.pacs.qrPort) }} +{{- end }} +{{- end }} {{- if .Values.networkPolicies.allowedIngressCIDRsWithPorts }} # Allow inbound DICOM from the trust PACS. # @@ -79,6 +92,9 @@ spec: cidr: {{ . | quote }} {{- end }} ports: + {{- if not .port }} + {{- fail "networkPolicies.allowedIngressCIDRsWithPorts: each entry needs an explicit `port`. Kubernetes reads an absent port as *all* ports, which would widen the one inbound path into the trust from DICOM to everything." }} + {{- end }} - port: {{ .port }} protocol: {{ .protocol | default "TCP" }} {{- end }} diff --git a/deploy/providers/kubernetes/templates/xnat-web.yaml b/deploy/providers/kubernetes/templates/xnat-web.yaml index c87ec13bf..1b1b9d756 100644 --- a/deploy/providers/kubernetes/templates/xnat-web.yaml +++ b/deploy/providers/kubernetes/templates/xnat-web.yaml @@ -48,6 +48,15 @@ metadata: app.kubernetes.io/component: xnat-web spec: type: {{ .Values.xnat.web.service.type }} + {{- if and (eq .Values.xnat.web.service.type "NodePort") .Values.xnat.web.dicomNodePort }} + # Preserve the PACS's source IP. Under the default `Cluster` policy kube-proxy SNATs NodePort + # traffic to the node address before it reaches the pod, so the ingress NetworkPolicy's PACS CIDR + # would never match and the C-STORE return leg would be dropped by default-deny — queries + # succeeding while retrievals silently time out, which is the bug this exists to prevent + # (FLIP#993). `Local` also means only nodes running the pod answer, which suits the single-node + # trust deployment this targets. + externalTrafficPolicy: Local + {{- end }} ports: - port: {{ .Values.xnat.web.service.port }} targetPort: tomcat diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index ccba73241..081455d39 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -742,7 +742,7 @@ podDisruptionBudget: # Upstream PACS that XNAT retrieves imaging from, via the DQR plugin. # # Defaults describe the mocked Orthanc deployed by this chart. A trust points these at its own PACS; -# see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". These replace the +# see docs/source/components/component-pacs.rst. These replace the # former orthanc.dicomHost / dicomPort / dicomAet values, which no template ever read — which is why # setting orthanc.enabled: false with an external.host never actually redirected DQR. pacs: diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index e4fb9cc45..68a07f217 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -199,6 +199,49 @@ trust's PACS team supplies; what they supply is covered above. The mocked PACS those defaults describe is covered below. +Exposing the DICOM Receiver +=========================== + +The page has said several times that the receiver must be reachable from the PACS. It is off by +default, because the mocked PACS reaches it over the container network and publishing it would be an +unnecessary opening. Turning it on differs by deployment: + +**Compose.** ``make -C trust/xnat up-xnat KIT= REAL_PACS=true`` adds an overlay that publishes +the receiver on the host. ``XNAT_WEB_PORT`` and ``XNAT_PORT`` must then differ, since both are +host-published; the Makefile refuses to deploy if they collide. + +**Kubernetes.** Three values, all off by default: + +.. code-block:: yaml + + xnat: + web: + service: + type: NodePort + # Pin the port so the PACS has a stable destination. Must be inside the API server's + # --service-node-port-range, or that range widened to admit the DICOM port. + dicomNodePort: 8104 + + networkPolicies: + # The C-STORE return leg. Scope to the PACS itself, never the whole trust network. + allowedIngressCIDRsWithPorts: + - cidrs: ["10.0.0.10/32"] + port: 8104 + # The outbound query. Without this, C-FIND never leaves the cluster. + allowedEgressCIDRsWithPorts: + - cidrs: ["10.0.0.10/32"] + port: 8059 + +The chart refuses to render a configured PACS with no egress rule, and refuses an ingress entry with +no ``port`` — Kubernetes reads an absent port as *all* ports, which would widen the one inbound path +into the trust from DICOM to everything. + +.. note:: + + Setting ``service.type: NodePort`` also sets ``externalTrafficPolicy: Local`` on that Service. + Under the default ``Cluster`` policy the PACS's source address is rewritten to the node's before + the pod sees it, so the ingress rule above would never match and the C-STORE would be dropped. + Development: the Mocked PACS ============================ @@ -289,13 +332,21 @@ carries an availability schedule with a per-day window, a thread count and a uti Where a trust has a test or pre-production PACS, connecting FLIP to that first is recommended, and is usually raised as a separate service request. -.. note:: +.. warning:: + + **Verify the window that is actually in force rather than assuming the configured one applied.** + + DQR pre-creates availability intervals when a PACS is registered, and rejects a write to a day + that already has one. On a first deployment the configured values are applied; on any XNAT where + intervals already exist — including one where the PACS has been re-registered, which leaves the + previous intervals behind — the write is rejected and the existing window stands. Changing it + then requires deleting the intervals through XNAT's administration UI. + + Confirm with ``GET /xapi/pacs/{id}/availability`` after configuring. Note also that XNAT + normalises an end time of ``24:00`` to ``00:00`` when it stores it. - The availability schedule is applied when the PACS is first registered. The DQR plugin pre-creates - the intervals, and rejects a later write to a day that already has one, so changing the window on - an already-configured instance requires deleting the existing intervals through XNAT's - administration UI first. The values above therefore take effect on a fresh deployment; on a - running one, check what is actually configured rather than assuming the setting was applied. + This matters because the window is usually something a trust's PACS manager has agreed to. If it + has not applied, retrieval runs outside it. Verification ============ diff --git a/docs/source/deploy-flip/deploy-flip-node-on-prem.rst b/docs/source/deploy-flip/deploy-flip-node-on-prem.rst index 5d5224e78..c8b5b46ce 100644 --- a/docs/source/deploy-flip/deploy-flip-node-on-prem.rst +++ b/docs/source/deploy-flip/deploy-flip-node-on-prem.rst @@ -7,7 +7,9 @@ Deploy a FLIP node on-prem An on-prem FLIP node runs the trust-side stack (trust-api, imaging-api, data-access-api, FL client, optional XNAT/Orthanc) on an Ubuntu host owned by the Trust. The node polls the Central Hub for tasks over HTTPS — all -communication is outbound, no inbound ports are opened. This is the deployment +communication with the hub is outbound and no inbound ports are opened to the internet. +Retrieval from the trust's own PACS is the exception — see +:doc:`../components/component-pacs`. This is the deployment model used when the Trust has direct, governed access to its own OMOP database and PACS. For deployment inside a TRE see :doc:`deploy-flip-node-in-tre`; for the Central Hub side see :doc:`deploy-central-hub`. @@ -262,7 +264,8 @@ the operator brings the stack up. Network requirements *********************** -**No inbound port forwarding is needed.** Trusts poll the hub outbound for +**No inbound port forwarding from the internet is needed.** (Retrieval from a trust PACS needs +one rule inside the trust's own network — see :doc:`../components/component-pacs`.) Trusts poll the hub outbound for tasks, and FL clients connect outbound to the FL server via the NLB. All communication is trust-initiated. diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 4b4b460fd..3a519a8e2 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -26,10 +26,10 @@ Glossary Picture Archiving and Communication System (PACS), the clinical system used to store and retrieve medical imaging studies (such as DICOM series). **AE Title** - Application Entity Title. A DICOM system's name on the network, at most 16 characters. When one system connects to another it announces which AE title it is calling and which it is calling from, and the receiver accepts the connection only if the called title is its own. AE titles are names rather than addresses: the IP and port are configured alongside them. See :doc:`components/component-xnat`. + Application Entity Title. A DICOM system's name on the network, at most 16 characters. When one system connects to another it announces which AE title it is calling and which it is calling from, and the receiver accepts the connection only if the called title is its own. AE titles are names rather than addresses: the IP and port are configured alongside them. See :doc:`components/component-pacs`. **SCU / SCP** - Service Class User and Service Class Provider — DICOM's terms for client and server. An SCU opens connections; an SCP listens for them. A system is often both: XNAT acts as an SCU when it queries a PACS, and as an SCP when it receives the resulting images. + Service Class User and Service Class Provider — DICOM's terms for the two sides of a service. The SCU requests it; the SCP provides it. The roles are per operation, not per system, and can swap mid-exchange: a PACS is the SCP for C-MOVE, then becomes the SCU of the C-STORE it opens back to the destination. XNAT is likewise an SCU when it queries a PACS and an SCP when it receives the images. **DIMSE** DICOM Message Service Element, the classic DICOM network protocol (as opposed to the newer HTTP-based DICOMweb). FLIP retrieves imaging over DIMSE. diff --git a/docs/source/security.rst b/docs/source/security.rst index 0ec520703..f02efa1ae 100644 --- a/docs/source/security.rst +++ b/docs/source/security.rst @@ -27,8 +27,8 @@ Network and perimeter **Trust systems accept no inbound connections from the internet or the Central Hub.** Each participating trust runs FLIP services that reach *out* to the Central Hub to collect work and report results. This is enforced in the infrastructure definitions -themselves — the security groups permit no inbound traffic at all — rather than -depending on configuration discipline. Operator access is via AWS Systems Manager +themselves — the AWS trust security groups define no ingress rules at all — rather +than depending on configuration discipline. Operator access is via AWS Systems Manager Session Manager, so port 22 is never opened. The one inbound connection in the design is internal to the trust. FLIP asks the trust's diff --git a/docs/source/sys-admin/admin-platform-support.rst b/docs/source/sys-admin/admin-platform-support.rst index 478286c55..a49cb2f02 100644 --- a/docs/source/sys-admin/admin-platform-support.rst +++ b/docs/source/sys-admin/admin-platform-support.rst @@ -8,7 +8,8 @@ Networking All trust communication is **outbound** — trusts poll the Central Hub for tasks over HTTPS (via the ALB), and FL clients connect outbound to the FL server via the NLB. The hub never -makes inbound connections to trusts, so no inbound firewall rules or port forwarding are +makes inbound connections to trusts, so no inbound firewall rules or port forwarding from the +internet are required on trust hosts. Operator access is via AWS Systems Manager Session Manager (SSH-over-SSM); XNAT, Orthanc, and the trust-api Swagger docs are reachable only through SSM port forwarding (``make forward-trust``). Orthanc additionally requires HTTP basic auth — log in @@ -36,9 +37,12 @@ opened. * - Description - Inbound - Outbound - * - DICOM ingestion from local PACS into the trust XNAT + * - DICOM query/retrieve from the local PACS (XNAT → PACS) + - + - local PACS query/retrieve port + * - DICOM C-STORE return leg (PACS → XNAT) + - XNAT DICOM port (``XNAT_PORT``, 8104 by default) - - - local PACS DICOM ports * - Trust → Central Hub task polling (HTTPS) - - 443 diff --git a/trust/.env.GSTT.development.example b/trust/.env.GSTT.development.example index 8baa25df9..7b758b241 100644 --- a/trust/.env.GSTT.development.example +++ b/trust/.env.GSTT.development.example @@ -78,7 +78,7 @@ GRAFANA_ADMIN_PASSWORD=admin # ── Upstream PACS ───────────────────────────────────────────────────────── # Defaults describe the mocked Orthanc that ships for development. A real trust points these at its -# PACS; see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". +# PACS; see docs/source/components/component-pacs.rst. # # XNAT_AETITLE is XNAT's own AE title, applied to its DICOM SCP receiver, the DQR calling AE, and the # C-MOVE destination handed to the PACS. It must match the AE title the PACS has registered for us, @@ -86,7 +86,8 @@ GRAFANA_ADMIN_PASSWORD=admin # # PACS_QR_PORT is the port XNAT dials, and must be reachable *from the XNAT container* — not a # host-published port. Getting that wrong is what the retired PACS_DICOM_PORT variable did -# (FLIP#822 / FLIP#862). (PACS_ID is resolved by AE title at configuration time; not a kit field.) +# (FLIP#822 / FLIP#862). (PACS_ID is read from XNAT at runtime as the sole registration; it remains only as the +# fallback for when XNAT is unreachable, and is not a kit field.) XNAT_AETITLE=XNAT PACS_HOST=orthanc PACS_AETITLE=ORTHANC diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index 60c15d4e2..8dbaefc8c 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -77,7 +77,7 @@ GRAFANA_ADMIN_PASSWORD=admin # ── Upstream PACS ───────────────────────────────────────────────────────── # Defaults describe the mocked Orthanc that ships for development. A real trust points these at its -# PACS; see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". +# PACS; see docs/source/components/component-pacs.rst. # # XNAT_AETITLE is XNAT's own AE title, applied to its DICOM SCP receiver, the DQR calling AE, and the # C-MOVE destination handed to the PACS. It must match the AE title the PACS has registered for us, @@ -85,7 +85,8 @@ GRAFANA_ADMIN_PASSWORD=admin # # PACS_QR_PORT is the port XNAT dials, and must be reachable *from the XNAT container* — not a # host-published port. Getting that wrong is what the retired PACS_DICOM_PORT variable did -# (FLIP#822 / FLIP#862). (PACS_ID is resolved by AE title at configuration time; not a kit field.) +# (FLIP#822 / FLIP#862). (PACS_ID is read from XNAT at runtime as the sole registration; it remains only as the +# fallback for when XNAT is unreachable, and is not a kit field.) XNAT_AETITLE=XNAT PACS_HOST=orthanc PACS_AETITLE=ORTHANC diff --git a/trust/.env.example b/trust/.env.example index 90511ac73..b12f4dabe 100644 --- a/trust/.env.example +++ b/trust/.env.example @@ -108,7 +108,7 @@ GRAFANA_ADMIN_PASSWORD=admin # ── Upstream PACS ───────────────────────────────────────────────────────── # Defaults describe the mocked Orthanc that ships for development. A real trust points these at its -# PACS; see docs/source/components/component-xnat.rst "Connecting to a Trust PACS". +# PACS; see docs/source/components/component-pacs.rst. # # XNAT_AETITLE is XNAT's own AE title, applied to its DICOM SCP receiver, the DQR calling AE, and the # C-MOVE destination handed to the PACS. It must match the AE title the PACS has registered for us, @@ -116,7 +116,8 @@ GRAFANA_ADMIN_PASSWORD=admin # # PACS_QR_PORT is the port XNAT dials, and must be reachable *from the XNAT container* — not a # host-published port. Getting that wrong is what the retired PACS_DICOM_PORT variable did -# (FLIP#822 / FLIP#862). (PACS_ID is resolved by AE title at configuration time; not a kit field.) +# (FLIP#822 / FLIP#862). (PACS_ID is read from XNAT at runtime as the sole registration; it remains only as the +# fallback for when XNAT is unreachable, and is not a kit field.) XNAT_AETITLE=XNAT PACS_HOST=orthanc PACS_AETITLE=ORTHANC diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index 4b4eec816..5f8430202 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -28,10 +28,15 @@ PACS_ID = get_settings().PACS_ID XNAT_URL = get_settings().XNAT_URL - -# Cache for resolve_pacs_id(). XNAT assigns PACS ids at registration time, so the mapping from AE -# title to id is fixed for the life of the registration; re-resolving on every query would add an -# XNAT round-trip per accession number. +# Bound the PACS-id lookup: it sits on the retrieval path, and an unbounded request to a wedged +# XNAT would hang the import rather than falling back. +XNAT_REQUEST_TIMEOUT = 30 + +# Cache for resolve_pacs_id(). XNAT assigns a PACS its id at registration time, so the id is fixed +# for the life of that registration and re-resolving on every query would add an XNAT round-trip per +# accession number. Note the cache outlives the registration: if the PACS is re-registered while +# imaging-api stays up — configure-xnat.sh deletes and recreates it when the AE title changes — the +# cached id points at a deleted entry, so it is cleared when XNAT reports the PACS missing. _resolved_pacs_id: int | None = None @@ -58,25 +63,62 @@ def resolve_pacs_id(headers: dict[str, str]) -> int: return _resolved_pacs_id try: - response = requests.get(f"{XNAT_URL}/xapi/pacs", headers=headers) + response = requests.get(f"{XNAT_URL}/xapi/pacs", headers=headers, timeout=XNAT_REQUEST_TIMEOUT) response.raise_for_status() registrations = response.json() - if registrations: - if len(registrations) > 1: - # configure-xnat.sh should have removed the others; if one reappeared, DQR's choice - # of PACS is ambiguous and the operator needs to know. - logger.warning( - f"XNAT has {len(registrations)} PACS registrations; expected one. " - f"Using '{registrations[0].get('aeTitle')}'." - ) - _resolved_pacs_id = int(registrations[0]["id"]) - logger.info(f"Resolved PACS '{registrations[0].get('aeTitle')}' to id {_resolved_pacs_id}") - return _resolved_pacs_id - logger.warning(f"XNAT reports no registered PACS; falling back to id {PACS_ID}") except Exception as e: - logger.warning(f"Could not resolve the PACS id ({e}); falling back to id {PACS_ID}") + # Deliberately not cached: a transient failure must not pin the fallback for the life of the + # process. Logged at error because the fallback is a guess — on an XNAT that carried the + # mocked Orthanc before the real PACS, id 1 is the known-wrong answer, and the symptom is + # "no study found" for every accession, which reads as a data problem rather than a + # misconfiguration (FLIP#993). + logger.error( + f"Could not resolve the PACS id from XNAT ({e}); falling back to id {PACS_ID}. " + f"Retrieval will target that id, which may not be the configured PACS." + ) + return PACS_ID + + if not isinstance(registrations, list) or not registrations: + logger.error( + f"XNAT reports no registered PACS; falling back to id {PACS_ID}. " + f"Retrieval will target that id, which may not be the configured PACS." + ) + return PACS_ID + + # configure-xnat.sh sets defaultQueryRetrievePacs on the one it owns and removes any other, so + # prefer that flag over list order — an extra registration added through the XNAT UI would + # otherwise be picked purely because XNAT happened to list it first. + default_qr = [p for p in registrations if p.get("defaultQueryRetrievePacs")] + chosen = (default_qr or registrations)[0] + + if len(registrations) > 1: + logger.warning( + f"XNAT has {len(registrations)} PACS registrations; expected one. " + f"Using '{chosen.get('aeTitle')}'" + f"{' (the default query/retrieve PACS)' if default_qr else ' (first listed)'}." + ) + + try: + _resolved_pacs_id = int(chosen["id"]) + except (KeyError, TypeError, ValueError) as e: + logger.error(f"PACS registration from XNAT has no usable id ({e}); falling back to id {PACS_ID}.") + return PACS_ID - return PACS_ID + logger.info(f"Resolved PACS '{chosen.get('aeTitle')}' to id {_resolved_pacs_id}") + return _resolved_pacs_id + + +def forget_resolved_pacs_id() -> None: + """ + Clears the cached PACS id so the next call re-reads it from XNAT. + + Called when XNAT reports the resolved PACS missing. ``configure-xnat.sh`` deletes and recreates + the registration when its AE title changes, which gives it a new id, and nothing restarts + imaging-api when XNAT is reconfigured — so without this the cache would point at a deleted + registration until the container was restarted (FLIP#993). + """ + global _resolved_pacs_id + _resolved_pacs_id = None def ping_pacs(pacs_id: int, headers: dict[str, str]) -> PacsStatus: @@ -124,6 +166,10 @@ def check_pacs(headers: dict[str, str], pacs_id: int = PACS_ID) -> None: try: pacs_status = ping_pacs(pacs_id, headers) except NotFoundError: + # The id we hold no longer exists in XNAT. If it came from the cache it is stale — the PACS + # was re-registered under a new id while this process stayed up — so drop it and let the + # next call re-read, rather than failing every import until the container restarts. + forget_resolved_pacs_id() raise NotFoundError(f"PACS with ID '{pacs_id}' not found.") except Exception: raise Exception(f"Failed to ping PACS with ID '{pacs_id}'.") diff --git a/trust/imaging-api/tests/services/test_imaging.py b/trust/imaging-api/tests/services/test_imaging.py index 8f4713272..faa6ba32b 100644 --- a/trust/imaging-api/tests/services/test_imaging.py +++ b/trust/imaging-api/tests/services/test_imaging.py @@ -385,7 +385,7 @@ def test_queue_image_import_request_partial_failure(mock_get_project, mock_post, assert response[1].status == "FAILED" -# --- PACS id resolution by AE title (FLIP#993) --------------------------------------------------- +# --- PACS id resolution (FLIP#993) --------------------------------------------------- @pytest.fixture(autouse=True) From 6ea694f5b4784f8f3b98f8791c3642550242c958 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 13:03:17 +0100 Subject: [PATCH 13/31] test: close the mutation gaps, and cover the wiring around the script (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutation testing on configure-xnat.sh: 45 single-token changes applied, 20 caught. The 25 survivors were not obscure. Every security-relevant field in the payloads could be flipped with the suite still green — allowAllUsersToUseDqr to true (any XNAT account can then pull arbitrary studies from the trust PACS), anonymizationEnabled to false (identifiable DICOM into the archive), the guest account left enabled, the site anonymization script uploaded but switched off, the SCP identifier changed away from dqrObjectIdentifier, directArchive and customProcessing off. So could the settings that decide whether retrieval works at all: XNAT_PORT replaced by a literal, the availability window written disabled, a refused window reported as applied, the credential redaction in the failure path removed. The suite asserted the values it had itself configured and little else. The stub was part of the problem. It echoed the configured AE title back, so "the configured title reached XNAT" held even when the script sent something else. It is now a small stateful XNAT: POST/PUT/DELETE mutate the /xapi/pacs and /xapi/dicomscp collections in files, and a GET reflects what the script actually did. Tests seed the starting state explicitly rather than the stub inferring it from the test's own configuration, ids start at 7 so nothing can pass by assuming 1, and knobs were added for the paths that were unreachable: an availability response, a registration that answers 200 without persisting (how FLIP#822 stayed hidden), and a nominated call that fails. Nineteen tests later, all 45 mutants die. The suite also went from 121s to under 2s: the script's fixed 10s settle is 12 of those seconds per run and the readiness loops it guards are satisfied instantly by the stub, so sleep is stubbed too. The plugin-readiness wait is capped, since with sleep stubbed a test that makes that route fail would otherwise spin at full speed for the default 900s budget. Then the layer the script cannot see. Every bug found while deploying this change lived there and none was reachable from inside the script: the Makefile defaulted the web port to a literal that collides with a second trust on the same host, exported an empty AE title, and the compose file's ${VAR:-default} cancelled the script's own fail-loud guard. test_deploy_wiring.py resolves the Makefile's variables by evaluating them in a throwaway target, runs the port guards for real against a temp data dir with sudo stubbed (make -n only prints a shell `if`, it never decides), and checks the two deployment files. Its strongest test derives the script's knobs from the script itself and asserts both compose and the Helm init job pass every one — the two paths drifted apart once already in this PR, and that is the shape of the drift. Each test was verified to fail against the bug it describes. Also in this commit, from the same review: - supportsExtendedNegotiations is now PACS_SUPPORTS_EXTENDED_NEGOTIATIONS, wired through compose, Helm and the kit examples. The docs already told operators to turn it off for a PACS that does not support relational queries; it was hardcoded true. Validated as literally true or false, so a "yes" fails naming the variable rather than inside a jq filter, and a bare 1 cannot register as the number 1. - trust-api's health probe pinned PACS id 1, making it a second source of truth for an id XNAT assigns at registration — a re-registered PACS reported permanently down while imports kept working. imaging-api's ping route now resolves the registration when no id is given, and trust-api's own PACS_ID setting is gone. check_pacs likewise resolves rather than defaulting to the configured fallback. - check_local_status.py probed the web UI on XNAT_PORT, which this PR turned into the DICOM port; it now reads XNAT_WEB_PORT with the same fallback the Makefile applies. Its runner enumerated test functions by hand, so a new test ran nowhere and the suite still reported green — it discovers them now. - The DICOM port map in TROUBLESHOOTING.md listed an "Imaging Worker" AE that does not exist (imaging-api drives retrieval through DQR's REST API, so every association on the wire is between XNAT and the PACS), and my earlier edit to it nested backticks inside backticks. Rewritten, with the Helm value that controls each cell named. - The workflow's path filter now includes the files these tests read, per the rule its own header states. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_trust_xnat.yml | 15 +- AGENTS.md | 4 + CLAUDE.md | 4 + .../providers/kubernetes/TROUBLESHOOTING.md | 22 +- .../kubernetes/templates/xnat-init-job.yaml | 2 + deploy/providers/kubernetes/values.yaml | 3 + docs/source/components/component-pacs.rst | 8 +- scripts/check_local_status.py | 13 +- scripts/tests/test_check_local_status.py | 31 +- trust/.env.GSTT.development.example | 3 + trust/.env.KCH.development.example | 3 + trust/.env.example | 3 + .../imaging_api/routers/imaging.py | 16 +- .../imaging_api/services/imaging.py | 8 +- .../imaging-api/tests/routers/test_imaging.py | 52 +++ .../tests/services/test_imaging.py | 14 + trust/trust-api/README.md | 1 - .../tests/services/test_health_collector.py | 5 +- trust/trust-api/trust_api/config.py | 1 - .../trust_api/services/health_collector.py | 6 +- trust/xnat/docker-compose-stack.yml | 3 + trust/xnat/tests/test_configure_pacs.py | 337 +++++++++++++++--- trust/xnat/tests/test_deploy_wiring.py | 241 +++++++++++++ trust/xnat/xnat/config/configure-xnat.sh | 17 +- 24 files changed, 730 insertions(+), 82 deletions(-) create mode 100644 trust/xnat/tests/test_deploy_wiring.py diff --git a/.github/workflows/test_trust_xnat.yml b/.github/workflows/test_trust_xnat.yml index 670b11099..e532ddbb8 100644 --- a/.github/workflows/test_trust_xnat.yml +++ b/.github/workflows/test_trust_xnat.yml @@ -12,10 +12,11 @@ name: Trust - XNAT CI -# The suite covers the anonymization script and the weak-password guards. The -# guard parity tests read their inputs from outside trust/xnat/tests/, so every -# file they assert on has to trigger this workflow — otherwise a change to a -# guard (or to the credential minter) lands with the test that pins it unrun. +# The suite covers the anonymization script, the weak-password guards, and the deployment wiring +# that carries configuration into configure-xnat.sh. Those tests read their inputs from outside +# trust/xnat/tests/, so every file they assert on has to trigger this workflow — otherwise a change +# to a guard, to the credential minter, or to either deployment path's environment block lands with +# the test that pins it unrun. on: push: branches: [main, develop] @@ -26,6 +27,9 @@ on: - "trust/xnat/scripts/ensure_plugins.sh" - "trust/xnat/tests/**" - "trust/xnat/Makefile" + - "trust/xnat/docker-compose-stack.yml" + - "trust/xnat/docker-compose-stack.real-pacs.yml" + - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - "trust/Makefile" - "Makefile" - "flip-api/Makefile" @@ -44,6 +48,9 @@ on: - "trust/xnat/scripts/ensure_plugins.sh" - "trust/xnat/tests/**" - "trust/xnat/Makefile" + - "trust/xnat/docker-compose-stack.yml" + - "trust/xnat/docker-compose-stack.real-pacs.yml" + - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - "trust/Makefile" - "Makefile" - "flip-api/Makefile" diff --git a/AGENTS.md b/AGENTS.md index 3f4f97642..6252e0620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,6 +387,10 @@ After changes, evaluate if docs need updating: did (FLIP#822/#862). `configure-xnat.sh` updates an existing registration in place when the host or port drift, and imaging-api reads the PACS id from XNAT at runtime rather than assuming 1 — `configure-xnat.sh` keeps exactly one registration, so it is the sole one XNAT reports. +- `PACS_SUPPORTS_EXTENDED_NEGOTIATIONS` — whether the PACS supports relational queries / extended + negotiation (default `true`). A capability of the PACS rather than a preference: one that does not + support it rejects the association outright. Validated as literally `true` or `false` before it + reaches jq, so a `yes` or a bare `1` fails naming the variable instead of registering the number 1. - `PACS_AVAILABILITY_DAYS` / `_START` / `_END` / `PACS_THREADS` / `PACS_UTILIZATION_PERCENT` / `DQR_MAX_PACS_REQUEST_ATTEMPTS` / `DQR_RETRY_WAIT_SECONDS` — the retrieval throttle. A production PACS may refuse further associations after a certain volume, so the window and thread count are diff --git a/CLAUDE.md b/CLAUDE.md index b6999cab8..dbcd0e5b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -387,6 +387,10 @@ After changes, evaluate if docs need updating: did (FLIP#822/#862). `configure-xnat.sh` updates an existing registration in place when the host or port drift, and imaging-api reads the PACS id from XNAT at runtime rather than assuming 1 — `configure-xnat.sh` keeps exactly one registration, so it is the sole one XNAT reports. +- `PACS_SUPPORTS_EXTENDED_NEGOTIATIONS` — whether the PACS supports relational queries / extended + negotiation (default `true`). A capability of the PACS rather than a preference: one that does not + support it rejects the association outright. Validated as literally `true` or `false` before it + reaches jq, so a `yes` or a bare `1` fails naming the variable instead of registering the number 1. - `PACS_AVAILABILITY_DAYS` / `_START` / `_END` / `PACS_THREADS` / `PACS_UTILIZATION_PERCENT` / `DQR_MAX_PACS_REQUEST_ATTEMPTS` / `DQR_RETRY_WAIT_SECONDS` — the retrieval throttle. A production PACS may refuse further associations after a certain volume, so the window and thread count are diff --git a/deploy/providers/kubernetes/TROUBLESHOOTING.md b/deploy/providers/kubernetes/TROUBLESHOOTING.md index 2b424c9ec..b5f24ad0b 100644 --- a/deploy/providers/kubernetes/TROUBLESHOOTING.md +++ b/deploy/providers/kubernetes/TROUBLESHOOTING.md @@ -455,11 +455,20 @@ The study data is sent to XNAT's prearchive. #### DICOM Port Map -| Service | AE Title | Host | Port | Purpose | -|---------|---------|------|------|---------| -| XNAT SCP | `XNAT` | xnat-web | 8104 | Receives C-STORE from PACS | -| Orthanc | `ORTHANC` | orthanc | 4242 | PACS — stores DICOM studies | -| Imaging Worker | `XNAT (configurable via `xnat.web.dicomAet`)` | (any) | — | C-MOVE source AE | +Values below are the shipped defaults for the mocked Orthanc; a trust PACS overrides them +through the Helm values named in each cell. + +| Service | AE title | Host | Port | Purpose | +|---------|----------|------|------|---------| +| XNAT SCP receiver | `XNAT` (`xnat.web.dicomAet`) | xnat-web (`pacs.host` dials it back) | 8104 (`xnat.web.dicomPort`) | Receives the C-STORE the PACS opens after a C-MOVE | +| PACS | `ORTHANC` (`pacs.aeTitle`) | orthanc (`pacs.host`) | 4242 (`pacs.qrPort`) | Serves C-FIND and C-MOVE | +| DQR calling AE | same as the SCP receiver | — | — | The AE XNAT presents when it queries the PACS | + +There is no separate AE for the imaging worker: imaging-api drives retrieval through XNAT's DQR +REST API, so every association on the wire is between XNAT and the PACS. The SCP receiver's AE +title and the DQR calling AE are both `xnat.web.dicomAet` and must stay equal — DQR matches the +C-MOVE destination against a registered receiver by exact `AE:port`, so a mismatch means the PACS +sends the studies to an address XNAT is not listening on. #### XNAT SCP Receiver Configuration @@ -470,7 +479,8 @@ kubectl exec -n flip-trust trust-release-flip-trust-xnat-db-0 -- psql -U xnat -d "SELECT id, ae_title, port, direct_archive, custom_processing, identifier FROM xhbm_dicomscpinstance;" ``` -Expected output: +Expected output (`ae_title` and `port` follow `xnat.web.dicomAet` / `xnat.web.dicomPort`; the +rest are fixed by `configure-xnat.sh`): `ae_title=XNAT, port=8104, direct_archive=t, custom_processing=t, identifier=dqrObjectIdentifier` If missing or wrong, recreate it via the REST API (see §2.2 — prefer the API diff --git a/deploy/providers/kubernetes/templates/xnat-init-job.yaml b/deploy/providers/kubernetes/templates/xnat-init-job.yaml index 82257aebb..64c4137dc 100644 --- a/deploy/providers/kubernetes/templates/xnat-init-job.yaml +++ b/deploy/providers/kubernetes/templates/xnat-init-job.yaml @@ -106,6 +106,8 @@ spec: value: {{ .Values.pacs.qrPort | quote }} - name: PACS_LABEL value: {{ .Values.pacs.label | quote }} + - name: PACS_SUPPORTS_EXTENDED_NEGOTIATIONS + value: {{ .Values.pacs.supportsExtendedNegotiations | quote }} # Throttle: a production PACS may refuse further associations after a certain volume. - name: PACS_AVAILABILITY_DAYS value: {{ .Values.pacs.availability.days | quote }} diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index 081455d39..970ba9890 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -750,6 +750,9 @@ pacs: aeTitle: ORTHANC qrPort: 4242 label: Test PACS instance + # Relational queries / extended negotiation. A per-PACS capability, not a preference: a PACS that + # does not support it rejects the association unless this is false. Ask the PACS team. + supportsExtendedNegotiations: true # Throttle. A production PACS may refuse further associations after a certain volume, and a trust # may want retrieval confined to out-of-hours. Agree the window with the PACS manager. availability: diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index 68a07f217..c74d9057c 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -115,8 +115,8 @@ are the questions that most often turn out to matter: * - Confirmation that STUDY-level matching on Accession Number ``(0008,0050)`` is supported - This is the only key FLIP queries by. Without it, nothing resolves * - Whether relational queries / extended negotiation are supported - - FLIP registers the PACS with ``supportsExtendedNegotiations`` enabled; a PACS that does not - support it needs that turned off + - Enabled by default; a PACS that does not support it rejects the association, and needs + ``PACS_SUPPORTS_EXTENDED_NEGOTIATIONS=false`` * - The transfer syntax studies will be sent in - Compressed or transcoded data still has to be readable by XNAT once archived * - Any per-connection or per-session limit on how much may be retrieved @@ -187,6 +187,10 @@ trust's PACS team supplies; what they supply is covered above. - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; separate from ``XNAT_PORT`` so the DICOM receiver can be published independently - whatever ``XNAT_PORT`` is set to + * - ``PACS_SUPPORTS_EXTENDED_NEGOTIATIONS`` + - Whether the PACS supports relational queries / extended negotiation. A capability of the + PACS, not a preference — see the table above + - ``true`` * - ``PACS_AVAILABILITY_DAYS`` / ``_START`` / ``_END`` - When retrieval may run, as a comma-separated day list and a daily window - all week, ``00:00``–``24:00`` diff --git a/scripts/check_local_status.py b/scripts/check_local_status.py index 755392f07..6fe3e3848 100755 --- a/scripts/check_local_status.py +++ b/scripts/check_local_status.py @@ -320,7 +320,10 @@ class TrustKit: name (str): TRUST_NAME for display; falls back to the CODE when absent. slot_number (int | None): Assigned FL kit slot number (FL_KIT_SLOT_NUMBER). Drives the XNAT stack name (xnat) and which trust exposes host APIs. - xnat_port (str | None): Host port for the trust's XNAT web UI. + xnat_web_port (str | None): Host port for the trust's XNAT web UI. Read from + XNAT_WEB_PORT, falling back to XNAT_PORT — the same precedence trust/xnat/Makefile + applies. Those were one variable until FLIP#993 split the DICOM listener off; a kit + that sets only XNAT_PORT still publishes the web UI there. pacs_ui_port (str | None): Host port for the trust's Orthanc PACS UI. trust_api_port (str | None): Host port for trust-api (slot-1 trust only). imaging_api_port (str | None): Host port for imaging-api (slot-1 trust only). @@ -330,7 +333,7 @@ class TrustKit: code: str name: str slot_number: int | None - xnat_port: str | None + xnat_web_port: str | None pacs_ui_port: str | None trust_api_port: str | None imaging_api_port: str | None @@ -381,7 +384,7 @@ def discover_trust_kits(trust_dir: Path, env: str) -> list[TrustKit]: code=code, name=env_vars.get("TRUST_NAME") or code, slot_number=slot_number, - xnat_port=env_vars.get("XNAT_PORT"), + xnat_web_port=env_vars.get("XNAT_WEB_PORT") or env_vars.get("XNAT_PORT"), pacs_ui_port=env_vars.get("PACS_UI_PORT"), trust_api_port=env_vars.get("TRUST_API_PORT"), imaging_api_port=env_vars.get("IMAGING_API_PORT"), @@ -770,8 +773,8 @@ def main( for kit in trust_kits: slot = f" slot {kit.slot_number}" if kit.slot_number else "" # 127.0.0.1 (not localhost) avoids IPv6 routing issues with Docker Swarm. - if kit.xnat_port: - xnat_url = f"http://127.0.0.1:{kit.xnat_port}" + if kit.xnat_web_port: + xnat_url = f"http://127.0.0.1:{kit.xnat_web_port}" check_http_endpoint(xnat_url, f"XNAT {kit.name}{slot} Web UI", [200, 302]) if kit.pacs_ui_port: pacs_url = f"http://localhost:{kit.pacs_ui_port}" diff --git a/scripts/tests/test_check_local_status.py b/scripts/tests/test_check_local_status.py index 047128153..6cc068b75 100644 --- a/scripts/tests/test_check_local_status.py +++ b/scripts/tests/test_check_local_status.py @@ -83,10 +83,26 @@ def test_discovers_code_kits() -> None: _assert("KCH" in by_code and "GSTT" in by_code, "keyed by CODE", f"got {sorted(by_code)}") kch = by_code.get("KCH", TrustKit("", "", None, None, None, None, None, None)) _assert(kch.slot_number == 1, "reads FL_KIT_SLOT_NUMBER", f"got {kch.slot_number!r}") - _assert(kch.xnat_port == "8106" and kch.pacs_ui_port == "8044", "reads XNAT/PACS ports") + _assert(kch.xnat_web_port == "8106" and kch.pacs_ui_port == "8044", "reads XNAT/PACS ports") _assert(kch.trust_api_port == "8020", "reads TRUST_API_PORT", f"got {kch.trust_api_port!r}") +def test_web_port_prefers_xnat_web_port() -> None: + """The web UI moved off XNAT_PORT in FLIP#993; probing the DICOM port would report it down.""" + trust = _trust_dir() + _write_kit(trust, "GSTT", "development", TRUST_NAME="GSTT", XNAT_PORT=8104, XNAT_WEB_PORT=8080) + kits = discover_trust_kits(trust, "development") + _assert(kits[0].xnat_web_port == "8080", "XNAT_WEB_PORT wins over XNAT_PORT", f"got {kits[0].xnat_web_port!r}") + + +def test_web_port_falls_back_to_xnat_port() -> None: + """A kit predating the split sets only XNAT_PORT, and still publishes the web UI there.""" + trust = _trust_dir() + _write_kit(trust, "GSTT", "development", TRUST_NAME="GSTT", XNAT_PORT=8104) + kits = discover_trust_kits(trust, "development") + _assert(kits[0].xnat_web_port == "8104", "falls back to XNAT_PORT", f"got {kits[0].xnat_web_port!r}") + + def test_ignores_examples_and_other_envs() -> None: trust = _trust_dir() _write_kit(trust, "KCH", "development", TRUST_NAME="KCH", FL_KIT_SLOT_NUMBER=1) @@ -115,12 +131,13 @@ def test_missing_dir_returns_empty() -> None: def main() -> None: - for test in ( - test_discovers_code_kits, - test_ignores_examples_and_other_envs, - test_missing_slot_and_name_fallback, - test_missing_dir_returns_empty, - ): + # Discovered rather than listed: the hand-maintained tuple this replaced meant a new test + # function ran nowhere and the suite still reported green. + tests = [v for k, v in list(globals().items()) if k.startswith("test_") and callable(v)] + if not tests: + print("no tests discovered") + sys.exit(1) + for test in tests: print(f"\n{test.__name__}") test() print(f"\n{PASS} passed, {FAIL} failed") diff --git a/trust/.env.GSTT.development.example b/trust/.env.GSTT.development.example index 7b758b241..f3df96c7b 100644 --- a/trust/.env.GSTT.development.example +++ b/trust/.env.GSTT.development.example @@ -93,6 +93,9 @@ PACS_HOST=orthanc PACS_AETITLE=ORTHANC PACS_QR_PORT=4242 PACS_LABEL=Test PACS instance +# Relational queries / extended negotiation. A per-PACS capability, not a preference: a PACS that +# does not support it rejects the association unless this is false. Ask the PACS team. +PACS_SUPPORTS_EXTENDED_NEGOTIATIONS=true # PACS throttle. A production PACS may refuse further associations after a certain volume, and a # trust may want retrieval confined to out-of-hours. Agree the window with the PACS manager. diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index 8dbaefc8c..e52ccc26a 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -92,6 +92,9 @@ PACS_HOST=orthanc PACS_AETITLE=ORTHANC PACS_QR_PORT=4242 PACS_LABEL=Test PACS instance +# Relational queries / extended negotiation. A per-PACS capability, not a preference: a PACS that +# does not support it rejects the association unless this is false. Ask the PACS team. +PACS_SUPPORTS_EXTENDED_NEGOTIATIONS=true # PACS throttle. A production PACS may refuse further associations after a certain volume, and a # trust may want retrieval confined to out-of-hours. Agree the window with the PACS manager. diff --git a/trust/.env.example b/trust/.env.example index b12f4dabe..93380152c 100644 --- a/trust/.env.example +++ b/trust/.env.example @@ -123,6 +123,9 @@ PACS_HOST=orthanc PACS_AETITLE=ORTHANC PACS_QR_PORT=4242 PACS_LABEL=Test PACS instance +# Relational queries / extended negotiation. A per-PACS capability, not a preference: a PACS that +# does not support it rejects the association unless this is false. Ask the PACS team. +PACS_SUPPORTS_EXTENDED_NEGOTIATIONS=true # PACS throttle. A production PACS may refuse further associations after a certain volume, and a # trust may want retrieval confined to out-of-hours. Agree the window with the PACS manager. diff --git a/trust/imaging-api/imaging_api/routers/imaging.py b/trust/imaging-api/imaging_api/routers/imaging.py index 3003bdf85..2dc73dd17 100644 --- a/trust/imaging-api/imaging_api/routers/imaging.py +++ b/trust/imaging-api/imaging_api/routers/imaging.py @@ -19,6 +19,7 @@ ping_pacs, query_by_accession_number, queue_image_import_request, + resolve_pacs_id, ) from imaging_api.utils.auth import get_xnat_auth_headers from imaging_api.utils.exceptions import NotFoundError @@ -29,14 +30,19 @@ XNATAuthHeaders = Annotated[dict[str, str], Depends(get_xnat_auth_headers)] +@router.get("/ping_pacs", summary="Ping the registered Imaging Provider (PACS)") @router.get("/ping_pacs/{pacs_id}", summary="Ping Imaging Provider (PACS) by ID") -def ping_pacs_endpoint(pacs_id: int, headers: XNATAuthHeaders) -> PacsStatus: - """ - Pings the imaging provider (PACS) to check if it is reachable. +def ping_pacs_endpoint(headers: XNATAuthHeaders, pacs_id: int | None = None) -> PacsStatus: + """Pings the imaging provider (PACS) to check if it is reachable. + + Two routes, one handler. Without an id the PACS is resolved from XNAT the same way the import + path resolves it — the id XNAT assigns at registration is not knowable in advance, so a caller + that only wants to know whether the trust's PACS answers should not have to guess one. The + by-id route stays for callers that genuinely mean a specific registration. Args: - pacs_id (int): PACS ID to ping. headers (XNATAuthHeaders): XNAT authentication headers. + pacs_id (int | None): PACS ID to ping. Resolved from XNAT when omitted. Returns: PacsStatus: Status of the PACS system. @@ -45,7 +51,7 @@ def ping_pacs_endpoint(pacs_id: int, headers: XNATAuthHeaders) -> PacsStatus: HTTPException: If PACS is not found or if there is an error during the ping operation. """ try: - return ping_pacs(pacs_id, headers) + return ping_pacs(resolve_pacs_id(headers) if pacs_id is None else pacs_id, headers) except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index 5f8430202..4e964ea91 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -148,13 +148,15 @@ def ping_pacs(pacs_id: int, headers: dict[str, str]) -> PacsStatus: raise Exception(f"Failed to ping PACS: {response.text}") -def check_pacs(headers: dict[str, str], pacs_id: int = PACS_ID) -> None: +def check_pacs(headers: dict[str, str], pacs_id: int | None = None) -> None: """ Checks if the PACS system is reachable by pinging it. Args: headers (dict[str, str]): XNAT authentication headers. - pacs_id (int): PACS ID to check. Default is the PACS_ID from settings. + pacs_id (int | None): PACS ID to check. Resolved from XNAT when omitted, rather than + defaulting to the configured ``PACS_ID`` — that setting is the unreachable-XNAT + fallback, not a description of what is registered. Returns: None @@ -163,6 +165,8 @@ def check_pacs(headers: dict[str, str], pacs_id: int = PACS_ID) -> None: imaging_api.utils.exceptions.NotFoundError: If the PACS with the given ID is not found. Exception: If there is an error during the ping request or if the PACS is not reachable or is disabled. """ + if pacs_id is None: + pacs_id = resolve_pacs_id(headers) try: pacs_status = ping_pacs(pacs_id, headers) except NotFoundError: diff --git a/trust/imaging-api/tests/routers/test_imaging.py b/trust/imaging-api/tests/routers/test_imaging.py index 4950c0382..8402065e1 100644 --- a/trust/imaging-api/tests/routers/test_imaging.py +++ b/trust/imaging-api/tests/routers/test_imaging.py @@ -54,6 +54,58 @@ def test_ping_pacs_failure(client): assert error_message in response.json()["detail"] +def test_ping_pacs_without_id_resolves_the_registration(client): + """trust-api's health probe calls this route: it must not have to know the id XNAT assigned.""" + mock_response = { + "pacsId": 7, + "successful": True, + "pingTime": 123, + "created": 1610000000, + "enabled": True, + "timestamp": 1610001234, + "id": 7, + "disabled": 0, + } + + with ( + patch("imaging_api.routers.imaging.resolve_pacs_id", return_value=7) as mock_resolve, + patch("imaging_api.routers.imaging.ping_pacs") as mock_ping_pacs, + ): + mock_ping_pacs.return_value = PacsStatus(**mock_response) + + response = client.get("/imaging/ping_pacs") + + assert response.status_code == 200 + assert mock_resolve.called, "the id was not resolved from XNAT" + assert mock_ping_pacs.call_args[0][0] == 7, "pinged an id other than the resolved one" + + +def test_ping_pacs_with_explicit_id_does_not_resolve(client): + """The by-id route still means that specific registration.""" + mock_response = { + "pacsId": 3, + "successful": True, + "pingTime": 123, + "created": 1610000000, + "enabled": True, + "timestamp": 1610001234, + "id": 3, + "disabled": 0, + } + + with ( + patch("imaging_api.routers.imaging.resolve_pacs_id") as mock_resolve, + patch("imaging_api.routers.imaging.ping_pacs") as mock_ping_pacs, + ): + mock_ping_pacs.return_value = PacsStatus(**mock_response) + + response = client.get("/imaging/ping_pacs/3") + + assert response.status_code == 200 + assert not mock_resolve.called, "an explicit id was overridden by the resolved one" + assert mock_ping_pacs.call_args[0][0] == 3 + + def test_query_by_accession_number_success(client): mock_study = Study( studyInstanceUid="1.2.3", diff --git a/trust/imaging-api/tests/services/test_imaging.py b/trust/imaging-api/tests/services/test_imaging.py index faa6ba32b..b84aa0055 100644 --- a/trust/imaging-api/tests/services/test_imaging.py +++ b/trust/imaging-api/tests/services/test_imaging.py @@ -181,6 +181,20 @@ def test_check_pacs_success(mock_ping): check_pacs({}, pacs_id=1) # should not raise +# --------------------------------------------------------------------------- +# check_pacs — no id supplied +# --------------------------------------------------------------------------- +@patch("imaging_api.services.imaging.resolve_pacs_id", return_value=7) +@patch("imaging_api.services.imaging.ping_pacs") +def test_check_pacs_without_id_resolves_rather_than_assuming_the_configured_one(mock_ping, mock_resolve): + """The configured PACS_ID is the unreachable-XNAT fallback, not a description of what exists.""" + mock_ping.return_value = MagicMock(successful=True, enabled=True) + + check_pacs({}) + + assert mock_ping.call_args[0][0] == 7 + + # --------------------------------------------------------------------------- # check_pacs — not found # --------------------------------------------------------------------------- diff --git a/trust/trust-api/README.md b/trust/trust-api/README.md index 580c223a6..789ec8190 100644 --- a/trust/trust-api/README.md +++ b/trust/trust-api/README.md @@ -74,7 +74,6 @@ trust's kit file (`trust/.env..`); hub-shared values (`AES_KEY_BASE64 | `HEALTH_COLLECT_INTERVAL_SECONDS` | How often the health collector probes the trust services (default: 30) | | `HEALTH_PROBE_DEGRADED_MS` | A successful probe slower than this reports `degraded` (default: 1000) | | `XNAT_URL` | Internal URL of XNAT for the health probe (default `http://xnat-web:8080`) | -| `PACS_ID` | XNAT DQR PACS id used for the `ping_pacs` deep probe (default: 1) | | `OMOP_DB_HOST` / `OMOP_DB_PORT` | OMOP PostgreSQL address for the TCP health probe (defaults `omop-db` / 5432) | | `TRUST_INTERNAL_SERVICE_KEY_HEADER` | Header name for trust-internal service auth (default `X-Trust-Internal-Service-Key`) | | `TRUST_INTERNAL_SERVICE_KEY` | Per-trust plaintext key. Forwarded outbound on every call to imaging-api and data-access-api so those services can authenticate the caller. Minted by `register_trust` (`make register-trust KIT=`) into this trust's kit file (`trust/.env..`). | diff --git a/trust/trust-api/tests/services/test_health_collector.py b/trust/trust-api/tests/services/test_health_collector.py index fa40984da..f2135a36d 100644 --- a/trust/trust-api/tests/services/test_health_collector.py +++ b/trust/trust-api/tests/services/test_health_collector.py @@ -241,7 +241,6 @@ async def test_probe_dicom_healthy_measures_own_round_trip_and_sends_internal_ke mock_client.get.return_value = _response(200, {"successful": True, "pingTime": 1_786_029_453_834}) with ( - patch.object(health_collector, "PACS_ID", 1), patch( "trust_api.services.health_collector.trust_internal_headers", return_value={"X-Trust-Internal-Service-Key": "secret"}, @@ -252,7 +251,9 @@ async def test_probe_dicom_healthy_measures_own_round_trip_and_sends_internal_ke assert result == {"status": "healthy", "version": None, "response_ms": 250} call_args = mock_client.get.call_args - assert call_args[0][0].endswith("/imaging/ping_pacs/1") + # No trailing id: imaging-api resolves the registered PACS. A pinned id here was a second + # source of truth for something XNAT assigns at registration (FLIP#993). + assert call_args[0][0].endswith("/imaging/ping_pacs") assert call_args[1]["headers"] == {"X-Trust-Internal-Service-Key": "secret"} diff --git a/trust/trust-api/trust_api/config.py b/trust/trust-api/trust_api/config.py index d457e0995..8cf16e6b0 100644 --- a/trust/trust-api/trust_api/config.py +++ b/trust/trust-api/trust_api/config.py @@ -79,7 +79,6 @@ def coerce_empty_env(cls, v: str) -> str: HEALTH_COLLECT_INTERVAL_SECONDS: int = 30 # How often to probe the trust services (seconds) HEALTH_PROBE_DEGRADED_MS: int = 1000 # Successful probe slower than this reports "degraded" XNAT_URL: str = "http://xnat-web:8080" - PACS_ID: int = 1 # XNAT DQR PACS id used for the ping_pacs deep probe (matches imaging-api's default) OMOP_DB_HOST: str = "omop-db" OMOP_DB_PORT: int = 5432 diff --git a/trust/trust-api/trust_api/services/health_collector.py b/trust/trust-api/trust_api/services/health_collector.py index ca2ec8f91..f7089b76e 100644 --- a/trust/trust-api/trust_api/services/health_collector.py +++ b/trust/trust-api/trust_api/services/health_collector.py @@ -47,7 +47,6 @@ DATA_ACCESS_API_URL = get_settings().DATA_ACCESS_API_URL IMAGING_API_URL = get_settings().IMAGING_API_URL XNAT_URL = get_settings().XNAT_URL -PACS_ID = get_settings().PACS_ID OMOP_DB_HOST = get_settings().OMOP_DB_HOST OMOP_DB_PORT = get_settings().OMOP_DB_PORT HEALTH_COLLECT_INTERVAL_SECONDS = get_settings().HEALTH_COLLECT_INTERVAL_SECONDS @@ -204,7 +203,10 @@ async def _probe_dicom(client: httpx.AsyncClient) -> dict: dict: Wire-shaped entry. ``unknown`` when the prober chain (imaging-api → XNAT) itself fails, ``down`` only when XNAT reports the DIMSE echo failed. """ - url = f"{IMAGING_API_URL}/imaging/ping_pacs/{PACS_ID}" + # No id in the path: imaging-api resolves the registered PACS from XNAT. Pinning one here + # made trust-api a second source of truth for an id XNAT assigns at registration — a + # re-registered PACS reported permanently down while imports kept working (FLIP#993). + url = f"{IMAGING_API_URL}/imaging/ping_pacs" start = time.monotonic() try: response = await client.get(url, headers=trust_internal_headers()) diff --git a/trust/xnat/docker-compose-stack.yml b/trust/xnat/docker-compose-stack.yml index aa13365bc..decb32562 100644 --- a/trust/xnat/docker-compose-stack.yml +++ b/trust/xnat/docker-compose-stack.yml @@ -49,6 +49,9 @@ services: - PACS_AETITLE=${PACS_AETITLE-ORTHANC} - PACS_QR_PORT=${PACS_QR_PORT-4242} - PACS_LABEL=${PACS_LABEL-Test PACS instance} + # A per-PACS capability, not a preference: a PACS without relational-query support + # rejects the association unless this is false. + - PACS_SUPPORTS_EXTENDED_NEGOTIATIONS=${PACS_SUPPORTS_EXTENDED_NEGOTIATIONS-true} # PACS throttle — a production PACS may refuse further associations after a certain volume. - PACS_AVAILABILITY_DAYS=${PACS_AVAILABILITY_DAYS-MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY} - PACS_AVAILABILITY_START=${PACS_AVAILABILITY_START-00:00} diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index 6626c74aa..fe75d134d 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -26,9 +26,10 @@ CONFIG_DIR = Path(__file__).resolve().parents[1] / "xnat" / "config" SCRIPT = CONFIG_DIR / "configure-xnat.sh" -# Answers the two listings the script parses. GET /xapi/pacs returns nothing until a POST has been -# seen, so a single run exercises the register-then-resolve path; seeding the marker file up front -# makes the same stub return an already-registered PACS instead. +# A miniature XNAT: it keeps the /xapi/pacs and /xapi/dicomscp collections in files and mutates them +# on POST/PUT/DELETE, so a GET reflects what the script actually did rather than what the test said. +# The earlier stub echoed the *configured* AE title back, which made "the configured title reached +# XNAT" assertions circular — they held even when the script sent something else. STUB_CURL = r"""#!/bin/bash url=""; data=""; method="GET"; status_only=0; outfile="" prev="" @@ -37,25 +38,64 @@ case "$a" in http*) url="$a";; '%{http_code}') status_only=1;; esac prev="$a" done -if [ -n "$data" ]; then - printf '%s\n' "=== $method $url" >> "$PAYLOADS" - printf '%s\n' "$data" >> "$PAYLOADS" -fi -[ "$method" = "DELETE" ] && printf '%s\n' "=== DELETE $url" >> "$PAYLOADS" + +# Every request, not only those carrying a body: a PUT whose whole meaning is its URL +# (/xapi/users/guest/enabled/false) is otherwise invisible to the tests. +printf '%s\n' "=== $method $url" >> "$PAYLOADS" +[ -n "$data" ] && printf '%s\n' "$data" >> "$PAYLOADS" + +edit() { # edit [args...] + local f="$1"; shift + jq "$@" "$f" > "$f.tmp" && mv "$f.tmp" "$f" +} + +status=200 body='{}' case "$url" in - *"/xapi/dicomscp"*) body="${STUB_SCP_JSON}" ;; + *"/xapi/pacs/"*"/availability") + status="${AVAIL_STATUS:-200}" + body="${AVAIL_BODY:-{\}}" + ;; *"/xapi/pacs") - if [ -f "$REGISTERED" ]; then - body='[{"id":'"$STUB_PACS_ID"',"aeTitle":"'"$STUB_PACS_AET"'","host":"'"$STUB_PACS_HOST"'","queryRetrievePort":'"$STUB_PACS_PORT"'}]' - else - body="${STUB_PACS_JSON:-[]}" + if [ "$method" = "POST" ] && [ -z "$SWALLOW_PACS_POST" ]; then + # XNAT assigns the id. Ours start at 7 so nothing can pass by assuming 1. + edit "$PACS_STATE" --argjson id "$(( $(jq 'length' "$PACS_STATE") + 7 ))" --argjson e "$data" \ + '. + [$e + {id: $id}]' + fi + body=$(cat "$PACS_STATE") + ;; + *"/xapi/pacs/"*) + id="${url##*/}" + case "$method" in + PUT) edit "$PACS_STATE" --argjson id "$id" --argjson e "$data" \ + 'map(if .id == $id then $e + {id: $id} else . end)' ;; + DELETE) edit "$PACS_STATE" --argjson id "$id" 'map(select(.id != $id))' ;; + esac + body=$(cat "$PACS_STATE") + ;; + *"/xapi/dicomscp") + if [ "$method" = "POST" ]; then + edit "$SCP_STATE" --argjson id "$(( $(jq 'length' "$SCP_STATE") + 5 ))" --argjson e "$data" \ + '. + [$e + {id: $id}]' fi - [ "$method" = "POST" ] && touch "$REGISTERED" + body=$(cat "$SCP_STATE") + ;; + *"/xapi/dicomscp/"*) + id="${url##*/}" + [ "$method" = "DELETE" ] && edit "$SCP_STATE" --argjson id "$id" 'map(select(.id != $id))' + body=$(cat "$SCP_STATE") ;; esac + +# Lets a test make one specific call fail, to exercise the error paths. +case "${FAIL_ON_URL:-__none__}" in + __none__) ;; + *) case "$url" in *"$FAIL_ON_URL"*) status="${FAIL_STATUS:-500}"; body='{"error":"stub failure"}' ;; esac ;; +esac + [ -n "$outfile" ] && [ "$outfile" != "/dev/null" ] && printf '%s' "$body" > "$outfile" -if [ "$status_only" = "1" ]; then printf '200'; else printf '%s\n200' "$body"; fi +if [ "$status_only" = "1" ]; then printf '%s' "$status"; else printf '%s\n%s' "$body" "$status"; fi +case "$status" in 2*) exit 0 ;; esac exit 0 """ @@ -66,36 +106,51 @@ "XNAT_SERVICE_USER": "flipServiceAccount", "XNAT_SERVICE_PASSWORD": "service", "XNAT_PORT": "8104", + # The plugin-readiness wait polls until a DQR route answers, bounded only by wall clock. With + # sleep stubbed out, a test that makes that route fail would spin at full speed for the default + # 900s budget rather than failing; cap it so the harness can never hang on one. + "XNAT_PLUGIN_READINESS_TIMEOUT_SECONDS": "5", + "XNAT_PLUGIN_READINESS_POLL_SECONDS": "0", } +# What the stub reports as already registered when a test does not say otherwise. +MOCK_PACS_REGISTRATION = '[{"id":7,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242}]' + + +def run_configure(tmp_path, env_overrides=None, pacs_state=None, scp_state=None): + """Runs configure-xnat.sh against the stub and returns (exit code, payloads, combined output). -def run_configure(tmp_path, env_overrides=None, pacs_already_registered=False): - """Runs configure-xnat.sh against the stub and returns (exit code, payloads, combined output).""" + Args: + tmp_path: pytest tmp_path for the stub PATH and state files. + env_overrides (dict | None): Environment for the run, layered over BASE_ENV. + pacs_state (str | None): JSON array the stub starts with as XNAT's PACS registrations. + scp_state (str | None): JSON array the stub starts with as XNAT's SCP receivers. + """ bin_dir = tmp_path / "bin" - bin_dir.mkdir() + bin_dir.mkdir(parents=True) # parents: tests that run twice pass a nested tmp_path stub = bin_dir / "curl" stub.write_text(STUB_CURL) stub.chmod(0o755) + # The script's fixed "wait for XNAT to settle" sleep is 10s of dead time per run, and the + # readiness loops it guards are already satisfied instantly by the stub. Stubbing sleep keeps + # the suite at seconds rather than minutes; nothing here is testing the waits. + no_sleep = bin_dir / "sleep" + no_sleep.write_text("#!/bin/sh\nexit 0\n") + no_sleep.chmod(0o755) payloads = tmp_path / "payloads.txt" - registered = tmp_path / "registered" - if pacs_already_registered: - registered.touch() + pacs_file = tmp_path / "pacs.json" + pacs_file.write_text(pacs_state if pacs_state is not None else "[]") + scp_file = tmp_path / "scp.json" + scp_file.write_text(scp_state if scp_state is not None else '[{"id":1,"aeTitle":"XNAT","port":8104}]') env = { **os.environ, **BASE_ENV, "PATH": f"{bin_dir}:{os.environ['PATH']}", "PAYLOADS": str(payloads), - "REGISTERED": str(registered), - # What the stub reports as registered. Defaults to the mock; when a test configures a - # different PACS the stub echoes that back, mimicking XNAT after the POST succeeded. - "STUB_SCP_JSON": '[{"id":1,"aeTitle":"XNAT","port":8104}]', - "STUB_PACS_JSON": "[]", - "STUB_PACS_ID": "7", - "STUB_PACS_AET": (env_overrides or {}).get("PACS_AETITLE", "ORTHANC"), - "STUB_PACS_HOST": "orthanc" if pacs_already_registered else (env_overrides or {}).get("PACS_HOST", "orthanc"), - "STUB_PACS_PORT": "4242" if pacs_already_registered else (env_overrides or {}).get("PACS_QR_PORT", "4242"), + "PACS_STATE": str(pacs_file), + "SCP_STATE": str(scp_file), **(env_overrides or {}), } @@ -106,6 +161,28 @@ def run_configure(tmp_path, env_overrides=None, pacs_already_registered=False): return result.returncode, body, result.stdout + result.stderr +def requests_made(payloads: str) -> list[tuple[str, str]]: + """Every (method, url) the script issued, in order.""" + made = [] + for block in payloads.split("=== "): + header = block.partition("\n")[0].split() + if len(header) == 2: + made.append((header[0], header[1])) + return made + + +def body_for(payloads: str, endpoint: str) -> str: + """The last raw request body sent to ``endpoint`` — for the payloads that are not objects.""" + found = None + for block in payloads.split("=== "): + header, _, rest = block.partition("\n") + parts = header.split() + if len(parts) == 2 and parts[1].endswith(endpoint) and rest.strip(): + found = rest.strip() + assert found is not None, f"no body sent to {endpoint}" + return found + + def payload_for(payloads: str, endpoint: str) -> dict: """Returns the last JSON payload sent to ``endpoint``. @@ -115,7 +192,8 @@ def payload_for(payloads: str, endpoint: str) -> dict: found = None for block in payloads.split("=== "): header, _, rest = block.partition("\n") - url = header.split()[-1] if header.split() else "" + parts = header.split() + url = parts[1] if len(parts) == 2 else "" matches = url.endswith(endpoint) or ( endpoint == "/xapi/pacs" and re.search(r"/xapi/pacs/\d+$", url) is not None ) @@ -200,7 +278,7 @@ def test_registration_updates_in_place_when_host_or_port_drift(tmp_path): code, payloads, output = run_configure( tmp_path, {"PACS_HOST": "10.0.0.10", "PACS_QR_PORT": "8059"}, - pacs_already_registered=True, # stub reports ORTHANC at orthanc:4242 + pacs_state=MOCK_PACS_REGISTRATION, ) assert code == 0, output assert "updating to 10.0.0.10:8059" in output @@ -212,14 +290,15 @@ def test_registration_updates_in_place_when_host_or_port_drift(tmp_path): def test_matching_registration_is_left_alone(tmp_path): """An unchanged registration must not be rewritten on every redeploy.""" - code, _, output = run_configure(tmp_path, pacs_already_registered=True) + code, payloads, output = run_configure(tmp_path, pacs_state=MOCK_PACS_REGISTRATION) assert code == 0, output assert "already registered at orthanc:4242 — leaving as-is" in output + assert not [m for m, u in requests_made(payloads) if m in ("POST", "PUT") and u.endswith("/xapi/pacs")] def test_availability_uses_the_resolved_pacs_id(tmp_path): """The schedule must be written against the real registration, not a hardcoded id of 1.""" - code, payloads, output = run_configure(tmp_path, pacs_already_registered=True) + code, payloads, output = run_configure(tmp_path, pacs_state=MOCK_PACS_REGISTRATION) assert code == 0, output assert payload_for(payloads, "/availability")["pacsId"] == 7 @@ -241,10 +320,11 @@ def test_receiver_on_our_port_is_reclaimed_whatever_it_is_called(tmp_path): """Renaming the AE title must not strand the old receiver fighting for the same port.""" code, payloads, output = run_configure( tmp_path, - {"STUB_SCP_JSON": '[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]'}, + scp_state='[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]', ) assert code == 0, output - assert "(id 3)" in output and "FLIPXNAT" in output + assert "(id 3)" in output + assert "FLIPXNAT" in output assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)) @@ -256,8 +336,8 @@ def test_foreign_pacs_registrations_are_removed(tmp_path): "PACS_AETITLE": "SECTRA_QR", "PACS_HOST": "10.0.0.10", "PACS_QR_PORT": "8059", - "STUB_PACS_JSON": '[{"id":1,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242}]', }, + pacs_state='[{"id":1,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242}]', ) assert code == 0, output assert "Removing PACS ORTHANC at orthanc:4242 (id 1)" in output @@ -268,11 +348,8 @@ def test_receiver_is_reclaimed_when_port_and_title_both_change(tmp_path): """Matching on our port *or* our AE title left an orphan when both moved in one change.""" code, payloads, output = run_configure( tmp_path, - { - "XNAT_PORT": "11112", - "XNAT_AETITLE": "FLIPXNAT2", - "STUB_SCP_JSON": '[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]', - }, + {"XNAT_PORT": "11112", "XNAT_AETITLE": "FLIPXNAT2"}, + scp_state='[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]', ) assert code == 0, output assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)), ( @@ -284,7 +361,7 @@ def test_refuses_to_delete_a_foreign_pacs_while_still_on_mock_defaults(tmp_path) """An unrelated redeploy must not delete a PACS the operator registered by hand.""" code, payloads, output = run_configure( tmp_path, - {"STUB_PACS_JSON": '[{"id":1,"aeTitle":"SECTRA_QR","host":"10.0.0.10","queryRetrievePort":8059}]'}, + pacs_state='[{"id":1,"aeTitle":"SECTRA_QR","host":"10.0.0.10","queryRetrievePort":8059}]', ) assert code != 0, "should refuse rather than delete a registration it may not own" assert "SECTRA_QR at 10.0.0.10:8059" in output @@ -307,3 +384,177 @@ def test_non_numeric_port_fails_naming_the_variable(tmp_path): """A bad port must fail here, not reach XNAT as an opaque 400.""" code, _, output = run_configure(tmp_path, {"PACS_QR_PORT": "8059abc"}) assert code != 0, "a non-numeric port was accepted" + + +def test_scp_receiver_binds_the_configured_port(tmp_path): + """XNAT_PORT is the C-MOVE destination port; a receiver on any other port never gets the study.""" + code, payloads, output = run_configure(tmp_path, {"XNAT_PORT": "11112"}) + assert code == 0, output + assert payload_for(payloads, "/xapi/dicomscp")["port"] == 11112 + + +def test_scp_receiver_keeps_the_settings_the_dqr_import_path_depends_on(tmp_path): + """These four are why the receiver is re-created rather than left at XNAT's defaults. + + ``dqrObjectIdentifier`` is what routes an arriving study to the project DQR requested it for; + without ``directArchive`` + ``customProcessing`` the study lands in the prearchive and is never + archived; ``anonymizationEnabled`` is what applies the site-wide anonymization script, so + turning it off sends identifiable DICOM into the archive. + """ + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + + receiver = payload_for(payloads, "/xapi/dicomscp") + assert receiver["identifier"] == "dqrObjectIdentifier" + assert receiver["directArchive"] is True + assert receiver["customProcessing"] is True + assert receiver["anonymizationEnabled"] is True + assert receiver["enabled"] is True + # Routing/whitelisting are off deliberately: FLIP routes by DQR's identifier, and a whitelist + # here would silently drop studies the platform asked for. + assert receiver["whitelistEnabled"] is False + assert receiver["routingExpressionsEnabled"] is False + + +def test_site_wide_anonymization_is_uploaded_and_enabled(tmp_path): + """The receiver's anonymizationEnabled only matters if the site script is on.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + assert body_for(payloads, "/xapi/anonymize/site/enabled") == "true" + assert ("PUT", "http://xnat-web:8080/xapi/anonymize/site") in requests_made(payloads) + + +def test_dqr_stays_restricted_to_authorised_accounts(tmp_path): + """allowAllUsersToUseDqr would let any XNAT account pull arbitrary studies from the trust PACS.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + assert payload_for(payloads, "/xapi/dqr/settings")["allowAllUsersToUseDqr"] is False + + +def test_guest_account_is_disabled(tmp_path): + """An enabled guest is an unauthenticated reader of an archive holding patient imaging.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + assert ("PUT", "http://xnat-web:8080/xapi/users/guest/enabled/false") in requests_made(payloads) + + +def test_pacs_registration_carries_the_flags_dqr_selects_on(tmp_path): + """defaultQueryRetrievePacs is how DQR picks this PACS, and imaging-api how it resolves the id.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + + pacs = payload_for(payloads, "/xapi/pacs") + assert pacs["defaultQueryRetrievePacs"] is True + assert pacs["defaultStoragePacs"] is True + assert pacs["queryable"] is True + assert pacs["storable"] is True + + +def test_extended_negotiation_defaults_on_and_is_configurable(tmp_path): + """A PACS without relational-query support rejects the association unless this is off.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + assert payload_for(payloads, "/xapi/pacs")["supportsExtendedNegotiations"] is True + + code, payloads, output = run_configure(tmp_path / "off", {"PACS_SUPPORTS_EXTENDED_NEGOTIATIONS": "false"}) + assert code == 0, output + assert payload_for(payloads, "/xapi/pacs")["supportsExtendedNegotiations"] is False + + +@pytest.mark.parametrize("value", ["yes", "True", "1", ""]) +def test_non_boolean_extended_negotiation_is_refused(tmp_path, value): + """jq would take `1` as the number 1 and reject `yes` with a message naming neither the + variable nor the accepted values.""" + code, _, output = run_configure(tmp_path, {"PACS_SUPPORTS_EXTENDED_NEGOTIATIONS": value}) + assert code != 0, f"{value!r} was accepted as a boolean" + assert "PACS_SUPPORTS_EXTENDED_NEGOTIATIONS" in output + + +def test_availability_window_is_enabled_and_live(tmp_path): + """A window written disabled is a schedule that silently never applies.""" + code, payloads, output = run_configure(tmp_path) + assert code == 0, output + + availability = payload_for(payloads, "/availability") + assert availability["enabled"] is True + assert availability["availableNow"] is True + + +def test_dqr_retry_and_poll_settings_reach_xnat(tmp_path): + """The retry count and wait are the throttle a PACS team agrees to; a swap inverts it.""" + code, payloads, output = run_configure( + tmp_path, + {"DQR_MAX_PACS_REQUEST_ATTEMPTS": "25", "DQR_RETRY_WAIT_SECONDS": "120"}, + ) + assert code == 0, output + + dqr = payload_for(payloads, "/xapi/dqr/settings") + assert dqr["dqrMaxPacsRequestAttempts"] == "25" + assert dqr["dqrWaitToRetryRequestInSeconds"] == "120" + assert dqr["pacsAvailabilityCheckFrequency"] == "1 minute" + assert dqr["allowAllProjectsToUseDqr"] is True + + +def test_a_rejected_availability_window_fails_the_run(tmp_path): + """A 400 that is not an overlap means the schedule was refused. + + Reporting it as applied is the worst outcome available: a trust that negotiated an + out-of-hours window would be told it was in force while DQR retrieved around the clock. + """ + code, _, output = run_configure( + tmp_path, + {"AVAIL_STATUS": "400", "AVAIL_BODY": '{"error":"Unknown day of week: FUNDAY"}'}, + ) + assert code != 0, "a refused availability window was reported as applied" + assert "availability" in output.lower() + + +def test_a_server_error_on_availability_fails_the_run(tmp_path): + code, _, output = run_configure(tmp_path, {"AVAIL_STATUS": "500", "AVAIL_BODY": '{"error":"boom"}'}) + assert code != 0, "a failed availability write was swallowed" + + +def test_an_overlapping_availability_interval_is_tolerated(tmp_path): + """DQR pre-creates intervals at registration, so this specific 400 is not a failure.""" + code, _, output = run_configure( + tmp_path, + { + "PACS_AVAILABILITY_DAYS": "MONDAY", + "AVAIL_STATUS": "400", + "AVAIL_BODY": '{"error":"probable overlap with existing interval"}', + }, + ) + assert code == 0, output + assert "already exists" in output + + +def test_a_registration_that_did_not_take_fails_rather_than_configuring_nothing(tmp_path): + """XNAT answering 200 without persisting is exactly how FLIP#822 stayed hidden.""" + code, _, output = run_configure(tmp_path, {"SWALLOW_PACS_POST": "1"}) + assert code != 0, "an unregistered PACS was treated as configured" + assert "not registered" in output + + +def test_drift_is_corrected_in_place_rather_than_re_created(tmp_path): + """A delete-and-recreate would change the PACS id under imaging-api's cache mid-flight.""" + code, payloads, output = run_configure( + tmp_path, + {"PACS_HOST": "10.0.0.10", "PACS_QR_PORT": "8059"}, + pacs_state=MOCK_PACS_REGISTRATION, + ) + assert code == 0, output + assert ("PUT", "http://xnat-web:8080/xapi/pacs/7") in requests_made(payloads) + assert not [u for m, u in requests_made(payloads) if m == "DELETE" and "/xapi/pacs/" in u] + + +def test_credentials_are_not_echoed_when_a_call_fails(tmp_path): + """The configure output is tee'd to a log file on the XNAT host.""" + # The password-rotation PUT: the one call whose -d body is itself a credential. + code, _, output = run_configure( + tmp_path, + {"FAIL_ON_URL": "/xapi/users/admin", "FAIL_STATUS": "500"}, + ) + assert code != 0, "a 500 from XNAT did not abort the run" + assert "" in output, "the failing request was echoed without redaction" + for secret in ("rotated", "initial", "service"): + assert secret not in output, f"the {secret!r} password reached the configure log" diff --git a/trust/xnat/tests/test_deploy_wiring.py b/trust/xnat/tests/test_deploy_wiring.py new file mode 100644 index 000000000..2cea323f1 --- /dev/null +++ b/trust/xnat/tests/test_deploy_wiring.py @@ -0,0 +1,241 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for the layers that carry configuration *to* configure-xnat.sh (FLIP#993). + +test_configure_pacs.py proves the script does the right thing with the environment it is given. +Nothing proved it is given that environment, and every bug found while deploying this change lived +in the gap: the Makefile defaulted the web port to a literal that collided with a second trust, it +exported an empty AE title, and the compose file's ``${VAR:-default}`` cancelled the script's own +fail-loud guard. None of those are visible from inside the script. + +These run make and read the deployment files; they never invoke docker. +""" + +import os +import re +import subprocess +from pathlib import Path + +import pytest + +XNAT_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = XNAT_DIR.parents[1] +SCRIPT = XNAT_DIR / "xnat" / "config" / "configure-xnat.sh" +COMPOSE = XNAT_DIR / "docker-compose-stack.yml" +REAL_PACS_OVERLAY = XNAT_DIR / "docker-compose-stack.real-pacs.yml" +INIT_JOB = REPO_ROOT / "deploy" / "providers" / "kubernetes" / "templates" / "xnat-init-job.yaml" + + +def make_vars(*names: str, **overrides: str) -> dict[str, str]: + """Resolves Make variables by evaluating them in a throwaway target. + + ``--eval`` appends the target after the makefiles are read, so the values are the ones a real + deploy would use. Only variables are expanded — no recipe from the Makefile itself runs, so + this needs neither docker nor a swarm. + + Args: + *names: Make variable names to resolve. + **overrides: Command-line variable assignments (``KIT=GSTT``), as an operator would pass. + + Returns: + dict[str, str]: Resolved value per requested name. + """ + # One recipe line per variable: make splits a recipe on newlines, so a single multi-line echo + # reaches the shell as several unterminated commands. + probe = "__probe:\n" + "".join(f"\t@echo '{n}=$({n})'\n" for n in names) + result = subprocess.run( + ["make", "--eval", probe, "__probe", *(f"{k}={v}" for k, v in overrides.items())], + cwd=XNAT_DIR, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + resolved = {} + for line in result.stdout.splitlines(): + key, _, value = line.partition("=") + if key in names: + resolved[key] = value + assert set(resolved) == set(names), f"unresolved: {set(names) - set(resolved)}" + return resolved + + +def configurable_variables() -> set[str]: + """Names the script reads with a bare-dash default — its operator-configurable knobs. + + ``${VAR-default}`` rather than ``${VAR:-default}`` is the script's marker for a value an + operator may set and which must never silently fall back when set empty. + """ + return set(re.findall(r'^([A-Z0-9_]+)="\$\{\1-', SCRIPT.read_text(), flags=re.MULTILINE)) + + +def compose_environment() -> dict[str, str]: + """The xnat-web service's ``environment:`` list, as {name: raw value}.""" + entries = {} + for line in COMPOSE.read_text().splitlines(): + match = re.match(r"\s*-\s+([A-Z0-9_]+)=(.*)$", line) + if match: + entries[match.group(1)] = match.group(2) + return entries + + +def test_every_script_knob_is_wired_through_compose(): + """A knob the compose file does not pass is one a kit file can set with no effect.""" + missing = configurable_variables() - set(compose_environment()) + assert not missing, f"configure-xnat.sh reads {sorted(missing)}, but docker-compose-stack.yml never passes them" + + +def test_every_script_knob_is_wired_through_the_helm_init_job(): + """Same contract on the Kubernetes path, which runs the same script from the same image. + + The two deployments drifted apart once already: imaging-api was given the DICOM port but not + the AE title, so k8s trusts ran with a receiver XNAT would not answer to. + """ + declared = set(re.findall(r"- name: ([A-Z0-9_]+)", INIT_JOB.read_text())) + missing = configurable_variables() - declared + assert not missing, f"configure-xnat.sh reads {sorted(missing)}, but xnat-init-job.yaml never sets them" + + +def test_compose_defaults_do_not_cancel_the_scripts_empty_check(): + """``${VAR:-default}`` substitutes for a set-but-empty variable; ``${VAR-default}`` does not. + + With the colon form an operator who writes ``PACS_HOST=`` in a kit file gets the mocked Orthanc + silently, which is precisely the fail-loud behaviour the script's guards exist to provide. + """ + offenders = { + name: value + for name, value in compose_environment().items() + if name in configurable_variables() and ":-" in value + } + assert not offenders, f"these cancel the script's guard by defaulting an empty value: {sorted(offenders)}" + + +def test_published_ports_are_flat_references(): + """``docker stack deploy`` rejects a nested default in a ports entry. + + ``${XNAT_WEB_PORT:-${XNAT_PORT}}:8080`` fails the whole deploy with "Does not match format + 'ports'" — the fallback has to be resolved by the Makefile, not by compose. + """ + for compose in (COMPOSE, REAL_PACS_OVERLAY): + in_ports = False + for line in compose.read_text().splitlines(): + if re.match(r"\s*ports:\s*$", line): + in_ports = True + continue + if not in_ports: + continue + # Comments and blank lines sit between `ports:` and its entries; only a key at the same + # or lower indent ends the block. + if not line.strip() or line.lstrip().startswith("#"): + continue + if not re.match(r"\s*-\s", line): + in_ports = False + continue + assert not re.search(r"\$\{[^}]*\$\{", line), ( + f"nested substitution in a ports entry of {compose.name}: {line.strip()}" + ) + + +def test_web_port_defaults_to_the_dicom_port(): + """They were one variable until this change, so a kit that sets only XNAT_PORT must still work. + + Defaulting to a literal instead broke the second trust on a host: KCH publishing on 8104 rather + than its own 8106 collides with a running GSTT. + """ + resolved = make_vars("XNAT_PORT_EFFECTIVE", "XNAT_WEB_PORT_EFFECTIVE", XNAT_PORT="8106") + assert resolved["XNAT_WEB_PORT_EFFECTIVE"] == "8106" + + +def test_web_port_can_be_separated_from_the_dicom_port(): + """Publishing the receiver on the host needs the two on different ports.""" + resolved = make_vars("XNAT_PORT_EFFECTIVE", "XNAT_WEB_PORT_EFFECTIVE", XNAT_PORT="8104", XNAT_WEB_PORT="8080") + assert (resolved["XNAT_PORT_EFFECTIVE"], resolved["XNAT_WEB_PORT_EFFECTIVE"]) == ("8104", "8080") + + +def test_ae_title_defaults_when_unset(): + """An unset AE title must reach the script as XNAT, not as an empty string.""" + assert make_vars("XNAT_AETITLE_EFFECTIVE")["XNAT_AETITLE_EFFECTIVE"] == "XNAT" + + +def test_ae_title_set_empty_stays_empty(): + """The empty value has to survive make so the script's guard is the thing that reports it. + + Substituting the default here would hand the script a configured-looking value and move the + failure to whenever the PACS first tries to C-STORE back — a wrong AE title is not detectable + by any earlier step. + """ + assert make_vars("XNAT_AETITLE_EFFECTIVE", XNAT_AETITLE="")["XNAT_AETITLE_EFFECTIVE"] == "" + + +def run_xnat_reset(tmp_path, **overrides: str) -> subprocess.CompletedProcess: + """Runs the real xnat-reset recipe with its destructive half neutered. + + ``make -n`` is no use here: the port guards are shell ``if``s inside the recipe, so a dry run + prints them without ever deciding anything. So the recipe runs for real, but against a data + directory under tmp_path and with ``sudo`` stubbed to a no-op — a guard that stops working + then creates a directory in a temp dir instead of deleting a trust's XNAT archive. + """ + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_sudo = bin_dir / "sudo" + fake_sudo.write_text("#!/bin/sh\nexit 0\n") + fake_sudo.chmod(0o755) + env = {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"} + return subprocess.run( + [ + "make", + "xnat-reset", + "KIT=GSTT", + f"XNAT_DATA_DIR={tmp_path / 'data'}", + *(f"{k}={v}" for k, v in overrides.items()), + ], + cwd=XNAT_DIR, + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + +@pytest.mark.parametrize("port_var", ["XNAT_PORT", "XNAT_WEB_PORT"]) +def test_non_numeric_ports_are_refused_before_deploy(tmp_path, port_var): + """A non-numeric port reaches docker as an unparseable ports entry, after the data wipe.""" + result = run_xnat_reset(tmp_path, **{port_var: "80a4"}) + assert result.returncode != 0, f"a non-numeric {port_var} was accepted" + assert port_var in result.stdout + result.stderr + + +def test_valid_ports_are_accepted(tmp_path): + """The negative cases above are only meaningful if the guard lets a good configuration through.""" + result = run_xnat_reset(tmp_path, XNAT_PORT="8104", XNAT_WEB_PORT="8080") + assert result.returncode == 0, result.stdout + result.stderr + + +def test_real_pacs_refuses_to_publish_both_services_on_one_port(tmp_path): + """REAL_PACS=true publishes the DICOM receiver alongside the web UI; one port cannot serve both.""" + result = run_xnat_reset(tmp_path, REAL_PACS="true", XNAT_PORT="8104", XNAT_WEB_PORT="8104") + assert result.returncode != 0, "the collision was accepted" + assert "must differ" in result.stdout + result.stderr + + +def test_one_port_is_fine_without_real_pacs(tmp_path): + """Sharing the number is the normal case: only the web UI is published.""" + result = run_xnat_reset(tmp_path, XNAT_PORT="8104", XNAT_WEB_PORT="8104") + assert result.returncode == 0, result.stdout + result.stderr + + +def test_real_pacs_overlay_is_only_added_on_request(): + """Publishing the receiver is an opening; the mocked PACS reaches it over the container network.""" + without = make_vars("STACK_FILES")["STACK_FILES"] + with_overlay = make_vars("STACK_FILES", REAL_PACS="true")["STACK_FILES"] + assert "real-pacs" not in without + assert "docker-compose-stack.real-pacs.yml" in with_overlay diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index 0e4205642..08916501f 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -46,6 +46,10 @@ PACS_HOST="${PACS_HOST-orthanc}" # service name in compose / k8s, or PACS_AETITLE="${PACS_AETITLE-ORTHANC}" PACS_QR_PORT="${PACS_QR_PORT-4242}" PACS_LABEL="${PACS_LABEL-Test PACS instance}" +# Relational queries / extended negotiation. On by default because both the mocked Orthanc and +# every PACS we have integrated with support it, but it is a genuine per-PACS capability: a PACS +# that does not support it must have this off or it rejects the association (FLIP#993). +PACS_SUPPORTS_EXTENDED_NEGOTIATIONS="${PACS_SUPPORTS_EXTENDED_NEGOTIATIONS-true}" # DQR retry behaviour and the PACS availability schedule — the throttle for a production PACS, which # may refuse further associations after a certain volume (FLIP#993). Defaults are today's values. @@ -62,7 +66,15 @@ PACS_UTILIZATION_PERCENT="${PACS_UTILIZATION_PERCENT-100}" : "${XNAT_URL:?}" "${XNAT_AETITLE:?}" "${PACS_HOST:?}" "${PACS_AETITLE:?}" "${PACS_QR_PORT:?}" : "${PACS_LABEL:?}" "${DQR_MAX_PACS_REQUEST_ATTEMPTS:?}" "${DQR_RETRY_WAIT_SECONDS:?}" : "${PACS_AVAILABILITY_DAYS:?}" "${PACS_AVAILABILITY_START:?}" "${PACS_AVAILABILITY_END:?}" -: "${PACS_THREADS:?}" "${PACS_UTILIZATION_PERCENT:?}" +: "${PACS_THREADS:?}" "${PACS_UTILIZATION_PERCENT:?}" "${PACS_SUPPORTS_EXTENDED_NEGOTIATIONS:?}" + +# Validated here rather than left to jq: --argjson accepts any JSON, so a "yes" or "True" would +# fail inside the filter with a parse error that names neither the variable nor the accepted +# values, and a bare `1` would silently register as the number 1 rather than a boolean. +case "${PACS_SUPPORTS_EXTENDED_NEGOTIATIONS}" in + true|false) ;; + *) echo "ERROR: PACS_SUPPORTS_EXTENDED_NEGOTIATIONS must be true or false, got '${PACS_SUPPORTS_EXTENDED_NEGOTIATIONS}'" >&2; exit 1 ;; +esac # jq parses the /xapi/dicomscp and /xapi/pacs listings below. It ships in the xnat-web image # (trust/xnat/xnat/Dockerfile), but fail loudly here rather than let a missing binary degrade into a @@ -410,6 +422,7 @@ pacs_payload=$(jq -n \ --arg host "${PACS_HOST}" \ --arg label "${PACS_LABEL}" \ --argjson port "${PACS_QR_PORT}" \ + --argjson ext "${PACS_SUPPORTS_EXTENDED_NEGOTIATIONS}" \ '{ aeTitle: $ae, defaultQueryRetrievePacs: true, @@ -420,7 +433,7 @@ pacs_payload=$(jq -n \ queryRetrievePort: $port, queryable: true, storable: true, - supportsExtendedNegotiations: true + supportsExtendedNegotiations: $ext }') existing_pacs=$(xnat_curl -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" "$XNAT_URL/xapi/pacs") From 5d3adce1bde326ca8c69331503f1275022f90004 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 13:19:19 +0100 Subject: [PATCH 14/31] docs: scope the inbound-connection claim to on-premises trusts (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS-hosted trusts are mock trusts carrying open-source datasets: they run FLIP's own bundled Orthanc, which XNAT reaches over the container network. They will not be connected to a real PACS, so the inbound C-STORE return leg this PR documents does not apply to them at all. Both pages read as though the return leg were a property of every deployment. That understated the guarantee for AWS trusts and left the neighbouring claim — that the AWS trust security groups define no ingress rules — true only by accident of nothing having needed one yet. It is actually true by design, and now says so. The practical point for a trust's network team is unchanged where it matters: connecting FLIP to their own PACS needs one inbound rule inside the trust, on the DICOM port, from the PACS itself. Signed-off-by: at24_bioeng625-pc --- docs/source/governance-and-compliance.rst | 9 +++++---- docs/source/security.rst | 9 ++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/source/governance-and-compliance.rst b/docs/source/governance-and-compliance.rst index 87f824742..062e8f027 100644 --- a/docs/source/governance-and-compliance.rst +++ b/docs/source/governance-and-compliance.rst @@ -77,10 +77,11 @@ Network architecture as a governance guarantee The network design is why the guarantees above are structural rather than procedural. Each trust polls the Central Hub outbound, and there is no route from the internet — or from the hub — into a trust's network. For a trust's own network team, onboarding FLIP -requires no inbound exposure to the outside world. The one inbound rule is internal to -the trust: FLIP asks the trust's PACS for a study, and the PACS opens a connection back -to XNAT to deliver it, on the DICOM port alone. A site-to-site VPN can be provisioned on request -where a trust's policy calls for network-layer separation as well. +requires no inbound exposure to the outside world. Where FLIP is connected to the trust's +own PACS, one inbound rule is needed *inside* the trust: FLIP asks the PACS for a study, +and the PACS opens a connection back to XNAT to deliver it, on the DICOM port alone. +A site-to-site VPN can be provisioned on request where a trust's policy calls for +network-layer separation as well. The practical governance point: a trust does not have to rely on the Central Hub's access controls to be confident its systems are unreachable. There is no path. diff --git a/docs/source/security.rst b/docs/source/security.rst index f02efa1ae..4bee86a26 100644 --- a/docs/source/security.rst +++ b/docs/source/security.rst @@ -31,9 +31,12 @@ themselves — the AWS trust security groups define no ingress rules at all — than depending on configuration discipline. Operator access is via AWS Systems Manager Session Manager, so port 22 is never opened. -The one inbound connection in the design is internal to the trust. FLIP asks the trust's -PACS for a study, and the PACS opens a connection back to XNAT to deliver it, on the -DICOM port alone. It stays inside the trust's own network. +The one inbound connection in the design applies only where FLIP is connected to a +trust's own PACS, which today means an on-premises deployment. FLIP asks the PACS for a +study, and the PACS opens a connection back to XNAT to deliver it, on the DICOM port +alone. It stays inside the trust's own network. AWS-hosted trusts have no such path: +they run FLIP's own bundled Orthanc, which XNAT reaches over the container network, so +their security groups keep no ingress rules at all. **Only the Central Hub is internet-facing.** It sits behind CloudFront with modern TLS, HSTS, AWS WAF managed rules, and an internal-only Application Load Balancer. Nothing From 0d81f1753b9fc6aa9f5ebfffdfd3d7d6044956d7 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 20:39:28 +0100 Subject: [PATCH 15/31] fix: address the Copilot review findings (#993) - Bound every XNAT call in imaging.py, not just the PACS-id lookup: 30s for metadata round-trips (ping), 300s for the DQR data-path calls that proxy live DIMSE operations against a possibly slow real PACS. - Heal the no-id ping on a stale cached PACS id: ping_registered_pacs drops the cache and retries once with a freshly resolved id, so the trust-api health probe recovers from a re-registration without an imaging-api restart (previously only the import path self-healed). - configure-xnat.sh: default XNAT_URL with the bare-dash form so a set-but-empty value trips the fail-loud guard, per the script's own stated contract. - Cover the resolve_pacs_id branches codecov flagged: the multiple- registration default-QR preference and the unusable-id fallback. Signed-off-by: at24_bioeng625-pc --- .../imaging_api/routers/imaging.py | 10 ++- .../imaging_api/services/imaging.py | 42 ++++++++- .../imaging-api/tests/routers/test_imaging.py | 13 +-- .../tests/services/test_imaging.py | 90 ++++++++++++++++++- trust/xnat/tests/test_configure_pacs.py | 2 +- trust/xnat/xnat/config/configure-xnat.sh | 2 +- 6 files changed, 144 insertions(+), 15 deletions(-) diff --git a/trust/imaging-api/imaging_api/routers/imaging.py b/trust/imaging-api/imaging_api/routers/imaging.py index 2dc73dd17..d36eead73 100644 --- a/trust/imaging-api/imaging_api/routers/imaging.py +++ b/trust/imaging-api/imaging_api/routers/imaging.py @@ -17,9 +17,9 @@ from imaging_api.routers.schemas import ImportStudyRequest, ImportStudyResponse, PacsStatus, Study from imaging_api.services.imaging import ( ping_pacs, + ping_registered_pacs, query_by_accession_number, queue_image_import_request, - resolve_pacs_id, ) from imaging_api.utils.auth import get_xnat_auth_headers from imaging_api.utils.exceptions import NotFoundError @@ -37,8 +37,10 @@ def ping_pacs_endpoint(headers: XNATAuthHeaders, pacs_id: int | None = None) -> Two routes, one handler. Without an id the PACS is resolved from XNAT the same way the import path resolves it — the id XNAT assigns at registration is not knowable in advance, so a caller - that only wants to know whether the trust's PACS answers should not have to guess one. The - by-id route stays for callers that genuinely mean a specific registration. + that only wants to know whether the trust's PACS answers should not have to guess one. A stale + cached id (the PACS was re-registered while imaging-api stayed up) is dropped and re-resolved + once rather than pinning the probe to a dead registration. The by-id route stays for callers + that genuinely mean a specific registration. Args: headers (XNATAuthHeaders): XNAT authentication headers. @@ -51,7 +53,7 @@ def ping_pacs_endpoint(headers: XNATAuthHeaders, pacs_id: int | None = None) -> HTTPException: If PACS is not found or if there is an error during the ping operation. """ try: - return ping_pacs(resolve_pacs_id(headers) if pacs_id is None else pacs_id, headers) + return ping_registered_pacs(headers) if pacs_id is None else ping_pacs(pacs_id, headers) except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index 4e964ea91..7d8a4d5e0 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -28,9 +28,12 @@ PACS_ID = get_settings().PACS_ID XNAT_URL = get_settings().XNAT_URL -# Bound the PACS-id lookup: it sits on the retrieval path, and an unbounded request to a wedged -# XNAT would hang the import rather than falling back. +# Bound every XNAT call: an unbounded request to a wedged XNAT would hang the import (and the +# health probe) rather than failing. The DQR data-path calls get a far larger bound because they +# proxy live DIMSE operations against the upstream PACS — a throttled or slow real PACS can hold +# a C-FIND well past what any metadata round-trip to XNAT itself would need. XNAT_REQUEST_TIMEOUT = 30 +XNAT_DQR_REQUEST_TIMEOUT = 300 # Cache for resolve_pacs_id(). XNAT assigns a PACS its id at registration time, so the id is fixed # for the life of that registration and re-resolving on every query would add an XNAT round-trip per @@ -121,6 +124,38 @@ def forget_resolved_pacs_id() -> None: _resolved_pacs_id = None +def ping_registered_pacs(headers: dict[str, str]) -> PacsStatus: + """ + Pings the PACS registered in XNAT, resolving its id first. + + On a 404 the resolved id is presumed stale — the PACS was re-registered under a new id while + this process stayed up — so the cache is dropped and the ping retried once against a freshly + resolved id. Without that, a caller that only ever pings (the trust-api health probe) would + keep failing on the dead id until the container restarted; ``check_pacs`` drops the cache the + same way on the import path (FLIP#993). + + Args: + headers (dict[str, str]): XNAT authentication headers. + + Returns: + PacsStatus: Status of the PACS system. + + Raises: + imaging_api.utils.exceptions.NotFoundError: If the PACS is not found under the freshly + resolved id either. + Exception: If there is an error during the ping request. + """ + pacs_id = resolve_pacs_id(headers) + try: + return ping_pacs(pacs_id, headers) + except NotFoundError: + forget_resolved_pacs_id() + fresh_id = resolve_pacs_id(headers) + if fresh_id == pacs_id: + raise + return ping_pacs(fresh_id, headers) + + def ping_pacs(pacs_id: int, headers: dict[str, str]) -> PacsStatus: """ Pings the imaging provider (PACS) to check if it is reachable. @@ -139,6 +174,7 @@ def ping_pacs(pacs_id: int, headers: dict[str, str]) -> PacsStatus: response = requests.get( f"{XNAT_URL}/xapi/pacs/{pacs_id}/status", headers=headers, + timeout=XNAT_REQUEST_TIMEOUT, ) if response.status_code == 200: return PacsStatus(**response.json()) @@ -205,6 +241,7 @@ def query_by_accession_number(accession_number: str, headers: dict[str, str]) -> f"{XNAT_URL}/xapi/dqr/query/studies", headers=headers, json=study_query.model_dump(by_alias=True), + timeout=XNAT_DQR_REQUEST_TIMEOUT, ) logger.debug(f"Query response: {response.text} - {response.status_code} - {response.reason}") @@ -272,6 +309,7 @@ def queue_image_import_request( f"{XNAT_URL}/xapi/dqr/import", headers=headers, json=import_request.model_dump(by_alias=True), + timeout=XNAT_DQR_REQUEST_TIMEOUT, ) if response.status_code == 200: diff --git a/trust/imaging-api/tests/routers/test_imaging.py b/trust/imaging-api/tests/routers/test_imaging.py index 8402065e1..05d09dce2 100644 --- a/trust/imaging-api/tests/routers/test_imaging.py +++ b/trust/imaging-api/tests/routers/test_imaging.py @@ -68,16 +68,17 @@ def test_ping_pacs_without_id_resolves_the_registration(client): } with ( - patch("imaging_api.routers.imaging.resolve_pacs_id", return_value=7) as mock_resolve, + patch("imaging_api.routers.imaging.ping_registered_pacs") as mock_ping_registered, patch("imaging_api.routers.imaging.ping_pacs") as mock_ping_pacs, ): - mock_ping_pacs.return_value = PacsStatus(**mock_response) + mock_ping_registered.return_value = PacsStatus(**mock_response) response = client.get("/imaging/ping_pacs") assert response.status_code == 200 - assert mock_resolve.called, "the id was not resolved from XNAT" - assert mock_ping_pacs.call_args[0][0] == 7, "pinged an id other than the resolved one" + assert mock_ping_registered.called, "the id was not resolved from XNAT" + assert not mock_ping_pacs.called, "bypassed the resolving (stale-cache-aware) ping" + assert response.json()["pacsId"] == 7 def test_ping_pacs_with_explicit_id_does_not_resolve(client): @@ -94,7 +95,7 @@ def test_ping_pacs_with_explicit_id_does_not_resolve(client): } with ( - patch("imaging_api.routers.imaging.resolve_pacs_id") as mock_resolve, + patch("imaging_api.routers.imaging.ping_registered_pacs") as mock_ping_registered, patch("imaging_api.routers.imaging.ping_pacs") as mock_ping_pacs, ): mock_ping_pacs.return_value = PacsStatus(**mock_response) @@ -102,7 +103,7 @@ def test_ping_pacs_with_explicit_id_does_not_resolve(client): response = client.get("/imaging/ping_pacs/3") assert response.status_code == 200 - assert not mock_resolve.called, "an explicit id was overridden by the resolved one" + assert not mock_ping_registered.called, "an explicit id was overridden by the resolved one" assert mock_ping_pacs.call_args[0][0] == 3 diff --git a/trust/imaging-api/tests/services/test_imaging.py b/trust/imaging-api/tests/services/test_imaging.py index b84aa0055..b6842204f 100644 --- a/trust/imaging-api/tests/services/test_imaging.py +++ b/trust/imaging-api/tests/services/test_imaging.py @@ -16,7 +16,14 @@ import pytest from imaging_api.routers.schemas import ImportStudyRequest -from imaging_api.services.imaging import check_pacs, ping_pacs, query_by_accession_number, queue_image_import_request +from imaging_api.services.imaging import ( + XNAT_DQR_REQUEST_TIMEOUT, + XNAT_REQUEST_TIMEOUT, + check_pacs, + ping_pacs, + query_by_accession_number, + queue_image_import_request, +) from imaging_api.utils.exceptions import NotFoundError @@ -57,6 +64,8 @@ def test_ping_pacs(mock_get): # Assertions assert pacs_status.successful is True assert pacs_status.enabled is True + # Bounded, so a wedged XNAT fails the health probe instead of hanging it + assert mock_get.call_args.kwargs["timeout"] == XNAT_REQUEST_TIMEOUT # Test for ping_pacs function with 404 error @@ -111,6 +120,8 @@ def test_query_by_accession_number(mock_post, headers): # Assertions assert len(studies) == 1 assert studies[0].accession_number == accession_number + # Bounded, so a wedged XNAT fails the query instead of hanging the import path + assert mock_post.call_args.kwargs["timeout"] == XNAT_DQR_REQUEST_TIMEOUT @patch("imaging_api.services.imaging.check_pacs") @@ -156,6 +167,8 @@ def test_queue_image_import_request(mock_check_pacs, mock_requests_post, mock_ge # Assertions assert response[0].status == "QUEUED" assert response[0].pacs_id == 1 + # Bounded, so a wedged XNAT fails the import instead of hanging it + assert mock_requests_post.call_args.kwargs["timeout"] == XNAT_DQR_REQUEST_TIMEOUT # --------------------------------------------------------------------------- @@ -452,6 +465,81 @@ def test_resolve_pacs_id_falls_back_when_xnat_unreachable(mock_get, headers): assert resolve_pacs_id(headers) == PACS_ID +@patch("imaging_api.services.imaging.requests.get") +def test_resolve_pacs_id_prefers_the_default_query_retrieve_pacs(mock_get, headers): + """With a stray extra registration, the flagged default wins over list order.""" + from imaging_api.services.imaging import resolve_pacs_id + + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: [ + {"id": 3, "aeTitle": "STRAY"}, + {"id": 9, "aeTitle": "SECTRA_QR", "defaultQueryRetrievePacs": True}, + ], + ) + + assert resolve_pacs_id(headers) == 9 + + +@patch("imaging_api.services.imaging.requests.get") +def test_resolve_pacs_id_falls_back_when_the_id_is_unusable(mock_get, headers): + """A registration without a usable id degrades to the fallback, which must not be cached.""" + import imaging_api.services.imaging as imaging_module + from imaging_api.services.imaging import PACS_ID, resolve_pacs_id + + mock_get.return_value = MagicMock(status_code=200, json=lambda: [{"aeTitle": "SECTRA_QR"}]) + + assert resolve_pacs_id(headers) == PACS_ID + assert imaging_module._resolved_pacs_id is None, "a fallback pinned in the cache would outlive the failure" + + +PACS_STATUS_OK = { + "pacsId": 9, + "successful": True, + "pingTime": 123, + "created": 1610000000, + "enabled": True, + "timestamp": 1610001234, + "id": 9, + "disabled": 0, +} + + +@patch("imaging_api.services.imaging.requests.get") +def test_ping_registered_pacs_drops_a_stale_cached_id_and_retries(mock_get, headers): + """A re-registration under a new id must heal on the next probe, not after a restart.""" + import imaging_api.services.imaging as imaging_module + from imaging_api.services.imaging import ping_registered_pacs + + imaging_module._resolved_pacs_id = 7 # stale: the registration it came from is gone + mock_get.side_effect = [ + MagicMock(status_code=404), # ping of the stale id + MagicMock(status_code=200, json=lambda: [{"id": 9, "aeTitle": "SECTRA_QR"}]), # re-resolve + MagicMock(status_code=200, json=lambda: PACS_STATUS_OK), # ping of the fresh id + ] + + status = ping_registered_pacs(headers) + + assert status.successful is True + assert imaging_module._resolved_pacs_id == 9, "the fresh id was not cached" + + +@patch("imaging_api.services.imaging.requests.get") +def test_ping_registered_pacs_raises_when_the_fresh_id_is_no_better(mock_get, headers): + """When re-resolving lands on the same missing id (e.g. the fallback), the 404 must surface.""" + import imaging_api.services.imaging as imaging_module + from imaging_api.services.imaging import ping_registered_pacs + + imaging_module._resolved_pacs_id = 7 + mock_get.side_effect = [ + MagicMock(status_code=404), # ping of the stale id + MagicMock(status_code=200, json=lambda: [{"id": 7, "aeTitle": "SECTRA_QR"}]), # same id again + ] + + with pytest.raises(NotFoundError): + ping_registered_pacs(headers) + + def test_import_request_ae_title_follows_settings(): """The C-MOVE destination AE title is configuration, not a hardcoded literal.""" from imaging_api.config import get_settings diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index fe75d134d..239d49bfa 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -303,7 +303,7 @@ def test_availability_uses_the_resolved_pacs_id(tmp_path): assert payload_for(payloads, "/availability")["pacsId"] == 7 -@pytest.mark.parametrize("var", ["XNAT_AETITLE", "PACS_HOST", "PACS_AETITLE", "PACS_QR_PORT"]) +@pytest.mark.parametrize("var", ["XNAT_URL", "XNAT_AETITLE", "PACS_HOST", "PACS_AETITLE", "PACS_QR_PORT"]) def test_empty_values_fail_loudly(tmp_path, var): """An empty value would produce malformed JSON that XNAT rejects silently (FLIP#822/#862).""" code, _, output = run_configure(tmp_path, {var: ""}) diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index 08916501f..b3743c04e 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -40,7 +40,7 @@ set -euo pipefail # calling AE, and the C-MOVE destination that imaging-api hands to the PACS. The PACS opens the # C-STORE association addressed to the AE title it has registered, so a receiver configured under a # different title rejects it. -XNAT_URL="${XNAT_URL:-http://xnat-web:8080}" # internal to the container network +XNAT_URL="${XNAT_URL-http://xnat-web:8080}" # internal to the container network XNAT_AETITLE="${XNAT_AETITLE-XNAT}" PACS_HOST="${PACS_HOST-orthanc}" # service name in compose / k8s, or a real PACS host PACS_AETITLE="${PACS_AETITLE-ORTHANC}" From 96fe3e4ecebf77a1fea8591a22a41ebb7fdfa74a Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 20:50:23 +0100 Subject: [PATCH 16/31] =?UTF-8?q?fix:=20keep=20XNAT=5FURL=20on=20the=20col?= =?UTF-8?q?on-dash=20form=20=E2=80=94=20it=20is=20wiring,=20not=20a=20knob?= =?UTF-8?q?=20(#993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-dash form is the marker test_deploy_wiring.py derives the operator-knob roster from, so switching XNAT_URL to it (0d81f175) made the wiring test demand compose pass XNAT_URL through — but XNAT_URL is container-network wiring that neither compose nor the Helm init job exposes to kit files. Revert to the colon form and document why it is exempt from the bare-dash contract instead. Signed-off-by: at24_bioeng625-pc --- trust/xnat/tests/test_configure_pacs.py | 2 +- trust/xnat/xnat/config/configure-xnat.sh | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index 239d49bfa..fe75d134d 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -303,7 +303,7 @@ def test_availability_uses_the_resolved_pacs_id(tmp_path): assert payload_for(payloads, "/availability")["pacsId"] == 7 -@pytest.mark.parametrize("var", ["XNAT_URL", "XNAT_AETITLE", "PACS_HOST", "PACS_AETITLE", "PACS_QR_PORT"]) +@pytest.mark.parametrize("var", ["XNAT_AETITLE", "PACS_HOST", "PACS_AETITLE", "PACS_QR_PORT"]) def test_empty_values_fail_loudly(tmp_path, var): """An empty value would produce malformed JSON that XNAT rejects silently (FLIP#822/#862).""" code, _, output = run_configure(tmp_path, {var: ""}) diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index b3743c04e..65beb5de9 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -31,6 +31,9 @@ set -euo pipefail # ${VAR-default} rather than ${VAR:-default} throughout: an *unset* variable takes the default, # but one set to the empty string stays empty and trips the guard below. An operator who writes # PACS_HOST= in a kit file must get a loud failure, not a silent fallback to the mocked PACS. +# The bare-dash form is also the marker test_deploy_wiring.py derives the operator-knob roster +# from, so XNAT_URL keeps the colon form deliberately: it is container-network wiring that +# neither compose nor the Helm init job exposes to kit files, not a knob. # # XNAT's own identity and the upstream PACS. Defaults reproduce the mocked Orthanc that ships for # development, so an unconfigured deployment behaves exactly as before; a real trust overrides them @@ -40,7 +43,7 @@ set -euo pipefail # calling AE, and the C-MOVE destination that imaging-api hands to the PACS. The PACS opens the # C-STORE association addressed to the AE title it has registered, so a receiver configured under a # different title rejects it. -XNAT_URL="${XNAT_URL-http://xnat-web:8080}" # internal to the container network +XNAT_URL="${XNAT_URL:-http://xnat-web:8080}" # internal to the container network XNAT_AETITLE="${XNAT_AETITLE-XNAT}" PACS_HOST="${PACS_HOST-orthanc}" # service name in compose / k8s, or a real PACS host PACS_AETITLE="${PACS_AETITLE-ORTHANC}" From 9c71d05903c7e5d6bc32c0b7de58ba9ecd4d6f06 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 23:16:50 +0100 Subject: [PATCH 17/31] feat: always publish the DICOM SCP receiver, retiring the REAL_PACS overlay (#993) Dev and prod now run the same wiring: the base swarm compose publishes the receiver on the host next to the web UI (same number both sides of the mapping - DQR matches the C-MOVE destination by exact AE:port, so no translation is possible on that leg), and the docker-compose-stack.real-pacs.yml overlay plus its REAL_PACS knob are gone. The xnat-reset port-collision guard is now unconditional, so every kit must give XNAT_PORT and XNAT_WEB_PORT distinct values; a pre-split kit that sets only XNAT_PORT still derives the web port from it and is routed into the guard - a loud instruction to allocate a second port instead of a silently moved web UI. XNAT_PORT keeps the canonical 8104: it is the number a real PACS dials, the firewall rule names, and the Helm chart pins. The web UI moves off it instead - dev allocation 8104/8105 (GSTT) and 8106/8107 (KCH) - and the dev tooling that dialled the web UI (demo recorder, seg uploader, EC2 status probe and port-forward helper, docs) follows it. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_trust_xnat.yml | 2 - AGENTS.md | 7 +- CLAUDE.md | 7 +- deploy/providers/AWS/README.md | 2 +- deploy/providers/AWS/check_status.py | 2 +- .../AWS/scripts/forward-trust-all.sh | 2 +- docs/source/components/component-pacs.rst | 20 +++-- flip-api/tests/demo_video.py | 4 +- flip-api/tests/xnat_seg_upload.py | 2 +- .../test/cypress/demo/03-xnat-ohif.spec.ts | 2 +- trust/.env.GSTT.development.example | 9 +- trust/.env.KCH.development.example | 7 +- trust/.env.example | 9 +- trust/AGENTS.md | 2 +- trust/CLAUDE.md | 2 +- trust/imaging-api/README.md | 2 +- trust/xnat/Makefile | 30 +++---- trust/xnat/docker-compose-stack.real-pacs.yml | 29 ------ trust/xnat/docker-compose-stack.yml | 17 +++- trust/xnat/tests/test_deploy_wiring.py | 89 +++++++++++-------- 20 files changed, 120 insertions(+), 126 deletions(-) delete mode 100644 trust/xnat/docker-compose-stack.real-pacs.yml diff --git a/.github/workflows/test_trust_xnat.yml b/.github/workflows/test_trust_xnat.yml index e532ddbb8..4c99c6ca6 100644 --- a/.github/workflows/test_trust_xnat.yml +++ b/.github/workflows/test_trust_xnat.yml @@ -28,7 +28,6 @@ on: - "trust/xnat/tests/**" - "trust/xnat/Makefile" - "trust/xnat/docker-compose-stack.yml" - - "trust/xnat/docker-compose-stack.real-pacs.yml" - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - "trust/Makefile" - "Makefile" @@ -49,7 +48,6 @@ on: - "trust/xnat/tests/**" - "trust/xnat/Makefile" - "trust/xnat/docker-compose-stack.yml" - - "trust/xnat/docker-compose-stack.real-pacs.yml" - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - "trust/Makefile" - "Makefile" diff --git a/AGENTS.md b/AGENTS.md index 6252e0620..7d2c7feab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -378,9 +378,10 @@ After changes, evaluate if docs need updating: why host 8104 served Tomcat while the DICOM receiver's 8104 was an unpublished container port (FLIP#993). `XNAT_AETITLE` is applied to the SCP receiver, `dqrCallingAe`, and the C-MOVE destination in `ImportStudyRequest` — DQR matches that destination against a registered receiver by - exact `AE:port`, so all three must agree and no translation is possible on that leg. Publishing the - receiver for a real PACS is opt-in: `make -C trust/xnat up-xnat KIT= REAL_PACS=true` adds - `docker-compose-stack.real-pacs.yml`, and the Makefile refuses to deploy if the two ports collide. + exact `AE:port`, so all three must agree and no translation is possible on that leg. Both ports are + host-published — the receiver so a real PACS can complete the C-STORE return leg of a retrieval, + and dev keeps the same wiring — so they must differ; the Makefile refuses to deploy if they + collide. Dev allocation: 8104/8105 (GSTT), 8106/8107 (KCH). - `PACS_HOST` / `PACS_AETITLE` / `PACS_QR_PORT` / `PACS_LABEL` — the upstream PACS, defaulting to the mocked Orthanc (`orthanc` / `ORTHANC` / `4242`). `PACS_QR_PORT` must be reachable *from the XNAT container*, not a host-published port — conflating the two is what the retired `PACS_DICOM_PORT` diff --git a/CLAUDE.md b/CLAUDE.md index dbcd0e5b5..a1faabbca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -378,9 +378,10 @@ After changes, evaluate if docs need updating: why host 8104 served Tomcat while the DICOM receiver's 8104 was an unpublished container port (FLIP#993). `XNAT_AETITLE` is applied to the SCP receiver, `dqrCallingAe`, and the C-MOVE destination in `ImportStudyRequest` — DQR matches that destination against a registered receiver by - exact `AE:port`, so all three must agree and no translation is possible on that leg. Publishing the - receiver for a real PACS is opt-in: `make -C trust/xnat up-xnat KIT= REAL_PACS=true` adds - `docker-compose-stack.real-pacs.yml`, and the Makefile refuses to deploy if the two ports collide. + exact `AE:port`, so all three must agree and no translation is possible on that leg. Both ports are + host-published — the receiver so a real PACS can complete the C-STORE return leg of a retrieval, + and dev keeps the same wiring — so they must differ; the Makefile refuses to deploy if they + collide. Dev allocation: 8104/8105 (GSTT), 8106/8107 (KCH). - `PACS_HOST` / `PACS_AETITLE` / `PACS_QR_PORT` / `PACS_LABEL` — the upstream PACS, defaulting to the mocked Orthanc (`orthanc` / `ORTHANC` / `4242`). `PACS_QR_PORT` must be reachable *from the XNAT container*, not a host-published port — conflating the two is what the retired `PACS_DICOM_PORT` diff --git a/deploy/providers/AWS/README.md b/deploy/providers/AWS/README.md index 5f4c19583..4b66e7b81 100644 --- a/deploy/providers/AWS/README.md +++ b/deploy/providers/AWS/README.md @@ -764,7 +764,7 @@ This prints a list of URLs you can paste into your browser: | Service | Local URL | Purpose | | --- | --- | --- | -| XNAT | `http://localhost:8104` | Neuroimaging platform UI | +| XNAT | `http://localhost:8105` | Neuroimaging platform UI | | Orthanc | `http://localhost:8042` | DICOM server UI (basic auth: the kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`) | | trust-api swagger | `http://localhost:8020/docs` | Trust API documentation | | imaging-api swagger | `http://localhost:8001/docs` | Imaging API documentation | diff --git a/deploy/providers/AWS/check_status.py b/deploy/providers/AWS/check_status.py index 5b27997f1..932da8a99 100755 --- a/deploy/providers/AWS/check_status.py +++ b/deploy/providers/AWS/check_status.py @@ -1363,7 +1363,7 @@ def main( # Grafana is part of the optional observability stack — treat its # absence as WARN, not FAIL. trust_endpoints = [ - ("XNAT", "http://127.0.0.1:8104/", ["200", "302"], "FAIL"), + ("XNAT", "http://127.0.0.1:8105/", ["200", "302"], "FAIL"), # Auth is always enforced (FLIP-PT-091): a 200 without # credentials means an unauthenticated PACS — fail. ("Orthanc", "http://127.0.0.1:8042/", ["401"], "FAIL"), diff --git a/deploy/providers/AWS/scripts/forward-trust-all.sh b/deploy/providers/AWS/scripts/forward-trust-all.sh index 42d8beb97..df5ce8319 100755 --- a/deploy/providers/AWS/scripts/forward-trust-all.sh +++ b/deploy/providers/AWS/scripts/forward-trust-all.sh @@ -56,7 +56,7 @@ trap cleanup EXIT INT TERM echo "🔀 Opening SSM port forwards to Trust EC2 ($INSTANCE_ID)..." echo "" -forward 8104 8104 "XNAT" "http://localhost:8104" +forward 8105 8105 "XNAT" "http://localhost:8105" forward 8042 8042 "Orthanc" "http://localhost:8042" forward 8020 8020 "trust-api" "http://localhost:8020/docs" forward 8001 8001 "imaging-api" "http://localhost:8001/docs" diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index c74d9057c..94aab5313 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -184,9 +184,9 @@ trust's PACS team supplies; what they supply is covered above. not a host-published port - ``4242`` * - ``XNAT_WEB_PORT`` - - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; separate from - ``XNAT_PORT`` so the DICOM receiver can be published independently - - whatever ``XNAT_PORT`` is set to + - Host-published port for XNAT's web UI and REST API. Unrelated to DICOM; the receiver is + host-published too, so this must differ from ``XNAT_PORT`` — the deploy refuses a collision + - ``8105`` * - ``PACS_SUPPORTS_EXTENDED_NEGOTIATIONS`` - Whether the PACS supports relational queries / extended negotiation. A capability of the PACS, not a preference — see the table above @@ -206,13 +206,15 @@ The mocked PACS those defaults describe is covered below. Exposing the DICOM Receiver =========================== -The page has said several times that the receiver must be reachable from the PACS. It is off by -default, because the mocked PACS reaches it over the container network and publishing it would be an -unnecessary opening. Turning it on differs by deployment: +The page has said several times that the receiver must be reachable from the PACS. How it is +exposed differs by deployment: -**Compose.** ``make -C trust/xnat up-xnat KIT= REAL_PACS=true`` adds an overlay that publishes -the receiver on the host. ``XNAT_WEB_PORT`` and ``XNAT_PORT`` must then differ, since both are -host-published; the Makefile refuses to deploy if they collide. +**Compose.** The receiver is always published on the host, next to the web UI, so a development +deployment runs the same wiring a real-PACS trust relies on. ``XNAT_WEB_PORT`` and ``XNAT_PORT`` +must therefore differ; the Makefile refuses to deploy if they collide. The mocked Orthanc does not +itself need the publication — it reaches the receiver over the container network — and the mock +deployments expose nothing by it: local development binds on the developer's machine, and the +AWS-hosted mock trusts sit behind security groups with no ingress rules. **Kubernetes.** Three values, all off by default: diff --git a/flip-api/tests/demo_video.py b/flip-api/tests/demo_video.py index f7c4757e1..a938e54c7 100644 --- a/flip-api/tests/demo_video.py +++ b/flip-api/tests/demo_video.py @@ -158,7 +158,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--skip-xnat", action="store_true", help="Skip the XNAT/OHIF segment") parser.add_argument( "--xnat-url", - default="http://127.0.0.1:8104", + default="http://127.0.0.1:8105", help=( "Trust XNAT base URL for segment 3. Keep the IPv4 literal: the XNAT ports are published by " "Docker Swarm ingress, which accepts but never answers ::1 connections — python-requests " @@ -358,7 +358,7 @@ def resolve_xnat_ids( ``?subjectId=&projectId=&experimentId=&experimentLabel=``). Args: - xnat_url (str): Base URL of the trust's XNAT (e.g. http://localhost:8104). + xnat_url (str): Base URL of the trust's XNAT (e.g. http://localhost:8105). username (str): XNAT login. password (str): XNAT password. flip_project_id (str): FLIP project UUID to match against secondary_ID. diff --git a/flip-api/tests/xnat_seg_upload.py b/flip-api/tests/xnat_seg_upload.py index b15472dbe..30d8de5b8 100644 --- a/flip-api/tests/xnat_seg_upload.py +++ b/flip-api/tests/xnat_seg_upload.py @@ -47,7 +47,7 @@ # Both dev trust XNATs. IPv4 literals on purpose: the XNAT ports are published by Docker # Swarm ingress, which accepts but never answers ::1, and localhost resolves there first. -DEFAULT_XNAT_URLS = ("http://127.0.0.1:8104", "http://127.0.0.1:8106") +DEFAULT_XNAT_URLS = ("http://127.0.0.1:8105", "http://127.0.0.1:8107") DCMQI_IMAGE = "qiicr/dcmqi:v1.5.6" ROI_COLLECTION_TYPE = "icr:roiCollectionData" diff --git a/flip-ui/test/cypress/demo/03-xnat-ohif.spec.ts b/flip-ui/test/cypress/demo/03-xnat-ohif.spec.ts index f569a53b5..122f08e0b 100644 --- a/flip-ui/test/cypress/demo/03-xnat-ohif.spec.ts +++ b/flip-ui/test/cypress/demo/03-xnat-ohif.spec.ts @@ -13,7 +13,7 @@ // Demo segment 3 — inside one trust: the imported cohort has landed in XNAT, // and a study is opened in the OHIF DICOM viewer. This segment runs with -// CYPRESS_BASE_URL pointed at the trust's XNAT (e.g. http://localhost:8104); +// CYPRESS_BASE_URL pointed at the trust's XNAT (e.g. http://localhost:8105); // the orchestrator resolves the XNAT project (matched on secondary_ID == // FLIP project id) and an experiment id before launching it. Navigating // straight to /VIEWER/ keeps OHIF in the recorded tab — the XNAT UI's own diff --git a/trust/.env.GSTT.development.example b/trust/.env.GSTT.development.example index f3df96c7b..4b4d5825e 100644 --- a/trust/.env.GSTT.development.example +++ b/trust/.env.GSTT.development.example @@ -13,11 +13,12 @@ TRUST_REGION=London OMOP_DB_PORT=5434 PACS_UI_PORT=8042 # XNAT_PORT is the DICOM SCP receiver port; XNAT_WEB_PORT is the host-published web UI. They were -# one variable until FLIP#993, which is why host 8104 served Tomcat while the DICOM receiver's 8104 -# was an unpublished container port. Keep them equal unless connecting a real PACS: publishing the -# receiver (REAL_PACS=true) needs two distinct host ports. +# one variable until FLIP#993. Both are host-published — the receiver so a real PACS can complete +# the C-STORE return leg of a retrieval, and dev keeps the same wiring — so they must differ; the +# deploy refuses if they collide. XNAT_PORT is the number the PACS dials and DQR matches exactly, +# so it keeps the canonical 8104 and the web UI takes the next port up. XNAT_PORT=8104 -XNAT_WEB_PORT=8104 +XNAT_WEB_PORT=8105 TRUST_DEBUG_PORT=5682 IMAGING_DEBUG_PORT=5681 DATA_ACCESS_DEBUG_PORT=5680 diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index e52ccc26a..1cb05f40a 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -13,10 +13,11 @@ TRUST_REGION=London OMOP_DB_PORT=5436 PACS_UI_PORT=8044 # XNAT_PORT is the DICOM SCP receiver port; XNAT_WEB_PORT is the host-published web UI. They were -# one variable until FLIP#993. XNAT_WEB_PORT defaults to XNAT_PORT, so it only needs setting when -# connecting a real PACS: publishing the receiver (REAL_PACS=true) needs two distinct host ports. +# one variable until FLIP#993. Both are host-published, so they must differ; the deploy refuses if +# they collide. The DICOM receiver keeps this trust's established 8106 and the web UI takes the +# next port up. XNAT_PORT=8106 -XNAT_WEB_PORT=8106 +XNAT_WEB_PORT=8107 TRUST_DEBUG_PORT=5685 IMAGING_DEBUG_PORT=5684 DATA_ACCESS_DEBUG_PORT=5683 diff --git a/trust/.env.example b/trust/.env.example index 93380152c..68fc67f4b 100644 --- a/trust/.env.example +++ b/trust/.env.example @@ -23,11 +23,12 @@ OMOP_DB_PORT=5434 PACS_UI_PORT=8042 # XNAT_PORT is the DICOM SCP receiver port; XNAT_WEB_PORT is the host-published web UI. They were -# one variable until FLIP#993, which is why host 8104 served Tomcat while the DICOM receiver's 8104 -# was an unpublished container port. Keep them equal unless connecting a real PACS: publishing the -# receiver (REAL_PACS=true) needs two distinct host ports. +# one variable until FLIP#993. Both are host-published — the receiver so a real PACS can complete +# the C-STORE return leg of a retrieval, and dev keeps the same wiring — so they must differ; the +# deploy refuses if they collide. XNAT_PORT is the number the PACS dials and DQR matches exactly, +# so it keeps the canonical 8104 and the web UI takes the next port up. XNAT_PORT=8104 -XNAT_WEB_PORT=8104 +XNAT_WEB_PORT=8105 TRUST_DEBUG_PORT=5682 IMAGING_DEBUG_PORT=5681 DATA_ACCESS_DEBUG_PORT=5680 diff --git a/trust/AGENTS.md b/trust/AGENTS.md index c6dc7289f..a85208d4e 100644 --- a/trust/AGENTS.md +++ b/trust/AGENTS.md @@ -12,7 +12,7 @@ Trust services run at each healthcare institution (cloud EC2 or on-prem). All tr | fl-client | — | FL participant (connects outbound to FL server via NLB) | | omop-db | 5432 | Mocked OMOP patient database (PostgreSQL); dir also holds the image build source + populate tooling (#834, see `omop-db/AGENTS.md`) | | orthanc | 8042 | Mocked DICOM PACS server (UI/REST behind HTTP basic auth — kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`; DICOM port 4242 is internal to the trust network and not bound to the host) | -| xnat | 8104 | Mocked neuroimaging platform. `XNAT_PORT` is the **DICOM SCP receiver** port; `XNAT_WEB_PORT` (also 8104 by default) is the host-published web UI. The receiver is published only with `REAL_PACS=true`, so the two must differ then (FLIP#993) | +| xnat | 8104/8105 | Mocked neuroimaging platform. `XNAT_PORT` (8104) is the **DICOM SCP receiver** port; `XNAT_WEB_PORT` (8105) is the web UI. Both are host-published — the receiver so a real PACS can C-STORE back in, dev included so it runs the same wiring — so the two must differ; the deploy refuses a collision (FLIP#993) | | observability | 3000/3100 | Grafana + Loki monitoring stack | ## Kit file structure diff --git a/trust/CLAUDE.md b/trust/CLAUDE.md index 8042a6b05..a327aa3f5 100644 --- a/trust/CLAUDE.md +++ b/trust/CLAUDE.md @@ -12,7 +12,7 @@ Trust services run at each healthcare institution (cloud EC2 or on-prem). All tr | fl-client | — | FL participant (connects outbound to FL server via NLB) | | omop-db | 5432 | Mocked OMOP patient database (PostgreSQL); dir also holds the image build source + populate tooling (#834, see `omop-db/CLAUDE.md`) | | orthanc | 8042 | Mocked DICOM PACS server (UI/REST behind HTTP basic auth — kit file's `ORTHANC_USERNAME`/`ORTHANC_PASSWORD`; DICOM port 4242 is internal to the trust network and not bound to the host) | -| xnat | 8104 | Mocked neuroimaging platform. `XNAT_PORT` is the **DICOM SCP receiver** port; `XNAT_WEB_PORT` (also 8104 by default) is the host-published web UI. The receiver is published only with `REAL_PACS=true`, so the two must differ then (FLIP#993) | +| xnat | 8104/8105 | Mocked neuroimaging platform. `XNAT_PORT` (8104) is the **DICOM SCP receiver** port; `XNAT_WEB_PORT` (8105) is the web UI. Both are host-published — the receiver so a real PACS can C-STORE back in, dev included so it runs the same wiring — so the two must differ; the deploy refuses a collision (FLIP#993) | | observability | 3000/3100 | Grafana + Loki monitoring stack | ## Kit file structure diff --git a/trust/imaging-api/README.md b/trust/imaging-api/README.md index 2a462e8ae..956a64873 100644 --- a/trust/imaging-api/README.md +++ b/trust/imaging-api/README.md @@ -60,7 +60,7 @@ Download and unzip a XNAT dataset to a local folder. ### Imaging Interfaces with XNAT's DICOM Query-Retrieve (DQR) plugin. Full DQR API docs available at -`http://127.0.0.1:8104/xapi/swagger-ui.html#/dicom-query-retrieve-api`. +`http://127.0.0.1:8105/xapi/swagger-ui.html#/dicom-query-retrieve-api`. - Query PACS with an accession number - Queue image retrieval from PACS to an XNAT project diff --git a/trust/xnat/Makefile b/trust/xnat/Makefile index df7685207..27fca90f3 100644 --- a/trust/xnat/Makefile +++ b/trust/xnat/Makefile @@ -85,13 +85,15 @@ endif TRUST_NUM := $(FL_KIT_SLOT_NUMBER) XNAT_STACK := xnat$(TRUST_NUM) XNAT_NETWORK := deploy_trust-network-$(TRUST_NUM) -# Defaults are the Trust_1 allocation, so a single-trust host (EC2 / on-prem) -# needs no XNAT port entries in its kit file. # XNAT_PORT is the DICOM SCP receiver port: the port XNAT binds, registers on its dicomscp -# receiver, and advertises as the C-MOVE destination. XNAT_WEB_PORT is the host-published web UI -# port. They were historically the same variable, which is why host 8104 serves Tomcat while the -# DICOM receiver's 8104 is an unpublished container port (FLIP#993). The web default is unchanged so -# existing kits, docs and SSM port-forwards keep working. +# receiver, advertises as the C-MOVE destination, and publishes on the host so a PACS outside the +# host can complete the C-STORE return leg of a DQR retrieval (FLIP#993). XNAT_WEB_PORT is the +# host-published web UI port. Both are host-published, so every kit must give them distinct values +# (the guard in xnat-reset refuses a collision); the shipped dev allocation is 8104/8105 (GSTT) and +# 8106/8107 (KCH). The web port deliberately defaults to XNAT_PORT rather than to its own literal: +# the two were one variable until the FLIP#993 split, so a kit that predates it sets only XNAT_PORT +# — deriving the web port from it routes that kit into the collision guard, a loud instruction to +# allocate a second port, instead of silently moving its web UI to a number nothing else expects. XNAT_PORT_EFFECTIVE := $(or $(XNAT_PORT),8104) XNAT_WEB_PORT_EFFECTIVE := $(or $(XNAT_WEB_PORT),$(XNAT_PORT_EFFECTIVE)) # XNAT's AE title, applied to the SCP receiver, dqrCallingAe and the C-MOVE destination. @@ -137,14 +139,6 @@ STACK_FILES = -c docker-compose-stack.yml -c docker-compose-stack.development.ym XNAT_PLUGINS_FROM_HOST = 1 endif -# REAL_PACS=true publishes XNAT's DICOM SCP receiver on the host, so a PACS outside the host can -# complete the C-STORE leg of a DQR retrieval. Off by default: the mocked Orthanc reaches -# xnat-web:$(XNAT_PORT_EFFECTIVE) over the container network and needs no published port, and -# publishing one would collide with the web UI while both default to 8104. -ifeq ($(REAL_PACS),true) -STACK_FILES += -c docker-compose-stack.real-pacs.yml -endif - # Guard: a KIT-selected target needs KIT set. define require_kit @if [ -z "$(KIT)" ]; then \ @@ -358,9 +352,11 @@ xnat-reset: @if ! echo "$(XNAT_WEB_PORT_EFFECTIVE)" | grep -qE '^[0-9]+$$'; then \ echo "ERROR: XNAT_WEB_PORT must be numeric for KIT=$(KIT), got '$(XNAT_WEB_PORT_EFFECTIVE)'"; exit 1; \ fi - @if [ "$(REAL_PACS)" = "true" ] && [ "$(XNAT_PORT_EFFECTIVE)" = "$(XNAT_WEB_PORT_EFFECTIVE)" ]; then \ - echo "ERROR: REAL_PACS=true publishes both the web UI and the DICOM receiver on the host,"; \ - echo " so XNAT_WEB_PORT ($(XNAT_WEB_PORT_EFFECTIVE)) and XNAT_PORT ($(XNAT_PORT_EFFECTIVE)) must differ."; \ + @if [ "$(XNAT_PORT_EFFECTIVE)" = "$(XNAT_WEB_PORT_EFFECTIVE)" ]; then \ + echo "ERROR: the web UI and the DICOM receiver are both published on the host, so"; \ + echo " XNAT_WEB_PORT ($(XNAT_WEB_PORT_EFFECTIVE)) and XNAT_PORT ($(XNAT_PORT_EFFECTIVE)) must differ."; \ + echo " They were one variable until FLIP#993 — set both in the kit file"; \ + echo " ($(KIT_FILE)), e.g. XNAT_PORT=8104 (DICOM) and XNAT_WEB_PORT=8105 (web UI)."; \ exit 1; \ fi @echo "Deleting previous XNAT data and creating new directories for $(KIT) ..."; \ diff --git a/trust/xnat/docker-compose-stack.real-pacs.yml b/trust/xnat/docker-compose-stack.real-pacs.yml deleted file mode 100644 index a366c4acb..000000000 --- a/trust/xnat/docker-compose-stack.real-pacs.yml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Opt-in overlay: publish XNAT's DICOM SCP receiver on the host. -# -# Applied by `make up-xnat ... REAL_PACS=true` (trust/xnat/Makefile). Needed only when the PACS is -# outside this host: FLIP retrieves by DQR, so after XNAT issues C-FIND/C-MOVE the PACS opens a -# *new* association back to XNAT to C-STORE the studies. That return leg is inbound, and without a -# published port it never arrives — queries succeed and retrievals silently time out (FLIP#993). -# -# The mocked Orthanc that ships for development does not need this: it reaches xnat-web:${XNAT_PORT} -# over the container network. -# -# XNAT_PORT is used on both sides of the mapping deliberately. DQR matches the C-MOVE destination -# against a registered SCP receiver by exact AE title and port, so the port the PACS connects to must -# be the same number XNAT binds — no translation is possible on this leg. The Makefile refuses to -# deploy if XNAT_WEB_PORT and XNAT_PORT collide. -services: - xnat-web: - ports: - - ${XNAT_PORT}:${XNAT_PORT} diff --git a/trust/xnat/docker-compose-stack.yml b/trust/xnat/docker-compose-stack.yml index decb32562..5b2d803d5 100644 --- a/trust/xnat/docker-compose-stack.yml +++ b/trust/xnat/docker-compose-stack.yml @@ -17,10 +17,21 @@ services: placement: constraints: [node.role == manager] ports: - # Host-published web UI. The DICOM SCP receiver binds ${XNAT_PORT} *inside* the container and - # is published only by docker-compose-stack.real-pacs.yml (make ... REAL_PACS=true), because - # the mocked Orthanc reaches it over the container network (FLIP#993). + # Host-published web UI, and the DICOM SCP receiver next to it. The receiver has to be + # published for a real trust PACS: FLIP retrieves by DQR, so after XNAT issues C-FIND/C-MOVE + # the PACS opens a *new* association back to XNAT to C-STORE the studies — that return leg is + # inbound, and without a published port it never arrives (queries succeed, retrievals silently + # time out; FLIP#993). The mocked Orthanc reaches xnat-web:${XNAT_PORT} over the container + # network and doesn't need the publication, but every deployment gets it so development + # exercises the same wiring a real-PACS trust runs. + # + # XNAT_PORT is used on both sides of the mapping deliberately: DQR matches the C-MOVE + # destination against a registered SCP receiver by exact AE title and port, so the port the + # PACS connects to must be the same number XNAT binds — no translation is possible on this + # leg. Both ports are host-published, so XNAT_WEB_PORT and XNAT_PORT must differ; the Makefile + # refuses to deploy if they collide. - ${XNAT_WEB_PORT}:8080 + - ${XNAT_PORT}:${XNAT_PORT} security_opt: - no-new-privileges:true cap_drop: diff --git a/trust/xnat/tests/test_deploy_wiring.py b/trust/xnat/tests/test_deploy_wiring.py index 2cea323f1..6f397f580 100644 --- a/trust/xnat/tests/test_deploy_wiring.py +++ b/trust/xnat/tests/test_deploy_wiring.py @@ -31,7 +31,6 @@ REPO_ROOT = XNAT_DIR.parents[1] SCRIPT = XNAT_DIR / "xnat" / "config" / "configure-xnat.sh" COMPOSE = XNAT_DIR / "docker-compose-stack.yml" -REAL_PACS_OVERLAY = XNAT_DIR / "docker-compose-stack.real-pacs.yml" INIT_JOB = REPO_ROOT / "deploy" / "providers" / "kubernetes" / "templates" / "xnat-init-job.yaml" @@ -119,37 +118,58 @@ def test_compose_defaults_do_not_cancel_the_scripts_empty_check(): assert not offenders, f"these cancel the script's guard by defaulting an empty value: {sorted(offenders)}" +def published_ports() -> list[str]: + """The compose file's ``ports:`` entries, comments and blanks skipped.""" + entries = [] + in_ports = False + for line in COMPOSE.read_text().splitlines(): + if re.match(r"\s*ports:\s*$", line): + in_ports = True + continue + if not in_ports: + continue + # Comments and blank lines sit between `ports:` and its entries; only a key at the same + # or lower indent ends the block. + if not line.strip() or line.lstrip().startswith("#"): + continue + if not re.match(r"\s*-\s", line): + in_ports = False + continue + entries.append(line.strip().lstrip("- ").strip()) + return entries + + def test_published_ports_are_flat_references(): """``docker stack deploy`` rejects a nested default in a ports entry. ``${XNAT_WEB_PORT:-${XNAT_PORT}}:8080`` fails the whole deploy with "Does not match format 'ports'" — the fallback has to be resolved by the Makefile, not by compose. """ - for compose in (COMPOSE, REAL_PACS_OVERLAY): - in_ports = False - for line in compose.read_text().splitlines(): - if re.match(r"\s*ports:\s*$", line): - in_ports = True - continue - if not in_ports: - continue - # Comments and blank lines sit between `ports:` and its entries; only a key at the same - # or lower indent ends the block. - if not line.strip() or line.lstrip().startswith("#"): - continue - if not re.match(r"\s*-\s", line): - in_ports = False - continue - assert not re.search(r"\$\{[^}]*\$\{", line), ( - f"nested substitution in a ports entry of {compose.name}: {line.strip()}" - ) + for entry in published_ports(): + assert not re.search(r"\$\{[^}]*\$\{", entry), f"nested substitution in a ports entry: {entry}" + + +def test_dicom_receiver_is_published_on_the_port_xnat_binds(): + """The receiver is published unconditionally, and on the same number both sides of the mapping. + + FLIP retrieves by DQR: after the C-MOVE the PACS opens a new association back to XNAT to + C-STORE the studies, and DQR matches that destination against a registered receiver by exact + AE title and port — so no host:container translation is possible on this leg. It used to be an + opt-in overlay; now every deployment publishes it so development runs the same wiring a + real-PACS trust relies on. + """ + entries = published_ports() + assert "${XNAT_PORT}:${XNAT_PORT}" in entries, f"DICOM receiver not published: {entries}" + assert "${XNAT_WEB_PORT}:8080" in entries, f"web UI not published: {entries}" def test_web_port_defaults_to_the_dicom_port(): - """They were one variable until this change, so a kit that sets only XNAT_PORT must still work. + """They were one variable until this change, so a kit that predates it sets only XNAT_PORT. - Defaulting to a literal instead broke the second trust on a host: KCH publishing on 8104 rather - than its own 8106 collides with a running GSTT. + Deriving the web port from it routes such a kit into the collision guard — a loud instruction + to allocate a second port. Defaulting to a literal instead would silently move that kit's web + UI to a number nothing else expects (and broke the second trust on a host: KCH publishing on + the literal rather than its own port collides with a running GSTT). """ resolved = make_vars("XNAT_PORT_EFFECTIVE", "XNAT_WEB_PORT_EFFECTIVE", XNAT_PORT="8106") assert resolved["XNAT_WEB_PORT_EFFECTIVE"] == "8106" @@ -220,22 +240,13 @@ def test_valid_ports_are_accepted(tmp_path): assert result.returncode == 0, result.stdout + result.stderr -def test_real_pacs_refuses_to_publish_both_services_on_one_port(tmp_path): - """REAL_PACS=true publishes the DICOM receiver alongside the web UI; one port cannot serve both.""" - result = run_xnat_reset(tmp_path, REAL_PACS="true", XNAT_PORT="8104", XNAT_WEB_PORT="8104") - assert result.returncode != 0, "the collision was accepted" - assert "must differ" in result.stdout + result.stderr - +def test_refuses_to_publish_both_services_on_one_port(tmp_path): + """The DICOM receiver is published alongside the web UI; one host port cannot serve both. -def test_one_port_is_fine_without_real_pacs(tmp_path): - """Sharing the number is the normal case: only the web UI is published.""" + This is also the fate of a kit that predates the FLIP#993 split and sets only XNAT_PORT: the + web port derives from it (see test_web_port_defaults_to_the_dicom_port), so the deploy stops + here with instructions instead of silently moving one of the services. + """ result = run_xnat_reset(tmp_path, XNAT_PORT="8104", XNAT_WEB_PORT="8104") - assert result.returncode == 0, result.stdout + result.stderr - - -def test_real_pacs_overlay_is_only_added_on_request(): - """Publishing the receiver is an opening; the mocked PACS reaches it over the container network.""" - without = make_vars("STACK_FILES")["STACK_FILES"] - with_overlay = make_vars("STACK_FILES", REAL_PACS="true")["STACK_FILES"] - assert "real-pacs" not in without - assert "docker-compose-stack.real-pacs.yml" in with_overlay + assert result.returncode != 0, "the collision was accepted" + assert "must differ" in result.stdout + result.stderr From 1acbe1e90aa02a4e69a21215a9993f43bb971902 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 19 Aug 2026 23:44:16 +0100 Subject: [PATCH 18/31] docs: align the mocked-PACS paragraph with the always-published receiver (#993) The 'Development: the Mocked PACS' section still described the receiver as something to expose explicitly; it is now published by every Compose deployment so the mocked setup runs the identical wiring. Signed-off-by: at24_bioeng625-pc --- docs/source/components/component-pacs.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index 94aab5313..4c3b25ecd 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -264,9 +264,11 @@ a trust PACS — only the peer differs. It is why the configuration defaults abo * ``PACS_AETITLE=ORTHANC`` — Orthanc's AE title * ``PACS_QR_PORT=4242`` — Orthanc's DICOM port -Because both sit on the same container network, Orthanc reaches XNAT's SCP receiver directly and no -host port needs publishing. A real PACS is outside that network, which is the one material -difference between the two setups and the reason the receiver must be explicitly exposed. +Because both sit on the same container network, Orthanc reaches XNAT's SCP receiver directly and +would not itself need the receiver's host port. A real PACS is outside that network — the one +material difference between the two setups, and the reason the Compose deployment publishes the +receiver everywhere (see above): the mocked setup then runs the identical wiring, rather than a +private variant of it. .. note:: From 764f4c0a2630dd4d0a2d4d73e39f6dac3f1d287e Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Fri, 21 Aug 2026 16:35:49 +0100 Subject: [PATCH 19/31] fix: scope SCP receiver reclamation and guard the DICOM return leg (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reclamation deleted every SCP receiver XNAT reported, so an operator who had registered a second receiver for another local DICOM source lost it on every redeploy and helm upgrade — visible only in a Job pod log the hook-delete-policy discards on success. Scope it to what this script owns: the identifier it stamps on its own receiver ("dqrObjectIdentifier", confirmed present in the GET /xapi/dicomscp payload) plus XNAT's stock "XNAT" receiver. A foreign receiver squatting the configured port now survives to the POST, which fails loud through xnat_curl rather than quietly deleting configuration this deployment may not own — the same choice the PACS-side guard makes. The chart failed the render when a real PACS had no egress rule but rendered clean when the inbound return leg was missing, which produces the failure mode this PR calls the hardest to diagnose: queries succeed, retrievals silently time out. Add the matching ingress guard, requiring an allowedIngressCIDRsWithPorts entry on xnat.web.dicomPort, and a CI step asserting both the omitted entry and the near miss (an operator listing dicomNodePort instead of the container port) are refused. Also drops the commented-out ${PACS_DICOM_PORT} orthanc port mappings left in the trust composes after that variable was retired. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 20 +++++++++ .../kubernetes/templates/network-policy.yaml | 16 +++++++ .../kubernetes/templates/xnat-init-job.yaml | 9 ++++ docs/source/components/component-pacs.rst | 10 +++-- trust/deploy/compose_trust.development.yml | 1 - trust/deploy/compose_trust.production.yml | 1 - trust/xnat/Makefile | 2 +- trust/xnat/tests/test_configure_pacs.py | 44 +++++++++++++++++-- trust/xnat/xnat/config/configure-xnat.sh | 38 +++++++++------- 9 files changed, 117 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 03f72ce8a..911838514 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -155,6 +155,26 @@ jobs: exit 1 fi + # The return leg. Egress alone gets C-FIND and C-MOVE out; the studies come back on a *new* + # association the PACS opens to XNAT, which the default-deny drops unless the ingress + # allowance names the receiver's port. That configuration used to render clean and fail only + # at retrieval time — queries succeed, retrievals silently time out (FLIP#993). The second + # case is the near miss the guard exists for: an operator who lists the NodePort instead of + # the pod's containerPort. Both must be refused, or the render guard is decorative. + - name: Render template (real PACS without matching ingress is refused) + run: | + for port_args in "" "--set networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32 --set networkPolicies.allowedIngressCIDRsWithPorts[0].port=31104"; do + # shellcheck disable=SC2086 # deliberate word splitting: $port_args carries several flags + if helm template trust-release deploy/providers/kubernetes/ \ + --set pacs.host=10.0.0.10 --set pacs.qrPort=8059 \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].port=8059' \ + $port_args > /dev/null 2>&1; then + echo "::error::a PACS with no ingress rule on the receiver's port rendered successfully (${port_args:-no ingress entry})" + exit 1 + fi + done + # Default-deny must survive: the ingress allowance is the one inbound path into a trust, so a # chart that opened it without being asked would silently widen every existing deployment. - name: Render template (default keeps ingress denied) diff --git a/deploy/providers/kubernetes/templates/network-policy.yaml b/deploy/providers/kubernetes/templates/network-policy.yaml index 230587b9c..49ce899df 100644 --- a/deploy/providers/kubernetes/templates/network-policy.yaml +++ b/deploy/providers/kubernetes/templates/network-policy.yaml @@ -61,6 +61,22 @@ spec: {{- if and (not $egressOk) (not .Values.networkPolicies.allowedEgressCIDRs) }} {{- fail (printf "pacs.host is %s but no egress rule reaches its query/retrieve port %v. XNAT could not issue C-FIND or C-MOVE, so retrieval would fail before it began. Add the PACS to networkPolicies.allowedEgressCIDRsWithPorts." .Values.pacs.host .Values.pacs.qrPort) }} {{- end }} +{{- $dicomPort := .Values.xnat.web.dicomPort | int }} +{{- $ingressOk := false }} +{{- range .Values.networkPolicies.allowedIngressCIDRsWithPorts }} + {{- if eq (.port | int) $dicomPort }}{{ $ingressOk = true }}{{ end }} +{{- end }} +{{- /* +The return leg, guarded to the same standard as the outbound one. Retrieval is two connections in +opposite directions: XNAT dials the PACS to C-FIND and C-MOVE, then the PACS opens a *new* +association back to XNAT to C-STORE the studies. Omitting the egress rule already fails this render; +omitting the inbound one used to render clean and produce the failure this chart's comments call the +hardest to diagnose — queries succeed, retrievals silently time out with nothing logged on either +side. Both directions now fail equally loudly, at render, naming the values to set. +*/ -}} +{{- if not $ingressOk }} +{{- fail (printf "pacs.host is %s but networkPolicies.allowedIngressCIDRsWithPorts has no entry on port %v. After XNAT issues C-MOVE the PACS opens a new association back to XNAT to C-STORE the studies; without that allowance it is dropped, so queries succeed and retrievals silently time out. Add the PACS CIDRs on port %v, and make the receiver reachable from outside the cluster with xnat.web.service.type: NodePort plus xnat.web.dicomNodePort. Note the port here is the pod's containerPort (xnat.web.dicomPort, default 8104) and not dicomNodePort: a NetworkPolicy matches the port the pod listens on, after the node has undone the NodePort translation." .Values.pacs.host $dicomPort $dicomPort) }} +{{- end }} {{- end }} {{- if .Values.networkPolicies.allowedIngressCIDRsWithPorts }} # Allow inbound DICOM from the trust PACS. diff --git a/deploy/providers/kubernetes/templates/xnat-init-job.yaml b/deploy/providers/kubernetes/templates/xnat-init-job.yaml index 64c4137dc..ef2b9955b 100644 --- a/deploy/providers/kubernetes/templates/xnat-init-job.yaml +++ b/deploy/providers/kubernetes/templates/xnat-init-job.yaml @@ -52,6 +52,15 @@ spec: # via wait-for-xnat-plugins.sh, then applies site, user, DQR, SCP receiver and PACS # configuration. It short-circuits when the site is already initialised, so re-running on # helm upgrade is safe. + # + # The `cd /data/xnat/config` below is a literal on purpose. XNAT_ROOT is a build ARG only — + # the image never exports it (the Dockerfile exports XNAT_HOME, not XNAT_ROOT), so there is + # no image-provided variable to read it back from at runtime. And /data/xnat is pinned + # chart-wide, not just here: xnat-web's six persistent-volume mountPaths, its XNAT_HOME, its + # -Dxnat.home, and the Container Service ConfigMap's "combined-path-translation" below all + # spell it out. An image rebuilt with a different XNAT_ROOT breaks every one of those with + # or without this line, so a value here would advertise a configurability the chart does not + # have. If XNAT_ROOT ever needs to move, it moves in all of those places at once. image: "{{ .Values.xnat.web.image.repository }}:{{ .Values.xnat.web.image.tag }}" imagePullPolicy: {{ .Values.xnat.web.image.pullPolicy }} command: diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index 4c3b25ecd..44ce67371 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -238,9 +238,13 @@ AWS-hosted mock trusts sit behind security groups with no ingress rules. - cidrs: ["10.0.0.10/32"] port: 8059 -The chart refuses to render a configured PACS with no egress rule, and refuses an ingress entry with -no ``port`` — Kubernetes reads an absent port as *all* ports, which would widen the one inbound path -into the trust from DICOM to everything. +The chart refuses to render a configured PACS with no egress rule, and equally one with no ingress +entry on ``xnat.web.dicomPort``: both directions of the retrieval have to be open, and an omitted +return leg is the failure mode where queries succeed and retrievals silently time out. The port in +the ingress entry is the pod's container port (``xnat.web.dicomPort``, default 8104), not +``dicomNodePort`` — a NetworkPolicy matches after the node has undone the NodePort translation. An +ingress entry with no ``port`` at all is refused too: Kubernetes reads an absent port as *all* +ports, which would widen the one inbound path into the trust from DICOM to everything. .. note:: diff --git a/trust/deploy/compose_trust.development.yml b/trust/deploy/compose_trust.development.yml index 4a40fe398..cd217d348 100644 --- a/trust/deploy/compose_trust.development.yml +++ b/trust/deploy/compose_trust.development.yml @@ -58,7 +58,6 @@ services: dockerfile: Dockerfile restart: always ports: - # - "${PACS_DICOM_PORT}:4242" - "${PACS_UI_PORT}:8042" security_opt: - no-new-privileges:true diff --git a/trust/deploy/compose_trust.production.yml b/trust/deploy/compose_trust.production.yml index e38f62dd2..398448465 100644 --- a/trust/deploy/compose_trust.production.yml +++ b/trust/deploy/compose_trust.production.yml @@ -50,7 +50,6 @@ services: image: ghcr.io/londonaicentre/orthanc:${DOCKER_TAG} restart: always ports: - # - "${PACS_DICOM_PORT}:4242" - "${PACS_UI_PORT}:8042" security_opt: - no-new-privileges:true diff --git a/trust/xnat/Makefile b/trust/xnat/Makefile index 27fca90f3..8e60444df 100644 --- a/trust/xnat/Makefile +++ b/trust/xnat/Makefile @@ -68,7 +68,7 @@ KIT_FILE := $(if $(wildcard ../.env.$(KIT).$(ENV)),../.env.$(KIT).$(ENV),../.env -include $(KIT_FILE) # Kit-file values must reach the shell env, not just Make vars. docker stack # deploy (unlike docker compose) has no --env-file flag; it substitutes -# ${XNAT_ADMIN_USER}, ${PACS_DICOM_PORT}, ${DOCKER_REGISTRY}, etc. in the +# ${XNAT_ADMIN_USER}, ${PACS_UI_PORT}, ${DOCKER_REGISTRY}, etc. in the # compose YAML from the calling shell env only. Without this export, the # stack came up with empty admin creds and configure-xnat.sh would 401/403 # on every auth call, leaving "Site has not yet been configured" forever. diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index fe75d134d..ad4a82258 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -116,6 +116,20 @@ # What the stub reports as already registered when a test does not say otherwise. MOCK_PACS_REGISTRATION = '[{"id":7,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242}]' +# The SCP receivers a test can seed XNAT with, one JSON object each — reclamation is scoped by what +# created a receiver, so which of these survives is the whole point of those tests. +# +# FLIP-owned: created by an earlier run of this script, identified by the identifier it stamps on, +# whatever AE title and port it happens to carry. `GET /xapi/dicomscp` does return `identifier` per +# receiver (verified against a live XNAT), which is what makes ownership readable at all. +FLIP_OWNED_RECEIVER = '{"id":3,"aeTitle":"FLIPXNAT","port":8104,"identifier":"dqrObjectIdentifier"}' +# XNAT's own, created by the webapp on first boot: always titled "XNAT", never carrying FLIP's +# identifier. +STOCK_RECEIVER = '{"id":1,"aeTitle":"XNAT","port":8104,"identifier":"dicomObjectIdentifier"}' +# An operator's, registered by hand for some other local DICOM source: neither marker, so nothing +# here owns it. +OPERATOR_RECEIVER = '{"id":9,"aeTitle":"WARDCT","port":11113,"identifier":"dicomObjectIdentifier"}' + def run_configure(tmp_path, env_overrides=None, pacs_state=None, scp_state=None): """Runs configure-xnat.sh against the stub and returns (exit code, payloads, combined output). @@ -142,7 +156,9 @@ def run_configure(tmp_path, env_overrides=None, pacs_state=None, scp_state=None) pacs_file = tmp_path / "pacs.json" pacs_file.write_text(pacs_state if pacs_state is not None else "[]") scp_file = tmp_path / "scp.json" - scp_file.write_text(scp_state if scp_state is not None else '[{"id":1,"aeTitle":"XNAT","port":8104}]') + # Default: XNAT's stock receiver, as the webapp creates it on first boot — title "XNAT" and the + # plain DICOM identifier, not FLIP's. + scp_file.write_text(scp_state if scp_state is not None else f"[{STOCK_RECEIVER}]") env = { **os.environ, @@ -320,7 +336,7 @@ def test_receiver_on_our_port_is_reclaimed_whatever_it_is_called(tmp_path): """Renaming the AE title must not strand the old receiver fighting for the same port.""" code, payloads, output = run_configure( tmp_path, - scp_state='[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]', + scp_state=f"[{FLIP_OWNED_RECEIVER}]", ) assert code == 0, output assert "(id 3)" in output @@ -328,6 +344,28 @@ def test_receiver_on_our_port_is_reclaimed_whatever_it_is_called(tmp_path): assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)) +def test_a_receiver_this_script_does_not_own_survives_the_reconcile(tmp_path): + """An operator's second receiver must not be collateral damage of every redeploy. + + Reclamation is scoped to what FLIP created (identifier ``dqrObjectIdentifier``) plus XNAT's + stock receiver. A receiver registered by hand for another local DICOM source carries neither + marker, and deleting it would be silent: on Kubernetes the only record is a Job pod log the + hook-delete-policy discards on success. + """ + code, payloads, output = run_configure( + tmp_path, + scp_state=f"[{OPERATOR_RECEIVER}, {FLIP_OWNED_RECEIVER}, {STOCK_RECEIVER}]", + ) + assert code == 0, output + + deleted = deletes(payloads) + assert not any(u.endswith("/xapi/dicomscp/9") for u in deleted), "deleted a receiver FLIP does not own" + assert "Removing SCP receiver 'WARDCT" not in output + # The two it does own still go, or the re-created receiver fights the old one for the port. + assert any(u.endswith("/xapi/dicomscp/3") for u in deleted), "left FLIP's own stale receiver behind" + assert any(u.endswith("/xapi/dicomscp/1") for u in deleted), "left XNAT's stock receiver behind" + + def test_foreign_pacs_registrations_are_removed(tmp_path): """A trust XNAT retrieves from one PACS; a stale entry would leave DQR's choice ambiguous.""" code, payloads, output = run_configure( @@ -349,7 +387,7 @@ def test_receiver_is_reclaimed_when_port_and_title_both_change(tmp_path): code, payloads, output = run_configure( tmp_path, {"XNAT_PORT": "11112", "XNAT_AETITLE": "FLIPXNAT2"}, - scp_state='[{"id":3,"aeTitle":"FLIPXNAT","port":8104}]', + scp_state=f"[{FLIP_OWNED_RECEIVER}]", ) assert code == 0, output assert any(u.endswith("/xapi/dicomscp/3") for u in deletes(payloads)), ( diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index 65beb5de9..fe5ac2dba 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -332,26 +332,33 @@ xnat_curl -X PUT "$XNAT_URL/xapi/anonymize/site/enabled" \ -H "Content-Type: application/json" \ -d 'true' -# Remove any pre-existing SCP receiver we are about to replace, matched on **the port we bind** -# rather than only on AE title. The receiver this script owns is defined by that port, so anything -# else listening on it has to go regardless of what it is called — including a receiver left behind -# by an earlier run under a different XNAT_AETITLE. Matching on title alone orphaned the old entry -# on a rename, leaving two receivers fighting over one port. Also removes XNAT's stock default -# receiver (always "XNAT", created by the webapp on first boot) wherever it is bound. +# Remove the pre-existing SCP receivers this script owns, so the POST below can re-create exactly +# one. Ownership is a property of the receiver itself, not of the port it binds or the title it +# carries: every receiver FLIP creates is stamped with identifier "dqrObjectIdentifier" by the POST +# below, and XNAT's stock receiver — created by the webapp on first boot, always called "XNAT" — is +# the other one that has to go. `GET /xapi/dicomscp` returns `identifier` per receiver, so both +# markers are readable from the listing. # -# `--arg`/`--argjson` keep the values as data rather than splicing them into the filter, so a title -# containing jq syntax cannot change what is selected. +# Matching instead on "our port or our AE title" left an orphan whenever both moved in one change: +# an existing FLIPXNAT:8104 under new config XNAT_AETITLE=FLIPXNAT2 XNAT_PORT=11112 matched neither, +# so the old receiver stayed enabled and bound (FLIP#993). +# +# A receiver an operator registered for some other local DICOM source carries neither marker and is +# left alone — it would otherwise be deleted on every redeploy and `helm upgrade`, with the deletion +# visible only in a Job pod log that is discarded on success. If such a receiver is squatting the +# port we are about to bind, it survives to the POST below, which then fails loud through xnat_curl: +# deliberately the same choice the PACS-side guard makes further down — refuse rather than silently +# delete configuration this deployment may not own. +# +# Both markers are literals in the filter, so nothing operator-supplied is spliced into jq. response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" "$XNAT_URL/xapi/dicomscp") if [[ -z "$response" || "$response" == "[]" ]]; then echo "No SCP receivers found." else - # Every receiver, not a filtered subset. Matching on "our port or our AE title" left an orphan - # whenever both moved in one change — an existing FLIPXNAT:8104 with new config - # XNAT_AETITLE=FLIPXNAT2 XNAT_PORT=11112 matched neither, so the old receiver stayed enabled and - # bound. XNAT carries exactly one FLIP-owned receiver and this script owns it, so the honest - # filter is all of them (FLIP#993). - stale_ids=$(printf '%s' "$response" | jq -r '.[] | "\(.id):\(.aeTitle):\(.port)"') + stale_ids=$(printf '%s' "$response" \ + | jq -r '.[] | select(.identifier == "dqrObjectIdentifier" or .aeTitle == "XNAT") + | "\(.id):\(.aeTitle):\(.port)"') if [[ -z "$stale_ids" ]]; then echo "No existing SCP receiver to replace." @@ -365,7 +372,8 @@ else fi fi -# Configure SCP receiver to have dqrObjectIdentifier as the identifier (the default is not) +# Configure SCP receiver to have dqrObjectIdentifier as the identifier (the default is not). That +# identifier is also what marks the receiver as FLIP-owned for the reclamation above. echo "Configuring SCP receiver '${XNAT_AETITLE}' on port ${XNAT_PORT}..." xnat_curl -X POST "$XNAT_URL/xapi/dicomscp" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ From 8469c5bf35ed2848e6d72a7966add142427e8a83 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 26 Aug 2026 11:26:36 +0100 Subject: [PATCH 20/31] review: refuse a real-PACS render on a ClusterIP service; document the pacs-id cache race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NetworkPolicy guards admit the C-STORE leg as far as the pod, but with xnat.web.service.type left ClusterIP both NodePort blocks in xnat-web.yaml silently no-op, so a chart could satisfy every guard and still render with no path a PACS packet can take. Refuse ClusterIP at render (not 'anything but NodePort' — a LoadBalancer receiver stays valid), and pin both directions in test_helm_chart.yml: ClusterIP + real PACS is refused, LoadBalancer keeps rendering. Also record why resolve_pacs_id's module-global cache is deliberately unlocked: cold-cache threads race to the same fixed id, so the loser only repeats one metadata round-trip. Verified locally with helm template: ClusterIP+real-PACS refused with the new message; NodePort, LoadBalancer and default (mock orthanc) renders all pass. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 25 +++++++++++++++++++ .../kubernetes/templates/network-policy.yaml | 12 +++++++++ .../imaging_api/services/imaging.py | 4 +++ 3 files changed, 41 insertions(+) diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 911838514..2396ed57c 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -175,6 +175,31 @@ jobs: fi done + # One layer up from the NetworkPolicy: with every egress/ingress rule in place but the + # Service left at its ClusterIP default, xnat-web.yaml's NodePort blocks silently no-op and + # the receiver is unreachable from outside the cluster — a render that satisfies both guards + # above yet gives the PACS's C-STORE leg no path to take. The guard refuses ClusterIP + # specifically (not "anything but NodePort") so a LoadBalancer receiver stays valid. + - name: Render template (real PACS on a ClusterIP service is refused) + run: | + if helm template trust-release deploy/providers/kubernetes/ \ + --set pacs.host=10.0.0.10 --set pacs.qrPort=8059 \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].port=8059' > /dev/null 2>&1; then + echo "::error::a real PACS with the Service left ClusterIP rendered successfully — the receiver is unreachable" + exit 1 + fi + # And the LoadBalancer allowance must keep rendering, or on-prem MetalLB trusts break. + helm template trust-release deploy/providers/kubernetes/ \ + --set xnat.web.service.type=LoadBalancer \ + --set pacs.host=10.0.0.10 --set pacs.qrPort=8059 \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].port=8059' > /dev/null + # Default-deny must survive: the ingress allowance is the one inbound path into a trust, so a # chart that opened it without being asked would silently widen every existing deployment. - name: Render template (default keeps ingress denied) diff --git a/deploy/providers/kubernetes/templates/network-policy.yaml b/deploy/providers/kubernetes/templates/network-policy.yaml index 49ce899df..6fcc7245b 100644 --- a/deploy/providers/kubernetes/templates/network-policy.yaml +++ b/deploy/providers/kubernetes/templates/network-policy.yaml @@ -77,6 +77,18 @@ side. Both directions now fail equally loudly, at render, naming the values to s {{- if not $ingressOk }} {{- fail (printf "pacs.host is %s but networkPolicies.allowedIngressCIDRsWithPorts has no entry on port %v. After XNAT issues C-MOVE the PACS opens a new association back to XNAT to C-STORE the studies; without that allowance it is dropped, so queries succeed and retrievals silently time out. Add the PACS CIDRs on port %v, and make the receiver reachable from outside the cluster with xnat.web.service.type: NodePort plus xnat.web.dicomNodePort. Note the port here is the pod's containerPort (xnat.web.dicomPort, default 8104) and not dicomNodePort: a NetworkPolicy matches the port the pod listens on, after the node has undone the NodePort translation." .Values.pacs.host $dicomPort $dicomPort) }} {{- end }} +{{- /* +The NetworkPolicy admits the C-STORE leg only as far as the pod; the packet still needs a route from +outside the cluster to that pod. templates/xnat-web.yaml publishes the DICOM receiver beyond the +cluster only when service.type is NodePort AND dicomNodePort is set — on ClusterIP both blocks +silently no-op, so a chart could satisfy every guard above and still render with no path a PACS +packet can take: the same queries-succeed/retrievals-time-out failure, one layer up. Refuse +ClusterIP specifically rather than demanding NodePort, because a LoadBalancer service (MetalLB and +friends) is an equally valid way to make the receiver reachable and must not be rejected. +*/ -}} +{{- if eq .Values.xnat.web.service.type "ClusterIP" }} +{{- fail (printf "pacs.host is %s but xnat.web.service.type is ClusterIP, so the DICOM receiver is unreachable from outside the cluster and the C-STORE return leg the PACS opens after C-MOVE can never arrive. Set xnat.web.service.type: NodePort plus xnat.web.dicomNodePort (equal to xnat.web.dicomPort), or expose the receiver through a LoadBalancer service." .Values.pacs.host) }} +{{- end }} {{- end }} {{- if .Values.networkPolicies.allowedIngressCIDRsWithPorts }} # Allow inbound DICOM from the trust PACS. diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index 7d8a4d5e0..bc495a71b 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -40,6 +40,10 @@ # accession number. Note the cache outlives the registration: if the PACS is re-registered while # imaging-api stays up — configure-xnat.sh deletes and recreates it when the AE title changes — the # cached id points at a deleted entry, so it is cleared when XNAT reports the PACS missing. +# Deliberately unlocked: routes here are sync defs dispatched to a threadpool, so two cold-cache +# threads can race the read-check-set — but both resolve the same fixed id from the same XNAT +# roster, so the loser merely repeats one metadata round-trip. A lock would serialise every request +# to save that one call. _resolved_pacs_id: int | None = None From 277ec6b747a5c581e1eefc7c73b5b2d67215ab6a Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Thu, 27 Aug 2026 17:01:56 +0100 Subject: [PATCH 21/31] fix(xnat): compare every kit-managed PACS field in the in-place drift check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update-in-place short-circuit compared only host and queryRetrievePort, so a kit change to PACS_LABEL or PACS_SUPPORTS_EXTENDED_NEGOTIATIONS logged 'leaving as-is' and never landed — including on the k8s init job's re-run at every helm upgrade, where flipping the documented extended-negotiations lever is exactly the operation an operator would attempt. Drift is now judged by jq over the kit-managed field set (host, queryRetrievePort, label, supportsExtendedNegotiations) against the desired payload, keeping the idempotent leave-as-is path for true no-ops and naming the drifted fields in the update log. The mock registration in the tests now carries the full field set a live GET /xapi/pacs returns, and a regression test pins the flag-only-drift case to a PUT. Signed-off-by: at24_bioeng625-pc --- trust/xnat/tests/test_configure_pacs.py | 31 ++++++++++++++++++++++-- trust/xnat/xnat/config/configure-xnat.sh | 16 +++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index ad4a82258..6556b93c4 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -113,8 +113,13 @@ "XNAT_PLUGIN_READINESS_POLL_SECONDS": "0", } -# What the stub reports as already registered when a test does not say otherwise. -MOCK_PACS_REGISTRATION = '[{"id":7,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242}]' +# What the stub reports as already registered when a test does not say otherwise. Carries every +# kit-managed field the drift check compares (a live XNAT GET /xapi/pacs returns them all), with +# values matching the script's defaults so the entry reads as in-sync unless a test drifts one. +MOCK_PACS_REGISTRATION = ( + '[{"id":7,"aeTitle":"ORTHANC","host":"orthanc","queryRetrievePort":4242,' + '"label":"Test PACS instance","supportsExtendedNegotiations":true}]' +) # The SCP receivers a test can seed XNAT with, one JSON object each — reclamation is scoped by what # created a receiver, so which of these survives is the whole point of those tests. @@ -304,6 +309,28 @@ def test_registration_updates_in_place_when_host_or_port_drift(tmp_path): assert pacs["queryRetrievePort"] == 8059 +def test_registration_updates_in_place_when_flags_drift(tmp_path): + """Drift in a kit-managed field other than host/port must also land, not log "leaving as-is". + + supportsExtendedNegotiations is the documented lever for a real PACS that rejects extended + negotiation, and the k8s init job re-runs this script on every helm upgrade against persistent + XNAT data — a host/port-only comparison keeps the old flag forever with no signal. + """ + code, payloads, output = run_configure( + tmp_path, + {"PACS_SUPPORTS_EXTENDED_NEGOTIATIONS": "false"}, + pacs_state=MOCK_PACS_REGISTRATION, + ) + assert code == 0, output + assert "leaving as-is" not in output + assert "supportsExtendedNegotiations" in output + + pacs = payload_for(payloads, "/xapi/pacs") + assert pacs["supportsExtendedNegotiations"] is False + assert pacs["host"] == "orthanc" + assert pacs["queryRetrievePort"] == 4242 + + def test_matching_registration_is_left_alone(tmp_path): """An unchanged registration must not be rewritten on every redeploy.""" code, payloads, output = run_configure(tmp_path, pacs_state=MOCK_PACS_REGISTRATION) diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index fe5ac2dba..bd630e343 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -495,13 +495,21 @@ if [[ -z "$pacs_entry" ]]; then -d "$pacs_payload" else PACS_ID=$(printf '%s' "$pacs_entry" | jq -r '.id') - current_host=$(printf '%s' "$pacs_entry" | jq -r '.host // empty') - current_port=$(printf '%s' "$pacs_entry" | jq -r '.queryRetrievePort // empty') - if [[ "$current_host" == "${PACS_HOST}" && "$current_port" == "${PACS_QR_PORT}" ]]; then + # Drift is judged over every kit-managed field, not just host+port: label and + # supportsExtendedNegotiations come from the kit too, and the k8s init job re-runs this script on + # every helm upgrade against persistent XNAT data — a narrower comparison logs "leaving as-is" + # and keeps the old flag forever, exactly the silently-ignored kit change the in-place update + # exists to prevent. Comparison against the desired payload rather than the env vars keeps type + # handling in jq (port and the flag are JSON number/boolean in both documents, not strings). + pacs_drift=$(jq -cn --argjson entry "$pacs_entry" --argjson desired "$pacs_payload" \ + '[("host","queryRetrievePort","label","supportsExtendedNegotiations") + | select($entry[.] != $desired[.])]') + + if [[ "$pacs_drift" == "[]" ]]; then echo "PACS '${PACS_AETITLE}' already registered at ${PACS_HOST}:${PACS_QR_PORT} — leaving as-is." else - echo "PACS '${PACS_AETITLE}' registered at ${current_host}:${current_port}," \ + echo "PACS '${PACS_AETITLE}' drifted on $(printf '%s' "$pacs_drift" | jq -r 'join(", ")')," \ "updating to ${PACS_HOST}:${PACS_QR_PORT}..." xnat_curl -X PUT "$XNAT_URL/xapi/pacs/${PACS_ID}" \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ From 66ea8ae09ca9d0c2e8ab82916e06bdfc301374b4 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 2 Sep 2026 00:55:37 +0100 Subject: [PATCH 22/31] fix(trust): refuse an explicitly-empty XNAT_AETITLE at the deploy entrypoint (#993) The AE title has two kinds of consumer with opposite empty-value behaviour. The XNAT stack passes an empty value through on purpose (bare-dash) so configure-xnat.sh's own guard reports it. The trust compose feeds Orthanc's modality entry and imaging-api, which default the value in code and have no empty-value check, so it keeps the colon-dash form: a bare-dash pass-through there would register an empty AE title rather than fail. What was missing was a loud stop for the trust side. up-trust and up-trust-ec2 now depend on require-xnat-aetitle, a make-level $(error) that fires when XNAT_AETITLE is set but empty -- from the kit file, the command line or the environment -- before the data fixtures, compose or the XNAT stack run. An undefined value still takes the XNAT default silently in both places, and down-trust / build / help are unaffected because the check lives in the recipe rather than at parse time. The deploy-wiring tests now cover the trust compose layer too: both compose_trust files must wire XNAT_AETITLE to exactly its two consumers in the colon-dash form with the same default the XNAT stack substitutes, the guard must refuse an explicitly-empty value (kit, blank, command line) without printing a single docker command, and an absent or commented-out line must still pass. The compose files now say why their form is the reverse of the stack's. Signed-off-by: at24_bioeng625-pc --- trust/Makefile | 29 ++++++- trust/deploy/compose_trust.development.yml | 8 ++ trust/deploy/compose_trust.production.yml | 8 ++ trust/xnat/tests/test_deploy_wiring.py | 94 ++++++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/trust/Makefile b/trust/Makefile index 938ffd584..6d99c0c41 100644 --- a/trust/Makefile +++ b/trust/Makefile @@ -13,7 +13,7 @@ .PHONY: build dev prod clean stop up down up-trust down-trust restart-trust up-trust-ec2 down-trust-ec2 \ up-fl-clients up-fl-clients-kit down-fl-clients down-fl-clients-kit \ create-networks remove-networks recreate-networks tests \ - update-omop-data update-orthanc-data integration_test + update-omop-data update-orthanc-data integration_test require-xnat-aetitle # PROD selects the compose-file suffix (__DCKR_SUFFIX: stag collapses to the # production compose) and the kit-file env token (ENV: stag stays distinct, so # a stag kit is .env..stag, never confused with .env..production). @@ -257,6 +257,26 @@ define require_kit_file fi endef +# Guard: refuse an explicitly-empty XNAT_AETITLE before either stack sees it. The value has two +# kinds of consumer with opposite empty-value behaviour, and the kit file is the one place an +# operator sets it: +# - the XNAT stack (xnat/Makefile → configure-xnat.sh) passes an empty value through on purpose +# (XNAT_AETITLE_EFFECTIVE) so the script's own ${VAR-default} guard is what reports it; +# - the trust compose (Orthanc's DICOM_MODALITIES entry, imaging-api's XNAT_AETITLE) uses +# ${XNAT_AETITLE:-XNAT}, because those consumers default the value in code and have no +# empty-value check of their own — a bare-dash pass-through would register an empty AE title +# rather than fail (see the note above imaging-api's environment in deploy/compose_trust.*.yml). +# So the explicit empty is refused here, once, before compose or the stack runs, and both sides +# keep agreeing on the AE title DQR matches the C-MOVE destination against. An undefined +# XNAT_AETITLE (line absent or commented out in the kit) still takes the XNAT default in both +# places. $(origin) rather than $(if $(XNAT_AETITLE),…): the latter cannot tell unset from empty, +# which is exactly the distinction that matters. Evaluated in the recipe, not at parse time, so +# down-trust / build / help still work against a kit that carries the bad value. +XNAT_AETITLE_SET_EMPTY := $(if $(filter undefined,$(origin XNAT_AETITLE)),,$(if $(strip $(XNAT_AETITLE)),,yes)) +XNAT_AETITLE_SOURCE := $(if $(filter file,$(origin XNAT_AETITLE)),$(if $(filter /%,$(KIT_FILE)),$(KIT_FILE),trust/$(KIT_FILE)),the $(origin XNAT_AETITLE)) +require-xnat-aetitle: + @$(if $(XNAT_AETITLE_SET_EMPTY),$(error XNAT_AETITLE is set but empty in $(XNAT_AETITLE_SOURCE). It is XNAT's own AE title (SCP receiver, DQR calling AE and C-MOVE destination): set it, or drop the line to take the default 'XNAT'),:) + # Guard: refuse to act on the compose project this kit resolves to when it is already held # by a DIFFERENT trust. TRUST_PROJECT is trust$(FL_KIT_SLOT_NUMBER), and slots are handed # out by the hub from its own database — so a second hub (FLIP_INSTANCE, FLIP#957) @@ -324,8 +344,9 @@ build: # Pull/build behaviour is governed by $(UP_PULL_FLAGS) (defined in the # root Makefile and inherited here): pulls fresh FL images when # DOCKER_FL_REGISTRY is set, builds from source on BUILD=true, no-op -# otherwise. -up-trust: update-omop-data update-orthanc-data create-networks +# otherwise. require-xnat-aetitle goes first so a bad kit is refused before the data +# fixtures are fetched, not just before compose runs. +up-trust: require-xnat-aetitle update-omop-data update-orthanc-data create-networks $(require_kit_file) @echo "🚢 Starting Trust services (KIT=$(KIT))..." @echo "⚙️ Using kit file trust/$(KIT_FILE)" @@ -363,7 +384,7 @@ restart-trust: down-trust up-trust # Together these defend the deploy against a kit seeded NUM_AVAILABLE_GPUS=1 (the # template default). A GPU-equipped EC2 instance would drop the override and add # $(GPU_OVERRIDE) back. -up-trust-ec2: +up-trust-ec2: require-xnat-aetitle $(require_kit_file) $(check_slot_not_taken) @echo "🚢 Starting Trust services on EC2 (KIT=$(KIT))..." diff --git a/trust/deploy/compose_trust.development.yml b/trust/deploy/compose_trust.development.yml index cf0f7c1e9..a2cdec07b 100644 --- a/trust/deploy/compose_trust.development.yml +++ b/trust/deploy/compose_trust.development.yml @@ -70,6 +70,9 @@ services: environment: ORTHANC__REGISTERED_USERS: | {"${ORTHANC_USERNAME}": "${ORTHANC_PASSWORD}"} + # Colon-dash on purpose: Orthanc has no empty-AET check, so a bare-dash pass-through of an + # explicitly-empty kit value would register an empty AE title rather than fail. The + # explicit empty is refused by trust/Makefile (require-xnat-aetitle) before this runs. ORTHANC__DICOM_MODALITIES: | {"XNAT": {"AET": "${XNAT_AETITLE:-XNAT}", "Host": "xnat-web", "Port": "${XNAT_PORT}"}} @@ -107,6 +110,11 @@ services: XNAT_PORT: ${XNAT_PORT} # Must match what configure-xnat.sh registered: this becomes the C-MOVE destination, # which DQR matches against a registered SCP receiver by exact AE title and port. + # Colon-dash, like the code-defaulted constants above: imaging_api/config.py defaults + # XNAT_AETITLE and has no empty-value check, so bare-dash would inject an empty AE title + # rather than fail. An explicitly-empty kit value is refused by trust/Makefile + # (require-xnat-aetitle) before compose runs; the XNAT stack keeps its own bare-dash + # form because configure-xnat.sh guards the empty itself. XNAT_AETITLE: ${XNAT_AETITLE:-XNAT} XNAT_SERVICE_USER: ${XNAT_SERVICE_USER} XNAT_SERVICE_PASSWORD: ${XNAT_SERVICE_PASSWORD} diff --git a/trust/deploy/compose_trust.production.yml b/trust/deploy/compose_trust.production.yml index 398448465..f15fc9579 100644 --- a/trust/deploy/compose_trust.production.yml +++ b/trust/deploy/compose_trust.production.yml @@ -69,6 +69,9 @@ services: environment: ORTHANC__REGISTERED_USERS: | {"${ORTHANC_USERNAME}": "${ORTHANC_PASSWORD}"} + # Colon-dash on purpose: Orthanc has no empty-AET check, so a bare-dash pass-through of an + # explicitly-empty kit value would register an empty AE title rather than fail. The + # explicit empty is refused by trust/Makefile (require-xnat-aetitle) before this runs. ORTHANC__DICOM_MODALITIES: | {"XNAT": {"AET": "${XNAT_AETITLE:-XNAT}", "Host": "xnat-web", "Port": "${XNAT_PORT}"}} @@ -95,6 +98,11 @@ services: XNAT_PORT: ${XNAT_PORT} # Must match what configure-xnat.sh registered: this becomes the C-MOVE destination, # which DQR matches against a registered SCP receiver by exact AE title and port. + # Colon-dash, like the code-defaulted constants above: imaging_api/config.py defaults + # XNAT_AETITLE and has no empty-value check, so bare-dash would inject an empty AE title + # rather than fail. An explicitly-empty kit value is refused by trust/Makefile + # (require-xnat-aetitle) before compose runs; the XNAT stack keeps its own bare-dash + # form because configure-xnat.sh guards the empty itself. XNAT_AETITLE: ${XNAT_AETITLE:-XNAT} XNAT_SERVICE_USER: ${XNAT_SERVICE_USER} XNAT_SERVICE_PASSWORD: ${XNAT_SERVICE_PASSWORD} diff --git a/trust/xnat/tests/test_deploy_wiring.py b/trust/xnat/tests/test_deploy_wiring.py index 6f397f580..6ad47a3ee 100644 --- a/trust/xnat/tests/test_deploy_wiring.py +++ b/trust/xnat/tests/test_deploy_wiring.py @@ -17,6 +17,12 @@ exported an empty AE title, and the compose file's ``${VAR:-default}`` cancelled the script's own fail-loud guard. None of those are visible from inside the script. +The AE title also has consumers the script never sees — Orthanc's mocked-modality entry and +imaging-api, wired in ``trust/deploy/compose_trust.{development,production}.yml`` — and they take +the opposite form (``${VAR:-default}``) on purpose, because they default the value in code and have +no empty-value check of their own. The tests at the end cover that layer: the two forms are +reconciled by ``trust/Makefile`` refusing an explicitly-empty value before either stack runs. + These run make and read the deployment files; they never invoke docker. """ @@ -32,6 +38,9 @@ SCRIPT = XNAT_DIR / "xnat" / "config" / "configure-xnat.sh" COMPOSE = XNAT_DIR / "docker-compose-stack.yml" INIT_JOB = REPO_ROOT / "deploy" / "providers" / "kubernetes" / "templates" / "xnat-init-job.yaml" +TRUST_DIR = XNAT_DIR.parent +# The trust-core compose files: the AE title's consumers outside the XNAT stack. +TRUST_COMPOSES = [TRUST_DIR / "deploy" / f"compose_trust.{env}.yml" for env in ("development", "production")] def make_vars(*names: str, **overrides: str) -> dict[str, str]: @@ -196,6 +205,91 @@ def test_ae_title_set_empty_stays_empty(): assert make_vars("XNAT_AETITLE_EFFECTIVE", XNAT_AETITLE="")["XNAT_AETITLE_EFFECTIVE"] == "" +# ── The trust compose layer: Orthanc and imaging-api ───────────────────────────────────────────── +# +# The XNAT stack passes an empty AE title through (bare-dash, tested above) because +# configure-xnat.sh guards it. The trust compose does the opposite: imaging_api/config.py defaults +# XNAT_AETITLE and Orthanc accepts any modality AET, so neither would report an empty value — a +# bare-dash there would register an empty AE title rather than fail. What makes an explicit empty +# fail loud for BOTH is trust/Makefile refusing it before either stack runs. + + +def trust_compose_ae_title_references() -> dict[str, list[str]]: + """Every ``${XNAT_AETITLE…}`` substitution in each trust compose file, verbatim.""" + return {compose.name: re.findall(r"\$\{XNAT_AETITLE[^}]*\}", compose.read_text()) for compose in TRUST_COMPOSES} + + +def dry_run_trust_up(tmp_path, kit_lines: str, *overrides: str) -> subprocess.CompletedProcess: + """Dry-runs ``make up-trust`` from trust/ against a throwaway kit file. + + ``-n`` prints recipes instead of running them, which is enough here: the guard is a make-level + ``$(error)`` evaluated while the recipe is expanded, so it decides under ``-n`` exactly as it + does for real, and nothing docker-shaped runs. The kit file is handed in as ``KIT_FILE`` so the + value arrives the way an operator's does — ``-include``d, origin ``file`` — rather than on the + command line. + """ + kit = tmp_path / ".env.Probe.development" + kit.write_text(kit_lines) + return subprocess.run( + ["make", "-n", "up-trust", "KIT=Probe", f"KIT_FILE={kit}", "PROD=", *overrides], + cwd=TRUST_DIR, + capture_output=True, + text=True, + timeout=120, + ) + + +def test_trust_compose_defaults_the_ae_title_for_its_code_defaulting_consumers(): + """The two consumers outside the XNAT stack take ``${XNAT_AETITLE:-XNAT}`` — colon-dash — by design. + + The reverse of test_compose_defaults_do_not_cancel_the_scripts_empty_check, and for the reverse + reason: nothing downstream of these lines checks for an empty AE title, so passing one through + would not fail loud, it would hand Orthanc an ``"AET": ""`` modality and imaging-api an empty + C-MOVE destination. The default must also be the one the XNAT stack substitutes for an unset + value, or the two sides disagree on the title DQR matches the return leg against. + """ + for name, found in trust_compose_ae_title_references().items(): + assert len(found) == 2, f"{name} should wire XNAT_AETITLE to Orthanc's modality entry and imaging-api: {found}" + assert set(found) == {"${XNAT_AETITLE:-XNAT}"}, f"{name} changed the AE title's form: {found}" + assert make_vars("XNAT_AETITLE_EFFECTIVE")["XNAT_AETITLE_EFFECTIVE"] == "XNAT" + + +@pytest.mark.parametrize( + ("kit_lines", "overrides"), + [ + ("XNAT_AETITLE=\n", ()), + ("XNAT_AETITLE= \n", ()), + ("XNAT_AETITLE=FLIPXNAT\n", ("XNAT_AETITLE=",)), + ], + ids=["kit-empty", "kit-blank", "command-line-empty"], +) +def test_explicitly_empty_ae_title_is_refused_before_anything_runs(tmp_path, kit_lines, overrides): + """An operator who clears the value gets told so, once, before either stack is touched. + + This is the guard that reconciles the two forms: the XNAT stack would report the empty value + from inside configure-xnat.sh after its own deploy, and the trust compose would never report it + at all. Refusing at the entrypoint keeps both from running with a title the other did not get. + """ + result = dry_run_trust_up(tmp_path, kit_lines, *overrides) + assert result.returncode != 0, "an empty XNAT_AETITLE was accepted:\n" + result.stdout + output = result.stdout + result.stderr + assert "XNAT_AETITLE" in output + source = str(tmp_path / ".env.Probe.development") if not overrides else "command line" + assert source in output, f"the refusal does not say where the empty value came from:\n{output}" + assert "docker" not in result.stdout, "something was run (or would have been) before the guard:\n" + result.stdout + + +@pytest.mark.parametrize( + "kit_lines", + ["", "# XNAT_AETITLE=XNAT\n", "XNAT_AETITLE=FLIPXNAT\n"], + ids=["absent", "commented-out", "configured"], +) +def test_unset_or_configured_ae_title_passes_the_guard(tmp_path, kit_lines): + """The guard distinguishes unset from empty: an absent line still takes the XNAT default silently.""" + result = dry_run_trust_up(tmp_path, kit_lines) + assert result.returncode == 0, result.stdout + result.stderr + + def run_xnat_reset(tmp_path, **overrides: str) -> subprocess.CompletedProcess: """Runs the real xnat-reset recipe with its destructive half neutered. From 92f92467294eefc770b1ee1a91a2906cf5e4f4c5 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Wed, 2 Sep 2026 01:13:45 +0100 Subject: [PATCH 23/31] test(xnat): make the up-trust dry run hermetic so it passes without a hub env (#993) CI has no root .env.development, so trust/Makefile's fl_backend.mk include stopped on an empty FL_BACKEND before the AE-title guard was reached, and all six dry-run cases failed with "Invalid FL_BACKEND ''". Locally the worktree's gitignored hub env masked it. A real kit carries FL_BACKEND in its Hub-shared block, so the throwaway kit now does too, and MAIN_ENV_FILE is pointed at nothing so a developer's copy cannot supply values CI does not have. Same six cases, same assertions. Signed-off-by: at24_bioeng625-pc --- trust/xnat/tests/test_deploy_wiring.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/trust/xnat/tests/test_deploy_wiring.py b/trust/xnat/tests/test_deploy_wiring.py index 6ad47a3ee..ca0f408bb 100644 --- a/trust/xnat/tests/test_deploy_wiring.py +++ b/trust/xnat/tests/test_deploy_wiring.py @@ -227,11 +227,25 @@ def dry_run_trust_up(tmp_path, kit_lines: str, *overrides: str) -> subprocess.Co does for real, and nothing docker-shaped runs. The kit file is handed in as ``KIT_FILE`` so the value arrives the way an operator's does — ``-include``d, origin ``file`` — rather than on the command line. + + Hermetic on purpose: ``MAIN_ENV_FILE`` is pointed at nothing, so a developer's gitignored hub + ``.env.development`` cannot supply values CI does not have. ``FL_BACKEND`` is the one such value + the Makefile cannot even parse without (``deploy/fl_backend.mk`` validates it at include time), + and a real kit carries it in its Hub-shared block, so the throwaway kit does too. """ kit = tmp_path / ".env.Probe.development" - kit.write_text(kit_lines) + kit.write_text("FL_BACKEND=nvflare\n" + kit_lines) return subprocess.run( - ["make", "-n", "up-trust", "KIT=Probe", f"KIT_FILE={kit}", "PROD=", *overrides], + [ + "make", + "-n", + "up-trust", + "KIT=Probe", + f"KIT_FILE={kit}", + "PROD=", + "MAIN_ENV_FILE=.env.absent-for-deploy-wiring-test", + *overrides, + ], cwd=TRUST_DIR, capture_output=True, text=True, From 83a206aa37a1c6b7a4894d697add37965c310fcf Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Thu, 3 Sep 2026 11:07:22 +0100 Subject: [PATCH 24/31] ci(xnat): run the trust XNAT suite when the compose files its tests read change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_deploy_wiring.py asserts that trust/deploy/compose_trust.{development,production}.yml each wire XNAT_AETITLE to exactly its two consumers, with the same default the XNAT stack substitutes, but neither path appeared in this workflow's filters — so a change to either compose file landed with the test that pins it unrun. That contract is the one the AE-title work exists to establish, and losing it silently lets the two sides drift apart, which is the queries-succeed/retrievals-time-out failure: DQR matches the C-MOVE destination by exact AE title and port. Also drops the duplicated xnat-init-job.yaml entry from each block. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_trust_xnat.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test_trust_xnat.yml b/.github/workflows/test_trust_xnat.yml index 7c8ffaf52..cd77ffebb 100644 --- a/.github/workflows/test_trust_xnat.yml +++ b/.github/workflows/test_trust_xnat.yml @@ -16,7 +16,10 @@ name: Trust - XNAT CI # that carries configuration into configure-xnat.sh. Those tests read their inputs from outside # trust/xnat/tests/, so every file they assert on has to trigger this workflow — otherwise a change # to a guard, to the credential minter, or to either deployment path's environment block lands with -# the test that pins it unrun. +# the test that pins it unrun. That includes both trust-core compose files: test_deploy_wiring.py +# asserts each wires XNAT_AETITLE to exactly its two consumers with the same default the XNAT stack +# substitutes, and the AE titles on the two sides drifting apart is the queries-succeed / +# retrievals-time-out failure, since DQR matches the C-MOVE destination by exact AE and port. # The dcm2niix-pin-sync job below likewise reads four files scattered across the # repo, so each of those triggers the workflow too. on: @@ -30,6 +33,8 @@ on: - "trust/xnat/tests/**" - "trust/xnat/Makefile" - "trust/xnat/docker-compose-stack.yml" + - "trust/deploy/compose_trust.development.yml" + - "trust/deploy/compose_trust.production.yml" - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - "trust/Makefile" - "Makefile" @@ -42,7 +47,6 @@ on: - "flip-api/src/flip_api/scripts/generate_xnat_credentials.py" - "trust/xnat/dcm2niix/**" - "trust/xnat/xnat/config/dcm2niix_command.json" - - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - ".github/workflows/test_trust_xnat.yml" pull_request: branches: [main, develop] @@ -54,6 +58,8 @@ on: - "trust/xnat/tests/**" - "trust/xnat/Makefile" - "trust/xnat/docker-compose-stack.yml" + - "trust/deploy/compose_trust.development.yml" + - "trust/deploy/compose_trust.production.yml" - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - "trust/Makefile" - "Makefile" @@ -66,7 +72,6 @@ on: - "flip-api/src/flip_api/scripts/generate_xnat_credentials.py" - "trust/xnat/dcm2niix/**" - "trust/xnat/xnat/config/dcm2niix_command.json" - - "deploy/providers/kubernetes/templates/xnat-init-job.yaml" - ".github/workflows/test_trust_xnat.yml" permissions: From cfb61638c6488fba1c84fdff0d156f3a082a3f89 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Thu, 3 Sep 2026 11:08:58 +0100 Subject: [PATCH 25/31] fix(k8s): fire the PACS reachability guard whether or not NetworkPolicy is enabled The refusal of a real-PACS install left on a ClusterIP service is a statement about the Service's exposure, not about NetworkPolicy, but it sat inside network-policy.yaml and so inherited 'if .Values.networkPolicies.enabled'. With policies disabled - a supported setting, and the right one on a CNI that does not enforce them, where rendering unenforced policies is worse than rendering none - a real-PACS install rendered cleanly with the receiver unreachable and no path for the C-STORE return leg. That is the queries-succeed/retrievals-time-out failure the guard exists to prevent. Moves it to a flip-trust.validatePacsReachable partial included from xnat-web.yaml, beside the Service it is about, and adds a CI render with networkPolicies.enabled=false. That render asserts on the refusal message rather than just a non-zero exit, so an unrelated render error cannot make the check pass vacuously. Also states the allowedEgressCIDRs escape hatch plainly in the egress guard's message and at the value itself: a non-empty all-ports CIDR list satisfies that check whatever is in it, because Helm cannot test CIDR containment. The guarantee is 'a port-scoped rule names the PACS port, or you have taken an all-ports rule on trust', which is narrower than the previous wording implied. The .secrets.baseline hunk is the pre-commit hook's doing: it drops a stale Basic Auth false positive recorded against values.yaml line 207. That line is untouched here, and the pinned detect-secrets finds nothing there in the unmodified file either, so the entry was already dead. The values-secrets.yaml entries are unchanged. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 21 ++++++++++++++ .secrets.baseline | 11 +------- .../kubernetes/templates/_helpers.tpl | 28 +++++++++++++++++++ .../kubernetes/templates/network-policy.yaml | 25 ++++++++++------- .../kubernetes/templates/xnat-web.yaml | 5 ++++ deploy/providers/kubernetes/values.yaml | 8 +++++- 6 files changed, 77 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 6e7336a67..451663dfa 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -210,6 +210,27 @@ jobs: echo "::error::a real PACS with the Service left ClusterIP rendered successfully — the receiver is unreachable" exit 1 fi + # Same refusal with NetworkPolicy turned off. This guard began inside network-policy.yaml + # and so inherited `if .Values.networkPolicies.enabled`, which made it silently absent for + # a supported configuration — policies disabled is the right call on a CNI that does not + # enforce them, and rendering unenforced policies is worse than rendering none. It now + # lives in the flip-trust.validatePacsReachable partial, evaluated from xnat-web.yaml + # beside the Service it is about, so it fires either way. No egress/ingress rules are set + # here on purpose: with policies off those checks do not apply, and this asserts the + # exposure guard stands on its own. + # Assert on the refusal *message*, not just a non-zero exit: any unrelated render error + # would otherwise make this pass while proving nothing. + if helm template trust-release deploy/providers/kubernetes/ \ + --set networkPolicies.enabled=false \ + --set pacs.host=10.0.0.10 --set pacs.qrPort=8059 > /dev/null 2> /tmp/nopolicy.err; then + echo "::error::a real PACS on ClusterIP rendered successfully with networkPolicies.enabled=false — the reachability guard is gated on NetworkPolicy again" + exit 1 + fi + if ! grep -q "xnat.web.service.type is ClusterIP" /tmp/nopolicy.err; then + echo "::error::render failed for some other reason than the ClusterIP reachability guard:" + cat /tmp/nopolicy.err + exit 1 + fi # And the LoadBalancer allowance must keep rendering, or on-prem MetalLB trusts break. helm template trust-release deploy/providers/kubernetes/ \ --set xnat.web.service.type=LoadBalancer \ diff --git a/.secrets.baseline b/.secrets.baseline index 6b4a9e386..e19b8c3d2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -172,16 +172,7 @@ "is_verified": false, "line_number": 14 } - ], - "deploy/providers/kubernetes/values.yaml": [ - { - "type": "Basic Auth Credentials", - "filename": "deploy/providers/kubernetes/values.yaml", - "hashed_secret": "440587e65c032a4ab2ed2bbd92aeadef265509dd", - "is_verified": false, - "line_number": 207 - } ] }, - "generated_at": "2026-07-29T18:25:08Z" + "generated_at": "2026-09-03T10:07:35Z" } diff --git a/deploy/providers/kubernetes/templates/_helpers.tpl b/deploy/providers/kubernetes/templates/_helpers.tpl index 67c686716..4bd77c6b4 100644 --- a/deploy/providers/kubernetes/templates/_helpers.tpl +++ b/deploy/providers/kubernetes/templates/_helpers.tpl @@ -143,3 +143,31 @@ drops a staged kit silently (#999) — so both call this helper. {{- define "flip-trust.flClientKitFromS3" -}} {{- if (index .Values.flClient .Values.flBackend).kitFromS3.enabled }}true{{ end }} {{- end }} + +{{/* +Refuse a real-PACS install whose DICOM receiver has no route from outside the cluster. + +Retrieval is two connections in opposite directions: XNAT dials the PACS to C-FIND and C-MOVE, then +the PACS opens a *new* association back to XNAT to C-STORE the studies. templates/xnat-web.yaml +publishes the receiver beyond the cluster only when service.type is NodePort AND dicomNodePort is +set — on ClusterIP both blocks silently no-op, so the render succeeds and produces the failure this +chart calls the hardest to diagnose: queries succeed, retrievals silently time out with nothing +logged on either side. + +This lives here, and is included from xnat-web.yaml, rather than in network-policy.yaml where it +started: it is a statement about the Service's exposure, not about NetworkPolicy. Inside that file +it inherited `if .Values.networkPolicies.enabled`, so an install that turns policies off — supported, +and the right call on a CNI that does not enforce them — rendered cleanly on ClusterIP with no path +a PACS packet could take, which is precisely the case the guard exists to stop. + +Refuses ClusterIP specifically rather than demanding NodePort, because a LoadBalancer service +(MetalLB and friends) is an equally valid way to make the receiver reachable and must not be +rejected. +*/}} +{{- define "flip-trust.validatePacsReachable" -}} +{{- if and .Values.xnat.enabled .Values.xnat.web.enabled (ne .Values.pacs.host "orthanc") }} +{{- if eq .Values.xnat.web.service.type "ClusterIP" }} +{{- fail (printf "pacs.host is %s but xnat.web.service.type is ClusterIP, so the DICOM receiver is unreachable from outside the cluster and the C-STORE return leg the PACS opens after C-MOVE can never arrive. Set xnat.web.service.type: NodePort plus xnat.web.dicomNodePort (equal to xnat.web.dicomPort), or expose the receiver through a LoadBalancer service." .Values.pacs.host) }} +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/providers/kubernetes/templates/network-policy.yaml b/deploy/providers/kubernetes/templates/network-policy.yaml index 6fcc7245b..9ab3ac762 100644 --- a/deploy/providers/kubernetes/templates/network-policy.yaml +++ b/deploy/providers/kubernetes/templates/network-policy.yaml @@ -58,8 +58,16 @@ spec: {{- range .Values.networkPolicies.allowedEgressPorts }} {{- if eq (.port | int) $pacsPort }}{{ $egressOk = true }}{{ end }} {{- end }} +{{- /* +A non-empty allowedEgressCIDRs satisfies this check whatever is in it: those entries are all-ports +rules, and Helm cannot do CIDR containment, so the chart cannot tell whether one of them actually +covers the PACS. That is a deliberate escape hatch rather than an oversight — but it means the +guarantee here is "either a port-scoped rule names the PACS port, or you have taken an all-ports +rule on trust", not "a PACS with no egress rule is always refused". Said plainly in the message +below and at networkPolicies.allowedEgressCIDRs in values.yaml. +*/ -}} {{- if and (not $egressOk) (not .Values.networkPolicies.allowedEgressCIDRs) }} -{{- fail (printf "pacs.host is %s but no egress rule reaches its query/retrieve port %v. XNAT could not issue C-FIND or C-MOVE, so retrieval would fail before it began. Add the PACS to networkPolicies.allowedEgressCIDRsWithPorts." .Values.pacs.host .Values.pacs.qrPort) }} +{{- fail (printf "pacs.host is %s but no egress rule reaches its query/retrieve port %v. XNAT could not issue C-FIND or C-MOVE, so retrieval would fail before it began. Add the PACS to networkPolicies.allowedEgressCIDRsWithPorts. (Note: a non-empty networkPolicies.allowedEgressCIDRs also satisfies this check — those are all-ports rules and the chart cannot verify one covers the PACS, so it assumes it does.)" .Values.pacs.host .Values.pacs.qrPort) }} {{- end }} {{- $dicomPort := .Values.xnat.web.dicomPort | int }} {{- $ingressOk := false }} @@ -79,16 +87,13 @@ side. Both directions now fail equally loudly, at render, naming the values to s {{- end }} {{- /* The NetworkPolicy admits the C-STORE leg only as far as the pod; the packet still needs a route from -outside the cluster to that pod. templates/xnat-web.yaml publishes the DICOM receiver beyond the -cluster only when service.type is NodePort AND dicomNodePort is set — on ClusterIP both blocks -silently no-op, so a chart could satisfy every guard above and still render with no path a PACS -packet can take: the same queries-succeed/retrievals-time-out failure, one layer up. Refuse -ClusterIP specifically rather than demanding NodePort, because a LoadBalancer service (MetalLB and -friends) is an equally valid way to make the receiver reachable and must not be rejected. +outside the cluster to that pod. That check is deliberately NOT here: it is a statement about the +Service's exposure rather than about NetworkPolicy, and inside this file it inherited the +`networkPolicies.enabled` condition above, so an install with policies disabled rendered cleanly on +ClusterIP with no path a PACS packet could take. It now lives in the +`flip-trust.validatePacsReachable` partial in _helpers.tpl, included from templates/xnat-web.yaml, +where it fires either way. */ -}} -{{- if eq .Values.xnat.web.service.type "ClusterIP" }} -{{- fail (printf "pacs.host is %s but xnat.web.service.type is ClusterIP, so the DICOM receiver is unreachable from outside the cluster and the C-STORE return leg the PACS opens after C-MOVE can never arrive. Set xnat.web.service.type: NodePort plus xnat.web.dicomNodePort (equal to xnat.web.dicomPort), or expose the receiver through a LoadBalancer service." .Values.pacs.host) }} -{{- end }} {{- end }} {{- if .Values.networkPolicies.allowedIngressCIDRsWithPorts }} # Allow inbound DICOM from the trust PACS. diff --git a/deploy/providers/kubernetes/templates/xnat-web.yaml b/deploy/providers/kubernetes/templates/xnat-web.yaml index 1b1b9d756..4917061d1 100644 --- a/deploy/providers/kubernetes/templates/xnat-web.yaml +++ b/deploy/providers/kubernetes/templates/xnat-web.yaml @@ -10,6 +10,11 @@ # limitations under the License. {{- if and .Values.xnat.enabled .Values.xnat.web.enabled }} +{{- /* +Refuse a real-PACS install whose receiver has no route in from outside the cluster. Evaluated here, +beside the Service this is a statement about, so it fires whether or not NetworkPolicy is enabled. +*/ -}} +{{- include "flip-trust.validatePacsReachable" . }} --- apiVersion: v1 kind: ConfigMap diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index 4aae263c5..0a8469318 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -801,7 +801,13 @@ networkPolicies: # - cidrs: # - 10.0.0.10/32 # port: 8104 - # CIDRs allowed for all-ports egress (in addition to intra-namespace) + # CIDRs allowed for all-ports egress (in addition to intra-namespace). + # + # Setting this to anything non-empty also satisfies the real-PACS egress guard in + # templates/network-policy.yaml. Helm cannot test CIDR containment, so the chart cannot check that + # one of these entries actually reaches the PACS — it assumes an all-ports rule does. If you rely + # on that, the PACS route is yours to verify; the port-scoped + # allowedEgressCIDRsWithPorts is what makes the chart check it for you. allowedEgressCIDRs: [] # - 10.0.0.0/8 # CIDRs allowed for egress on a specific port, e.g. fl-server gRPC (port 8002) From bef7428dc664e881285dccf4f2bcdb3ac4425814 Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Thu, 3 Sep 2026 11:10:15 +0100 Subject: [PATCH 26/31] fix(xnat): refuse a squatted DICOM port before deleting our own receiver The reclamation loop correctly leaves alone a receiver carrying neither FLIP ownership marker, including one bound to the port this deployment is about to register. XNAT then rejects the POST as a duplicate port and the script fails loud - but by that point the loop has already deleted FLIP's own working receiver, so a redeploy that changed nothing else ends with XNAT holding no DICOM receiver at all, which is worse than the state it started in. On Kubernetes the only record is a Job pod log the hook-delete-policy discards on success. Checks for a squatter before issuing any DELETE and exits naming it, which is the ordering the PACS-side guard further down already uses. The listing the check needs was already fetched, so it costs nothing. The comment claiming the squatter 'survives to the POST below, which then fails loud' is corrected. Covered by a test driving the existing curl stub: with a squatter on the bound port the run exits non-zero, issues no DELETE at all, and names the offending receiver. It fails against the previous script, which exited 0 after deleting. Also gives the two idempotency probes the deadlines every other curl in this script carries. The wall-clock wait above them proves XNAT serves its login page, not that an authenticated route answers, so an XNAT wedged on its database would hang the deploy there with no output and no timeout. The three allowlist pragmas on BASE_ENV are unrelated housekeeping: those fake fixture passwords already tripped detect-secrets on the unmodified file, so any commit staging this file was blocked until they were marked. Signed-off-by: at24_bioeng625-pc --- trust/xnat/tests/test_configure_pacs.py | 29 ++++++++++++++-- trust/xnat/xnat/config/configure-xnat.sh | 42 ++++++++++++++++++++---- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/trust/xnat/tests/test_configure_pacs.py b/trust/xnat/tests/test_configure_pacs.py index 6556b93c4..e6b2873f9 100644 --- a/trust/xnat/tests/test_configure_pacs.py +++ b/trust/xnat/tests/test_configure_pacs.py @@ -101,10 +101,10 @@ BASE_ENV = { "XNAT_ADMIN_USER": "admin", - "XNAT_ADMIN_INITIAL_PASSWORD": "initial", - "XNAT_ADMIN_PASSWORD": "rotated", + "XNAT_ADMIN_INITIAL_PASSWORD": "initial", # pragma: allowlist secret + "XNAT_ADMIN_PASSWORD": "rotated", # pragma: allowlist secret "XNAT_SERVICE_USER": "flipServiceAccount", - "XNAT_SERVICE_PASSWORD": "service", + "XNAT_SERVICE_PASSWORD": "service", # pragma: allowlist secret "XNAT_PORT": "8104", # The plugin-readiness wait polls until a DQR route answers, bounded only by wall clock. With # sleep stubbed out, a test that makes that route fail would spin at full speed for the default @@ -134,6 +134,9 @@ # An operator's, registered by hand for some other local DICOM source: neither marker, so nothing # here owns it. OPERATOR_RECEIVER = '{"id":9,"aeTitle":"WARDCT","port":11113,"identifier":"dicomObjectIdentifier"}' +# The same unowned receiver, but squatting the port this deployment is about to bind. XNAT rejects a +# second receiver on a bound port, so this one cannot be worked around by registering alongside it. +SQUATTING_RECEIVER = '{"id":9,"aeTitle":"WARDCT","port":8104,"identifier":"dicomObjectIdentifier"}' def run_configure(tmp_path, env_overrides=None, pacs_state=None, scp_state=None): @@ -393,6 +396,26 @@ def test_a_receiver_this_script_does_not_own_survives_the_reconcile(tmp_path): assert any(u.endswith("/xapi/dicomscp/1") for u in deleted), "left XNAT's stock receiver behind" +def test_a_receiver_squatting_our_port_is_refused_before_anything_is_deleted(tmp_path): + """Refusing has to happen before the reclamation loop, not at the POST. + + An unowned receiver on the port this deployment binds is left alone by the reclamation above + (correctly — it is not ours), and the POST that follows is then rejected by XNAT as a duplicate + port. If the deletes have already run by that point, a redeploy that changed nothing else ends + with FLIP's own working receiver gone and no replacement: strictly worse than the state it + started in, and on Kubernetes the only record is a Job pod log discarded on success. The + PACS-side guard further down refuses ahead of its deletes; this asserts the SCP side matches. + """ + code, payloads, output = run_configure( + tmp_path, + scp_state=f"[{FLIP_OWNED_RECEIVER}, {SQUATTING_RECEIVER}]", + ) + assert code != 0, "a squatted port should abort the run" + assert not deletes(payloads), f"deleted something before refusing: {deletes(payloads)}" + assert "WARDCT" in output, "the refusal must name the receiver holding the port" + assert "8104" in output + + def test_foreign_pacs_registrations_are_removed(tmp_path): """A trust XNAT retrieves from one PACS; a stale entry would leave DQR's choice ambiguous.""" code, payloads, output = run_configure( diff --git a/trust/xnat/xnat/config/configure-xnat.sh b/trust/xnat/xnat/config/configure-xnat.sh index bd630e343..3dd232cb0 100644 --- a/trust/xnat/xnat/config/configure-xnat.sh +++ b/trust/xnat/xnat/config/configure-xnat.sh @@ -183,12 +183,17 @@ xnat_curl() { # registration, availability intervals) carry their own already-configured # guards instead of relying on this short-circuit. # (These probes check status codes explicitly — a non-200 here is a signal, -# not an error, so they intentionally stay bare curl rather than xnat_curl.) -init_pw_status=$(curl -s -o /dev/null -w '%{http_code}' \ +# not an error, so they intentionally stay bare curl rather than xnat_curl. +# They carry xnat_curl's deadlines even so: the wall-clock wait above proves XNAT serves the login +# page, not that an authenticated route answers, so an XNAT wedged on its database — the documented +# "reverted to uninitialized Setup mode" failure — would otherwise hang the deploy here with no +# output and no timeout.) +init_pw_status=$(curl -s --connect-timeout 5 --max-time 15 -o /dev/null -w '%{http_code}' \ -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_INITIAL_PASSWORD}" \ "$XNAT_URL/xapi/siteConfig/initialized") if [[ "${init_pw_status}" != "200" ]]; then - initialized=$(curl -s -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ + initialized=$(curl -s --connect-timeout 5 --max-time 15 \ + -u "${XNAT_ADMIN_USER}:${XNAT_ADMIN_PASSWORD}" \ "$XNAT_URL/xapi/siteConfig/initialized") if [[ "${initialized}" == "true" ]]; then echo "XNAT already configured (initialized=true, initial password no longer works) — skipping." @@ -345,10 +350,14 @@ xnat_curl -X PUT "$XNAT_URL/xapi/anonymize/site/enabled" \ # # A receiver an operator registered for some other local DICOM source carries neither marker and is # left alone — it would otherwise be deleted on every redeploy and `helm upgrade`, with the deletion -# visible only in a Job pod log that is discarded on success. If such a receiver is squatting the -# port we are about to bind, it survives to the POST below, which then fails loud through xnat_curl: -# deliberately the same choice the PACS-side guard makes further down — refuse rather than silently -# delete configuration this deployment may not own. +# visible only in a Job pod log that is discarded on success. +# +# If such a receiver is squatting the port we are about to bind, refuse BEFORE deleting anything. +# The POST below does fail loud on the duplicate port, but by then the reclamation loop has already +# removed FLIP's own working receiver, so a redeploy that changed nothing else leaves XNAT with no +# DICOM receiver at all — worse than the state it started in. The PACS-side guard further down gets +# this ordering right (it refuses ahead of its deletes), and the listing this check needs has +# already been fetched, so the check is free. # # Both markers are literals in the filter, so nothing operator-supplied is spliced into jq. response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" "$XNAT_URL/xapi/dicomscp") @@ -356,6 +365,25 @@ response=$(xnat_curl -u "$XNAT_ADMIN_USER:$XNAT_ADMIN_PASSWORD" "$XNAT_URL/xapi/ if [[ -z "$response" || "$response" == "[]" ]]; then echo "No SCP receivers found." else + # Pre-flight: a receiver carrying neither ownership marker, already bound to the port this + # deployment is about to register, blocks the POST below. Refuse now, while FLIP's own receiver + # is still in place. + squatters=$(printf '%s' "$response" \ + | jq -r --argjson port "${XNAT_PORT}" \ + '.[] | select(.port == $port) + | select(.identifier != "dqrObjectIdentifier" and .aeTitle != "XNAT") + | "\(.aeTitle):\(.port) (id \(.id))"') + if [[ -n "$squatters" ]]; then + echo "ERROR: another DICOM SCP receiver already holds port ${XNAT_PORT}, and it carries" >&2 + echo " neither FLIP ownership marker (identifier 'dqrObjectIdentifier', AE title" >&2 + echo " 'XNAT'). Refusing to delete this deployment's receiver for a registration" >&2 + echo " that would then be rejected as a duplicate port, which would leave XNAT with" >&2 + echo " no receiver at all. Free the port or remove the receiver in XNAT's admin UI," >&2 + echo " or point this deployment at another port via XNAT_PORT. Found:" >&2 + printf ' %s\n' "$squatters" >&2 + exit 1 + fi + stale_ids=$(printf '%s' "$response" \ | jq -r '.[] | select(.identifier == "dqrObjectIdentifier" or .aeTitle == "XNAT") | "\(.id):\(.aeTitle):\(.port)"') From b057bc01f2c1672270641a182d798f0a82ce871a Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Thu, 3 Sep 2026 11:10:24 +0100 Subject: [PATCH 27/31] fix(imaging-api): stop logging the DQR response body; mark pacs_id server-resolved The DQR query response is a study list carrying patient id, name, sex, referring physician, accession number and study date, and it was logged verbatim. That is mock data until this XNAT is pointed at a real hospital PACS and real patient identifiers afterwards, which is exactly what this branch enables. It is not hypothetical either: dev trusts run at LOG_LEVEL=DEBUG, so the line is emitted today. Logs the status and reason instead; the study count is already logged after parsing, which is the part that helps when debugging a retrieval. services/retrieval.py already takes this line, logging 'accession number i/total' rather than the number itself. pacs_id on ImportStudyRequest is overwritten with the runtime-resolved id before the request is sent, so a caller-supplied value has no effect. The field stays - the resolved id is assigned to it and serialised onward to DQR - but the OpenAPI schema now says so instead of advertising a knob that silently does nothing. Signed-off-by: at24_bioeng625-pc --- trust/imaging-api/imaging_api/routers/schemas.py | 10 +++++++++- trust/imaging-api/imaging_api/services/imaging.py | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/trust/imaging-api/imaging_api/routers/schemas.py b/trust/imaging-api/imaging_api/routers/schemas.py index 8e94ecc73..421fb443a 100644 --- a/trust/imaging-api/imaging_api/routers/schemas.py +++ b/trust/imaging-api/imaging_api/routers/schemas.py @@ -236,7 +236,15 @@ def __init__(self, **data: object) -> None: class ImportStudyRequest(BaseModel): """Represents an image import request for DQR.""" - pacs_id: int = Field(default=PACS_ID, alias="pacsId") + # Resolved from XNAT at request time and overwritten in services/imaging.py before the request + # is sent, so a caller-supplied value has no effect. Kept on the model because the resolved id + # is assigned here and serialised onward to DQR; the description says so in the OpenAPI schema + # rather than leaving a knob that silently does nothing. + pacs_id: int = Field( + default=PACS_ID, + alias="pacsId", + description="Resolved from XNAT server-side; any value supplied here is ignored.", + ) # The C-MOVE destination the PACS is told to send to. DQR matches this against a registered # SCP receiver by exact AE title and port, so it must equal what configure-xnat.sh registered. ae_title: str = Field(default=XNAT_AETITLE, alias="aeTitle") diff --git a/trust/imaging-api/imaging_api/services/imaging.py b/trust/imaging-api/imaging_api/services/imaging.py index bc495a71b..6f73f2a67 100644 --- a/trust/imaging-api/imaging_api/services/imaging.py +++ b/trust/imaging-api/imaging_api/services/imaging.py @@ -247,7 +247,13 @@ def query_by_accession_number(accession_number: str, headers: dict[str, str]) -> json=study_query.model_dump(by_alias=True), timeout=XNAT_DQR_REQUEST_TIMEOUT, ) - logger.debug(f"Query response: {response.text} - {response.status_code} - {response.reason}") + # Status and reason only. The DQR query response body is a study list carrying patient id, name, + # sex, referring physician, accession number and study date (see routers/schemas.py), so logging + # it verbatim writes patient identifiers into the trust's container logs — mock data until this + # XNAT is pointed at a real hospital PACS, real patients afterwards. Dev trusts run at + # LOG_LEVEL=DEBUG, so this line is emitted today. The study count is logged after parsing below, + # which is the part that is actually useful when debugging a retrieval. + logger.debug(f"Query response: {response.status_code} - {response.reason}") if response.status_code == 200: logger.info("Successfully queried PACS via DQR") From 00cba99c8e5441aa497aa01207c7d36a8cdf851c Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Thu, 3 Sep 2026 11:10:32 +0100 Subject: [PATCH 28/31] docs: note that a trust connecting a real PACS must add XNAT_WEB_PORT XNAT_PORT used to mean both the DICOM SCP receiver and the web UI; it now means the receiver only, and the web UI has its own XNAT_WEB_PORT. A kit deployed before the split sets only XNAT_PORT and keeps working, because the web port derives from it - but a trust connecting a real PACS publishes both on the host and the deploy refuses to start when the two collide. Neither the AWS nor the on-prem guide said so, which left the refusal correct and loud but undocumented. Also flags that the 8105 in the forward-trust URL table is a convention rather than something the deploy enforces, so a trust that allocated different numbers forwards its own. Signed-off-by: at24_bioeng625-pc --- deploy/providers/AWS/README.md | 9 +++++++++ docs/source/deploy-flip/deploy-flip-node-on-prem.rst | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/deploy/providers/AWS/README.md b/deploy/providers/AWS/README.md index 2fb191bfd..0199418ac 100644 --- a/deploy/providers/AWS/README.md +++ b/deploy/providers/AWS/README.md @@ -777,6 +777,15 @@ This prints a list of URLs you can paste into your browser: Press Ctrl+C to stop all forwards. The Central Hub UI and API are accessed via the CloudFront distribution at the canonical subdomain (e.g. `https://app.flip.aicentre.co.uk`) — no port forwarding needed. The ALB is internal (private subnets, no public IP); CloudFront reaches it through a VPC origin. +> **Upgrading a trust deployed before the XNAT port split (FLIP#993):** `XNAT_PORT` used to mean +> both the DICOM receiver and the web UI. It now means the DICOM receiver only, and the web UI has +> its own `XNAT_WEB_PORT`. A kit file that sets only `XNAT_PORT` still works — the web port derives +> from it — but a trust connecting a **real** PACS publishes both on the host, and the deploy +> refuses to start if the two collide. Add `XNAT_WEB_PORT` to that trust's kit file (the shipped dev +> allocation is 8104 DICOM / 8105 web for the first trust, 8106/8107 for the second). The +> `forward-trust` URL above is 8105 by convention, not by enforcement; a trust that chose different +> numbers forwards its own. + ## Checkov Security Lint CI goes red on PRs that regress this tree's security posture (FLIP#1052 + the FLIP#1058 triage): the diff --git a/docs/source/deploy-flip/deploy-flip-node-on-prem.rst b/docs/source/deploy-flip/deploy-flip-node-on-prem.rst index bbe468ebf..ca9a35930 100644 --- a/docs/source/deploy-flip/deploy-flip-node-on-prem.rst +++ b/docs/source/deploy-flip/deploy-flip-node-on-prem.rst @@ -193,6 +193,17 @@ at any point — including before the kit is fully staged — just to read off t The prod trust compose mounts the extracted ``net-1/`` hierarchy into the fl-client container, so preserve it as extracted. +.. note:: + + **Upgrading a trust deployed before the XNAT port split (FLIP#993).** + ``XNAT_PORT`` used to mean both the DICOM SCP receiver and the web UI. It + now means the receiver only, and the web UI has its own ``XNAT_WEB_PORT``. + An existing kit that sets only ``XNAT_PORT`` keeps working — the web port + derives from it — but a trust connecting a **real** PACS publishes both on + the host, and ``up-onprem-trust`` refuses to start when the two collide. + Add ``XNAT_WEB_PORT`` to the Host-local profile in that case; the shipped + allocation is 8104 for DICOM and 8105 for the web UI. + **Optional — set your site privacy policy (NVFLARE backend).** As the data holder you can enforce your own update-privacy filter on everything the fl-client sends to the FL server, independently of the researcher's app From 9ab4a926fa830a7c58746588e27df218e613888d Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Fri, 4 Sep 2026 12:23:09 +0100 Subject: [PATCH 29/31] fix(k8s): refuse a real-PACS NodePort install with no dicomNodePort xnat-web.yaml gates both the NodePort pin and externalTrafficPolicy: Local on service.type == NodePort AND a set dicomNodePort, but the reachability guard only refused ClusterIP. A real-PACS install on NodePort with dicomNodePort left at its empty default therefore rendered clean while breaking the C-STORE return leg twice: Kubernetes allocates the node port at random, so the port the PACS was told to dial is not the one that reaches the pod, and Cluster policy SNATs the source address so the ingress NetworkPolicy CIDR cannot match. That is the same queries-succeed / retrievals-time-out failure the guard exists to stop, through the one combination none of the five CI render cases covered. Widen the guard to the second condition and add a sixth render case asserting the refusal by message, not just by exit code. Signed-off-by: at24_bioeng625-pc --- .github/workflows/test_helm_chart.yml | 26 +++++++++++++++++++ .../kubernetes/templates/_helpers.tpl | 11 ++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index 6257b0e5d..11ff36090 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -240,6 +240,32 @@ jobs: --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].port=8059' > /dev/null + # The other half of the conjunction xnat-web.yaml gates the NodePort pin and + # externalTrafficPolicy on. NodePort with dicomNodePort left at its empty default satisfies + # every guard above yet breaks the return leg twice: a random node port the PACS was never + # told to dial, and Cluster policy SNATting the source address so the ingress CIDR cannot + # match. Same queries-succeed / retrievals-time-out failure, through the one combination the + # other refusals leave open (FLIP#993). + - name: Render template (real PACS on NodePort without dicomNodePort is refused) + run: | + if helm template trust-release deploy/providers/kubernetes/ \ + --set xnat.web.service.type=NodePort \ + --set pacs.host=10.0.0.10 --set pacs.qrPort=8059 \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedIngressCIDRsWithPorts[0].port=8104' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].cidrs[0]=10.0.0.10/32' \ + --set 'networkPolicies.allowedEgressCIDRsWithPorts[0].port=8059' > /dev/null 2> /tmp/nonodeport.err; then + echo "::error::a real PACS on NodePort with no dicomNodePort rendered successfully — the PACS has no stable destination port" + exit 1 + fi + # Assert on the message, not just the exit code: an unrelated render error would + # otherwise make this pass while proving nothing. + if ! grep -q "xnat.web.dicomNodePort is unset" /tmp/nonodeport.err; then + echo "::error::render failed for some other reason than the dicomNodePort guard:" + cat /tmp/nonodeport.err + exit 1 + fi + # Default-deny must survive: the ingress allowance is the one inbound path into a trust, so a # chart that opened it without being asked would silently widen every existing deployment. - name: Render template (default keeps ingress denied) diff --git a/deploy/providers/kubernetes/templates/_helpers.tpl b/deploy/providers/kubernetes/templates/_helpers.tpl index 4bd77c6b4..89f561b60 100644 --- a/deploy/providers/kubernetes/templates/_helpers.tpl +++ b/deploy/providers/kubernetes/templates/_helpers.tpl @@ -163,11 +163,22 @@ a PACS packet could take, which is precisely the case the guard exists to stop. Refuses ClusterIP specifically rather than demanding NodePort, because a LoadBalancer service (MetalLB and friends) is an equally valid way to make the receiver reachable and must not be rejected. + +Both halves of that conjunction are checked, not just the Service type. NodePort with +`dicomNodePort` left at its empty default renders clean while breaking the same leg twice over: +Kubernetes allocates a random NodePort, so the port the PACS was told to dial is not the one that +reaches the pod, and `externalTrafficPolicy: Local` is gated on the same pair (xnat-web.yaml:56), +so kube-proxy SNATs the source address and the ingress CIDR cannot match even if a packet did +arrive. That is the same queries-succeed / retrievals-time-out failure, reached through the one +combination the other refusals leave open. */}} {{- define "flip-trust.validatePacsReachable" -}} {{- if and .Values.xnat.enabled .Values.xnat.web.enabled (ne .Values.pacs.host "orthanc") }} {{- if eq .Values.xnat.web.service.type "ClusterIP" }} {{- fail (printf "pacs.host is %s but xnat.web.service.type is ClusterIP, so the DICOM receiver is unreachable from outside the cluster and the C-STORE return leg the PACS opens after C-MOVE can never arrive. Set xnat.web.service.type: NodePort plus xnat.web.dicomNodePort (equal to xnat.web.dicomPort), or expose the receiver through a LoadBalancer service." .Values.pacs.host) }} {{- end }} +{{- if and (eq .Values.xnat.web.service.type "NodePort") (not .Values.xnat.web.dicomNodePort) }} +{{- fail (printf "pacs.host is %s and xnat.web.service.type is NodePort, but xnat.web.dicomNodePort is unset, so the receiver's node port is allocated at random and externalTrafficPolicy stays Cluster. The PACS cannot be given a stable destination port, and kube-proxy rewrites its source address so the ingress NetworkPolicy CIDR never matches — queries succeed and retrievals silently time out. Set xnat.web.dicomNodePort (equal to xnat.web.dicomPort, %v), widening the API server's --service-node-port-range if needed." .Values.pacs.host (.Values.xnat.web.dicomPort | default 8104)) }} +{{- end }} {{- end }} {{- end }} From 02c9ab1cc546be46e03956a6fc11312c35b47a9c Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Fri, 4 Sep 2026 12:23:23 +0100 Subject: [PATCH 30/31] docs: describe the receiver's real exposure and the port split's upgrade path Four statements disagreed with the code they described. The PACS page said the Compose publication binds on the developer's machine. It does not: a Swarm service publishes through the routing mesh, which takes no bind address, so the receiver is on every interface of the host. On the AWS mock trusts that stops at the security group; on an on-prem host or a developer machine it reaches whatever network the host is on, and Docker's DNAT precedes the host firewall's INPUT chain so a host rule does not close it. Say so, and state alongside it that the receiver accepts an association from any calling AE - DIMSE is unauthenticated and the whitelist ships off - so an information-governance review sees it rather than infers it. FLIP#1142 and FLIP#1143 are named as the tracked follow-ups. The AWS and on-prem upgrade notes said a kit setting only XNAT_PORT keeps working. Neither half held: XNAT_WEB_PORT defaults to XNAT_PORT, and both ports are published on every deployment rather than only real-PACS ones, so an un-migrated kit always resolves the two to the same number and is always refused by the collision guard - not only when connecting a real PACS. Both notes now say every existing kit must add XNAT_WEB_PORT before its next deploy, and why that refusal is the intended upgrade path. The XNAT page called the anonymisation profile comprehensive. It is a 28-rule denylist that has only ever seen synthetic data; connecting a real PACS changes who reads that sentence. Record the boundaries it cannot cover - vendor private tags, unshifted study dates, burned-in annotation - against the unconditional Patient Identity Removed = YES it asserts, and point at them from the PACS page. No change to the script. Signed-off-by: at24_bioeng625-pc --- deploy/providers/AWS/README.md | 21 +++++++++------ docs/source/components/component-pacs.rst | 27 ++++++++++++++++--- docs/source/components/component-xnat.rst | 13 ++++++++- .../deploy-flip/deploy-flip-node-on-prem.rst | 18 ++++++++----- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/deploy/providers/AWS/README.md b/deploy/providers/AWS/README.md index 2e6a8ebe0..4be686ad7 100644 --- a/deploy/providers/AWS/README.md +++ b/deploy/providers/AWS/README.md @@ -833,14 +833,19 @@ This prints a list of URLs you can paste into your browser: Press Ctrl+C to stop all forwards. The Central Hub UI and API are accessed via the CloudFront distribution at the canonical subdomain (e.g. `https://app.flip.aicentre.co.uk`) — no port forwarding needed. The ALB is internal (private subnets, no public IP); CloudFront reaches it through a VPC origin. -> **Upgrading a trust deployed before the XNAT port split (FLIP#993):** `XNAT_PORT` used to mean -> both the DICOM receiver and the web UI. It now means the DICOM receiver only, and the web UI has -> its own `XNAT_WEB_PORT`. A kit file that sets only `XNAT_PORT` still works — the web port derives -> from it — but a trust connecting a **real** PACS publishes both on the host, and the deploy -> refuses to start if the two collide. Add `XNAT_WEB_PORT` to that trust's kit file (the shipped dev -> allocation is 8104 DICOM / 8105 web for the first trust, 8106/8107 for the second). The -> `forward-trust` URL above is 8105 by convention, not by enforcement; a trust that chose different -> numbers forwards its own. +> **Upgrading a trust deployed before the XNAT port split (FLIP#993) — every existing trust must +> act:** `XNAT_PORT` used to mean both the DICOM receiver and the web UI. It now means the DICOM +> receiver only, and the web UI has its own `XNAT_WEB_PORT`. Both are published on the host on +> **every** deployment, not only where a real PACS is configured, so the two must differ. A kit +> file that sets only `XNAT_PORT` resolves them to the same number — `XNAT_WEB_PORT` defaults to +> `XNAT_PORT` — so the next `make up-trust` / `up-trust-ec2` on that kit is **refused** by the +> collision guard, which names both values and the kit file to edit. The refusal is the intended +> upgrade path: deriving the web port from `XNAT_PORT` routes a pre-split kit into a loud +> instruction instead of silently moving its web UI to a number nothing else expects. Add +> `XNAT_WEB_PORT` to that trust's kit file before the next deploy (the shipped dev allocation is +> 8104 DICOM / 8105 web for the first trust, 8106/8107 for the second). The `forward-trust` URL +> above is 8105 by convention, not by enforcement; a trust that chose different numbers forwards +> its own. ## Checkov Security Lint diff --git a/docs/source/components/component-pacs.rst b/docs/source/components/component-pacs.rst index 44ce67371..c4f02a5af 100644 --- a/docs/source/components/component-pacs.rst +++ b/docs/source/components/component-pacs.rst @@ -78,7 +78,9 @@ performs the retrieval, driven over REST by the imaging API: 3. XNAT issues a **C-MOVE** to the PACS, naming itself as the move destination. 4. The PACS **C-STOREs** the study back to XNAT's DICOM SCP receiver, where the site-wide anonymisation script runs on receipt, before the session is archived — see - :ref:`DICOM Anonymization `. + :ref:`DICOM Anonymization `. That script is a denylist, and its limits + (vendor private tags, unshifted dates, burned-in annotation) are stated there; read them before + connecting a real PACS, since until now the script has only seen synthetic data. Only accession numbers belonging to an approved project cohort are ever requested. There is no standing forward rule and no bulk transfer. @@ -212,9 +214,26 @@ exposed differs by deployment: **Compose.** The receiver is always published on the host, next to the web UI, so a development deployment runs the same wiring a real-PACS trust relies on. ``XNAT_WEB_PORT`` and ``XNAT_PORT`` must therefore differ; the Makefile refuses to deploy if they collide. The mocked Orthanc does not -itself need the publication — it reaches the receiver over the container network — and the mock -deployments expose nothing by it: local development binds on the developer's machine, and the -AWS-hosted mock trusts sit behind security groups with no ingress rules. +itself need the publication — it reaches the receiver over the container network — but it is +published there too, on every Compose deployment rather than only where a real PACS is configured. + +That publication is **unscoped**: it binds every interface of the host, not the loopback. A Swarm +service publishes through the routing mesh, which takes no bind address, so there is no way to +narrow it in the Compose file. What the exposure amounts to therefore depends on the host — the +AWS-hosted mock trusts sit behind security groups with no ingress rules and so reach no further +than the instance, while an on-prem trust host or a developer machine publishes the receiver to +whatever network it is on. Docker's DNAT precedes the host firewall's ``INPUT`` chain, so a +host-level rule does not close it; scope it at the network the host sits on. + +.. warning:: + + The receiver accepts an association from **any** calling AE. DIMSE carries no authentication, + and the receiver is registered with its calling-AE whitelist off + (``whitelistEnabled: false``) and ``directArchive`` on, so what confines C-STORE to the trust's + own PACS is the surrounding network rather than anything XNAT checks. Treat the DICOM port as + an unauthenticated write path into the archive when specifying firewall rules for it. + Making the Compose publication conditional on a real PACS being configured is tracked in + FLIP#1142, and deriving the whitelist from the configured PACS in FLIP#1143. **Kubernetes.** Three values, all off by default: diff --git a/docs/source/components/component-xnat.rst b/docs/source/components/component-xnat.rst index 919824d37..10f6df9cd 100644 --- a/docs/source/components/component-xnat.rst +++ b/docs/source/components/component-xnat.rst @@ -182,7 +182,7 @@ The default script performs only basic label mapping: FLIP Anonymization Script ========================= -FLIP replaces the default script with a comprehensive site-wide anonymization script (``anon_script.das``) that provides more thorough PHI removal, including: +FLIP replaces the default script with a site-wide anonymization script (``anon_script.das``) that removes considerably more PHI than the XNAT default, including: - **Patient identifiers**: birth date, address, telephone numbers, other patient IDs - **Institutional identifiers**: institution name, address, department @@ -193,6 +193,17 @@ FLIP replaces the default script with a comprehensive site-wide anonymization sc The script is configured automatically during XNAT initialization via ``configure-xnat.sh``. It is applied to all incoming DICOM data when the SCP receiver has ``anonymizationEnabled`` set to ``true``. +Limits of the profile +--------------------- + +The script is a denylist: 28 removal rules naming specific tags. It removes what it enumerates, and anything unenumerated survives. Three boundaries are worth stating explicitly, because a trust connecting a real PACS (see :doc:`component-pacs`) sends this script real patient data rather than the synthetic studies it has been exercised against: + +- **Vendor private tags are not removed.** Real PACS populate these heavily and they routinely carry identifiers. No rule in the script reaches them. +- **Dates are not shifted or removed.** Only patient birth date and time are dropped; study, series and acquisition dates pass through unchanged. +- **Burned-in annotation is untouched.** Identifiers rendered into pixel data are beyond the reach of any header rule. + +The script nevertheless sets ``Patient Identity Removed`` to ``YES`` on every object it processes. That assertion is only as strong as the rules above, so a trust connecting a real PACS should review the profile against its own information-governance requirements rather than read the tag as an assurance. Declaring a DICOM PS3.15 confidentiality profile, with coded ``De-identification Method Code Sequence`` values, is tracked as separate work. + Anonymize API Endpoints ======================= diff --git a/docs/source/deploy-flip/deploy-flip-node-on-prem.rst b/docs/source/deploy-flip/deploy-flip-node-on-prem.rst index ca9a35930..8b39676b2 100644 --- a/docs/source/deploy-flip/deploy-flip-node-on-prem.rst +++ b/docs/source/deploy-flip/deploy-flip-node-on-prem.rst @@ -195,13 +195,17 @@ fl-client container, so preserve it as extracted. .. note:: - **Upgrading a trust deployed before the XNAT port split (FLIP#993).** - ``XNAT_PORT`` used to mean both the DICOM SCP receiver and the web UI. It - now means the receiver only, and the web UI has its own ``XNAT_WEB_PORT``. - An existing kit that sets only ``XNAT_PORT`` keeps working — the web port - derives from it — but a trust connecting a **real** PACS publishes both on - the host, and ``up-onprem-trust`` refuses to start when the two collide. - Add ``XNAT_WEB_PORT`` to the Host-local profile in that case; the shipped + **Upgrading a trust deployed before the XNAT port split (FLIP#993) — every + existing trust must act.** ``XNAT_PORT`` used to mean both the DICOM SCP + receiver and the web UI. It now means the receiver only, and the web UI has + its own ``XNAT_WEB_PORT``. Both are published on the host on **every** + deployment, not only where a real PACS is connected, so the two must differ. + A kit that sets only ``XNAT_PORT`` resolves them to the same number — the + web port defaults to ``XNAT_PORT`` — so your next ``up-onprem-trust`` is + **refused**, naming both values and the file to edit. That refusal is the + upgrade path, not a fault: it asks you to allocate a second port rather than + moving your web UI to a number nothing else expects. Add ``XNAT_WEB_PORT`` + to the Host-local profile before your next deployment; the shipped allocation is 8104 for DICOM and 8105 for the web UI. **Optional — set your site privacy policy (NVFLARE backend).** As the data From edd7f6d52456bdc5dc1f2029d88b4c85bcf79a5a Mon Sep 17 00:00:00 2001 From: at24_bioeng625-pc Date: Fri, 4 Sep 2026 12:23:33 +0100 Subject: [PATCH 31/31] fix: point the spleen label uploader at XNAT's web port, not the DICOM receiver The port split redefined 8104/8106 as the DICOM SCP receiver ports and moved the dev web UIs to 8105/8107, but six places still told the spleen enrichment uploader to dial the old numbers. It speaks REST to XNAT, so with the receiver now published those reach a DIMSE listener and hang rather than refusing the connection. flip-api/Makefile's SPLEEN_XNAT_URLS default is the one that actually runs - it carries the enrichment command for e2e_smoke_spleen. The other five are worked examples in CLAUDE.md, its AGENTS twin, the data-enrichment user guide and both spleen tutorial READMEs, each of which also described the roster in prose. Corrected together, with the reason stated so the next reader does not re-derive it. Signed-off-by: at24_bioeng625-pc --- AGENTS.md | 9 ++++++--- CLAUDE.md | 9 ++++++--- docs/source/user-guides/user-data-enrichment.rst | 7 ++++--- fl-tutorials/flower/3d_spleen_segmentation/README.md | 5 +++-- .../image_segmentation/3d_spleen_segmentation/README.md | 6 +++--- flip-api/Makefile | 7 ++++--- 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd96e3299..a3a962839 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,11 +181,14 @@ outside the smoke: ```bash make -C fl-tutorials upload-spleen-labels FLIP_PROJECT_ID= \ - XNAT_URLS="http://127.0.0.1:8104 http://127.0.0.1:8106" DRY_RUN=1 # then drop DRY_RUN + XNAT_URLS="http://127.0.0.1:8105 http://127.0.0.1:8107" DRY_RUN=1 # then drop DRY_RUN ``` The mapping is cached beside the labels dir (so a re-run needs no huggingface.co egress) and -`HF_TRUST_DATA_REVISION=` pins the dataset revision instead of the moving `main`. +`HF_TRUST_DATA_REVISION=` pins the dataset revision instead of the moving `main`. The uploader +speaks REST to XNAT, so those URLs carry each trust's **`XNAT_WEB_PORT`** — 8105 (GSTT) and 8107 +(KCH) since the FLIP#993 split, not 8104/8106, which are now the DICOM SCP receiver ports. Dialling +the old numbers reaches a DIMSE listener and hangs rather than refusing the connection. Through the smoke, `make -C flip-api e2e_smoke_spleen` (or `e2e_smoke_spleen_evaluation`) carries the in-tree command already, targeting both dev trusts via `SPLEEN_XNAT_URLS` (override for another roster; @@ -199,7 +202,7 @@ cd flip-api && uv run python -m tests.e2e_smoke \ --model-files-dir ../fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/app_files \ --query-file ../fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/query.sql \ --data-enrichment-cwd ../fl-tutorials/datasets/spleen \ - --data-enrichment-cmd 'uv run --no-project --with ../../../flip-utils python upload_spleen_labels_to_xnat.py --flip-project-id "$FLIP_PROJECT_ID" --labels-dir ../../data/spleen/images --xnat-url http://127.0.0.1:8104 --xnat-url http://127.0.0.1:8106' + --data-enrichment-cmd 'uv run --no-project --with ../../../flip-utils python upload_spleen_labels_to_xnat.py --flip-project-id "$FLIP_PROJECT_ID" --labels-dir ../../data/spleen/images --xnat-url http://127.0.0.1:8105 --xnat-url http://127.0.0.1:8107' ``` (The `$`-escaping trap still applies to any hand-written `EXTRA_ARGS`: `make -C flip-api …` expands once, so diff --git a/CLAUDE.md b/CLAUDE.md index 7b5670370..dcfc5c175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,11 +181,14 @@ outside the smoke: ```bash make -C fl-tutorials upload-spleen-labels FLIP_PROJECT_ID= \ - XNAT_URLS="http://127.0.0.1:8104 http://127.0.0.1:8106" DRY_RUN=1 # then drop DRY_RUN + XNAT_URLS="http://127.0.0.1:8105 http://127.0.0.1:8107" DRY_RUN=1 # then drop DRY_RUN ``` The mapping is cached beside the labels dir (so a re-run needs no huggingface.co egress) and -`HF_TRUST_DATA_REVISION=` pins the dataset revision instead of the moving `main`. +`HF_TRUST_DATA_REVISION=` pins the dataset revision instead of the moving `main`. The uploader +speaks REST to XNAT, so those URLs carry each trust's **`XNAT_WEB_PORT`** — 8105 (GSTT) and 8107 +(KCH) since the FLIP#993 split, not 8104/8106, which are now the DICOM SCP receiver ports. Dialling +the old numbers reaches a DIMSE listener and hangs rather than refusing the connection. Through the smoke, `make -C flip-api e2e_smoke_spleen` (or `e2e_smoke_spleen_evaluation`) carries the in-tree command already, targeting both dev trusts via `SPLEEN_XNAT_URLS` (override for another roster; @@ -199,7 +202,7 @@ cd flip-api && uv run python -m tests.e2e_smoke \ --model-files-dir ../fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/app_files \ --query-file ../fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/query.sql \ --data-enrichment-cwd ../fl-tutorials/datasets/spleen \ - --data-enrichment-cmd 'uv run --no-project --with ../../../flip-utils python upload_spleen_labels_to_xnat.py --flip-project-id "$FLIP_PROJECT_ID" --labels-dir ../../data/spleen/images --xnat-url http://127.0.0.1:8104 --xnat-url http://127.0.0.1:8106' + --data-enrichment-cmd 'uv run --no-project --with ../../../flip-utils python upload_spleen_labels_to_xnat.py --flip-project-id "$FLIP_PROJECT_ID" --labels-dir ../../data/spleen/images --xnat-url http://127.0.0.1:8105 --xnat-url http://127.0.0.1:8107' ``` (The `$`-escaping trap still applies to any hand-written `EXTRA_ARGS`: `make -C flip-api …` expands once, so diff --git a/docs/source/user-guides/user-data-enrichment.rst b/docs/source/user-guides/user-data-enrichment.rst index 88ae4c468..014065fb2 100644 --- a/docs/source/user-guides/user-data-enrichment.rst +++ b/docs/source/user-guides/user-data-enrichment.rst @@ -206,10 +206,11 @@ The spleen segmentation tutorials ship a complete, runnable version of this work make -C fl-tutorials download-spleen-data NUM_CASES=41 make -C fl-tutorials upload-spleen-labels FLIP_PROJECT_ID= \ - XNAT_URLS="http://127.0.0.1:8104 http://127.0.0.1:8106" DRY_RUN=1 + XNAT_URLS="http://127.0.0.1:8105 http://127.0.0.1:8107" DRY_RUN=1 -Drop ``DRY_RUN=1`` to perform the upload. The two URLs are the dev roster's XNATs — GSTT on 8104 and -KCH on 8106 — and one invocation enriches both. For per-Trust logins, pass +Drop ``DRY_RUN=1`` to perform the upload. The two URLs are the dev roster's XNATs — GSTT on 8105 and +KCH on 8107 — and one invocation enriches both. These are the XNAT web ports; 8104 and 8106 are the +DICOM receiver ports and will not answer a REST call. For per-Trust logins, pass ``XNAT_CREDENTIALS_FILES`` instead. ``NUM_CASES=41`` matters: the mapping covers 41 accessions, and a smaller download silently enriches only part of the cohort, which the command now warns about. diff --git a/fl-tutorials/flower/3d_spleen_segmentation/README.md b/fl-tutorials/flower/3d_spleen_segmentation/README.md index 688f07ae2..493dcb3e4 100644 --- a/fl-tutorials/flower/3d_spleen_segmentation/README.md +++ b/fl-tutorials/flower/3d_spleen_segmentation/README.md @@ -196,11 +196,12 @@ export XNAT_USER=your-username export XNAT_PASS=your-password make -C fl-tutorials upload-spleen-labels FL_BACKEND=flower FLIP_PROJECT_ID= \ - XNAT_URLS="http://127.0.0.1:8104 http://127.0.0.1:8106" DRY_RUN=1 + XNAT_URLS="http://127.0.0.1:8105 http://127.0.0.1:8107" DRY_RUN=1 ``` `DRY_RUN=1` reports what would happen without changing anything — do that first, then drop it to upload. -One invocation covers every Trust in `XNAT_URLS` (above, the dev roster: GSTT on 8104, KCH on 8106), which +One invocation covers every Trust in `XNAT_URLS` (above, the dev roster: GSTT on 8105, KCH on 8107 — +the XNAT **web** ports; 8104 and 8106 are the DICOM receivers and will not answer a REST call), which matters because each Trust's XNAT holds only its own studies and a Trust left without labels fails training. > **This tutorial's download covers only part of the cohort.** `download-spleen-data FL_BACKEND=flower` diff --git a/fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/README.md b/fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/README.md index f5911019b..6a90fdf9d 100644 --- a/fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/README.md +++ b/fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/README.md @@ -180,7 +180,7 @@ export XNAT_USER=your-username export XNAT_PASS=your-password make -C fl-tutorials upload-spleen-labels FLIP_PROJECT_ID= \ - XNAT_URLS="http://127.0.0.1:8104 http://127.0.0.1:8106" DRY_RUN=1 + XNAT_URLS="http://127.0.0.1:8105 http://127.0.0.1:8107" DRY_RUN=1 ``` `DRY_RUN=1` reports what would happen without changing anything — do that first, then drop it to @@ -188,8 +188,8 @@ upload. **Enrich every Trust.** Each Trust's XNAT holds only its own studies, so the project is enriched only once all of them are; a Trust left without labels fails training at the zero-pairs guard. The -`XNAT_URLS` list above is the dev roster — GSTT on 8104, KCH on 8106 — and one invocation covers -both. Where the Trusts need different logins, repeat `XNAT_CREDENTIALS_FILES` instead. Sending the +`XNAT_URLS` list above is the dev roster — GSTT on 8105, KCH on 8107, the XNAT **web** ports; 8104 +and 8106 are the DICOM receivers and will not answer a REST call — and one invocation covers both. Where the Trusts need different logins, repeat `XNAT_CREDENTIALS_FILES` instead. Sending the whole mapping to every Trust is safe: an accession exists at exactly one site, and the others report it as "no matching scan". diff --git a/flip-api/Makefile b/flip-api/Makefile index 5301a37cd..0bdfe323a 100644 --- a/flip-api/Makefile +++ b/flip-api/Makefile @@ -164,8 +164,9 @@ e2e_smoke: # # Every trust needs enriching, not just one: each trust's XNAT holds only its own studies, so a # trust left without labels takes the whole run down at that guard. SPLEEN_XNAT_URLS is the roster -# the uploader visits in a single invocation — it defaults to the two dev trusts (GSTT :8104, -# KCH :8106; IPv4 literals because "localhost" can resolve to ::1, which the swarm-published +# the uploader visits in a single invocation — it defaults to the two dev trusts (GSTT :8105, +# KCH :8107 — the XNAT_WEB_PORT of each, not XNAT_PORT, which is the DICOM SCP receiver since the +# FLIP#993 port split; IPv4 literals because "localhost" can resolve to ::1, which the swarm-published # XNAT ports do not answer on). Override it for any other roster, or blank it to fall back to a # single XNAT_HOST. # @@ -177,7 +178,7 @@ e2e_smoke: # dir, two levels below the repo root), so relative paths written from flip-api/ would not # resolve there. SPLEEN_ENRICHMENT_CWD := ../fl-tutorials/datasets/spleen -SPLEEN_XNAT_URLS ?= http://127.0.0.1:8104 http://127.0.0.1:8106 +SPLEEN_XNAT_URLS ?= http://127.0.0.1:8105 http://127.0.0.1:8107 # The MSD build by default even though the uploaded app is Flower's: enrichment is # backend-agnostic, and only the MSD download can reach all 41 mapped cases. SPLEEN_LABELS_DIR ?= $(abspath ../fl-tutorials/data/spleen/images)