Compare commits

Author SHA1 Message Date
Kemal Yaylali 2558046d7d chore: keep the write-up out of the repository
ci / api (push) Failing after 2s
ci / pipeline (push) Failing after 1s
ci / ml (push) Failing after 8s
ci / terraform (push) Successful in 13s
ci / web (push) Successful in 44s
ci / images (api, api, api/Dockerfile) (push) Skipped
ci / images (loader, pipeline, pipeline/loader.Dockerfile) (push) Skipped
ci / images (ml, ml, ml/Dockerfile) (push) Skipped
ci / images (pipeline, pipeline, pipeline/Dockerfile) (push) Skipped
ci / images (web, web, web/Dockerfile) (push) Skipped
docs/blog/ is untracked and ignored. The article, its figures and the diagram
sources stay in the working directory - the PDF is still the thing to send -
but 6 MB of rendered output does not belong in the history, and the post now
lives as a WordPress draft with its own copies of the images.
2026-09-12 13:56:32 +01:00
Kemal Yaylali a7691c6bd6 chore: ignore .DS_Store
macOS writes these into any directory Finder touches; two had appeared at the
repo root and under docs/.
2026-09-12 13:25:12 +01:00
Kemal Yaylali 1e46fff2ff docs: a technical introduction to the platform, as a LaTeX article
docs/blog/rarelens.tex builds a 10-page write-up covering the Nextflow DSL2
pipeline, the three execution backends behind one API call, the Argo and
ArgoCD track, Terraform, the external systems integrated, the ranking, the
benchmark and the model.

It is deliberately as much an account of what was wrong as of what works: the
components that scored evidence nobody had looked up, the missense AUROC that
fell from 0.872 to 0.500 once allele frequency was removed, the ontology walk
that silently dropped 399 terms, and the propagation change that measured
slightly worse than what it replaced and was kept anyway with the numbers
published.

Figures come from one headless-browser script. Screenshots are of the running
application; the six diagrams are hand-written HTML styled from the same
palette, rendered by the same script. Keeping both in one place is what stops
the article drifting from the system, and every number in a diagram has to be
changed deliberately.
2026-09-12 13:10:47 +01:00
Kemal Yaylali c25fb53666 feat(deploy): a Railway deployment of the analysed cases, behind one credential
Three services -- Postgres, API, UI -- with the API on Railway's private
network only, so the UI's /api proxy is the single public entry point and
there is no CORS.

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. Rather
than leave a button that always fails, cases are annotated locally and copied
up by scripts/seed-remote.sh, and PUBLIC_PIPELINE_ENABLED=false hides the
analyse/score actions and the create-case form.

DATABASE_IDLE_CONNECTIONS=false is what makes idling work. Railway decides a
service is idle from its *outbound* traffic and sleeps it after ~5-10 minutes;
a pooled database connection is outbound traffic, so SQLAlchemy's default pool
would have kept the API awake and billable for ever. Setting it false switches
to NullPool, which costs a connection per request -- nothing at demo traffic,
the wrong trade under real load, hence the flag rather than a rewrite.

BASIC_AUTH_USER / BASIC_AUTH_PASSWORD put one shared credential in front of
the site. Nothing deployed is patient data, so this stops the URL being
wandered into rather than protecting anyone's privacy; unset, the site is
open, which is what local development wants. Compared in constant time, and
both halves of the credential are checked even when the first fails.
2026-09-12 12:23:19 +01:00
10 changed files with 242 additions and 9 deletions
+3
View File
@@ -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
+37
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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)
+60
View File
@@ -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
View File
@@ -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
+53
View File
@@ -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);
});
});
+45
View File
@@ -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);
};
+12 -1
View File
@@ -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)}
+11
View File
@@ -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,10 +164,12 @@
</p> </p>
{:else} {:else}
<p class="actions"> <p class="actions">
{#if canAnalyse}
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button> <button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
{#if unscored} {#if unscored}
<button class="quiet" onclick={scoreThenLoad}>Score variants</button> <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>
{/if} {/if}
@@ -219,7 +225,12 @@
</div> </div>
{:else if page && !running} {:else if page && !running}
<div class="empty"> <div class="empty">
{#if canAnalyse}
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
left against the phenotype. 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}