fix: overhaul the platform skeleton, add a serverless deployment track

An end-to-end audit found the repo could not build, test or run as shipped. This
fixes every finding, then adds a Cloud Run track so the demo costs about £1/month
idle instead of ~£150.

CI (red on its first run)
- api: setuptools could not build the package (flat layout with app/ and alembic/)
- web: missing @types/node; `vitest run` exited 1 with no test files
- pipeline: the stub run needed a gitignored VCF, and no process had a stub block
- ruff pinned, mypy configured, DB tests on real Postgres (pgserver locally, service in CI)

ML serving (scores were meaningless)
- the registered model now carries its own feature engineering and returns predict_proba,
  so serving sends raw columns and cannot drift from training
- resolve by registry alias (stages are deprecated in MLflow 3) and record the real
  version; re-scoring upserts instead of failing on the unique constraint
- ClinVar labels parsed from VEP's lowercase terms

Pipeline
- exact ref/alt recovered from a CHROM_POS_REF_ALT VCF ID; loading is idempotent
- job status reaches running/failed/succeeded, so the UI stops polling dead jobs
- DATABASE_URL travels in the environment or a Nextflow secret, never on a command line
- VEP cache and plugins staged as inputs; the gcp profile runs tasks on Google Batch

Deployment
- the API serves /api (matching the ingress); the web app reads its API URL at runtime
- migrations run in an init container under a Postgres advisory lock
- terraform: custom VPC shared with Batch, private Cloud SQL, API enablement, Workload
  Identity bindings, Secret Manager, deletion protection
- serverless track, now the default: Cloud Run services scaling to zero, a Cloud Run job
  for the Nextflow driver, and Neon or Cloud SQL behind one DATABASE_URL secret. GKE and
  Argo remain, behind -var deploy_kubernetes=true. See docs/cloud.md.

Correctness and security
- 409 on duplicate sample names, 422 on bad paging, natural chromosome ordering, wider
  VEP text columns, enum dropped on downgrade, the sample's assembly actually used
- vcf_uri restricted to gs:// objects or files under the data root, blocking option injection
- CORS restricted to configured origins; `make down` no longer deletes volumes

Data
- docs/data.md records the peer-reviewed, openly licensed sources (GIAB HG002, ClinVar,
  gnomAD) with citations and an honest evaluation plan; `make data` fetches a chr22 slice

