Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password
# SHUTDOWN_TIMEOUT_MS=30000
#
# Read only by docker-compose.yml (Docker Compose reads this file for them), not by the API:
# MGPL_API_VERSION=latest api image tag: pin a release in production, e.g. 1.3.0 (docs/18-updates.md)
# MGPL_DASHBOARD_VERSION=latest dashboard image tag
# MGPL_API_IMAGE=ghcr.io/mgpl-diagnostics/api
# MGPL_DASHBOARD_IMAGE=ghcr.io/mgpl-diagnostics/dashboard
# API_REPLICAS=2 api containers behind nginx (or `docker compose up -d --scale api=N`)
# HTTP_PORT=8080 nginx's port on the host
# POSTGRES_PASSWORD=postgres [secret] URL-safe characters only: it goes into connection URLs
Expand All @@ -201,3 +205,16 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password
# MONGOMS_SYSTEM_BINARY= `npm test`: path to a local mongod
# PGBOUNCER_BIN=pgbouncer `npm test`: the pgbouncer binary for the [pgbouncer] and [replicas] checks (skipped without it)
# DEBUG=1 `npm test`: print resolver errors


# ── Updates: notices for owners and admins (docs/18-updates.md) ──────────────────────────────────────
# [optional] The api reads the newest GitHub releases of the api and the dashboard (one GET each, every
# UPDATE_CHECK_INTERVAL_MS) and the dashboard shows "update available". false: no outbound call.
# UPDATE_CHECK=true
# UPDATE_CHECK_INTERVAL_MS=21600000
# [optional] [secret] A GitHub token with no scopes, only for a higher rate limit.
# UPDATE_GITHUB_TOKEN=
# UPDATE_REPO_API=mgpl-diagnostics/api
# UPDATE_REPO_DASHBOARD=mgpl-diagnostics/dashboard
# UPDATE_FEED_URL=https://api.github.com
# IMAGE_REGISTRY=ghcr.io
79 changes: 79 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Release

