Skip to content
Closed
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
11 changes: 8 additions & 3 deletions .github/workflows/quality-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
PG_SCHEMA_TEST="te_test"
PG_DB_STAGING="te_mgmt_staging"
PG_SCHEMA_STAGING="te_staging"
PG_DB_PROD="te_mgmt_prod"
PG_DB_PROD="te_prod"
PG_SCHEMA_PROD="te_prod"
EOF

Expand All @@ -133,12 +133,17 @@ jobs:
done
ls -1 build/environments/

# Prod's database is te_prod owned by pgadmin, as in Azure (#172); the
# others are te_mgmt_<env> owned by postgres. The owners must match each
# build/environments/env_<env>.example.sql, or deploy_all.sh warns.
- name: Create environment databases
run: |
for db in te_mgmt_dev te_mgmt_test te_mgmt_staging te_mgmt_prod; do
psql -v ON_ERROR_STOP=1 -d postgres -c "CREATE ROLE pgadmin NOLOGIN"
for db in te_mgmt_dev te_mgmt_test te_mgmt_staging te_prod; do
owner=postgres; [ "$db" = te_prod ] && owner=pgadmin
if [ -z "$(psql -d postgres -tA -c "SELECT 1 FROM pg_database WHERE datname = '$db'")" ]; then
psql -v ON_ERROR_STOP=1 -d postgres -c \
"CREATE DATABASE \"$db\" WITH OWNER = postgres ENCODING = 'UTF8' TEMPLATE = template0 CONNECTION LIMIT = -1"
"CREATE DATABASE \"$db\" WITH OWNER = $owner ENCODING = 'UTF8' TEMPLATE = template0 CONNECTION LIMIT = -1"
fi
done

Expand Down
60 changes: 60 additions & 0 deletions DECISIONS/2026-11-09-prod-db-naming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Decision: Production database is `te_prod`, owned by `pgadmin`

Status: Decided and applied
Date: 2026-11-09
Review: On trigger (see below)

## Choice

The production database is **`te_prod`** and its owner is **`pgadmin`**, in all three
artefacts that describe it: `infra/terraform-prod/main.tf`, `build/deploy_all.sh` and
`infra/entrypoint.sh`. Option (a) of #172. The schema inside it stays `te_prod`.

`build/environments/env_prod.example.sql` is the single place that states the name and
owner (`db_name`, `db_owner`). The deployer reads them from the launcher it is about to
run instead of keeping its own copy.

## Context

