diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8895fd1b..e32109b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: # tests need. numpy is required because constants.py imports it; requests # because Authlib's Flask client imports it. - name: Install test deps - run: python -m pip install --upgrade pip pytest werkzeug SQLAlchemy Flask-SQLAlchemy alembic argon2-cffi Authlib requests flask-cors numpy scipy nibabel openpyxl pillow python-dotenv reportlab websockets scikit-image pandas trimesh + run: python -m pip install --upgrade pip pytest SQLAlchemy Flask-SQLAlchemy alembic argon2-cffi Authlib flask-cors numpy scipy nibabel pillow python-dotenv reportlab websockets scikit-image pandas trimesh -r readonly_data/requirements.txt # Catches syntax errors anywhere in the backend (api_blueprint.py etc.) # without installing the heavy runtime deps - nothing else in CI parses @@ -80,6 +80,9 @@ jobs: - name: Path-safety unit tests run: python -m pytest tests/unit/test_path_safety.py -v + - name: Read-only development data boundary and snapshot tests + run: python -m pytest tests/unit/test_readonly_data.py -v + # User-dataset admission gatekeeper: quotas, dedup, CT/segmentation validity, # promotion into the PanTS-mirroring layout. - name: User-dataset gatekeeper unit tests diff --git a/PanTS-Demo/vite.config.ts b/PanTS-Demo/vite.config.ts index f3bf8a95..459a307c 100644 --- a/PanTS-Demo/vite.config.ts +++ b/PanTS-Demo/vite.config.ts @@ -62,9 +62,9 @@ export default defineConfig({ cors: true, proxy: { "/api": { - target: env.VITE_API_BASE, + target: env.VITE_PROXY_TARGET || env.VITE_API_BASE || "http://127.0.0.1:5001", changeOrigin: true, - secure: false, + secure: true, }, "/ws": { target: env.VITE_WS_BASE || "ws://127.0.0.1:8001", diff --git a/README.md b/README.md index 5750bdb5..3b489b7d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Backend +For local frontend/backend development using approved JHU cases, follow +[read-only development setup](flask-server/deploy/READONLY_DEVELOPMENT.md). +It uses a restricted data service and a local database; server provisioning is a +separate reviewed step. Do not point development writes at the production API. + The viewer's AI assistant also needs its model service running. See [AI model setup and persistent Ollama service](PanTS-Demo/src/components/AIAssistant/README_AI_MODEL_SETUP.md#hosting-the-bodymaps-ai-models-ollama-on-the-server) for installation, restart, and health checks. Restarting Flask alone does not diff --git a/flask-server/deploy/READONLY_DEVELOPMENT.md b/flask-server/deploy/READONLY_DEVELOPMENT.md new file mode 100644 index 00000000..33d91c94 --- /dev/null +++ b/flask-server/deploy/READONLY_DEVELOPMENT.md @@ -0,0 +1,194 @@ +# Local development using approved JHU data + +This setup copies explicitly approved PanTS cases through a separate read-only +service. The frontend and normal BodyMaps backend then run on your computer. +The service does not import the production app, load its `.env`, connect to its +database, lease inference jobs, generate meshes, or accept uploads. + +```text +LOCAL COMPUTER JHU (VPN only) +frontend :5173 -> local backend :5001 + | + local snapshot <--- GET --- SSH tunnel -> read service :8766 + local SQLite DB -> existing PanTS files + local uploads/results +``` + +This first version uses selected-case snapshots, not a live mirror of the whole +dataset. A fresh snapshot goes into a new directory; existing local work is never +overwritten. Disconnect the VPN after downloading and continue developing offline. +Missing source meshes are omitted and may be generated by the **local** backend. +GPU inference on JHU and production account/subscription testing are not part of +this connection. Model weights/environments must be installed locally to test +local inference; a separate approved staging worker is needed for JHU GPU tests. + +## What enforces read-only access + +- A dedicated service account, without sudo or membership in production groups. + The systemd service has a read-only filesystem, hidden home directories, and + read-only dataset mounts. Only private temporary files and service logs change. +- A separate SSH account/key with forwarding restricted to this service's port. + Shells, commands, SFTP, other ports and reverse tunnels are denied by sshd. +- An expiring bearer token per developer, containing a hash on the server and an + explicit case allowlist. There is no wildcard/full-dataset grant. +- Three GET/HEAD endpoints only: `/v1/catalog`, `/v1/cases/`, and + `/v1/cases//assets/`. Asset names are fixed; arbitrary paths, + symlink escapes, request bodies, query parameters and write methods are denied. +- The local launcher discards inherited application credentials and disables + backend `.env` loading. Vite targets only the local backend. Production keys and + read-service tokens are never passed to the browser. + +Do not use a tunnel to production port 8000 as a substitute. Some production GET +routes have side effects (job leasing and on-demand mesh generation). Also, an +unrestricted `visitor` SSH key retains its existing production powers: using that +key with `ssh -N` does not turn it into read-only access. + +## Developer setup (after administrator provisioning) + +Use the README's Python 3.11 backend environment and Node installation. Install +the backend requirements and run `npm ci` in `PanTS-Demo`. Use a private directory +outside the checkout and OneDrive for tokens, snapshots and test state. + +From `flask-server` in PowerShell, with the backend environment activated: + +```powershell +$devRoot = Join-Path $env:LOCALAPPDATA 'BodyMapsDev' +New-Item -ItemType Directory -Force -Path $devRoot | Out-Null +python -m readonly_data.credentials --cases 35,8854 --days 7 --token-file "$devRoot\jhu.token" --grant-file "$devRoot\jhu-grant.json" +``` + +The cases are examples: the data owner must approve them. Send only +`jhu-grant.json` and your **dedicated SSH public key** to the administrator over +the team's approved channel. Keep `jhu.token` private; on Windows verify that its +file ACL permits only your user and administrators. The grant is inactive until +an administrator installs it. There is no production login or admin coupon here. + +Connect the JHU VPN and open a separate terminal, replacing the SSH username and +key path with the dedicated account/key the administrator provisioned: + +```powershell +ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -i "$env:USERPROFILE\.ssh\bodymaps-data-ro" -L 127.0.0.1:8766:127.0.0.1:8766 YOUR_READONLY_USER@bdmap1.wse.jhu.edu +``` + +Keep that terminal open during import. From `flask-server`: + +```powershell +python -m readonly_data.client --token-file "$devRoot\jhu.token" --cases 35,8854 --output "$devRoot\snapshot-01" --max-gb 10 +``` + +Only approved cases are fetched. The size limit includes all scan/mesh bytes. +There is no upload operation and HTTP redirects are refused. Source changes or +interrupted downloads leave an `INCOMPLETE` marker; the launcher refuses that +snapshot. Retry into a different new directory. Snapshots are copies of existing +files, not atomic database backups. The shared read limit is 120 requests/minute; +the client retries HTTP 429 twice at 60-second intervals. + +Open two terminals in `flask-server`, with the Python environment activated, and +set `$devRoot` in each as above: + +```powershell +# Terminal 1: local backend and a new local database (Alembic runs automatically) +python -m readonly_data.local backend --snapshot "$devRoot\snapshot-01" --state "$devRoot\state-01" + +# Terminal 2: local frontend +python -m readonly_data.local frontend --snapshot "$devRoot\snapshot-01" --state "$devRoot\state-01" +``` + +Open **http://127.0.0.1:5173/dashboard**. API requests go through Vite to +**http://127.0.0.1:5001**. Local email registration uses the new SQLite database; +OAuth and SMTP credentials are absent. Ports already in use cause startup to +fail; stop the previous local instance first. Live-room WebSockets need the +separate local service described in `LIVE_ROOMS.md`; no JHU WebSocket proxy is set. + +## Administrator: reviewed one-time installation + +**Not an automatic deploy script.** Perform this only after its PR is approved, +merged and explicitly approved for this host. None of these settings are active +just because these files exist in Git. Provisioning needs a JHU administrator +with sudo; a production application account is not a substitute. + +1. Record the approved full commit SHA and PR. Export only + `flask-server/readonly_data` and `flask-server/deploy/readonly` from that exact + merged commit into a **new** root-owned release directory under + `/opt/bodymaps-readonly/releases//`. Do not copy `.env`, databases, + credentials, uploaded scans, or the main app. Make + `/opt/bodymaps-readonly/current` point to that reviewed release. The service + account must not be able to change any release, symlink or virtual environment. +2. Create the dedicated system account/group `bodymaps-data-ro` with no login or + sudo, a root-owned `/opt/bodymaps-readonly/venv` using Python 3.11, and install + that release's `readonly_data/requirements.txt` in it. Create mount targets + `/srv/bodymaps-readonly/PanTS` and optionally `meshes`. Verify the real dataset + path and existing metadata layout before starting anything. Grant only the + required read/traverse ACLs if necessary; do not make production data + world-readable or grant access to the production application directory. +3. Create `/etc/bodymaps-readonly` owned by root, group `bodymaps-data-ro`, mode + 0750. Create `tokens.json` with `[]`, owner root, group `bodymaps-data-ro`, mode + 0640. After the data owner approves a grant, append that grant object to the + JSON array using an atomic replacement that preserves owner/group/mode. + Confirm its case list and expiry. Plaintext tokens never belong on JHU. +4. Review `readonly/bodymaps-readonly.service`. Confirm its dataset mount; enable + the two commented mesh lines only if that verified mesh directory exists. + Install as `/etc/systemd/system/bodymaps-readonly.service`. Run + `systemd-analyze verify /etc/systemd/system/bodymaps-readonly.service`, then + `systemctl daemon-reload` and `systemctl enable --now bodymaps-readonly`. + Check `journalctl -u bodymaps-readonly` and `ss -ltnp`: **127.0.0.1:8766 only**. + Do not weaken namespace restrictions if startup fails; check ACLs and mounts. + Keep one worker because the request limit is process-local. No nginx/public + route or production restart is needed. +5. Create a separate `bodymaps-data-tunnel` group and one nonprivileged SSH + account per developer. Those accounts get no dataset/app groups or sudo. + Install their dedicated public keys in root-controlled authorized-key files. + Review and install the Match block in `readonly/sshd-readonly.conf` under the + host's sshd configuration. Never put `visitor` or deployment accounts in this + group. Keep an existing admin SSH session open; run `sshd -t` and inspect + `sshd -T -C user=THE_USER,host=bdmap1.wse.jhu.edu,addr=CLIENT_VPN_IP` before + reloading SSH. Verify all effective restrictions, not just the template. +6. With the dedicated account, verify a GET succeeds through the tunnel, an + unapproved case gives 404, POST gives 405, and an expired token gives 401. + Verify shell/SFTP and forwarding to ports 8000/11434 are denied. Inside the + service's mount namespace, inspect the mount flags to confirm the dataset is + read-only; confirm the service UID cannot read production secrets/database. + Check the production site's health and unchanged application revision. + +The systemd/SSH restrictions require validation on the actual Linux host. Local +Windows tests cover the application boundary, not the server's OS configuration. +Reads consume some JHU disk/network bandwidth and create access logs; they do not +edit the source scans or app. The service limits CPU, memory and concurrency. + +To revoke access, remove the hash from `tokens.json` atomically and remove that +developer's SSH key. Token changes apply on the next request; a download already +in progress may finish. To disable the entire facility, stop/disable only +`bodymaps-readonly`; production gunicorn/nginx/Ollama remain untouched. + +## Approval and deployment workflow + +1. Develop on a feature branch with the isolated local state above. +2. Push a PR. Run CI, review the diff, and obtain the team's approval before merge. +3. Obtain explicit deployment approval for the **merged commit SHA**. A push or + PR merge alone must not restart or copy files to JHU. +4. An authorized deployer installs that exact revision during the approved + window, verifies health, and retains the previous release for rollback. + +This PR introduces **no automatic JHU deployment**. Repository administrators +should configure protected `main` rules (required PR approval and CI; no direct +push/force push) and, before adding automation, a `jhu-production` environment +with required reviewers. Those GitHub settings require repository-admin access +and are not enforced by adding a YAML file or this document. Existing privileged +server credentials can still change production; keep them out of developer +machines/CI and give routine testers only the restricted credentials above. + +## Validation + +```powershell +cd flask-server +python -m pytest tests/unit/test_readonly_data.py -v +``` + +Tests use synthetic bytes and a temporary XLSX, not JHU scans. They cover scoped +reads, expiry/revocation, malformed grants, traversal/symlink protection, method +restrictions, existing-file-only behavior, real HTTP snapshot import, size limits, +source hashes/mtime preservation and local environment separation. + +Implementation references: [OpenSSH forwarding/session restrictions](https://man.openbsd.org/sshd_config), +[systemd read-only mounts and sandboxing](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html), +and [python-dotenv disable switch](https://bbc2.github.io/python-dotenv/#disable-load_dotenv). diff --git a/flask-server/deploy/readonly/bodymaps-readonly.service b/flask-server/deploy/readonly/bodymaps-readonly.service new file mode 100644 index 00000000..46e7b0ad --- /dev/null +++ b/flask-server/deploy/readonly/bodymaps-readonly.service @@ -0,0 +1,42 @@ +[Unit] +Description=BodyMaps approved dataset reads for local development +After=network.target + +[Service] +Type=simple +User=bodymaps-data-ro +Group=bodymaps-data-ro +WorkingDirectory=/opt/bodymaps-readonly/current/flask-server +Environment=PYTHONDONTWRITEBYTECODE=1 +Environment=PYTHON_DOTENV_DISABLED=1 +Environment=BODYMAPS_RO_DATA_ROOT=/srv/bodymaps-readonly/PanTS +Environment=BODYMAPS_RO_TOKENS_FILE=/etc/bodymaps-readonly/tokens.json +# Existing meshes are optional. Verify the actual production path before enabling. +# Environment=BODYMAPS_RO_MESH_ROOT=/srv/bodymaps-readonly/meshes +# BindReadOnlyPaths=/home/visitor/data/render_only:/srv/bodymaps-readonly/meshes +BindReadOnlyPaths=/mnt/bodymaps/zzhou82/data/PanTS:/srv/bodymaps-readonly/PanTS +ExecStart=/opt/bodymaps-readonly/venv/bin/gunicorn --workers 1 --threads 2 --bind 127.0.0.1:8766 --timeout 120 --worker-tmp-dir /tmp --access-logfile - readonly_data.service:create_app() +Restart=on-failure +RestartSec=5 +UMask=0077 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=tmpfs +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectKernelLogs=true +RestrictSUIDSGID=true +LockPersonality=true +CapabilityBoundingSet= +RestrictAddressFamilies=AF_INET AF_UNIX +IPAddressDeny=any +IPAddressAllow=localhost +CPUQuota=50% +MemoryMax=512M +TasksMax=32 + +[Install] +WantedBy=multi-user.target diff --git a/flask-server/deploy/readonly/sshd-readonly.conf b/flask-server/deploy/readonly/sshd-readonly.conf new file mode 100644 index 00000000..0cc7f978 --- /dev/null +++ b/flask-server/deploy/readonly/sshd-readonly.conf @@ -0,0 +1,18 @@ +# Admin-reviewed template. Dedicated accounts in this group have NO shell/SFTP. +# Never add visitor, a deploy user, or a privileged account to this group. +Match Group bodymaps-data-tunnel + AuthenticationMethods publickey + PasswordAuthentication no + KbdInteractiveAuthentication no + AllowTcpForwarding local + PermitOpen 127.0.0.1:8766 + PermitListen none + AllowStreamLocalForwarding no + AllowAgentForwarding no + X11Forwarding no + PermitTTY no + PermitTunnel no + PermitUserRC no + MaxSessions 0 + ForceCommand /usr/sbin/nologin +Match all diff --git a/flask-server/readonly_data/__init__.py b/flask-server/readonly_data/__init__.py new file mode 100644 index 00000000..c18b470e --- /dev/null +++ b/flask-server/readonly_data/__init__.py @@ -0,0 +1 @@ +"""Standalone dataset export service. Never imports the production application.""" diff --git a/flask-server/readonly_data/client.py b/flask-server/readonly_data/client.py new file mode 100644 index 00000000..ee7fc8ae --- /dev/null +++ b/flask-server/readonly_data/client.py @@ -0,0 +1,132 @@ +"""Copy approved cases to a NEW local snapshot. No upload or proxy operation exists.""" +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from urllib.parse import urlsplit + +import requests +from openpyxl import Workbook + +from .service import MESH_RE, case_id + + +def validate_server(url): + parsed = urlsplit(url) + if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}: + raise ValueError("Use an origin URL without credentials, paths or query parameters") + if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "localhost", "::1"}): + raise ValueError("HTTPS is required except for a loopback SSH tunnel") + if not parsed.hostname: + raise ValueError("Server hostname is required") + return url.rstrip("/") + + +def asset_relative(identifier, asset): + identifier = case_id(identifier) + paths = { + "ct": f"PanTS/image_only/{identifier}/ct.nii.gz", + "mask": f"PanTS/mask_only/{identifier}/combined_labels.nii.gz", + "thumbnail": f"PanTS/profile_only/{identifier}/profile.jpg", + "mesh-manifest": f"meshes/{identifier}/manifest.json", + } + if asset in paths: + return paths[asset] + if MESH_RE.fullmatch(asset): + return f"meshes/{identifier}/{asset}" + raise ValueError("Unexpected asset name") + + +def fetch_snapshot(server, token_file, identifiers, output, max_bytes=10 * 1024**3): + server = validate_server(server) + identifiers = list(dict.fromkeys(case_id(value) for value in identifiers)) + if not identifiers or len(identifiers) > 100 or max_bytes <= 0: + raise ValueError("Select 1–100 approved cases and a positive size limit") + token = Path(token_file).read_text(encoding="utf-8").strip() + if not 32 <= len(token) <= 256: + raise ValueError("Invalid developer token file") + output = Path(output).absolute() + # Exclusive creation prevents overwriting snapshots, edited masks or results. + output.mkdir(parents=True, exist_ok=False) + (output / "INCOMPLETE").write_text("Download not finished; do not use this snapshot.\n") + session = requests.Session() + session.trust_env = False # No ambient proxy or .netrc credentials. + session.headers["Authorization"] = "Bearer " + token + total = 0 + descriptions = [] + try: + def get(path, **kwargs): + for attempt in range(3): + response = session.get(server + path, allow_redirects=False, timeout=(10, 120), **kwargs) + if response.status_code != 429 or attempt == 2: + break + response.close() + time.sleep(60) + if response.status_code != 200: + response.close() + raise RuntimeError(f"Read service returned HTTP {response.status_code}; no redirects are followed") + return response + + for identifier in identifiers: + with get(f"/v1/cases/{identifier}") as response: + description = response.json() + if description.get("schema") != 1 or description.get("case_id") != identifier: + raise ValueError("Unexpected case metadata") + if not {"ct", "mask"} <= description["assets"].keys(): + raise ValueError(f"{identifier} needs an existing CT and combined mask") + descriptions.append(description) + for asset, info in description["assets"].items(): + expected = int(info["bytes"]) + if expected < 0 or total + expected > max_bytes: + raise ValueError("Snapshot exceeds the configured download limit") + destination = output / asset_relative(identifier, asset) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(destination.suffix + ".part") + written = 0 + with get(f"/v1/cases/{identifier}/assets/{asset}", stream=True) as response, temporary.open("xb") as handle: + for chunk in response.iter_content(1024 * 1024): + written += len(chunk) + if written > expected or total + written > max_bytes: + raise ValueError("Asset size exceeded its declared limit") + handle.write(chunk) + if written != expected: + raise ValueError("Incomplete asset download") + total += written + temporary.replace(destination) + print(f"Fetched {identifier}", flush=True) + columns = descriptions[0]["columns"] + if any(d["columns"] != columns or len(d["row"]) != len(columns) for d in descriptions): + raise ValueError("Inconsistent metadata columns") + workbook = Workbook() + sheet = workbook.active + sheet.title = "PanTS_metadata" + for values in [columns] + [d["row"] for d in descriptions]: + sheet.append(values) + # Treat spreadsheet strings as literal data, never formulas. + for cell in sheet[sheet.max_row]: + if isinstance(cell.value, str): + cell.data_type = "s" + workbook.save(output / "PanTS" / "metadata.xlsx") + (output / "snapshot.json").write_text(json.dumps({"schema": 1, "cases": identifiers, "bytes": total}, indent=2), encoding="utf-8") + (output / "INCOMPLETE").unlink() + return output + finally: + session.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server", default="http://127.0.0.1:8766") + parser.add_argument("--token-file", required=True) + parser.add_argument("--cases", required=True, help="Comma-separated approved PanTS IDs") + parser.add_argument("--output", required=True, help="A new local directory; existing directories are refused") + parser.add_argument("--max-gb", type=float, default=10) + args = parser.parse_args() + result = fetch_snapshot(args.server, args.token_file, args.cases.split(","), args.output, int(args.max_gb * 1024**3)) + print(f"Complete local snapshot: {result}") + + +if __name__ == "__main__": + main() diff --git a/flask-server/readonly_data/credentials.py b/flask-server/readonly_data/credentials.py new file mode 100644 index 00000000..b680a4e3 --- /dev/null +++ b/flask-server/readonly_data/credentials.py @@ -0,0 +1,44 @@ +"""Create a local secret and a non-secret grant for an administrator to approve.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import secrets +import time + +from .service import case_id + + +def prepare_grant(cases, days, token_file, grant_file): + identifiers = sorted({case_id(value) for value in cases}) + if not 1 <= days <= 30 or not 1 <= len(identifiers) <= 100: + raise ValueError("Choose 1-100 cases and an expiry of 1-30 days") + token_file, grant_file = Path(token_file), Path(grant_file) + if token_file.exists() or grant_file.exists() or token_file.absolute() == grant_file.absolute(): + raise ValueError("Use two new files; existing credentials are never overwritten") + token = secrets.token_urlsafe(48) + grant = {"sha256": hashlib.sha256(token.encode()).hexdigest(), "cases": identifiers, + "expires_at": int(time.time()) + days * 86400} + # On Windows, put this in a private user directory and check the ACL. + with os.fdopen(os.open(token_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as stream: + stream.write(token + "\n") + with grant_file.open("x", encoding="utf-8") as stream: + json.dump(grant, stream, indent=2) + return grant + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cases", required=True) + parser.add_argument("--days", type=int, default=7) + parser.add_argument("--token-file", required=True) + parser.add_argument("--grant-file", required=True) + args = parser.parse_args() + prepare_grant(args.cases.split(","), args.days, args.token_file, args.grant_file) + print("Files created. Send only the grant file to the administrator for approval.") + print("Access is disabled until the administrator installs that grant.") + + +if __name__ == "__main__": + main() diff --git a/flask-server/readonly_data/local.py b/flask-server/readonly_data/local.py new file mode 100644 index 00000000..4416c64c --- /dev/null +++ b/flask-server/readonly_data/local.py @@ -0,0 +1,86 @@ +"""Start a local-only BodyMaps backend or frontend with an isolated test state.""" +from __future__ import annotations + +import argparse +import importlib.metadata +import json +import os +from pathlib import Path +import secrets +import shutil +import subprocess +import sys + + +def local_environment(snapshot, state, inherited=None): + snapshot, state = Path(snapshot).resolve(strict=True), Path(state).absolute() + if (snapshot / "INCOMPLETE").exists() or not (snapshot / "PanTS/metadata.xlsx").is_file(): + raise ValueError("Use a completed dataset snapshot") + if json.loads((snapshot / "snapshot.json").read_text())["schema"] != 1: + raise ValueError("Unsupported snapshot") + # Do not inherit production credentials, URLs, filesystem paths or worker keys. + allowed = {"PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", "PATHEXT", "COMSPEC", "LANG", "LC_ALL", "CONDA_PREFIX"} + source = os.environ if inherited is None else inherited + env = {k: v for k, v in source.items() if k.upper() in allowed} + env.update({ + "PYTHON_DOTENV_DISABLED": "1", "FLASK_SKIP_DOTENV": "1", + "PYTHONDONTWRITEBYTECODE": "1", "PYTHONIOENCODING": "utf-8", + "PANTS_PATH": str(snapshot / "PanTS"), "CANCERVERSE_PATH": "", + "CANCERVERSE_LOWRES_PATH": str(state / "lowres-cv"), + "PANTS_LOWRES_PATH": str(state / "lowres"), + "DATABASE_URL": "sqlite:///" + (state / "test.db").as_posix(), + "SESSIONS_DIR_PATH": str(state / "sessions"), + "PERMISSIONS_DIR": str(state / "data"), "MESH_PATH": str(snapshot / "meshes"), + "BODYMAPS_UPLOAD_CHUNK_DIR": str(state / "uploads"), + "USER_DATASET_PATH": str(state / "user-dataset"), + "BODYMAPS_ACCEL_REDIRECT_ENABLED": "false", "EPAI_REMOTE_ENABLED": "false", + "BASE_PATH": "/", "USE_SSL": "false", "TRUST_PROXY": "false", + "PUBLIC_BASE_URL": "http://127.0.0.1:5173", "FRONTEND_URL": "http://127.0.0.1:5173", + "ALLOWED_ORIGINS": "http://127.0.0.1:5173,http://localhost:5173", + "OLLAMA_BASE_URL": "http://127.0.0.1:11434", "SMTP_HOST": "", "SMTP_USER": "", "SMTP_PASSWORD": "", + "GOOGLE_CLIENT_ID": "", "GOOGLE_CLIENT_SECRET": "", "GITHUB_CLIENT_ID": "", "GITHUB_CLIENT_SECRET": "", + "VITE_API_BASE": "", "VITE_PROXY_TARGET": "http://127.0.0.1:5001", + "VITE_WS_BASE": "ws://127.0.0.1:8001", + "VITE_BASENAME": "/", + }) + return env + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("component", choices=["backend", "frontend"]) + parser.add_argument("--snapshot", required=True) + parser.add_argument("--state", required=True, help="Local test database/output directory") + args = parser.parse_args() + # Older dotenv versions do not honor the global disable switch. + version = tuple(int(p) for p in importlib.metadata.version("python-dotenv").split(".")[:2]) + if version < (1, 2): + raise SystemExit("Install the pinned backend requirements (python-dotenv >= 1.2 required)") + env = local_environment(args.snapshot, args.state) + state = Path(args.state).absolute() + state.mkdir(parents=True, exist_ok=True) + for folder in ("sessions", "data", "uploads", "user-dataset"): + (state / folder).mkdir(exist_ok=True) + key = state / "session-secret" + if not key.exists(): + with key.open("x", encoding="utf-8") as stream: + stream.write(secrets.token_hex(32)) + key.chmod(0o600) + env["SECRET_KEY"] = key.read_text(encoding="utf-8").strip() + backend = Path(__file__).resolve().parents[1] + if args.component == "backend": + subprocess.run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=backend, env=env, check=True) + command = [sys.executable, "-m", "flask", "--app", "app:app", "run", "--host", "127.0.0.1", "--port", "5001", "--no-debugger", "--no-reload"] + cwd = backend + else: + npm = shutil.which("npm.cmd" if os.name == "nt" else "npm") + if not npm: + raise SystemExit("Install Node.js and run npm ci in PanTS-Demo first") + command = [npm, "run", "dev", "--", "--host", "127.0.0.1", "--port", "5173", "--strictPort"] + cwd = backend.parent / "PanTS-Demo" + print(f"Local {args.component}; test state: {state}. Production credentials are not inherited.", flush=True) + subprocess.run(command, cwd=cwd, env=env, check=True) + + +if __name__ == "__main__": + main() diff --git a/flask-server/readonly_data/requirements.txt b/flask-server/readonly_data/requirements.txt new file mode 100644 index 00000000..6b5de675 --- /dev/null +++ b/flask-server/readonly_data/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.1.3 +Werkzeug==3.1.9 +openpyxl==3.1.5 +gunicorn==26.2.0; sys_platform != "win32" +requests==2.34.2 diff --git a/flask-server/readonly_data/service.py b/flask-server/readonly_data/service.py new file mode 100644 index 00000000..c3450d07 --- /dev/null +++ b/flask-server/readonly_data/service.py @@ -0,0 +1,210 @@ +"""Read existing PanTS assets only; no production API, database or job access.""" +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import os +import re +import threading +import time +from collections import defaultdict, deque +from pathlib import Path + +from flask import Flask, abort, g, jsonify, request, send_file +from openpyxl import load_workbook + +CASE_RE = re.compile(r"(?:PanTS_)?([0-9]{1,8})\Z", re.ASCII) +MESH_RE = re.compile(r"[a-zA-Z0-9_-]{1,120}\.glb\Z", re.ASCII) +CASE_COLUMNS = ("PanTS ID", "PanTS_ID", "case_id", "id", "case", "CaseID") + + +def case_id(value): + match = CASE_RE.fullmatch(str(value)) + if not match or int(match[1]) < 1: + raise ValueError("Invalid PanTS case ID") + return f"PanTS_{int(match[1]):08d}" + + +def existing_file(root: Path, relative: str) -> Path: + """Allow no file outside a configured export root, including symlink escapes.""" + path = (root / relative).resolve(strict=True) + path.relative_to(root.resolve(strict=True)) + if not path.is_file(): + raise FileNotFoundError(relative) + return path + + +def json_value(value): + if isinstance(value, float) and not math.isfinite(value): + return None + if hasattr(value, "isoformat"): + return value.isoformat() + return value + + +def read_metadata(root): + try: + path = existing_file(root, "metadata.xlsx") + except FileNotFoundError: + path = existing_file(root, "data/metadata.xlsx") + workbook = load_workbook(path, read_only=True, data_only=True) + try: + sheet = workbook["PanTS_metadata"] if "PanTS_metadata" in workbook.sheetnames else workbook.active + rows = sheet.iter_rows(values_only=True) + columns = [str(v or "") for v in next(rows)] + index = next((columns.index(c) for c in CASE_COLUMNS if c in columns), None) + if index is None: + raise ValueError("No recognized case ID column in metadata") + catalog = {} + for row in rows: + try: + identifier = case_id(row[index]) + except ValueError: + continue + catalog[identifier] = [json_value(v) for v in row] + return columns, catalog + finally: + workbook.close() + + +def create_app(config=None): + app = Flask(__name__, static_folder=None) + app.config.update( + DATA_ROOT=os.environ.get("BODYMAPS_RO_DATA_ROOT", ""), + MESH_ROOT=os.environ.get("BODYMAPS_RO_MESH_ROOT", ""), + TOKENS_FILE=os.environ.get("BODYMAPS_RO_TOKENS_FILE", ""), + REQUESTS_PER_MINUTE=120, + MAX_CONTENT_LENGTH=0, + ) + app.config.update(config or {}) + if not app.config["DATA_ROOT"] or not app.config["TOKENS_FILE"]: + raise ValueError("DATA_ROOT and TOKENS_FILE must be configured") + data_root = Path(app.config["DATA_ROOT"]).resolve(strict=True) + mesh_root = Path(app.config["MESH_ROOT"]).resolve(strict=True) if app.config["MESH_ROOT"] else None + columns, metadata = read_metadata(data_root) + attempts = defaultdict(deque) + lock = threading.Lock() + + def token_records(): + # Reload every request: deleting a token immediately revokes access. + records = json.loads(Path(app.config["TOKENS_FILE"]).read_text(encoding="utf-8")) + if not isinstance(records, list) or len(records) > 1000: + raise ValueError("Invalid token configuration") + for record in records: + if not isinstance(record, dict): + raise ValueError("Invalid token record") + if not re.fullmatch(r"[a-f0-9]{64}", record["sha256"]): + raise ValueError("Invalid token hash") + if not isinstance(record["cases"], list) or not record["cases"]: + raise ValueError("Explicit approved cases are required") + record["cases"] = {case_id(c) for c in record["cases"]} + expiry = record["expires_at"] + if isinstance(expiry, bool) or not isinstance(expiry, (int, float)) or not math.isfinite(expiry): + raise ValueError("A finite expiry timestamp is required") + return records + + token_records() # Fail closed at startup on invalid configuration. + + @app.before_request + def protect(): + if request.method not in {"GET", "HEAD"}: + abort(405) + if request.content_length or request.headers.get("Transfer-Encoding"): + abort(400) + # No arbitrary query controls, URL fetching, cookies or production sessions. + if request.query_string: + abort(400) + now = time.monotonic() + with lock: + # Service binds loopback, so this also bounds unauthenticated traffic + # across SSH tunnels without trusting spoofable forwarded headers. + bucket = attempts["all"] + while bucket and now - bucket[0] >= 60: + bucket.popleft() + if len(bucket) >= app.config["REQUESTS_PER_MINUTE"]: + return jsonify(error="Read limit reached; retry later"), 429, {"Retry-After": "60"} + bucket.append(now) + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or not 32 <= len(auth[7:]) <= 256: + abort(401) + digest = hashlib.sha256(auth[7:].encode()).hexdigest() + try: + records = token_records() + except (OSError, ValueError, KeyError, TypeError): + abort(503, "Access configuration unavailable") + principal = next((r for r in records if hmac.compare_digest(r["sha256"], digest)), None) + if principal is None or float(principal["expires_at"]) <= time.time(): + abort(401) + g.approved_cases = principal["cases"] + + @app.after_request + def headers(response): + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + def authorize(identifier): + try: + identifier = case_id(identifier) + except ValueError: + abort(404) + if identifier not in g.approved_cases or identifier not in metadata: + abort(404) + return identifier + + def assets(identifier): + candidates = { + "ct": (data_root, f"image_only/{identifier}/ct.nii.gz"), + "mask": (data_root, f"mask_only/{identifier}/combined_labels.nii.gz"), + "thumbnail": (data_root, f"profile_only/{identifier}/profile.jpg"), + } + # Only existing GLBs are exposed. Missing meshes are generated LOCALLY + # by the normal backend after import, never on this export service. + if mesh_root: + try: + manifest_path = existing_file(mesh_root, f"{identifier}/manifest.json") + if manifest_path.stat().st_size > 1024 * 1024: + raise ValueError("Oversized mesh manifest") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or not isinstance(manifest.get("organs"), list): + raise ValueError("Invalid mesh manifest") + candidates["mesh-manifest"] = (mesh_root, f"{identifier}/manifest.json") + for organ in manifest.get("organs", []): + if not isinstance(organ, dict): + continue + filename = str(organ.get("key", "")) + ".glb" + if MESH_RE.fullmatch(filename): + candidates[filename] = (mesh_root, f"{identifier}/{filename}") + except (OSError, ValueError): + pass + found = {} + for key, (root, relative) in candidates.items(): + try: + found[key] = existing_file(root, relative) + except (OSError, ValueError): + pass + return found + + @app.get("/v1/catalog") + def catalog(): + return jsonify(schema=1, dataset="PanTS", cases=sorted(g.approved_cases & metadata.keys())) + + @app.get("/v1/cases/") + def describe_case(identifier): + identifier = authorize(identifier) + return jsonify( + schema=1, case_id=identifier, columns=columns, row=metadata[identifier], + assets={key: {"bytes": path.stat().st_size} for key, path in assets(identifier).items()}, + ) + + @app.get("/v1/cases//assets/") + def download_asset(identifier, asset): + identifier = authorize(identifier) + path = assets(identifier).get(asset) + if path is None: + abort(404) + return send_file(path, conditional=True, etag=True, as_attachment=True) + + return app diff --git a/flask-server/tests/unit/test_readonly_data.py b/flask-server/tests/unit/test_readonly_data.py new file mode 100644 index 00000000..572d31fd --- /dev/null +++ b/flask-server/tests/unit/test_readonly_data.py @@ -0,0 +1,265 @@ +"""Exercise the export boundary without production credentials or real scans.""" +import hashlib +import json +import os +from pathlib import Path +import sys +import subprocess +import socket +import threading +import time + +import pytest +from openpyxl import Workbook, load_workbook +from werkzeug.serving import make_server + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from readonly_data.client import asset_relative, fetch_snapshot, validate_server +from readonly_data.credentials import prepare_grant +from readonly_data.local import local_environment +from readonly_data.service import create_app + +TOKEN = "test-only-not-a-production-token-" * 2 + + +@pytest.fixture +def export(tmp_path): + data = tmp_path / "source" + meshes = tmp_path / "meshes" + data.mkdir() + meshes.mkdir() + workbook = Workbook() + sheet = workbook.active + sheet.title = "PanTS_metadata" + sheet.append(["PanTS ID", "Sex", "Age"]) + for identifier in (35, 36): + canonical = f"PanTS_{identifier:08d}" + sheet.append([canonical, "F", 45]) + for relative, content in [(f"image_only/{canonical}/ct.nii.gz", b"synthetic-ct"), + (f"mask_only/{canonical}/combined_labels.nii.gz", b"synthetic-mask"), + (f"profile_only/{canonical}/profile.jpg", b"synthetic-thumbnail")]: + path = data / relative + path.parent.mkdir(parents=True) + path.write_bytes(content) + workbook.save(data / "metadata.xlsx") + case = meshes / "PanTS_00000035" + case.mkdir() + (case / "manifest.json").write_text(json.dumps({"organs": [{"key": "liver"}, {"key": "../../secret"}]})) + (case / "liver.glb").write_bytes(b"synthetic-glb") + (case / "private.txt").write_text("not exported") + records = [{"sha256": hashlib.sha256(TOKEN.encode()).hexdigest(), "cases": [35], "expires_at": time.time() + 600}] + tokens = tmp_path / "tokens.json" + tokens.write_text(json.dumps(records)) + config = {"TESTING": True, "DATA_ROOT": str(data), "MESH_ROOT": str(meshes), "TOKENS_FILE": str(tokens)} + app = create_app(config) + return app, {"Authorization": "Bearer " + TOKEN}, data, meshes, tokens, records, config + + +def test_scoped_catalog_and_exact_asset_allowlist(export): + app, headers, *_ = export + client = app.test_client() + assert client.get("/v1/catalog", headers=headers).json["cases"] == ["PanTS_00000035"] + description = client.get("/v1/cases/35", headers=headers).json + assert description["row"] == ["PanTS_00000035", "F", 45] + assert set(description["assets"]) == {"ct", "mask", "thumbnail", "mesh-manifest", "liver.glb"} + for endpoint in ("/v1/cases/36", "/v1/cases/36/assets/ct", "/v1/cases/35/assets/private.txt", + "/api/jobs/next", "/api/meshes/35/manifest", "/v1/cases/35/assets/../../tokens.json", + "/v1/cases/%2e%2e/assets/ct", "/v1/cases/35/assets/%2fetc%2fpasswd"): + assert client.get(endpoint, headers=headers).status_code == 404 + + +def test_unauthorized_expired_and_revoked_tokens(export): + app, headers, _, _, tokens, records, _ = export + client = app.test_client() + assert client.get("/v1/catalog").status_code == 401 + assert client.get("/v1/catalog", headers={"Authorization": "Bearer " + "x" * 64}).status_code == 401 + records[0]["expires_at"] = time.time() - 1 + tokens.write_text(json.dumps(records)) + assert client.get("/v1/catalog", headers=headers).status_code == 401 + tokens.write_text("[]") + assert client.get("/v1/catalog", headers=headers).status_code == 401 + tokens.write_text("not valid json") + assert client.get("/v1/catalog", headers=headers).status_code == 503 + + +@pytest.mark.parametrize("expiry", [float("nan"), float("inf"), "tomorrow", True, None]) +def test_invalid_expiry_fails_closed(export, expiry): + _, _, _, _, tokens, records, config = export + records[0]["expires_at"] = expiry + tokens.write_text(json.dumps(records)) + with pytest.raises(ValueError): + create_app(config) + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) +def test_write_methods_rejected(export, method): + app, headers, *_ = export + assert app.test_client().open("/v1/cases/35/assets/ct", method=method, headers=headers).status_code == 405 + + +def test_body_query_rate_limit_and_range(export): + app, headers, *_ = export + client = app.test_client() + assert client.get("/v1/catalog?url=http://localhost", headers=headers).status_code == 400 + assert client.get("/v1/catalog", headers=headers, data="payload").status_code == 400 + response = client.get("/v1/cases/35/assets/ct", headers={**headers, "Range": "bytes=0-3"}) + assert response.status_code == 206 and response.data == b"synt" + response.close() + response = client.head("/v1/cases/35/assets/ct", headers=headers) + assert response.status_code == 200 and response.data == b"" + assert response.headers["Cache-Control"] == "private, no-store" + response.close() + app.config["REQUESTS_PER_MINUTE"] = 2 + assert client.get("/v1/catalog", headers=headers).status_code == 429 + + +def test_missing_files_do_not_generate_and_symlinks_cannot_escape(export, tmp_path): + app, headers, data, _, *_ = export + source = data / "image_only/PanTS_00000035/ct.nii.gz" + source.unlink() + client = app.test_client() + assert client.get("/v1/cases/35/assets/ct", headers=headers).status_code == 404 + assert not source.exists() + outside = tmp_path / "secret" + outside.write_text("secret") + try: + source.symlink_to(outside) + except OSError: + pytest.skip("Symlink creation needs Windows Developer Mode; exercised on Linux CI") + assert client.get("/v1/cases/35/assets/ct", headers=headers).status_code == 404 + + +@pytest.fixture +def running_export(export): + server = make_server("127.0.0.1", 0, export[0]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def test_http_snapshot_and_local_environment_are_isolated(export, running_export, tmp_path): + _, _, data, meshes, *_ = export + source_files = [path for root in (data, meshes) for path in root.rglob("*") if path.is_file()] + before = {path: (hashlib.sha256(path.read_bytes()).hexdigest(), path.stat().st_mtime_ns) for path in source_files} + token = tmp_path / "client.token" + token.write_text(TOKEN) + snapshot = fetch_snapshot(running_export, token, [35], tmp_path / "snapshot") + assert not (snapshot / "INCOMPLETE").exists() + assert (snapshot / "PanTS/image_only/PanTS_00000035/ct.nii.gz").read_bytes() == b"synthetic-ct" + workbook = load_workbook(snapshot / "PanTS/metadata.xlsx", read_only=True) + assert list(workbook.active.values) == [("PanTS ID", "Sex", "Age"), ("PanTS_00000035", "F", 45)] + workbook.close() + state = tmp_path / "local-state" + env = local_environment(snapshot, state, {"PATH": "local-path", "DATABASE_URL": "postgresql://production", + "EPAI_REMOTE_HOST": "production", "SECRET_KEY": "production", + "VITE_API_BASE": "https://production"}) + assert env["DATABASE_URL"] == "sqlite:///" + (state / "test.db").as_posix() + assert "SECRET_KEY" not in env and "EPAI_REMOTE_HOST" not in env + assert env["EPAI_REMOTE_ENABLED"] == "false" and env["PYTHON_DOTENV_DISABLED"] == "1" + assert env["VITE_PROXY_TARGET"] == "http://127.0.0.1:5001" and env["VITE_API_BASE"] == "" + assert env["PATH"] == "local-path" + assert before == {path: (hashlib.sha256(path.read_bytes()).hexdigest(), path.stat().st_mtime_ns) for path in source_files} + with pytest.raises(FileExistsError): + fetch_snapshot(running_export, token, [35], snapshot) + + +def test_failed_or_oversized_snapshot_cannot_start_backend(export, running_export, tmp_path): + token = tmp_path / "client.token" + token.write_text(TOKEN) + output = tmp_path / "partial" + with pytest.raises(ValueError, match="limit"): + fetch_snapshot(running_export, token, [35], output, max_bytes=1) + assert (output / "INCOMPLETE").exists() + with pytest.raises(ValueError, match="completed"): + local_environment(output, tmp_path / "state") + + +@pytest.mark.parametrize("url", ["http://production", "https://user:secret@production", "https://production/api", "https://production?token=secret"]) +def test_unsafe_transport_rejected(url): + with pytest.raises(ValueError): + validate_server(url) + + +@pytest.mark.parametrize("asset", ["../secret", "..\\secret", "/etc/passwd", "secret.txt", "C:secret.glb"]) +def test_remote_asset_names_cannot_choose_local_paths(asset): + with pytest.raises(ValueError): + asset_relative("35", asset) + + +def test_generated_grant_contains_only_hash_and_explicit_scope(tmp_path): + token_file, grant_file = tmp_path / "secret.token", tmp_path / "grant.json" + grant = prepare_grant([35, "PanTS_00000035"], 1, token_file, grant_file) + token = token_file.read_text().strip() + assert grant["sha256"] == hashlib.sha256(token.encode()).hexdigest() + assert token not in grant_file.read_text() and grant["cases"] == ["PanTS_00000035"] + with pytest.raises(ValueError): + prepare_grant([36], 1, token_file, grant_file) + + +def test_snapshot_does_not_follow_redirects(export, running_export, tmp_path): + from flask import redirect + app = export[0] + app.view_functions["describe_case"] = lambda **kwargs: redirect("https://example.invalid/") + token = tmp_path / "client.token" + token.write_text(TOKEN) + with pytest.raises(RuntimeError, match="302"): + fetch_snapshot(running_export, token, [35], tmp_path / "snapshot") + assert (tmp_path / "snapshot/INCOMPLETE").exists() + + +def test_local_launcher_disables_dotenv_in_a_fresh_process(tmp_path): + pytest.importorskip("dotenv") + snapshot = tmp_path / "snapshot" + (snapshot / "PanTS").mkdir(parents=True) + (snapshot / "PanTS/metadata.xlsx").touch() + (snapshot / "snapshot.json").write_text('{"schema": 1}') + env_file = tmp_path / ".env" + env_file.write_text("PRODUCTION_TEST_CREDENTIAL=must-not-load\nDATABASE_URL=postgresql://production\n") + env = local_environment(snapshot, tmp_path / "state") + check = "import os,sys; from dotenv import load_dotenv; load_dotenv(sys.argv[1],override=True); assert 'PRODUCTION_TEST_CREDENTIAL' not in os.environ; assert os.environ['DATABASE_URL'].startswith('sqlite:///')" + subprocess.run([sys.executable, "-c", check, str(env_file)], env=env, check=True) + + +@pytest.mark.skipif(os.name == "nt", reason="Gunicorn is Linux/Unix only; exercised by CI") +def test_gunicorn_entrypoint_is_independent_of_production(export, tmp_path): + import requests + _, headers, data, meshes, tokens, *_ = export + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + env = {key: os.environ[key] for key in ("PATH", "HOME", "LANG") if key in os.environ} + env.update({"BODYMAPS_RO_DATA_ROOT": str(data), "BODYMAPS_RO_MESH_ROOT": str(meshes), + "BODYMAPS_RO_TOKENS_FILE": str(tokens), "PYTHONDONTWRITEBYTECODE": "1", + "PYTHON_DOTENV_DISABLED": "1"}) + log_path = tmp_path / "gunicorn.log" + with log_path.open("w") as log: + process = subprocess.Popen([sys.executable, "-m", "gunicorn", "--workers", "1", "--threads", "2", + "--bind", f"127.0.0.1:{port}", "readonly_data.service:create_app()"], + cwd=Path(__file__).resolve().parents[2], env=env, stdout=log, stderr=log) + try: + with requests.Session() as session: + session.trust_env = False + for _ in range(50): + if process.poll() is not None: + pytest.fail(log_path.read_text()) + try: + response = session.get(f"http://127.0.0.1:{port}/v1/catalog", headers=headers, timeout=1) + assert response.status_code == 200 + assert response.json()["cases"] == ["PanTS_00000035"] + break + except requests.ConnectionError: + time.sleep(0.1) + else: + pytest.fail("Gunicorn did not become ready") + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5)