Files
bd-fhir-national/ops/deployment-guide.md
2026-03-16 00:02:58 +06:00

895 lines
27 KiB
Markdown

# BD FHIR National — Production Deployment Guide
**Target OS:** Ubuntu 22.04 LTS
**Audience:** DGHS infrastructure team
**Estimated time:** 90 minutes first deployment, 15 minutes subsequent upgrades
---
## Prerequisites checklist
Before starting, confirm all of the following:
- [ ] Ubuntu 22.04 LTS server provisioned with minimum 8GB RAM, 4 vCPU, 100GB disk
- [ ] Server has outbound HTTPS access to:
- `auth.dghs.gov.bd` (Keycloak)
- `tr.ocl.dghs.gov.bd` (OCL)
- `icd11.dghs.gov.bd` (cluster validator)
- Your private Docker registry
- [ ] TLS certificates provisioned at paths matching `.env` `TLS_CERT_PATH` / `TLS_KEY_PATH`
- [ ] Keycloak `hris` realm configured per `ops/keycloak-setup.md`
- [ ] BD Core IG `bd.gov.dghs.core-0.2.1.tgz` present in `hapi-overlay/src/main/resources/packages/` on CI machine
- [ ] CI machine has built and pushed the Docker image to private registry
- [ ] `.env` file prepared from `.env.example` with all secrets filled in
---
## Part 1 — Server preparation
### 1.1 — Install Docker Engine
```bash
# Remove any conflicting packages
for pkg in docker.io docker-doc docker-compose docker-compose-v2 \
podman-docker containerd runc; do
sudo apt-get remove -y $pkg 2>/dev/null
done
# Add Docker's official GPG key
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine and Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Verify
docker --version # Docker Engine 25.x or higher
docker compose version # Docker Compose v2.x or higher
```
### 1.2 — Configure Docker daemon
```bash
# Create daemon config: limit log size, set storage driver
sudo tee /etc/docker/daemon.json <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "5"
},
"storage-driver": "overlay2",
"live-restore": true
}
EOF
sudo systemctl restart docker
sudo systemctl enable docker
# Add your deploy user to the docker group (avoids sudo on every docker command)
sudo usermod -aG docker $USER
# Log out and back in for group membership to take effect
```
### 1.3 — Create application directory
```bash
sudo mkdir -p /opt/bd-fhir-national
sudo chown $USER:$USER /opt/bd-fhir-national
cd /opt/bd-fhir-national
```
### 1.4 — Deploy project files
Copy the entire project directory to the server. Recommended approach:
```bash
# From your CI/deployment machine:
rsync -avz --exclude='.git' \
--exclude='hapi-overlay/target' \
--exclude='hapi-overlay/src' \
./bd-fhir-national/ \
deploy@your-server:/opt/bd-fhir-national/
# The server needs:
# /opt/bd-fhir-national/
# ├── docker-compose.yml
# ├── .env ← you create this (see 1.5)
# ├── nginx/nginx.conf
# ├── postgres/fhir/postgresql.conf
# ├── postgres/fhir/init.sql
# ├── postgres/audit/postgresql.conf
# └── postgres/audit/init.sql
#
# The hapi-overlay/ source tree does NOT need to be on the production server.
# Only the Docker image (pre-built and pushed to registry) is needed.
```
### 1.5 — Create .env file
```bash
cd /opt/bd-fhir-national
cp .env.example .env
chmod 600 .env # restrict to owner only — contains secrets
# Edit .env with actual values
nano .env
```
**Required values in .env:**
```bash
# Docker image — must match what CI pushed
HAPI_IMAGE=your-registry.dghs.gov.bd/bd-fhir-hapi:1.0.0
# FHIR database
FHIR_DB_NAME=fhirdb
FHIR_DB_SUPERUSER=postgres
FHIR_DB_SUPERUSER_PASSWORD=$(openssl rand -base64 32)
FHIR_DB_APP_USER=hapi_app
FHIR_DB_APP_PASSWORD=$(openssl rand -base64 32)
# Audit database
AUDIT_DB_NAME=auditdb
AUDIT_DB_SUPERUSER=postgres
AUDIT_DB_SUPERUSER_PASSWORD=$(openssl rand -base64 32)
AUDIT_DB_WRITER_USER=audit_writer_login
AUDIT_DB_WRITER_PASSWORD=$(openssl rand -base64 32)
AUDIT_DB_MAINTAINER_USER=audit_maintainer_login
AUDIT_DB_MAINTAINER_PASSWORD=$(openssl rand -base64 32)
# TLS certificate paths (absolute paths on this server)
TLS_CERT_PATH=/etc/ssl/dghs/fhir.dghs.gov.bd.crt
TLS_KEY_PATH=/etc/ssl/dghs/fhir.dghs.gov.bd.key
```
> **Security:** Never commit `.env` to version control. Store the filled
> `.env` in your secrets vault (HashiCorp Vault, AWS SSM, or encrypted backup).
> Verify permissions after creation: `ls -la .env` should show `-rw-------`.
### 1.6 — Fix PostgreSQL init script password injection
The `postgres/audit/init.sql` file contains placeholder passwords.
PostgreSQL's Docker entrypoint does not perform variable substitution in
`.sql` init files — only `.sh` files. Replace the init SQL with a shell script:
```bash
# Create shell-based init script for audit database
cat > /opt/bd-fhir-national/postgres/audit/init.sh <<'INITSCRIPT'
#!/bin/bash
set -e
# Load passwords from environment variables
# (These env vars are set in docker-compose.yml from .env)
WRITER_USER="${AUDIT_DB_WRITER_USER:-audit_writer_login}"
WRITER_PASS="${AUDIT_DB_WRITER_PASSWORD}"
MAINTAINER_USER="${AUDIT_DB_MAINTAINER_USER:-audit_maintainer_login}"
MAINTAINER_PASS="${AUDIT_DB_MAINTAINER_PASSWORD}"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Create writer login user
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${WRITER_USER}') THEN
CREATE USER ${WRITER_USER}
WITH NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT LOGIN
CONNECTION LIMIT 20
PASSWORD '${WRITER_PASS}';
END IF;
END
\$\$;
-- Create maintainer login user
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${MAINTAINER_USER}') THEN
CREATE USER ${MAINTAINER_USER}
WITH NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT LOGIN
CONNECTION LIMIT 5
PASSWORD '${MAINTAINER_PASS}';
END IF;
END
\$\$;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${WRITER_USER};
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${MAINTAINER_USER};
EOSQL
INITSCRIPT
chmod +x /opt/bd-fhir-national/postgres/audit/init.sh
```
Update `docker-compose.yml` to mount `init.sh` instead of `init.sql` for
the `postgres-audit` service:
```yaml
# In postgres-audit volumes: section, change:
# - ./postgres/audit/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
# To:
- ./postgres/audit/init.sh:/docker-entrypoint-initdb.d/init.sh:ro
```
Also pass the audit user environment variables to `postgres-audit`:
```yaml
# In postgres-audit environment: section, add:
AUDIT_DB_WRITER_USER: ${AUDIT_DB_WRITER_USER}
AUDIT_DB_WRITER_PASSWORD: ${AUDIT_DB_WRITER_PASSWORD}
AUDIT_DB_MAINTAINER_USER: ${AUDIT_DB_MAINTAINER_USER}
AUDIT_DB_MAINTAINER_PASSWORD: ${AUDIT_DB_MAINTAINER_PASSWORD}
```
Similarly for `postgres-fhir`, create `init.sh`:
```bash
cat > /opt/bd-fhir-national/postgres/fhir/init.sh <<'INITSCRIPT'
#!/bin/bash
set -e
APP_USER="${FHIR_DB_APP_USER:-hapi_app}"
APP_PASS="${FHIR_DB_APP_PASSWORD}"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${APP_USER}') THEN
CREATE USER ${APP_USER}
WITH NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT LOGIN
CONNECTION LIMIT 30
PASSWORD '${APP_PASS}';
END IF;
END
\$\$;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${APP_USER};
GRANT USAGE ON SCHEMA public TO ${APP_USER};
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${APP_USER};
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO ${APP_USER};
EOSQL
INITSCRIPT
chmod +x /opt/bd-fhir-national/postgres/fhir/init.sh
```
### 1.7 — Authenticate with private Docker registry
```bash
# Log in to your private registry
docker login your-registry.dghs.gov.bd \
--username ${REGISTRY_USER} \
--password-stdin <<< "${REGISTRY_PASSWORD}"
# Verify the login persisted
cat ~/.docker/config.json | jq '.auths | keys'
# Should include "your-registry.dghs.gov.bd"
```
---
## Part 2 — First deployment
### 2.1 — Pull images
```bash
cd /opt/bd-fhir-national
# Pull all images declared in docker-compose.yml
docker compose --env-file .env pull
# Verify images are present locally
docker images | grep -E "hapi|postgres|pgbouncer|nginx"
```
### 2.2 — Start infrastructure services first
Start databases before HAPI. HAPI's `depends_on` with `condition: service_healthy`
handles this automatically, but starting manually in stages helps isolate
any first-run issues.
```bash
# Start databases
docker compose --env-file .env up -d postgres-fhir postgres-audit
# Wait for health checks to pass (up to 60 seconds)
echo "Waiting for PostgreSQL to be ready..."
until docker compose --env-file .env ps postgres-fhir \
| grep -q "healthy"; do
sleep 3
echo -n "."
done
echo ""
echo "postgres-fhir: healthy"
until docker compose --env-file .env ps postgres-audit \
| grep -q "healthy"; do
sleep 3
echo -n "."
done
echo ""
echo "postgres-audit: healthy"
```
### 2.3 — Verify PostgreSQL user creation
```bash
# Verify FHIR app user was created
docker exec bd-postgres-fhir psql -U postgres -d fhirdb -c \
"SELECT rolname, rolcanlogin FROM pg_roles WHERE rolname = 'hapi_app';"
# Expected: hapi_app | t
# Verify audit writer user was created
docker exec bd-postgres-audit psql -U postgres -d auditdb -c \
"SELECT rolname, rolcanlogin FROM pg_roles WHERE rolname = 'audit_writer_login';"
# Expected: audit_writer_login | t
```
### 2.4 — Start pgBouncer
```bash
docker compose --env-file .env up -d pgbouncer-fhir pgbouncer-audit
# Verify pgBouncer is healthy
until docker compose --env-file .env ps pgbouncer-fhir \
| grep -q "healthy"; do
sleep 3
done
echo "pgbouncer-fhir: healthy"
```
### 2.5 — Start HAPI (first replica)
```bash
docker compose --env-file .env up -d hapi
# Follow startup logs — this takes 60-120 seconds on first run
# Watch for these key log events in order:
# 1. "Running FHIR Flyway migrations" — V1 schema creation
# 2. "Running Audit Flyway migrations" — V2 audit schema creation
# 3. "Advisory lock acquired" — IG package initialisation begins
# 4. "BD Core IG package loaded successfully" — IG loaded
# 5. "BdTerminologyValidationSupport initialised" — OCL integration ready
# 6. "KeycloakJwtInterceptor initialised" — JWT validation ready
# 7. "HAPI RestfulServer interceptors registered" — server ready
# 8. Spring Boot startup completion message with port 8080
docker compose --env-file .env logs -f hapi
# Press Ctrl+C when you see the startup completion message
```
**Expected startup log sequence (key lines only):**
```
INFO o.f.core.internal.command.DbMigrate - Running FHIR Flyway migrations
INFO o.f.core.internal.command.DbMigrate - Successfully applied 1 migration to schema "public"
INFO o.f.core.internal.command.DbMigrate - Running Audit Flyway migrations
INFO o.f.core.internal.command.DbMigrate - Successfully applied 1 migration to schema "audit"
INFO b.g.d.f.init.IgPackageInitializer - Advisory lock acquired: lockKey=... waitedMs=...
INFO b.g.d.f.init.IgPackageInitializer - BD Core IG package loaded successfully: version=0.2.1
INFO b.g.d.f.t.BdTerminologyValidationSupport - BdTerminologyValidationSupport initialised
INFO b.g.d.f.i.KeycloakJwtInterceptor - KeycloakJwtInterceptor initialised
INFO b.g.d.f.c.SecurityConfig - HAPI RestfulServer interceptors registered
INFO o.s.b.w.e.t.TomcatWebServer - Tomcat started on port(s): 8080
INFO b.g.d.f.BdFhirApplication - Started BdFhirApplication in XX.XXX seconds
```
### 2.6 — Start nginx
```bash
docker compose --env-file .env up -d nginx
# Verify nginx started without config errors
docker compose --env-file .env logs nginx | tail -20
# Should NOT contain: [emerg] or [crit] — only [notice] lines
# Verify nginx health
docker compose --env-file .env ps nginx
# Status should be: Up (healthy)
```
### 2.7 — Verify full stack health
```bash
# Internal health check (bypasses nginx, hits HAPI directly)
docker exec $(docker compose --env-file .env ps -q hapi | head -1) \
curl -s http://localhost:8080/actuator/health | jq .
# Expected output:
# {
# "status": "UP",
# "components": {
# "db": { "status": "UP" },
# "auditDb": { "status": "UP" },
# "ocl": { "status": "UP" },
# "livenessState": { "status": "UP" },
# "readinessState": { "status": "UP" }
# }
# }
# External health check (through nginx + TLS)
curl -s https://fhir.dghs.gov.bd/actuator/health/liveness | jq .
# Expected: { "status": "UP" }
# FHIR metadata endpoint (unauthenticated)
curl -s https://fhir.dghs.gov.bd/fhir/metadata | jq '{
resourceType,
fhirVersion,
software: .software,
implementation: .implementation
}'
# Expected:
# {
# "resourceType": "CapabilityStatement",
# "fhirVersion": "4.0.1",
# "software": { "name": "BD FHIR National Repository", "version": "0.2.1" }
# }
```
---
## Part 3 — Phase 2 acceptance tests
Run all seven tests before declaring the deployment production-ready.
Each test includes the expected HTTP status, expected response body shape,
and what to check in the audit log if the test fails.
### Setup: obtain a vendor test token
```bash
VENDOR_TOKEN=$(curl -s -X POST \
"https://auth.dghs.gov.bd/realms/hris/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=fhir-vendor-TEST-FAC-001" \
-d "client_secret=${TEST_VENDOR_SECRET}" \
| jq -r '.access_token')
echo "Token obtained: ${VENDOR_TOKEN:0:20}..."
```
---
### Test 1 — Valid Condition with valid ICD-11 code → 201
Submits a BD Core IG-compliant `bd-condition` resource with a valid
ICD-11 Diagnosis-class code.
```bash
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${VENDOR_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Condition",
"meta": {
"profile": ["https://fhir.dghs.gov.bd/core/StructureDefinition/bd-condition"]
},
"clinicalStatus": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": "active"
}]
},
"verificationStatus": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-ver-status",
"code": "confirmed"
}]
},
"code": {
"coding": [{
"system": "http://id.who.int/icd/release/11/mms",
"code": "1C62.0",
"display": "Typhoid fever"
}]
},
"subject": {
"reference": "Patient/test-patient-001"
},
"recordedDate": "2025-03-01"
}'
```
**Expected:** `HTTP 201 Created` with `Location` header containing the new resource URL.
**If 422 instead:**
- Check OCL connectivity: `curl https://tr.ocl.dghs.gov.bd/api/fhir/CodeSystem/$validate-code?system=http://id.who.int/icd/release/11/mms&code=1C62.0`
- Check IG is loaded: `curl http://localhost:8080/actuator/health` — OCL component should be UP
- Check HAPI logs for profile validation errors
---
### Test 2 — Invalid ICD-11 code → 422
```bash
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${VENDOR_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Condition",
"meta": {
"profile": ["https://fhir.dghs.gov.bd/core/StructureDefinition/bd-condition"]
},
"clinicalStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": "active"}]
},
"verificationStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", "code": "confirmed"}]
},
"code": {
"coding": [{
"system": "http://id.who.int/icd/release/11/mms",
"code": "INVALID-CODE-99999",
"display": "This code does not exist"
}]
},
"subject": {"reference": "Patient/test-patient-001"},
"recordedDate": "2025-03-01"
}'
```
**Expected:** `HTTP 422 Unprocessable Entity` with OperationOutcome containing:
- `issue[0].severity`: `error`
- `issue[0].diagnostics`: contains "INVALID-CODE-99999" and rejection reason
- `issue[0].expression`: contains `Condition.code`
**Verify in audit table:**
```sql
SELECT rejection_code, rejection_reason, invalid_code, element_path
FROM audit.fhir_rejected_submissions
ORDER BY submission_time DESC LIMIT 1;
-- Expected: TERMINOLOGY_INVALID_CODE | OCL rejected code... | INVALID-CODE-99999 | Condition.code...
```
---
### Test 3 — Device-class ICD-11 code in Condition.code → 422
Device-class codes are valid ICD-11 codes but are not in the
`bd-condition-icd11-diagnosis-valueset` (restricted to Diagnosis + Finding).
```bash
# XA7RE2 is an example Device-class code in ICD-11 MMS
# Verify it is Device-class in OCL before running this test:
# curl "https://tr.ocl.dghs.gov.bd/api/fhir/CodeSystem/$lookup?system=http://id.who.int/icd/release/11/mms&code=XA7RE2"
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${VENDOR_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Condition",
"meta": {
"profile": ["https://fhir.dghs.gov.bd/core/StructureDefinition/bd-condition"]
},
"clinicalStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": "active"}]
},
"verificationStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", "code": "confirmed"}]
},
"code": {
"coding": [{
"system": "http://id.who.int/icd/release/11/mms",
"code": "XA7RE2",
"display": "Device code — should be rejected"
}]
},
"subject": {"reference": "Patient/test-patient-001"},
"recordedDate": "2025-03-01"
}'
```
**Expected:** `HTTP 422` with OperationOutcome.
Rejection code in audit: `TERMINOLOGY_INVALID_CLASS`.
**If 201 instead (code accepted):**
- OCL ValueSet class restriction is not enforcing correctly
- Verify the ValueSet collection in OCL has correct concept_class filter
- Run: `python version_upgrade.py --verify-class-restriction`
---
### Test 4 — Profile violation (missing required field) → 422
Submits a Condition missing `clinicalStatus` which is required by `bd-condition` profile.
```bash
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${VENDOR_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Condition",
"meta": {
"profile": ["https://fhir.dghs.gov.bd/core/StructureDefinition/bd-condition"]
},
"code": {
"coding": [{
"system": "http://id.who.int/icd/release/11/mms",
"code": "1C62.0"
}]
},
"subject": {"reference": "Patient/test-patient-001"}
}'
```
**Expected:** `HTTP 422` with OperationOutcome referencing missing `clinicalStatus`.
**If 201 instead:**
- BD Core IG is not loaded or profile is not enforcing `clinicalStatus` as required
- Check startup logs for IG load success
- Verify: `curl http://localhost:8080/fhir/StructureDefinition/bd-condition`
---
### Test 5 — No Bearer token → 401
```bash
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType": "Condition"}'
```
**Expected:** `HTTP 401` with `WWW-Authenticate` header and OperationOutcome.
```bash
# Verify WWW-Authenticate header is present
curl -s -I \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType":"Condition"}' \
| grep -i "www-authenticate"
# Expected: WWW-Authenticate: Bearer realm="BD FHIR National Repository"...
```
---
### Test 6 — Valid token but missing mci-api role → 401
Create a test client WITHOUT `mci-api` role in Keycloak for this test.
Or use a token from a different realm.
```bash
# Token from a client without mci-api role
NO_ROLE_TOKEN=$(curl -s -X POST \
"https://auth.dghs.gov.bd/realms/hris/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=fhir-test-no-role" \
-d "client_secret=${TEST_NO_ROLE_SECRET}" \
| jq -r '.access_token')
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${NO_ROLE_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType": "Condition"}'
```
**Expected:** `HTTP 401`.
**Verify in audit log:**
```sql
SELECT event_type, outcome_detail, client_id
FROM audit.audit_events
WHERE event_type = 'AUTH_FAILURE'
ORDER BY event_time DESC LIMIT 1;
-- Expected: AUTH_FAILURE | Required role 'mci-api' not present... | fhir-test-no-role
```
---
### Test 7 — Expired token → 401
```bash
# An expired token is one whose 'exp' claim is in the past.
# Easiest approach: obtain a token, wait for it to expire (default: 5 minutes),
# then use it.
#
# For automated testing, forge an expired token manually:
# (This requires knowing the signing key — use only in test environments)
#
# Alternative: Use a token from a deactivated Keycloak client
# (revoke the client's credentials, existing tokens become invalid)
# Or simply wait:
echo "Waiting 6 minutes for token to expire..."
EXPIRED_TOKEN=$(curl -s -X POST \
"https://auth.dghs.gov.bd/realms/hris/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=fhir-vendor-TEST-FAC-001" \
-d "client_secret=${TEST_VENDOR_SECRET}" \
| jq -r '.access_token')
sleep 360 # wait for 5-minute Keycloak default expiry
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${EXPIRED_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType": "Condition"}'
```
**Expected:** `HTTP 401` — "Token has expired".
---
### Test 8 — Cluster expression: raw postcoordinated code without extension → 422
```bash
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X POST https://fhir.dghs.gov.bd/fhir/Condition \
-H "Authorization: Bearer ${VENDOR_TOKEN}" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Condition",
"meta": {
"profile": ["https://fhir.dghs.gov.bd/core/StructureDefinition/bd-condition"]
},
"clinicalStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": "active"}]
},
"verificationStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", "code": "confirmed"}]
},
"code": {
"coding": [{
"system": "http://id.who.int/icd/release/11/mms",
"code": "1C62.0&has_severity=mild",
"display": "Raw postcoordinated string — prohibited"
}]
},
"subject": {"reference": "Patient/test-patient-001"},
"recordedDate": "2025-03-01"
}'
```
**Expected:** `HTTP 422` with OperationOutcome diagnosing:
`"ICD-11 postcoordinated expression in Condition.code.coding[0] must use the icd11-cluster-expression extension"`
Rejection code in audit: `CLUSTER_STEM_MISSING_EXTENSION`.
---
### Test 9 — Cache flush endpoint requires fhir-admin role
```bash
# Attempt with vendor token (mci-api only) — should be 403
curl -s -w "\n--- HTTP %{http_code} ---\n" \
-X DELETE https://fhir.dghs.gov.bd/admin/terminology/cache \
-H "Authorization: Bearer ${VENDOR_TOKEN}"
# Expected: 403 (blocked by nginx IP restriction OR TerminologyCacheManager role check)
# Attempt with fhir-admin token — should be 200
ADMIN_TOKEN=$(curl -s -X POST \
"https://auth.dghs.gov.bd/realms/hris/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=fhir-admin-pipeline" \
-d "client_secret=${FHIR_ADMIN_CLIENT_SECRET}" \
| jq -r '.access_token')
# Note: /admin/ is restricted to 172.20.0.0/16 in nginx.
# Run this from within the Docker network or from the server itself:
docker exec $(docker compose --env-file .env ps -q hapi | head -1) \
curl -s -X DELETE \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
http://localhost:8080/admin/terminology/cache | jq .
# Expected: 200 with { "status": "flushed", "entriesEvicted": N, ... }
```
---
## Part 4 — Subsequent deployments (image upgrade)
When a new Docker image is built and pushed (new IG version, code changes):
```bash
cd /opt/bd-fhir-national
# 1. Update image tag in .env
nano .env
# Change: HAPI_IMAGE=your-registry.dghs.gov.bd/bd-fhir-hapi:1.0.0
# To: HAPI_IMAGE=your-registry.dghs.gov.bd/bd-fhir-hapi:1.1.0
# 2. Pull new image
docker compose --env-file .env pull hapi
# 3. Rolling restart — replaces containers one at a time
# At 1 replica (pilot): brief downtime expected (~30s)
docker compose --env-file .env up -d --no-deps hapi
# At 3 replicas (Phase 2): true rolling update — scale up then scale down
docker compose --env-file .env up -d --no-deps --scale hapi=4 hapi
# Wait for new replica to be healthy:
sleep 30
docker compose --env-file .env up -d --no-deps --scale hapi=3 hapi
# 4. Verify startup
docker compose --env-file .env logs --tail=50 hapi
# 5. Run acceptance tests (at minimum Tests 1, 2, 5)
```
---
## Part 5 — Operational runbook
### View logs
```bash
# All services
docker compose --env-file .env logs -f
# HAPI only (structured JSON — pipe through jq)
docker compose --env-file .env logs -f hapi | jq -R 'try fromjson'
# nginx access log
docker compose --env-file .env logs -f nginx
# Filter for rejected submissions in HAPI logs
docker compose --env-file .env logs hapi | \
jq -R 'try fromjson | select(.message | contains("rejected"))'
```
### Restart a specific service
```bash
docker compose --env-file .env restart hapi
docker compose --env-file .env restart nginx
```
### Emergency: full stack restart
```bash
docker compose --env-file .env down
docker compose --env-file .env up -d
```
### Query rejected submissions
```bash
docker exec bd-postgres-audit psql -U postgres -d auditdb -c "
SELECT
submission_time,
resource_type,
rejection_code,
LEFT(rejection_reason, 100) as reason,
client_id
FROM audit.fhir_rejected_submissions
ORDER BY submission_time DESC
LIMIT 20;"
```
### Check pgBouncer pool status
```bash
# Connect to pgBouncer admin interface
docker exec -it bd-pgbouncer-fhir \
psql -h localhost -p 5432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
```
### Monitor disk usage
```bash
# PostgreSQL data volumes
docker system df -v | grep -E "postgres|audit"
# Log volume
docker system df -v | grep hapi-logs
```