#172 (RV-S1) found three artefacts disagreeing: Terraform created `te_prod` for `pgadmin`;
the deployer created `te_mgmt_prod` for `postgres`; the entrypoint needed an
`env_prod.sql` that a freshly built image does not contain. Nothing was deployed yet, so
nothing was broken; the first Azure deploy would have failed at the entrypoint with a
cryptic error. The fix had to land before Azure plumbing (#31, #32, #34) resumed.

The 3 Nov decision was to fix all three together in one PR. The product owner chose the
canonical name on 9 Nov 2026.

## Reasoning

- Terraform is the source of truth for the Azure database; matching it is the
conventional direction for infrastructure-as-code.
- `postgres` owning an application database is poor practice on its own, and
`te_mgmt_prod` would have kept it.
- The hardcoded copies in the deployer were what drifted. Reading the launcher removes the
second copy rather than aligning two values by hand.

## Trade-offs accepted

- Prod now differs from dev, test and staging (`te_mgmt_<env>`, owner `postgres`). The
reading code does not care; the asymmetry is in the data.
- Where `pgadmin` does not exist (CI, a dev server, the offline install), `deploy_all.sh`
creates it as `NOLOGIN`. It owns the database and nothing else, so it adds no credential,
but a stray role appears on those servers.
- The offline installer (`scripts/offline/install.sh`) follows: its prod database is now
`te_prod`, not `te_mgmt_prod`. Its #60 dry run (due 15 Nov) has not exercised this change.
- A database that already exists with a different owner is reported, not altered.
- `deploy` now requires `APP_USER_PASSWORD`. Without it the application role would be
created with the template's placeholder password.

## Not decided here

`infra/terraform-test` and `infra/terraform-staging` create `te_test` and `te_staging`,
while the deployer builds `te_mgmt_test` and `te_mgmt_staging`: the same disagreement.
Those stacks are code only (#31). Settle their names before #31 resumes.

## Reopen trigger

Reconsider if test and staging are given the Azure-style names (then prod no longer
stands apart and the `te_mgmt_` prefix may be dropped everywhere), or if the application
is ever to run as a role other than the database owner in Azure.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ organisations ──< personnel

| Setting | Dev | Test | Staging | Prod |
|---|---|---|---|---|
| Database | `te_mgmt_dev` | `te_mgmt_test` | `te_mgmt_staging` | `te_mgmt_prod` |
| Database | `te_mgmt_dev` | `te_mgmt_test` | `te_mgmt_staging` | `te_prod` |
| Schema | `te_dev` | `te_test` | `te_staging` | `te_prod` |
| App User | `te_dev_user` | `te_test_user` | `te_stg_user` | `te_prod_user` |
| Connection Limit | 10 | 15 | 25 | 50 |
Expand Down
2 changes: 1 addition & 1 deletion build/config.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ PG_APP_USER_STAGING="te_stg_user"
PG_APP_PASSWORD_STAGING=""
PG_CONN_LIMIT_STAGING="20"

PG_DB_PROD="te_mgmt_prod"
PG_DB_PROD="te_prod"
PG_SCHEMA_PROD="te_prod"
PG_APP_USER_PROD="te_prod_user"
PG_APP_PASSWORD_PROD=""
Expand Down
42 changes: 39 additions & 3 deletions build/deploy_all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ warn() { echo -e "${YELLOW}[⚠]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*" >&2; }
separator(){ echo "────────────────────────────────────────────────────────────"; }

# Read one \set variable from an environment launcher (RV-S1, #172).
# env_<env>.sql is the single place that says which database an environment
# deploys to and who owns it. This script used to keep its own copy
# (te_mgmt_<env>, owner postgres) which drifted from Terraform's.
launcher_var() {
tr -d '\r' < "$1" | sed -n "s/^\\\\set[[:space:]][[:space:]]*$2[[:space:]][[:space:]]*'\{0,1\}\([^' ]*\)'\{0,1\}.*/\1/p" | head -n1
}

# ── Parse arguments ───────────────────────────────────────────────────────────
ENVS_TO_DEPLOY=("${@:-${ALL_ENVS[@]}}")

Expand Down Expand Up @@ -75,7 +83,14 @@ for env in "${ENVS_TO_DEPLOY[@]}"; do
continue
fi

DB_NAME="te_mgmt_${env}"
DB_NAME="$(launcher_var "${env_file}" db_name)"
DB_OWNER="$(launcher_var "${env_file}" db_owner)"
# Both land inside quoted SQL identifiers/literals below, so insist on plain names.
if [[ ! "${DB_NAME}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ || ! "${DB_OWNER}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
error "${env_file}: db_name ('${DB_NAME}') and db_owner ('${DB_OWNER}') must both be set to plain identifiers."
FAILED+=("${env}")
continue
fi

echo ""
warn "Deploying environment: ${env^^} (database: ${DB_NAME})"
Expand All @@ -86,10 +101,24 @@ for env in "${ENVS_TO_DEPLOY[@]}"; do
DB_EXISTS=$(${PSQL} -d postgres -tA -c \
"SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}'" 2>/dev/null || true)
if [[ -z "${DB_EXISTS}" ]]; then
log "Creating database: ${DB_NAME}"
# The owner has to exist before it can own anything. On Azure it is the
# server admin and already does. Anywhere else (CI, the offline install, a
# fresh dev server) it is created NOLOGIN: it owns the database and nothing
# else, so it adds no credential and no way in.
OWNER_EXISTS=$(${PSQL} -d postgres -tA -c \
"SELECT 1 FROM pg_roles WHERE rolname = '${DB_OWNER}'" 2>/dev/null || true)
if [[ -z "${OWNER_EXISTS}" ]]; then
log "Creating database owner role: ${DB_OWNER} (NOLOGIN)"
${PSQL} -d postgres -c "CREATE ROLE \"${DB_OWNER}\" NOLOGIN" || {
error "Failed to create owner role ${DB_OWNER}"
FAILED+=("${env}")
continue
}
fi
log "Creating database: ${DB_NAME} (owner: ${DB_OWNER})"
${PSQL} -d postgres -c \
"CREATE DATABASE \"${DB_NAME}\"
WITH OWNER = postgres
WITH OWNER = \"${DB_OWNER}\"
ENCODING = 'UTF8'
TEMPLATE = template0
CONNECTION LIMIT = -1" || {
Expand All @@ -100,6 +129,13 @@ for env in "${ENVS_TO_DEPLOY[@]}"; do
log "Database ${DB_NAME} created."
else
log "Database ${DB_NAME} already exists — skipping CREATE."
# Report drift, never fix it: changing the owner of a database that already
# holds data is not a decision a deploy script should take silently.
ACTUAL_OWNER=$(${PSQL} -d postgres -tA -c \
"SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = '${DB_NAME}'" 2>/dev/null || true)
if [[ -n "${ACTUAL_OWNER}" && "${ACTUAL_OWNER}" != "${DB_OWNER}" ]]; then
warn "${DB_NAME} is owned by '${ACTUAL_OWNER}', but ${env_file##*/} says '${DB_OWNER}'. Left unchanged."
fi
fi

if ${PSQL} \
Expand Down
6 changes: 4 additions & 2 deletions build/environments/env_prod.example.sql
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
-- env_prod.example.sql (TEMPLATE - safe to commit; no real secrets)
-- Copy to env_prod.sql (gitignored) and fill in, or inject app_password at deploy.
-- db_name and db_owner are read by build/deploy_all.sh and must match infra/terraform-prod
-- (database te_prod, server admin pgadmin). RV-S1, #172; DECISIONS/2026-11-09-prod-db-naming.md.
\set env_label PROD
\set db_name te_mgmt_prod
\set db_owner postgres
\set db_name te_prod
\set db_owner pgadmin
\set schema_name te_prod
\set app_user te_prod_user
\set app_password '__INJECT_AT_DEPLOY__'
Expand Down
9 changes: 8 additions & 1 deletion docs/DATABASE_ENVIRONMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ for names: `build/config.env.example` (template) / `build/config.local.env`
| **dev** | `te_mgmt_dev` | `te_dev` | `te_dev_user` | 10 | yes (`SEED_DEV=true`) |
| **test** | `te_mgmt_test` | `te_test` | `te_test_user` | 15 | yes (`SEED_TEST=true`) |
| **staging** | `te_mgmt_staging` | `te_staging` | `te_stg_user` | 25 | no (`SEED_STAGING=false`) |
| **prod** | `te_mgmt_prod` | `te_prod` | `te_prod_user` | 50 | no (`SEED_PROD=false`) |
| **prod** | `te_prod` | `te_prod` | `te_prod_user` | 50 | no (`SEED_PROD=false`) |

The database name and owner come from `build/environments/env_<env>.sql` (`db_name`,
`db_owner`), which `deploy_all.sh` reads; nothing else keeps its own copy. Prod's
database is `te_prod`, owned by `pgadmin`, to match `infra/terraform-prod` (#172;
`DECISIONS/2026-11-09-prod-db-naming.md`). Where `pgadmin` does not exist yet (a dev
server, CI, the offline install) `deploy_all.sh` creates it `NOLOGIN`: it owns the
database and nothing else. Dev, test and staging keep `te_mgmt_<env>` owned by `postgres`.

Every environment also gets a `csv_uploads` schema in its own database
(same name across environments — no collision, since each environment is a
Expand Down
26 changes: 22 additions & 4 deletions docs/ENV_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ the environment SQL templates. Copy `build/config.env.example` to

| Variable | Dev | Test | Staging | Prod |
|---|---|---|---|---|
| `PG_DB_*` | `te_mgmt_dev` | `te_mgmt_test` | `te_mgmt_staging` | `te_mgmt_prod` |
| `PG_DB_*` | `te_mgmt_dev` | `te_mgmt_test` | `te_mgmt_staging` | `te_prod` |
| `PG_SCHEMA_*` | `te_dev` | `te_test` | `te_staging` | `te_prod` |
| `PG_APP_USER_*` | `te_dev_user` | `te_test_user` | `te_stg_user` | `te_prod_user` |
| `PG_APP_PASSWORD_*` | _(empty)_ | _(empty)_ | _(empty)_ | _(empty)_ |
Expand Down Expand Up @@ -162,14 +162,32 @@ $env:PGPASSWORD = "changeme_local_only"
python -m pytest tests\ -m "unit or regression or security or snapshot"
```

### Deploying to Azure
### Running the migration job (`infra/entrypoint.sh`)
Terraform sets these on the Container Apps Job (`infra/terraform-prod/main.tf`):
```
TARGET_ENV = prod
PGHOST = your-pg-host.postgres.database.azure.com
PGPORT = 5432
PGUSER = pgadmin # the server admin, and the owner of te_prod
PGPASSWORD = <from Key Vault: pg-postgres-password>
PGDATABASE = te_prod # must equal db_name in env_prod.example.sql
APP_USER_PASSWORD = <from Key Vault: pg-app-user-password>
```
`APP_USER_PASSWORD` is the password for the `te_prod_user` application role. The
image carries only `env_prod.example.sql`; `deploy` fills the password into a
launcher at run time and deletes it on exit, and refuses to run without it.
`deploy` also refuses to run if `PGDATABASE` differs from the launcher's
`db_name`. `MIGRATION_ROOT` (default `/opt/migration`) moves where the script
looks for the runner; it exists so the script can be tested outside a container.

### Deploying the API to Azure
Set these in the Container App environment (not in code):
```
PGHOST = your-pg-host.postgres.database.azure.com
PGPORT = 5432
PGUSER = postgres
PGUSER = pgadmin
PGPASSWORD = <from Key Vault>
PGDATABASE = te_mgmt_prod
PGDATABASE = te_prod
API_KEY = <strong random value>
CORS_ORIGINS = https://your-frontend.azurestaticapps.net
TE_SCHEMA = te_prod
Expand Down
6 changes: 6 additions & 0 deletions docs/PROD_DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ branch) — this binds the SP to GitHub Environment approval.
- Triggers a single execution of the Job
- Streams logs from Log Analytics
- Exits 0 only if the Job status is `Succeeded`
5. What the Job does on a fresh server: `infra/entrypoint.sh` generates
`env_prod.sql` from the image's `env_prod.example.sql` and the
`pg-app-user-password` secret, `deploy_all.sh` deploys into the `te_prod`
database Terraform created, and the generated file is deleted on exit. It fails
fast, naming the cause, if `APP_USER_PASSWORD` is missing or `PGDATABASE` is not
the launcher's database (#172).

### Step 3 — Verify

Expand Down
2 changes: 1 addition & 1 deletion docs/offline-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The script runs six steps:
1. Checks every file against `SHA256SUMS`.
2. Installs the Ubuntu packages, using the bundle as the only apt source. The VM's own apt sources are ignored.
3. Copies the app to `/opt/te-migration`, creates the `te-app` system user, and builds a Python virtual environment from `wheels/` without network access.
4. Deploys database `te_mgmt_prod` with schema `te_prod` and its owner role `te_prod_user`. It also creates the runtime role `te_prod_runtime`, which serves requests and can only read and insert the audit and upload tables (M3d, #107). Both get generated passwords.
4. Deploys database `te_prod` with schema `te_prod` and its owner role `te_prod_user`. It also creates the runtime role `te_prod_runtime`, which serves requests and can only read and insert the audit and upload tables (M3d, #107). Both get generated passwords.
5. Writes `/opt/te-migration/env` (mode 600) with both passwords and a generated API key, and starts the `te-api` (port 8000) and `te-frontend` (port 3000) services under systemd. The API key is for scripts only. The frontend holds no key; people sign in.
6. Creates the first tenant and its tenant admin, then runs the smoke test.

Expand Down
2 changes: 1 addition & 1 deletion evals/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ def _run_fresh_deploy_then_tests(
("dev", "te_mgmt_dev", "te_dev"),
("test", "te_mgmt_test", "te_test"),
("staging", "te_mgmt_staging", "te_staging"),
("prod", "te_mgmt_prod", "te_prod"),
("prod", "te_prod", "te_prod"),
]


Expand Down
11 changes: 9 additions & 2 deletions infra/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@
# Run locally (against a PG container on the same network):
# docker run --rm \
# -e PGHOST=postgres -e PGPORT=5432 -e PGUSER=postgres \
# -e PGPASSWORD=devpass -e PGDATABASE=te_dev \
# -e PGPASSWORD=devpass -e PGDATABASE=te_mgmt_dev \
# -e APP_USER_PASSWORD=change-me \
# -e TARGET_ENV=dev \
# te-migration:dev deploy
#
# The image holds only the env_<env>.example.sql templates. `deploy` generates
# env_<env>.sql from the template and APP_USER_PASSWORD at run time and deletes it
# on exit, so no launcher with a password is ever in an image (RV-S4, #174).
# PGDATABASE must equal the db_name in that launcher (prod: te_prod, matching
# infra/terraform-prod); `deploy` refuses to run if it does not (RV-S1, #172).

FROM python:3.11-slim AS base

Expand Down Expand Up @@ -65,7 +72,7 @@ USER app
ENV PGHOST=localhost \
PGPORT=5432 \
PGUSER=postgres \
PGDATABASE=te_dev \
PGDATABASE=te_mgmt_dev \
TARGET_ENV=dev \
WAIT_FOR_DB_SECONDS=60

Expand Down
Loading
Loading