# A `v*` tag (admin-only) or a manual run on main publishes one release:
# 1. checks that the tag equals package.json's version,
# 2. builds the Docker image for linux/amd64 and linux/arm64 and pushes it to GHCR:
# ghcr.io/mgpl-diagnostics/api:<version>, :<major>.<minor>, :<major> and :latest,
# 3. creates the GitHub release with generated notes (the changelog the dashboard shows owners and admins).
# Bump the version in a PR first; then `git tag v1.2.3 && git push origin v1.2.3`, or run this workflow on main.
on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: write
packages: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Version from package.json (must match the tag)
id: v
run: |
version=$(node -p "require('./package.json').version")
if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "$GITHUB_REF_NAME" != "v$version" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match package.json version $version"; exit 1
fi
if [ "$GITHUB_REF_TYPE" != "tag" ] && [ "$GITHUB_REF_NAME" != "main" ]; then
echo "::error::run this workflow on main or push a v* tag"; exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=v${{ steps.v.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ steps.v.outputs.version }}
type=semver,pattern={{major}},value=v${{ steps.v.outputs.version }},enable=${{ !startsWith(steps.v.outputs.version, '0.') }}
type=raw,value=latest
labels: |
org.opencontainers.image.title=MGPL Diagnostics API
org.opencontainers.image.version=${{ steps.v.outputs.version }}
# the kits come from GitHub Packages: the workflow token reads them (build secret, never a build arg)
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: npm_token=${{ secrets.GITHUB_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: GitHub release with generated notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: v${{ steps.v.outputs.version }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "release $TAG exists"
else
gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes \
--notes "Image: \`ghcr.io/${{ github.repository }}:${{ steps.v.outputs.version }}\` (linux/amd64, linux/arm64). Update guide: docs/18-updates.md"
fi
13 changes: 12 additions & 1 deletion deploy/nginx.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# The load balancer in docker-compose.yml: spreads requests over every `api` replica.
# The front door in docker-compose.yml: /graphql and /health go to the `api` replicas (spread over all of them),
# everything else to the dashboard. One origin for the browser: no CORS.
# Docker's DNS (127.0.0.11) answers "api" with one address per replica; resolving through a variable makes
# nginx re-resolve every 10 s, so `docker compose up --scale api=N` needs no restart here.
# TLS: terminate it here (listen 443 ssl + certificates) or in front of it (a CDN or a cloud load balancer);
Expand All @@ -15,6 +16,16 @@ http {
listen 80;

location / {
set $dashboard http://dashboard:3000;
proxy_pass $dashboard;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location ~ ^/(graphql|health)$ {
set $api http://api:4000;
proxy_pass $api;
proxy_http_version 1.1;
Expand Down
19 changes: 19 additions & 0 deletions docker-compose.build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Build the api image from this checkout instead of pulling the released one:
# NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
# The kits come from GitHub Packages (a token with read:packages, passed as a build secret).
services:
db-sync:
image: mgpl-api:local
build:
context: .
secrets: [npm_token]
api:
image: mgpl-api:local
pull_policy: never
worker:
image: mgpl-api:local
pull_policy: never

secrets:
npm_token:
environment: NODE_AUTH_TOKEN
43 changes: 26 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
# The whole backend on one host: PostgreSQL ← PgBouncer ← N api replicas ← nginx, plus Redis and the worker.
# Guide: docs/17-scaling.md.
# The whole system on one host: PostgreSQL ← PgBouncer ← N api replicas, the worker, Redis, the dashboard,
# and nginx in front (/graphql and /health → api, everything else → dashboard). Guides: docs/17-scaling.md,
# docs/18-updates.md.
#
# cp .env.example .env # set JWT_SECRET, CORS_ORIGINS, POSTGRES_PASSWORD, …
# NODE_AUTH_TOKEN=… docker compose build # the kits come from GitHub Packages (read:packages)
# docker compose up -d # API_REPLICAS replicas (default 2) on http://localhost:8080
# docker compose up -d --scale api=4 # more or fewer, at any time
# docker compose up -d # pulls the released images; http://localhost:8080
# docker compose up -d --scale api=4 # more or fewer api replicas, at any time
#
# Updating: set MGPL_API_VERSION / MGPL_DASHBOARD_VERSION in .env (default "latest"), then
# docker compose pull && docker compose up -d
# db-sync adds new tables first; the replicas restart after it. Owners and admins see new releases in the
# dashboard (Updates).
#
# Building the api from this checkout instead of pulling it:
# NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
#
# The api and the worker read .env too; the values below (database, Redis, proxy) win over it, because the
# services find each other by name inside this network.
name: mgpl

# api and worker run the image that db-sync builds (one build, three commands)
# api, worker and db-sync run the same image (three commands)
x-api: &api
image: mgpl-api
pull_policy: never
image: ${MGPL_API_IMAGE:-ghcr.io/mgpl-diagnostics/api}:${MGPL_API_VERSION:-latest}
restart: unless-stopped
env_file:
- path: .env
Expand Down Expand Up @@ -74,10 +81,7 @@ services:

# Creates missing tables once per `up`, straight on PostgreSQL, before any replica starts (they run with DB_SYNC=false).
db-sync:
image: mgpl-api
build:
context: .
secrets: [npm_token]
image: ${MGPL_API_IMAGE:-ghcr.io/mgpl-diagnostics/api}:${MGPL_API_VERSION:-latest}
env_file:
- path: .env
required: false
Expand Down Expand Up @@ -119,16 +123,21 @@ services:
disable: true
stop_grace_period: 40s

# The staff dashboard (mgpl-diagnostics/dashboard). The browser reaches it and the api through nginx on one
# origin, so no CORS and one trusted proxy (TRUST_PROXY=1). MGPL_API_URL is only for its own /graphql fallback.
dashboard:
image: ${MGPL_DASHBOARD_IMAGE:-ghcr.io/mgpl-diagnostics/dashboard}:${MGPL_DASHBOARD_VERSION:-latest}
restart: unless-stopped
environment:
MGPL_API_URL: http://api:4000
depends_on: [api]

nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports: ["${HTTP_PORT:-8080}:80"]
volumes: [./deploy/nginx.conf:/etc/nginx/nginx.conf:ro]
depends_on: [api]

secrets:
npm_token:
environment: NODE_AUTH_TOKEN
depends_on: [api, dashboard]

volumes:
pgdata:
Expand Down
6 changes: 6 additions & 0 deletions docs/04-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,9 @@ and runnable examples are in [examples/](examples/).
| mutation | `matchExternalReport(id: ID!, bookingId: ID!): ExternalReport!` | `report:import` | Ties an import to a booking; patient differences become warnings. |
| mutation | `nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!` | `signatory:manage` | Names a partner signature; the organisation's unapplied imports that carry it are read again. |
| mutation | `rejectExternalReport(id: ID!, reason: String!): ExternalReport!` | `report:import` | |

## system

| Kind | Operation | Permission | Notes |
|---|---|---|---|
| query | `systemUpdates(dashboardVersion: String, refresh: Boolean): SystemUpdates!` | `settings:manage` | Owner and admin (settings:manage): is a newer api or dashboard release out, what changed, and how to update. The dashboard passes its own version; refresh skips the cache (at most once a minute). |
9 changes: 8 additions & 1 deletion docs/09-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,15 @@ variables already set in the real environment win over the file.
| `MGPL_SIGNATURE_FILE` | — | One file for both |
| `SHUTDOWN_DRAIN_MS` | `5000` in production, `0` otherwise | On SIGTERM, `GET /health` answers 503 this long while requests are still served ([17-scaling.md](17-scaling.md#graceful-shutdown)) |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Exit anyway this long after the signal (API and worker) |
| `UPDATE_CHECK` | `true` (`false` in tests) | Update notices for owners and admins: read the newest GitHub releases ([19-updates.md](19-updates.md)); `false` makes no outbound call |
| `UPDATE_CHECK_INTERVAL_MS` | `21600000` (6 h) | How long one check is reused (at least 60000) |
| `UPDATE_GITHUB_TOKEN` | empty | Optional token (no scopes) for GitHub's higher rate limit |
| `UPDATE_REPO_API` / `UPDATE_REPO_DASHBOARD` | `mgpl-diagnostics/api` / `mgpl-diagnostics/dashboard` | Where the releases are read |
| `UPDATE_FEED_URL` | `https://api.github.com` | The releases API (GitHub Enterprise or a mirror) |
| `IMAGE_REGISTRY` | `ghcr.io` | Registry shown in the image names |

`docker-compose.yml` reads a few more from `.env` (not the API): `API_REPLICAS`, `HTTP_PORT`,
`docker-compose.yml` reads a few more from `.env` (not the API): `MGPL_API_VERSION`, `MGPL_DASHBOARD_VERSION`
(image tags, default `latest`), `MGPL_API_IMAGE`, `MGPL_DASHBOARD_IMAGE`, `API_REPLICAS`, `HTTP_PORT`,
`POSTGRES_PASSWORD`, `POSTGRES_MAX_CONNECTIONS` and the `PGBOUNCER_*` pool settings
([17-scaling.md](17-scaling.md#docker-compose)).

Expand Down
4 changes: 3 additions & 1 deletion docs/12-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ NODE_ENV=production node dist/main.js # API
NODE_ENV=production node dist/worker.js # worker (with REDIS_URL)
```

A container image: the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`;
Released images: `ghcr.io/mgpl-diagnostics/api` and `ghcr.io/mgpl-diagnostics/dashboard`, one tag per
release; owners and admins see new releases in the dashboard ([19-updates.md](19-updates.md)).
The image is built from the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`;
`node dist/main.js`, `node dist/worker.js` or `node dist/sync.js`; port 4000). It needs `NODE_AUTH_TOKEN` as a
build secret for the kits. The PDF engine needs no system fonts (fonts are bundled), but
report-kit's resvg is a native module: build the image on the same platform it runs on.
Expand Down
12 changes: 7 additions & 5 deletions docs/17-scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,16 @@ Stopping one replica of three under constant traffic through nginx gave 40 of 40

## Docker Compose

[`docker-compose.yml`](../docker-compose.yml) runs the whole backend on one host: PostgreSQL, PgBouncer
(transaction mode), Redis (AOF on), a one-shot `db-sync`, `API_REPLICAS` API containers, a worker and
nginx ([`deploy/nginx.conf`](../deploy/nginx.conf)) in front of the replicas on `HTTP_PORT`.
[`docker-compose.yml`](../docker-compose.yml) runs the whole system on one host from the released images
([19-updates.md](19-updates.md)): PostgreSQL, PgBouncer (transaction mode), Redis (AOF on), a one-shot
`db-sync`, `API_REPLICAS` API containers, a worker, the dashboard and nginx
([`deploy/nginx.conf`](../deploy/nginx.conf)) in front on `HTTP_PORT` (`/graphql` and `/health` → the
replicas, everything else → the dashboard).

```bash
cp .env.example .env # NODE_ENV=production, JWT_SECRET, CORS_ORIGINS, POSTGRES_PASSWORD, SMTP_URL, …
NODE_AUTH_TOKEN=… docker compose build # the kits come from GitHub Packages (read:packages)
docker compose up -d # http://localhost:8080/graphql
docker compose up -d # pulls the images; http://localhost:8080 (/graphql: the api)
NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build # or build the api here
docker compose up -d --scale api=4 # change the number of replicas at any time
docker compose exec -e PGPASSWORD=… postgres psql -h pgbouncer -p 6432 -U postgres pgbouncer -c 'SHOW POOLS'
```
Expand Down
Loading
Loading