Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2558046d7d | ||
|
|
a7691c6bd6 | ||
|
|
1e46fff2ff | ||
|
|
c25fb53666 |
@@ -19,4 +19,7 @@ data/*.vcf*
|
|||||||
data/*.tsv
|
data/*.tsv
|
||||||
data/*.case.json
|
data/*.case.json
|
||||||
data/*.zip
|
data/*.zip
|
||||||
|
# The write-up and its figures are kept locally but out of the repository.
|
||||||
|
docs/blog/
|
||||||
!data/README.md
|
!data/README.md
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -109,6 +109,43 @@ propagation does not.
|
|||||||
Local Kubernetes: `make kind` builds the images, loads them into a kind cluster and applies
|
Local Kubernetes: `make kind` builds the images, loads them into a kind cluster and applies
|
||||||
`infra/k8s/overlays/local`.
|
`infra/k8s/overlays/local`.
|
||||||
|
|
||||||
|
## Deploying to Railway
|
||||||
|
|
||||||
|
The quickest way to put it in front of people. Three services — Postgres, the API, the UI — with
|
||||||
|
the API reachable only over Railway's private network, so the UI's `/api` proxy is the single
|
||||||
|
public entry point and there is no CORS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
railway link --project <id> --environment production --service api
|
||||||
|
cd api && railway up --service api # the repo is on Gitea, so deploy from the working copy
|
||||||
|
cd ../web && railway up --service web
|
||||||
|
```
|
||||||
|
|
||||||
|
Set on the API: `DATABASE_URL` (pointing at `postgres.railway.internal`) and
|
||||||
|
`DATABASE_IDLE_CONNECTIONS=false`. On the UI: `API_INTERNAL_URL=http://api.railway.internal:8000`,
|
||||||
|
`PUBLIC_PIPELINE_ENABLED=false`, and `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD`.
|
||||||
|
|
||||||
|
Three things are worth knowing before copying this:
|
||||||
|
|
||||||
|
- **The pipeline cannot run there.** Nextflow shells out to `docker run` for VEP and bcftools, and
|
||||||
|
Railway gives you a container, not a Docker daemon. So the cases are annotated here and copied
|
||||||
|
up with `scripts/seed-remote.sh`, and `PUBLIC_PIPELINE_ENABLED=false` hides the buttons that
|
||||||
|
would otherwise be left to fail. Visitors explore real analysed cases; they do not run VEP.
|
||||||
|
- **`DATABASE_IDLE_CONNECTIONS=false` is what makes sleeping work.** Railway decides a service is
|
||||||
|
idle from its *outbound* traffic, and a pooled database connection is outbound traffic, so the
|
||||||
|
default pool would keep the API awake and billable for ever. It uses `NullPool` instead, which
|
||||||
|
costs a connection per request and is the wrong trade under real load.
|
||||||
|
- **Serverless must be enabled per service and only takes effect on the next deploy.** Leave it off
|
||||||
|
for Postgres, which holds the volume.
|
||||||
|
|
||||||
|
`BASIC_AUTH_USER`/`BASIC_AUTH_PASSWORD` put one shared credential in front of the whole site
|
||||||
|
(`web/src/hooks.server.ts`); unset, the site is open, which is what local development wants.
|
||||||
|
Nothing here is patient data, so this stops the URL being wandered into rather than protecting
|
||||||
|
anyone's privacy.
|
||||||
|
|
||||||
|
Cost: Railway's Hobby plan is $5/month flat including $5 of usage, so it costs that whether or not
|
||||||
|
anyone visits — more at rest than the GCP track below, and much less to operate.
|
||||||
|
|
||||||
## Deploying to GCP
|
## Deploying to GCP
|
||||||
|
|
||||||
Two tracks, same code. The serverless one is the default because it costs about £1/month idle;
|
Two tracks, same code. The serverless one is the default because it costs about £1/month idle;
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ class Settings(BaseSettings):
|
|||||||
local_data_root: Path = Path("/data")
|
local_data_root: Path = Path("/data")
|
||||||
# Browsers calling the API cross-origin; behind the ingress the UI is same-origin.
|
# Browsers calling the API cross-origin; behind the ingress the UI is same-origin.
|
||||||
cors_origins: list[str] = ["http://localhost:5173"]
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
# Whether to keep pooled database connections open between requests. Set it false on a host
|
||||||
|
# that sleeps idle containers to save money -- Railway's serverless mode decides a service is
|
||||||
|
# idle from its *outbound* traffic, and a held connection is outbound traffic, so a pool keeps
|
||||||
|
# the service awake and billable forever. Costs a connection setup per request, which is
|
||||||
|
# nothing at demo traffic and the wrong trade under real load.
|
||||||
|
database_idle_connections: bool = True
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+7
-1
@@ -4,6 +4,7 @@ from typing import Annotated
|
|||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -21,7 +22,12 @@ def normalize_async_url(url: str) -> str:
|
|||||||
return parsed.set(query=query).render_as_string(hide_password=False)
|
return parsed.set(query=query).render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
engine = create_async_engine(normalize_async_url(settings.database_url), pool_pre_ping=True)
|
# NullPool opens and closes a connection per checkout, so an idle API sends nothing and a host
|
||||||
|
# that sleeps idle containers can actually do so. See settings.database_idle_connections.
|
||||||
|
_pool = {} if settings.database_idle_connections else {"poolclass": NullPool}
|
||||||
|
engine = create_async_engine(
|
||||||
|
normalize_async_url(settings.database_url), pool_pre_ping=True, **_pool
|
||||||
|
)
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copy the analysed demo cases and the HPO reference data into a remote database.
|
||||||
|
#
|
||||||
|
# The deployed demo has no Nextflow and no Docker daemon, so it cannot run VEP. Instead the cases
|
||||||
|
# are annotated here, where the pipeline works, and the results are copied up. Visitors get real
|
||||||
|
# analysed cases -- funnel, ranking, evidence, decisions, report -- against a database that is
|
||||||
|
# never asked to produce them.
|
||||||
|
#
|
||||||
|
# Everything copied is derived from public, openly licensed sources (docs/data.md). There is no
|
||||||
|
# patient data in this dump, and there must never be.
|
||||||
|
#
|
||||||
|
# scripts/seed-remote.sh "$(railway variables -s Postgres --kv | grep DATABASE_PUBLIC_URL | cut -d= -f2-)"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET=${1:-${TARGET_DATABASE_URL:-}}
|
||||||
|
SOURCE=${SOURCE_DATABASE_URL:-postgresql://rarelens:rarelens@localhost:5432/rarelens}
|
||||||
|
IMAGE=${PG_IMAGE:-postgres:16-alpine}
|
||||||
|
|
||||||
|
# Reference data first, then a case's own rows: variants reference jobs, jobs reference cases.
|
||||||
|
TABLES=(hpo_terms gene_phenotypes cases case_phenotypes jobs variants predictions variant_decisions)
|
||||||
|
|
||||||
|
if [ -z "$TARGET" ]; then
|
||||||
|
echo "usage: $0 <target postgres URL> (or set TARGET_DATABASE_URL)" >&2
|
||||||
|
echo "the target must already have the schema: run 'alembic upgrade head' against it first" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$TARGET" in
|
||||||
|
postgres://*|postgresql://*) ;;
|
||||||
|
*) echo "target must be a postgres:// URL, got ${TARGET%%:*}:..." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "==> source: ${SOURCE%%\?*}" >&2
|
||||||
|
echo "==> target: ${TARGET%%:*}://…${TARGET##*@}" >&2 # host only: the password stays out of the log
|
||||||
|
echo "==> tables: ${TABLES[*]}" >&2
|
||||||
|
|
||||||
|
# --data-only: the target's schema comes from Alembic, so the two can never disagree about it.
|
||||||
|
# Truncating first makes the script repeatable; cascade because variants hang off jobs.
|
||||||
|
docker run --rm -i --add-host=host.docker.internal:host-gateway \
|
||||||
|
-e PGPASSWORD_UNUSED=1 "$IMAGE" \
|
||||||
|
pg_dump --data-only --no-owner --no-privileges \
|
||||||
|
$(printf -- '--table=%s ' "${TABLES[@]}") \
|
||||||
|
"${SOURCE/localhost/host.docker.internal}" \
|
||||||
|
> /tmp/rarelens-seed.sql
|
||||||
|
|
||||||
|
lines=$(wc -l < /tmp/rarelens-seed.sql)
|
||||||
|
echo "==> dumped $lines lines" >&2
|
||||||
|
|
||||||
|
docker run --rm -i "$IMAGE" psql "$TARGET" -v ON_ERROR_STOP=1 \
|
||||||
|
-c "TRUNCATE $(IFS=,; echo "${TABLES[*]}") RESTART IDENTITY CASCADE" >&2
|
||||||
|
|
||||||
|
docker run --rm -i "$IMAGE" psql "$TARGET" -v ON_ERROR_STOP=1 -q < /tmp/rarelens-seed.sql
|
||||||
|
rm -f /tmp/rarelens-seed.sql
|
||||||
|
|
||||||
|
echo "==> loaded. counts on the target:" >&2
|
||||||
|
docker run --rm -i "$IMAGE" psql "$TARGET" -At -c "
|
||||||
|
select 'cases: '||count(*) from cases
|
||||||
|
union all select 'variants: '||count(*) from variants
|
||||||
|
union all select 'gene_phenotypes: '||count(*) from gene_phenotypes
|
||||||
|
union all select 'hpo_terms: '||count(*) from hpo_terms"
|
||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
# .env holds the local dev API URL; the image reads PUBLIC_API_URL at runtime instead.
|
|
||||||
.env
|
|
||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
# .env sets PUBLIC_API_URL for local development. Baked into the image it would override the
|
||||||
|
# same-origin /api proxy the deployment relies on, so it must not travel.
|
||||||
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const env: Record<string, string | undefined> = {};
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env }));
|
||||||
|
|
||||||
|
const { handle } = await import('./hooks.server');
|
||||||
|
|
||||||
|
const credential = (user: string, password: string) =>
|
||||||
|
`Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
||||||
|
|
||||||
|
function run(authorization?: string) {
|
||||||
|
const event = {
|
||||||
|
request: new Request('https://demo.example/cases', {
|
||||||
|
headers: authorization ? { authorization } : {}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return handle({ event, resolve: async () => new Response('ok') } as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('basic auth', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
env.BASIC_AUTH_USER = 'demo';
|
||||||
|
env.BASIC_AUTH_PASSWORD = 'letmein';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is off when no credential is configured, so local development is unaffected', async () => {
|
||||||
|
env.BASIC_AUTH_USER = undefined;
|
||||||
|
env.BASIC_AUTH_PASSWORD = undefined;
|
||||||
|
expect((await run()).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('challenges an anonymous request', async () => {
|
||||||
|
const res = await run();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.headers.get('www-authenticate')).toContain('Basic realm="rarelens"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the right credential through', async () => {
|
||||||
|
expect((await run(credential('demo', 'letmein'))).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['wrong password', credential('demo', 'nope')],
|
||||||
|
['wrong user', credential('someone', 'letmein')],
|
||||||
|
['password that is a prefix of the real one', credential('demo', 'letme')],
|
||||||
|
['not basic at all', 'Bearer letmein'],
|
||||||
|
['no colon in the decoded value', `Basic ${Buffer.from('demo').toString('base64')}`],
|
||||||
|
['empty', '']
|
||||||
|
])('rejects %s', async (_label, header) => {
|
||||||
|
expect((await run(header)).status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared credential in front of the whole site, for the deployed demo.
|
||||||
|
*
|
||||||
|
* Everything here is public, openly licensed data and there is no patient information, so this is
|
||||||
|
* not protecting anyone's privacy. It keeps the URL from being wandered into or indexed while a
|
||||||
|
* handful of people are looking at it, which is the actual requirement. Unset the variables and
|
||||||
|
* the site is open, which is what local development wants.
|
||||||
|
*/
|
||||||
|
const REALM = 'rarelens';
|
||||||
|
|
||||||
|
/** Constant time, and safe when the two differ in length (timingSafeEqual throws on that). */
|
||||||
|
function matches(given: string, expected: string): boolean {
|
||||||
|
const a = Buffer.from(given);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorised(header: string | null, user: string, password: string): boolean {
|
||||||
|
if (!header?.startsWith('Basic ')) return false;
|
||||||
|
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||||
|
const separator = decoded.indexOf(':');
|
||||||
|
if (separator < 0) return false;
|
||||||
|
// Compare both halves even when the first fails, so the reply time says nothing about which did.
|
||||||
|
const okUser = matches(decoded.slice(0, separator), user);
|
||||||
|
const okPassword = matches(decoded.slice(separator + 1), password);
|
||||||
|
return okUser && okPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const user = env.BASIC_AUTH_USER;
|
||||||
|
const password = env.BASIC_AUTH_PASSWORD;
|
||||||
|
if (!user || !password) return resolve(event);
|
||||||
|
|
||||||
|
if (!authorised(event.request.headers.get('authorization'), user, password)) {
|
||||||
|
return new Response('Authentication required', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, type Assembly, type Case, type PhenotypeTerm } from '$lib/api';
|
import { api, type Assembly, type Case, type PhenotypeTerm } from '$lib/api';
|
||||||
import PhenotypePicker from '$lib/components/PhenotypePicker.svelte';
|
import PhenotypePicker from '$lib/components/PhenotypePicker.svelte';
|
||||||
@@ -38,6 +39,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = (c: Case) => c.latest_job?.status ?? 'not analysed';
|
const status = (c: Case) => c.latest_job?.status ?? 'not analysed';
|
||||||
|
// Nothing can be analysed where the pipeline cannot run, so do not offer to create a case there.
|
||||||
|
const canAnalyse = env.PUBLIC_PIPELINE_ENABLED !== 'false';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>rarelens</h1>
|
<h1>rarelens</h1>
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
candidate carrying the evidence for its rank. Research demo, not a diagnostic tool.
|
candidate carrying the evidence for its rank. Research demo, not a diagnostic tool.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{#if canAnalyse}
|
||||||
<h2>New case</h2>
|
<h2>New case</h2>
|
||||||
<div class="newcase">
|
<div class="newcase">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
<button onclick={add} disabled={!name || !vcfUri}>Create case</button>
|
<button onclick={add} disabled={!name || !vcfUri}>Create case</button>
|
||||||
</div>
|
</div>
|
||||||
{#if error}<p role="alert">Could not create the case: {error}</p>{/if}
|
{#if error}<p role="alert">Could not create the case: {error}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>Cases</h2>
|
<h2>Cases</h2>
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
@@ -70,7 +75,13 @@
|
|||||||
{:else if !loaded}
|
{:else if !loaded}
|
||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{:else if cases.length === 0}
|
{:else if cases.length === 0}
|
||||||
<div class="empty">No cases yet. Create one above, then run the annotation pipeline on it.</div>
|
<div class="empty">
|
||||||
|
{#if canAnalyse}
|
||||||
|
No cases yet. Create one above, then run the annotation pipeline on it.
|
||||||
|
{:else}
|
||||||
|
No cases loaded.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="caselist">
|
<ul class="caselist">
|
||||||
{#each cases as c (c.id)}
|
{#each cases as c (c.id)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
|
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
|
||||||
import { poll } from '$lib/poll';
|
import { poll } from '$lib/poll';
|
||||||
import { formatElapsed, latestStep } from '$lib/progress';
|
import { formatElapsed, latestStep } from '$lib/progress';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
import { missingEvidenceNote, needsScoring, plural } from '$lib/candidates';
|
import { missingEvidenceNote, needsScoring, plural } from '$lib/candidates';
|
||||||
import CandidateRow from '$lib/components/CandidateRow.svelte';
|
import CandidateRow from '$lib/components/CandidateRow.svelte';
|
||||||
import Funnel from '$lib/components/Funnel.svelte';
|
import Funnel from '$lib/components/Funnel.svelte';
|
||||||
@@ -35,6 +36,9 @@
|
|||||||
// not need a five-minute re-run of VEP to get its score.
|
// not need a five-minute re-run of VEP to get its score.
|
||||||
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items, page.evidence));
|
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items, page.evidence));
|
||||||
const evidenceNote = $derived(page ? missingEvidenceNote(page.evidence) : null);
|
const evidenceNote = $derived(page ? missingEvidenceNote(page.evidence) : null);
|
||||||
|
// The deployed demo has no Nextflow and no Docker daemon to run VEP in, so the pipeline cannot
|
||||||
|
// run there. Hide the buttons rather than leave them to fail: the cases are already analysed.
|
||||||
|
const canAnalyse = env.PUBLIC_PIPELINE_ENABLED !== 'false';
|
||||||
|
|
||||||
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
|
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -160,9 +164,11 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="actions">
|
<p class="actions">
|
||||||
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
|
{#if canAnalyse}
|
||||||
{#if unscored}
|
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
|
||||||
<button class="quiet" onclick={scoreThenLoad}>Score variants</button>
|
{#if unscored}
|
||||||
|
<button class="quiet" onclick={scoreThenLoad}>Score variants</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if page && page.funnel.total > 0}
|
{#if page && page.funnel.total > 0}
|
||||||
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
|
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
|
||||||
@@ -219,7 +225,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if page && !running}
|
{:else if page && !running}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
|
{#if canAnalyse}
|
||||||
left against the phenotype.
|
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
|
||||||
|
left against the phenotype.
|
||||||
|
{:else}
|
||||||
|
No results for this case. This deployment shows cases that were analysed beforehand; running
|
||||||
|
the pipeline needs Nextflow and a Docker daemon, which it does not have.
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user