Verified: api 50 tests, ml 18, loader 16, web 12; ruff, mypy, svelte-check, terraform
validate and both kustomize overlays clean.
This commit is contained in:
Kemal Yaylali
2026-09-12 07:21:11 +01:00
parent 5463f489a3
commit 11fb6b3d73
100 changed files with 3431 additions and 340 deletions
+18
View File
@@ -0,0 +1,18 @@
resource "google_project_service" "enabled" {
for_each = toset([
"artifactregistry.googleapis.com",
"batch.googleapis.com",
"compute.googleapis.com",
"container.googleapis.com",
"iam.googleapis.com",
"iamcredentials.googleapis.com",
"logging.googleapis.com",
"pubsub.googleapis.com",
"secretmanager.googleapis.com",
"servicenetworking.googleapis.com",
"sqladmin.googleapis.com",
"sts.googleapis.com",
])
service = each.value
disable_on_destroy = false
}
+178
View File
@@ -0,0 +1,178 @@
# The serverless track: scale-to-zero services and an on-demand pipeline driver.
# Idle cost is storage only; see docs/cloud.md.
resource "google_cloud_run_v2_service" "api" {
name = "rarelens-api"
location = var.region
deletion_protection = false
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = google_service_account.api.email
scaling {
min_instance_count = 0 # nothing runs, and nothing is billed, between visits
max_instance_count = var.max_instances
}
containers {
image = "${local.registry}/api:${var.image_tag}"
ports { container_port = 8000 }
resources {
limits = { cpu = "1", memory = "1Gi" }
cpu_idle = true # bill CPU only while a request is in flight
startup_cpu_boost = true
}
env {
name = "DATABASE_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.api_database_url.secret_id
version = "latest"
}
}
}
env {
name = "CLOUDRUN_JOB"
value = google_cloud_run_v2_job.nextflow.name
}
env {
name = "GCP_PROJECT"
value = var.project
}
env {
name = "GCP_REGION"
value = var.region
}
env {
name = "GCS_BUCKET"
value = google_storage_bucket.data.name
}
env {
name = "MODEL_URI"
value = var.model_uri
}
}
}
depends_on = [google_secret_manager_secret_version.api_database_url]
}
resource "google_cloud_run_v2_service" "web" {
name = "rarelens-web"
location = var.region
deletion_protection = false
ingress = "INGRESS_TRAFFIC_ALL"
template {
scaling {
min_instance_count = 0
max_instance_count = var.max_instances
}
containers {
image = "${local.registry}/web:${var.image_tag}"
ports { container_port = 3000 }
resources {
limits = { cpu = "1", memory = "512Mi" }
cpu_idle = true
startup_cpu_boost = true
}
# The browser calls /api on this origin; src/routes/api/[...path] forwards it, so there is
# one public URL and no CORS, exactly as the ingress arranges in the Kubernetes track.
env {
name = "PUBLIC_API_URL"
value = "/api"
}
env {
name = "API_INTERNAL_URL"
value = google_cloud_run_v2_service.api.uri
}
# adapter-node sits behind Cloud Run's proxy; derive the origin from the forwarded headers.
env {
name = "PROTOCOL_HEADER"
value = "x-forwarded-proto"
}
env {
name = "HOST_HEADER"
value = "x-forwarded-host"
}
}
}
}
# The Nextflow driver. Started per annotation by the API (overriding the container args); the
# pipeline's own tasks then run on Google Batch (the gcp profile in pipeline/nextflow.config).
resource "google_cloud_run_v2_job" "nextflow" {
name = "rarelens-nextflow"
location = var.region
deletion_protection = false
template {
task_count = 1
template {
service_account = google_service_account.pipeline.email
max_retries = 0
timeout = "7200s"
containers {
image = "${local.registry}/pipeline:${var.image_tag}"
args = ["-version"] # replaced on every execution by the API's overrides
resources {
limits = { cpu = "1", memory = "2Gi" }
}
env {
name = "GCP_PROJECT"
value = var.project
}
env {
name = "GCP_REGION"
value = var.region
}
env {
name = "GCS_BUCKET"
value = google_storage_bucket.data.name
}
env {
name = "NXF_ANSI_LOG"
value = "false"
}
}
}
}
}
# Anyone can open the UI and the API. There is no authentication by design (docs/architecture.md);
# max_instances and a billing budget are what bound the cost.
resource "google_cloud_run_v2_service_iam_member" "web_public" {
project = var.project
location = google_cloud_run_v2_service.web.location
name = google_cloud_run_v2_service.web.name
role = "roles/run.invoker"
member = "allUsers"
}
resource "google_cloud_run_v2_service_iam_member" "api_public" {
project = var.project
location = google_cloud_run_v2_service.api.location
name = google_cloud_run_v2_service.api.name
role = "roles/run.invoker"
member = "allUsers"
}
# Least privilege: the API may execute this one job with argument overrides, nothing more.
resource "google_cloud_run_v2_job_iam_member" "api_runs_nextflow" {
project = var.project
location = google_cloud_run_v2_job.nextflow.location
name = google_cloud_run_v2_job.nextflow.name
role = "roles/run.jobsExecutorWithOverrides"
member = "serviceAccount:${google_service_account.api.email}"
}
resource "google_secret_manager_secret_iam_member" "api_database_url" {
secret_id = google_secret_manager_secret.api_database_url.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.api.email}"
}
# Reading the model artifact from gs://<bucket>/models/... when MODEL_URI is set.
resource "google_storage_bucket_iam_member" "api_reads_data" {
bucket = google_storage_bucket.data.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.api.email}"
}
+16 -8
View File
@@ -1,8 +1,10 @@
resource "google_sql_database_instance" "pg" {
name = "rarelens-pg"
database_version = "POSTGRES_16"
region = var.region
deletion_protection = false
count = var.deploy_cloud_sql ? 1 : 0
name = "rarelens-pg"
database_version = "POSTGRES_16"
region = var.region
deletion_protection = var.deletion_protection
depends_on = [google_service_networking_connection.private_services]
settings {
tier = "db-f1-micro" # lab budget; bump for real use
@@ -16,14 +18,20 @@ resource "google_sql_database_instance" "pg" {
}
resource "google_sql_database" "rarelens" {
count = var.deploy_cloud_sql ? 1 : 0
name = "rarelens"
instance = google_sql_database_instance.pg.name
instance = google_sql_database_instance.pg[0].name
}
resource "google_sql_user" "api" {
count = var.deploy_cloud_sql ? 1 : 0
name = "rarelens"
instance = google_sql_database_instance.pg.name
password = random_password.pg.result
instance = google_sql_database_instance.pg[0].name
password = random_password.pg[0].result
}
resource "random_password" "pg" { length = 32 }
resource "random_password" "pg" {
count = var.deploy_cloud_sql ? 1 : 0
length = 32
special = false # embedded in DATABASE_URL, where characters like @ / # % would break parsing
}
+9 -4
View File
@@ -1,8 +1,13 @@
resource "google_container_cluster" "rarelens" {
name = "rarelens"
location = var.region
enable_autopilot = true
deletion_protection = false
count = var.deploy_kubernetes ? 1 : 0
name = "rarelens"
location = var.region
enable_autopilot = true
deletion_protection = var.deletion_protection
# Same VPC as Cloud SQL's private IP; without this the cluster lands on the "default" network.
network = google_compute_network.vpc.id
subnetwork = google_compute_subnetwork.gke.id
ip_allocation_policy {}
workload_identity_config { workload_pool = "${var.project}.svc.id.goog" }
release_channel { channel = "REGULAR" }
+52 -7
View File
@@ -1,6 +1,7 @@
# Workload Identity Federation: GitHub Actions pushes images without long-lived keys.
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github"
depends_on = [google_project_service.enabled]
}
resource "google_iam_workload_identity_pool_provider" "github" {
@@ -29,22 +30,66 @@ resource "google_artifact_registry_repository_iam_member" "ci_push" {
member = "serviceAccount:${google_service_account.ci.email}"
}
# Runtime identities (bound to k8s ServiceAccounts via GKE Workload Identity)
resource "google_service_account" "api" { account_id = "rarelens-api" }
# Runtime identities, bound to Kubernetes ServiceAccounts in namespace "rarelens" via GKE
# Workload Identity (the gcp overlay annotates the k8s side).
resource "google_service_account" "api" { account_id = "rarelens-api" }
resource "google_service_account" "pipeline" { account_id = "rarelens-pipeline" }
resource "google_service_account_iam_member" "api_workload_identity" {
count = var.deploy_kubernetes ? 1 : 0
service_account_id = google_service_account.api.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project}.svc.id.goog[rarelens/rarelens-api]"
}
resource "google_service_account_iam_member" "pipeline_workload_identity" {
count = var.deploy_kubernetes ? 1 : 0
service_account_id = google_service_account.pipeline.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project}.svc.id.goog[rarelens/rarelens-pipeline]"
}
resource "google_project_iam_member" "api_sql" {
project = var.project
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.api.email}"
}
resource "google_project_iam_member" "api_pubsub" {
project = var.project
role = "roles/pubsub.publisher"
member = "serviceAccount:${google_service_account.api.email}"
resource "google_pubsub_topic_iam_member" "api_publish" {
topic = google_pubsub_topic.vcf_uploaded.name
role = "roles/pubsub.publisher"
member = "serviceAccount:${google_service_account.api.email}"
}
resource "google_pubsub_subscription_iam_member" "pipeline_subscribe" {
subscription = google_pubsub_subscription.vcf_uploaded_argo.name
role = "roles/pubsub.subscriber"
member = local.pipeline_sa_member
}
resource "google_storage_bucket_iam_member" "pipeline_data" {
bucket = google_storage_bucket.data.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.pipeline.email}"
member = local.pipeline_sa_member
}
resource "google_artifact_registry_repository_iam_member" "pipeline_pull" {
repository = google_artifact_registry_repository.images.name
location = var.region
role = "roles/artifactregistry.reader"
member = local.pipeline_sa_member
}
# Nextflow's google-batch executor submits Batch jobs that run as the pipeline SA itself.
resource "google_project_iam_member" "pipeline_batch" {
for_each = toset(["roles/batch.jobsEditor", "roles/batch.agentReporter", "roles/logging.logWriter"])
project = var.project
role = each.value
member = local.pipeline_sa_member
}
resource "google_service_account_iam_member" "pipeline_act_as_self" {
service_account_id = google_service_account.pipeline.name
role = "roles/iam.serviceAccountUser"
member = local.pipeline_sa_member
}
+16
View File
@@ -0,0 +1,16 @@
locals {
registry = "${var.region}-docker.pkg.dev/${var.project}/rarelens"
# join("", ...) rather than one(...): with count = 0 these collapse to "" instead of null.
db_credentials = "${join("", google_sql_user.api[*].name)}:${join("", random_password.pg[*].result)}"
db_name = join("", google_sql_database.rarelens[*].name)
db_private_ip = join("", google_sql_database_instance.pg[*].private_ip_address)
# The API reaches Cloud SQL through its cloud-sql-proxy sidecar on localhost; pipeline tasks
# (Google Batch VMs, Argo pods) use the private IP inside the VPC. With deploy_cloud_sql = false
# both use the URL you supplied, which is expected to be reachable over TLS.
api_database_url = var.deploy_cloud_sql ? "postgresql+asyncpg://${local.db_credentials}@127.0.0.1:5432/${local.db_name}" : var.database_url
pipeline_database_url = var.deploy_cloud_sql ? "postgresql://${local.db_credentials}@${local.db_private_ip}:5432/${local.db_name}" : var.database_url
pipeline_sa_member = "serviceAccount:${google_service_account.pipeline.email}"
}
+28 -1
View File
@@ -1,4 +1,31 @@
resource "google_compute_network" "vpc" {
name = "rarelens-vpc"
auto_create_subnetworks = true
auto_create_subnetworks = false
depends_on = [google_project_service.enabled]
}
# Shared by GKE and the Google Batch VMs that run pipeline tasks (pipeline/nextflow.config).
resource "google_compute_subnetwork" "gke" {
name = "rarelens-gke"
region = var.region
network = google_compute_network.vpc.id
ip_cidr_range = "10.10.0.0/20"
private_ip_google_access = true
}
# Private services access, so Cloud SQL gets a private IP inside this VPC. Only needed with it.
resource "google_compute_global_address" "private_services" {
count = var.deploy_cloud_sql ? 1 : 0
name = "rarelens-private-services"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = google_compute_network.vpc.id
}
resource "google_service_networking_connection" "private_services" {
count = var.deploy_cloud_sql ? 1 : 0
network = google_compute_network.vpc.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.private_services[0].name]
}
+14 -5
View File
@@ -1,5 +1,14 @@
output "cluster_name" { value = google_container_cluster.rarelens.name }
output "sql_connection" { value = google_sql_database_instance.pg.connection_name }
output "data_bucket" { value = google_storage_bucket.data.name }
output "wif_provider" { value = google_iam_workload_identity_pool_provider.github.name }
output "ci_sa" { value = google_service_account.ci.email }
output "web_url" {
description = "The one URL to share"
value = google_cloud_run_v2_service.web.uri
}
output "api_url" { value = google_cloud_run_v2_service.api.uri }
output "nextflow_job" { value = google_cloud_run_v2_job.nextflow.name }
output "data_bucket" { value = google_storage_bucket.data.name }
output "cluster_name" { value = one(google_container_cluster.rarelens[*].name) }
output "sql_connection" { value = one(google_sql_database_instance.pg[*].connection_name) }
output "sql_private_ip" { value = one(google_sql_database_instance.pg[*].private_ip_address) }
output "wif_provider" { value = google_iam_workload_identity_pool_provider.github.name }
output "ci_sa" { value = google_service_account.ci.email }
output "api_sa" { value = google_service_account.api.email }
output "pipeline_sa" { value = google_service_account.pipeline.email }
+11
View File
@@ -0,0 +1,11 @@
resource "google_pubsub_topic" "vcf_uploaded" {
name = "vcf-uploaded"
depends_on = [google_project_service.enabled]
}
# Consumed by the Argo Events EventSource in infra/argo-workflows/events.yaml.
resource "google_pubsub_subscription" "vcf_uploaded_argo" {
name = "vcf-uploaded-argo"
topic = google_pubsub_topic.vcf_uploaded.id
ack_deadline_seconds = 60
}
+41
View File
@@ -0,0 +1,41 @@
# Copied into Kubernetes secrets by `make gcp-secrets PROJECT=<id>`.
resource "google_secret_manager_secret" "api_database_url" {
secret_id = "rarelens-api-database-url"
replication {
auto {}
}
depends_on = [google_project_service.enabled]
}
resource "google_secret_manager_secret_version" "api_database_url" {
secret = google_secret_manager_secret.api_database_url.id
secret_data = local.api_database_url
lifecycle {
precondition {
condition = var.deploy_cloud_sql || var.database_url != ""
error_message = "Set database_url (e.g. a Neon URL), or deploy_cloud_sql = true."
}
}
}
# The id must match the Nextflow `secret = 'DATABASE_URL'` directive in pipeline/nextflow.config:
# on Google Batch, Nextflow resolves secrets from Secret Manager by name.
resource "google_secret_manager_secret" "pipeline_database_url" {
secret_id = "DATABASE_URL"
replication {
auto {}
}
depends_on = [google_project_service.enabled]
}
resource "google_secret_manager_secret_version" "pipeline_database_url" {
secret = google_secret_manager_secret.pipeline_database_url.id
secret_data = local.pipeline_database_url
}
resource "google_secret_manager_secret_iam_member" "pipeline_database_url" {
secret_id = google_secret_manager_secret.pipeline_database_url.id
role = "roles/secretmanager.secretAccessor"
member = local.pipeline_sa_member
}
+3 -2
View File
@@ -2,6 +2,8 @@ resource "google_storage_bucket" "data" {
name = "${var.project}-rarelens-data"
location = var.region
uniform_bucket_level_access = true
public_access_prevention = "enforced"
depends_on = [google_project_service.enabled]
lifecycle_rule {
condition {
age = 30
@@ -15,6 +17,5 @@ resource "google_artifact_registry_repository" "images" {
repository_id = "rarelens"
location = var.region
format = "DOCKER"
depends_on = [google_project_service.enabled]
}
resource "google_pubsub_topic" "vcf_uploaded" { name = "vcf-uploaded" }
+55 -5
View File
@@ -1,9 +1,59 @@
variable "project" { type = string }
variable "project" {
description = "GCP project id"
type = string
}
variable "region" {
type = string
default = "europe-west2" # London: keeps public genomic test data and the Cambridge team in one jurisdiction
description = "GCP region for every regional resource"
type = string
default = "europe-west2" # London: keeps public genomic test data and the Cambridge team in one jurisdiction
}
variable "github_repo" {
type = string
default = "lynchaos/rarelens"
description = "owner/name of the GitHub repo allowed to push images via Workload Identity Federation"
type = string
default = "lynchaos/rarelens"
}
variable "deletion_protection" {
description = "Protect the GKE cluster and Cloud SQL instance from `terraform destroy`; set false to tear the lab down"
type = bool
default = true
}
variable "deploy_kubernetes" {
description = "Create the GKE cluster (Argo/ArgoCD track). Off by default: it costs ~$150/month idle, while the serverless track costs ~£1 (docs/cloud.md)"
type = bool
default = false
}
variable "deploy_cloud_sql" {
description = "Create a Cloud SQL instance (~$10/month). Off by default: set database_url to a free scale-to-zero Postgres such as Neon"
type = bool
default = false
}
variable "database_url" {
description = "Postgres URL used when deploy_cloud_sql is false, e.g. postgresql+asyncpg://user:pass@host/db?sslmode=require"
type = string
default = ""
sensitive = true
}
variable "image_tag" {
description = "Image tag deployed to Cloud Run; CI pushes the commit SHA"
type = string
default = "latest"
}
variable "model_uri" {
description = "Optional model artifact to score with, e.g. gs://<project>-rarelens-data/models/pathogenicity/1. Empty means use the MLflow registry"
type = string
default = ""
}
variable "max_instances" {
description = "Cloud Run instance ceiling per service: scale-to-zero bounds the floor, this bounds the bill"
type = number
default = 2
}
+4 -1
View File
@@ -4,7 +4,10 @@ terraform {
google = { source = "hashicorp/google", version = "~> 6.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
backend "gcs" { bucket = "REPLACE-tfstate", prefix = "rarelens" }
# Partial config: terraform init -backend-config="bucket=<your-tfstate-bucket>"
backend "gcs" {
prefix = "rarelens"
}
}
provider "google" {