diff --git a/.github/workflows/test_helm_chart.yml b/.github/workflows/test_helm_chart.yml index fe2759399..b1fcc3925 100644 --- a/.github/workflows/test_helm_chart.yml +++ b/.github/workflows/test_helm_chart.yml @@ -101,6 +101,26 @@ jobs: --set omopDb.external.host=test.example.com \ > /dev/null + # monailabel.enabled defaults false, so every other render skips its whole body — + # render coverage only: the kind install job must NOT enable it (the image is + # ~10.4 GB and needs a GPU, neither of which kind has). Also asserts the + # publicUrl `required` guard actually fires, since it is the one setting that + # cannot be defaulted (the clinician's browser calls it — see trust/README.md). + - name: Render template (monailabel enabled) + run: | + helm template trust-release deploy/providers/kubernetes/ \ + --set monailabel.enabled=true \ + --set monailabel.publicUrl=http://node.example.com:30030 > /tmp/monailabel.yaml + if ! grep -q "flip-trust.*-monailabel" /tmp/monailabel.yaml; then + echo "::error::monailabel resources did not render with monailabel.enabled=true" + exit 1 + fi + if helm template trust-release deploy/providers/kubernetes/ \ + --set monailabel.enabled=true > /dev/null 2>&1; then + echo "::error::monailabel rendered without publicUrl — the required guard is gone" + 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/deploy/providers/kubernetes/README.md b/deploy/providers/kubernetes/README.md index 307def5e8..a7c44bb33 100644 --- a/deploy/providers/kubernetes/README.md +++ b/deploy/providers/kubernetes/README.md @@ -237,6 +237,34 @@ Available services: | `observability.loki` | Log aggregation | Yes | | `observability.alloy` | Log collection agent | No (DaemonSet) | | `observability.grafana` | Metrics dashboard | Yes | +| `monailabel` | Optional AI-assisted annotation in the XNAT OHIF viewer (off by default) | Yes | + +### MONAI Label (optional) + +Off by default — needs an NVIDIA GPU node and a ~10.4 GB image. Enable with: + +```yaml +monailabel: + enabled: true + publicUrl: "http://:30030" # REQUIRED: the clinician's BROWSER calls this — + # XNAT stores it and never proxies it +``` + +Chart-specific notes (the full operational guide, including the stock-viewer quirks, is +[trust/README.md#monai-label-optional](../../../trust/README.md#monai-label-optional)): + +- Reads DICOM straight off xnat-web's archive PVC (read-only `archive` subPath). With the + default `ReadWriteOnce` storage the pod is pinned to xnat-web's node + (`coScheduleWithXnat: true`); only set it `false` on an RWX storage class. +- Exposed as a `NodePort` (default `30030`) so the browser can reach it; a scoped + NetworkPolicy opens exactly that port through the namespace's default-deny ingress + (`service.allowExternalIngress`). The API is unauthenticated — restrict node-port reach + at the network layer. +- Pretrained weights (incl. the ~900 MB SAM checkpoint) persist in the + `monailabel-models` PVC; first start on a cold volume takes minutes (the startup probe + allows 30). +- The chart's XNAT already ships the `ohif-viewer` plugin in its roster (`xnat.web.plugins`), + so no extra plugin step — unlike the compose trust, which deliberately excludes it (FLIP#662). ### External Service Override diff --git a/deploy/providers/kubernetes/templates/monailabel.yaml b/deploy/providers/kubernetes/templates/monailabel.yaml new file mode 100644 index 000000000..8b49b4416 --- /dev/null +++ b/deploy/providers/kubernetes/templates/monailabel.yaml @@ -0,0 +1,223 @@ +# 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. + +{{- if .Values.monailabel.enabled }} +# Optional MONAI Label server: AI-assisted annotation (SAM click-to-segment, DeepEdit +# multi-organ) in the XNAT OHIF viewer. Mirrors the compose overlay +# (trust/deploy/compose_trust.*.monailabel.yml) — see trust/README.md#monai-label-optional +# for the operational caveats (browser-reachable URL, unauthenticated API, stock-viewer +# quirks). Unlike compose, the chart's XNAT already ships the ohif-viewer plugin in its +# roster, so there is no separate plugin step here. +# +# The entrypoint handles XNAT registration itself: it polls XNAT and the service account, +# starts the server, then PUTs monailabel.publicUrl to /xapi/ohifaiaa/servers and fails +# loudly if the registration is rejected. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "flip-trust.fullname" . }}-monailabel + namespace: {{ include "flip-trust.namespace" . }} + labels: + {{- include "flip-trust.labels" . | nindent 4 }} + app.kubernetes.io/component: monailabel +spec: + replicas: 1 + # Recreate: the weights PVC is ReadWriteOnce and the server holds GPU memory — a rolling + # update would deadlock on both. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "flip-trust.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: monailabel + template: + metadata: + labels: + {{- include "flip-trust.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: monailabel + spec: + enableServiceLinks: false + {{- if .Values.imagePullSecrets }} + imagePullSecrets: +{{ include "flip-trust.imagePullSecrets" . | nindent 8 }} + {{- end }} + {{- if .Values.monailabel.gpu.enabled }} + tolerations: + - key: nvidia.com/gpu + operator: Exists + effect: NoSchedule + {{- end }} + {{- if .Values.monailabel.coScheduleWithXnat }} + # The XNAT archive lives on xnat-web's data PVC. With the default ReadWriteOnce + # access mode that volume can only be mounted on the node where xnat-web runs, so + # this pod must land there too. Set coScheduleWithXnat: false only on an RWX + # storage class. + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + {{- include "flip-trust.selectorLabels" . | nindent 18 }} + app.kubernetes.io/component: xnat-web + topologyKey: kubernetes.io/hostname + {{- end }} + containers: + - name: monailabel + image: "{{ .Values.monailabel.image.repository }}:{{ .Values.monailabel.image.tag }}" + imagePullPolicy: {{ .Values.monailabel.image.pullPolicy }} + env: + - name: MONAI_LABEL_PORT + value: {{ .Values.monailabel.port | quote }} + - name: MONAI_LABEL_DATASTORE + value: "xnat" + - name: MONAI_LABEL_DATASTORE_URL + value: "http://xnat-web:8080" + - name: MONAI_LABEL_DATASTORE_USERNAME + valueFrom: + secretKeyRef: + name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} + key: xnat-service-user + - name: MONAI_LABEL_DATASTORE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} + key: xnat-service-password + # Mounted at the archive itself, not its parent: the datastore resolves files + # as + , so a parent + # path silently misses and falls back to per-scan HTTP downloads. + - name: MONAI_LABEL_DATASTORE_ASSET_PATH + value: "/workspace/xnat-data/archive" + - name: MONAI_LABEL_DATASTORE_PROJECT + value: {{ .Values.monailabel.projects | quote }} + - name: MONAI_LABEL_MODELS + value: {{ .Values.monailabel.models | quote }} + # Handed to the OHIF viewer, which calls it from the clinician's BROWSER — + # XNAT never proxies it. Must resolve outside the cluster, which the chart + # cannot derive, so it is required rather than defaulted. + - name: MONAI_LABEL_PUBLIC_URL + value: {{ required "monailabel.publicUrl is required when monailabel.enabled — the URL the clinician's browser uses to reach the MONAI Label service (e.g. http://:)" .Values.monailabel.publicUrl | quote }} + ports: + - name: http + containerPort: {{ .Values.monailabel.port }} + # Model load downloads pretrained weights on a cold volume (minutes, incl. the + # ~900MB SAM checkpoint); the startup probe gives it half an hour before the + # readiness probe takes over. + startupProbe: + httpGet: + path: /info/ + port: http + periodSeconds: 15 + failureThreshold: 120 + readinessProbe: + httpGet: + path: /info/ + port: http + periodSeconds: 30 + resources: + {{- if .Values.monailabel.gpu.enabled }} + limits: + nvidia.com/gpu: {{ .Values.monailabel.gpu.count | quote }} + {{- end }} + {{- with .Values.monailabel.resources }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: xnat-data + mountPath: /workspace/xnat-data/archive + subPath: archive + # Labels are written back through the XNAT REST API, never the filesystem. + readOnly: true + - name: models + mountPath: /workspace/app/radiology/model + - name: shm + mountPath: /dev/shm + volumes: + - name: xnat-data + persistentVolumeClaim: + claimName: {{ include "flip-trust.fullname" . }}-xnat-web + - name: models + persistentVolumeClaim: + claimName: {{ include "flip-trust.fullname" . }}-monailabel-models + - name: shm + emptyDir: + medium: Memory + sizeLimit: {{ .Values.monailabel.shmSize }} +--- +apiVersion: v1 +kind: Service +metadata: + name: monailabel + namespace: {{ include "flip-trust.namespace" . }} + labels: + {{- include "flip-trust.labels" . | nindent 4 }} + app.kubernetes.io/component: monailabel +spec: + # The clinician's browser must reach this directly (see MONAI_LABEL_PUBLIC_URL above), + # so NodePort/LoadBalancer is the working default posture; ClusterIP only makes sense + # behind an ingress that fronts it. + type: {{ .Values.monailabel.service.type }} + selector: + {{- include "flip-trust.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: monailabel + ports: + - name: http + port: {{ .Values.monailabel.port }} + targetPort: http + {{- if and (eq .Values.monailabel.service.type "NodePort") .Values.monailabel.service.nodePort }} + nodePort: {{ .Values.monailabel.service.nodePort }} + {{- end }} +--- +# Pretrained model weights (incl. the SAM checkpoint) — fetched on first start, persisted +# so a pod restart does not re-download them. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "flip-trust.fullname" . }}-monailabel-models + namespace: {{ include "flip-trust.namespace" . }} + labels: + {{- include "flip-trust.labels" . | nindent 4 }} + app.kubernetes.io/component: monailabel +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.monailabel.persistence.storageClassName }} + storageClassName: {{ .Values.monailabel.persistence.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.monailabel.persistence.size }} +{{- if and .Values.networkPolicies.enabled .Values.monailabel.service.allowExternalIngress }} +--- +# The namespace default-denies ingress; the MONAI Label API must be reachable from the +# clinician's browser (outside the cluster), so open exactly its port on exactly its pod. +# The API is unauthenticated and holds the XNAT service-account credentials — matching the +# compose deployment's posture, where the port is host-published; restrict who can reach +# the node port at the network layer. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "flip-trust.fullname" . }}-monailabel-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: monailabel + ingress: + - ports: + - port: {{ .Values.monailabel.port }} + protocol: TCP + policyTypes: + - Ingress +{{- end }} +{{- end }} diff --git a/deploy/providers/kubernetes/values.schema.json b/deploy/providers/kubernetes/values.schema.json index 9ce746c52..080d29f3d 100644 --- a/deploy/providers/kubernetes/values.schema.json +++ b/deploy/providers/kubernetes/values.schema.json @@ -4,7 +4,10 @@ "description": "Schema for values.yaml of the flip-trust Helm chart", "type": "object", "additionalProperties": true, - "required": ["trustName", "trustNumber"], + "required": [ + "trustName", + "trustNumber" + ], "properties": { "trustName": { "type": "string", @@ -17,7 +20,11 @@ }, "environment": { "type": "string", - "enum": ["production", "staging", "development"], + "enum": [ + "production", + "staging", + "development" + ], "description": "Deployment environment" }, "logLevel": { @@ -30,23 +37,34 @@ }, "flBackend": { "type": "string", - "enum": ["nvflare", "flower"], + "enum": [ + "nvflare", + "flower" + ], "description": "FL backend selection" }, "namespace": { "type": "object", "additionalProperties": true, "properties": { - "create": { "type": "boolean" }, - "name": { "type": "string" } + "create": { + "type": "boolean" + }, + "name": { + "type": "string" + } } }, "secrets": { "type": "object", "additionalProperties": true, "properties": { - "create": { "type": "boolean" }, - "existingName": { "type": "string" }, + "create": { + "type": "boolean" + }, + "existingName": { + "type": "string" + }, "data": { "type": "object", "additionalProperties": true @@ -57,8 +75,13 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, - "replicas": { "type": "integer", "minimum": 1 }, + "enabled": { + "type": "boolean" + }, + "replicas": { + "type": "integer", + "minimum": 1 + }, "resources": { "type": "object", "additionalProperties": true, @@ -66,15 +89,23 @@ "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -86,17 +117,29 @@ "liveness": { "type": "object", "properties": { - "initialDelaySeconds": { "type": "integer" }, - "periodSeconds": { "type": "integer" }, - "timeoutSeconds": { "type": "integer" } + "initialDelaySeconds": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } } }, "readiness": { "type": "object", "properties": { - "initialDelaySeconds": { "type": "integer" }, - "periodSeconds": { "type": "integer" }, - "timeoutSeconds": { "type": "integer" } + "initialDelaySeconds": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } } } } @@ -104,16 +147,26 @@ "image": { "type": "object", "properties": { - "repository": { "type": "string" }, - "tag": { "type": "string" }, - "pullPolicy": { "type": "string" } + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string" + } } }, "service": { "type": "object", "properties": { - "port": { "type": "integer" }, - "type": { "type": "string" } + "port": { + "type": "integer" + }, + "type": { + "type": "string" + } } } } @@ -122,8 +175,13 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, - "replicas": { "type": "integer", "minimum": 1 }, + "enabled": { + "type": "boolean" + }, + "replicas": { + "type": "integer", + "minimum": 1 + }, "resources": { "type": "object", "additionalProperties": true, @@ -131,15 +189,23 @@ "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -151,17 +217,29 @@ "liveness": { "type": "object", "properties": { - "initialDelaySeconds": { "type": "integer" }, - "periodSeconds": { "type": "integer" }, - "timeoutSeconds": { "type": "integer" } + "initialDelaySeconds": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } } }, "readiness": { "type": "object", "properties": { - "initialDelaySeconds": { "type": "integer" }, - "periodSeconds": { "type": "integer" }, - "timeoutSeconds": { "type": "integer" } + "initialDelaySeconds": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } } } } @@ -169,16 +247,26 @@ "image": { "type": "object", "properties": { - "repository": { "type": "string" }, - "tag": { "type": "string" }, - "pullPolicy": { "type": "string" } + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string" + } } }, "service": { "type": "object", "properties": { - "port": { "type": "integer" }, - "type": { "type": "string" } + "port": { + "type": "integer" + }, + "type": { + "type": "string" + } } } } @@ -187,8 +275,13 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, - "replicas": { "type": "integer", "minimum": 1 }, + "enabled": { + "type": "boolean" + }, + "replicas": { + "type": "integer", + "minimum": 1 + }, "resources": { "type": "object", "additionalProperties": true, @@ -196,15 +289,23 @@ "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -216,17 +317,29 @@ "liveness": { "type": "object", "properties": { - "initialDelaySeconds": { "type": "integer" }, - "periodSeconds": { "type": "integer" }, - "timeoutSeconds": { "type": "integer" } + "initialDelaySeconds": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } } }, "readiness": { "type": "object", "properties": { - "initialDelaySeconds": { "type": "integer" }, - "periodSeconds": { "type": "integer" }, - "timeoutSeconds": { "type": "integer" } + "initialDelaySeconds": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } } } } @@ -234,16 +347,26 @@ "image": { "type": "object", "properties": { - "repository": { "type": "string" }, - "tag": { "type": "string" }, - "pullPolicy": { "type": "string" } + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string" + } } }, "service": { "type": "object", "properties": { - "port": { "type": "integer" }, - "type": { "type": "string" } + "port": { + "type": "integer" + }, + "type": { + "type": "string" + } } } } @@ -252,22 +375,32 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, + "enabled": { + "type": "boolean" + }, "resources": { "type": "object", "properties": { "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -275,8 +408,12 @@ "gpu": { "type": "object", "properties": { - "enabled": { "type": "boolean" }, - "count": { "type": "integer" } + "enabled": { + "type": "boolean" + }, + "count": { + "type": "integer" + } } } } @@ -285,16 +422,33 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, + "enabled": { + "type": "boolean" + }, "vocabLoad": { "type": "object", "properties": { - "enabled": { "type": "boolean" }, - "s3Bucket": { "type": "string" }, - "bundleName": { "type": "string" }, - "workDirSize": { "type": "string", "minLength": 1 }, - "fetchResources": { "type": "object", "minProperties": 1 }, - "loadResources": { "type": "object", "minProperties": 1 } + "enabled": { + "type": "boolean" + }, + "s3Bucket": { + "type": "string" + }, + "bundleName": { + "type": "string" + }, + "workDirSize": { + "type": "string", + "minLength": 1 + }, + "fetchResources": { + "type": "object", + "minProperties": 1 + }, + "loadResources": { + "type": "object", + "minProperties": 1 + } } }, "resources": { @@ -303,15 +457,23 @@ "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -322,23 +484,36 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, - "replicas": { "type": "integer", "minimum": 1 }, + "enabled": { + "type": "boolean" + }, + "replicas": { + "type": "integer", + "minimum": 1 + }, "resources": { "type": "object", "properties": { "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -349,26 +524,38 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, + "enabled": { + "type": "boolean" + }, "web": { "type": "object", "properties": { - "enabled": { "type": "boolean" }, + "enabled": { + "type": "boolean" + }, "resources": { "type": "object", "properties": { "requests": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } }, "limits": { "type": "object", "properties": { - "memory": { "type": "string" }, - "cpu": { "type": "string" } + "memory": { + "type": "string" + }, + "cpu": { + "type": "string" + } } } } @@ -384,19 +571,25 @@ "loki": { "type": "object", "properties": { - "enabled": { "type": "boolean" } + "enabled": { + "type": "boolean" + } } }, "alloy": { "type": "object", "properties": { - "enabled": { "type": "boolean" } + "enabled": { + "type": "boolean" + } } }, "grafana": { "type": "object", "properties": { - "enabled": { "type": "boolean" } + "enabled": { + "type": "boolean" + } } } } @@ -405,28 +598,158 @@ "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, - "minReplicas": { "type": "integer" }, - "maxReplicas": { "type": "integer" } + "enabled": { + "type": "boolean" + }, + "minReplicas": { + "type": "integer" + }, + "maxReplicas": { + "type": "integer" + } } }, "networkPolicies": { "type": "object", "additionalProperties": true, "properties": { - "enabled": { "type": "boolean" }, + "enabled": { + "type": "boolean" + }, "allowedEgressCIDRsWithPorts": { "type": "array", "description": "Egress rules combining CIDRs with a specific port (e.g. fl-server gRPC on 8002)", "items": { "type": "object", - "required": ["cidrs", "port"], + "required": [ + "cidrs", + "port" + ], "properties": { - "cidrs": { "type": "array", "items": { "type": "string" } }, - "port": { "type": "integer" }, - "protocol": { "type": "string", "enum": ["TCP", "UDP", "SCTP"] } + "cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string", + "enum": [ + "TCP", + "UDP", + "SCTP" + ] + } + } + } + } + } + }, + "monailabel": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string", + "enum": [ + "Always", + "IfNotPresent", + "Never" + ] + } + } + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "models": { + "type": "string" + }, + "projects": { + "type": "string" + }, + "publicUrl": { + "type": "string" + }, + "shmSize": { + "type": "string" + }, + "gpu": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "count": { + "type": "integer", + "minimum": 0 + } + } + }, + "coScheduleWithXnat": { + "type": "boolean" + }, + "service": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ClusterIP", + "NodePort", + "LoadBalancer" + ] + }, + "nodePort": { + "anyOf": [ + { + "type": "integer", + "minimum": 30000, + "maximum": 32767 + }, + { + "type": "null" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "allowExternalIngress": { + "type": "boolean" } } + }, + "persistence": { + "type": "object", + "properties": { + "size": { + "type": "string" + }, + "storageClassName": { + "type": "string" + } + } + }, + "resources": { + "type": "object" } } } diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index 03bc23ccb..1bedc8680 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -648,6 +648,51 @@ xnat: memory: "256Mi" cpu: "200m" +# --------------------------------------------------------------------------- +# MONAI Label — optional AI-assisted annotation in the XNAT OHIF viewer +# --------------------------------------------------------------------------- +# Off by default: needs an NVIDIA GPU node and pulls a ~10.4GB image. Mirrors the compose +# overlay (trust/deploy/compose_trust.*.monailabel.yml); operational caveats — the +# browser-reachable URL, the unauthenticated API, the stock-viewer quirks — are documented +# in trust/README.md#monai-label-optional. +monailabel: + enabled: false + image: + repository: ghcr.io/londonaicentre/monailabel + tag: stag + pullPolicy: IfNotPresent + port: 8030 + # Radiology models to load. 'all' loads nine, each downloading its own weights; the SAM + # tasks (sam_2d/sam_3d) register whenever the sam2 package is importable, independent of + # this list. + models: "deepedit" + # Comma-separated XNAT project allowlist; empty means every project on this trust. + projects: "" + # REQUIRED when enabled: the URL the clinician's BROWSER uses to reach the service + # (e.g. http://:30030). XNAT stores it and hands it to the OHIF viewer — it is + # never proxied, so a cluster-internal name will not work. + publicUrl: "" + # /dev/shm for dataloader workers (emptyDir, medium: Memory). + shmSize: 8Gi + gpu: + enabled: true + count: 1 + # Pin the pod to xnat-web's node so the archive PVC (ReadWriteOnce by default) can be + # mounted read-only here too. Set false only on an RWX storage class. + coScheduleWithXnat: true + service: + type: NodePort + # Fixed nodePort (30000-32767) so publicUrl stays stable; empty lets the cluster pick. + nodePort: 30030 + # Adds a NetworkPolicy opening the service port on this pod through the namespace's + # default-deny ingress — required for browser access when networkPolicies.enabled. + allowExternalIngress: true + persistence: + # Pretrained weights (incl. the ~900MB SAM checkpoint). + size: 20Gi + storageClassName: "" + resources: {} + # --------------------------------------------------------------------------- # Observability Stack — Loki, Alloy, Grafana # ---------------------------------------------------------------------------