fix(deploy): load production environment before backup

deploy.sh symlinked /opt/job-tracker/shared/.env for docker compose but
never loaded it into its own shell. Its own decisions therefore ran
against an empty environment: DATABASE_PROVIDER fell back to sqlite on a
MariaDB host, so the deploy tarred the data volume, printed "Backup
verified" and continued with no database dump. The operator saw a green
backup line and a new file in the backups directory, and had no restore
point.

Load the shared env before any decision. Parsed line by line rather than
sourced, because a compose .env is not a shell script and an unquoted
value containing spaces would execute as a command. Values already in
the environment win, so CI-provided APP_VERSION and friends still
override the file. No value is echoed.

Remove the sqlite default. DATABASE_PROVIDER must be stated; missing or
unrecognised aborts the deploy.

Validate deployment configuration before the backup, and so before
anything is built, stopped or replaced: the connection string when the
provider needs one, AI_SERVICE_TOKEN (compose declares it with :?) and
AUTH_JWT_KEY (the backend throws on a blank key). Names in the output,
never values.

Verify each backup against its own format. A dump must be valid gzip,
contain CREATE TABLE, and carry the "Dump completed" trailer, so a dump
that died partway through is rejected. An archive must contain
jobtracker.db. A tar can no longer pass the dump check.

Also resolve the SQLite volume by its project-prefixed name and fail if
absent. The bare jobtracker_data name would have silently created an
empty volume and backed that up -- the same class of bug, found while
testing this fix.

Verified against a seeded MariaDB 11 container and real Docker volumes:
provider selection, all four validation failures, both backup formats
and their failure paths, truncated and trailer-stripped dumps, and zero
secret occurrences across every test's output.

Docs updated for the drift: deploy/README.md, deploy/first-production-
deployment.md, docs/release-candidate-review.md (B1 closed) and
.env.example, which now names the two database variables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 18:44:23 +02:00
parent ab53582c71
commit 66b02bcab8
5 changed files with 380 additions and 49 deletions
+37 -14
View File
@@ -11,16 +11,25 @@
Verified by reading `deploy/deploy.sh` and `JobTrackerApi/Program.cs`:
1. `deploy.sh` links `/opt/job-tracker/shared/.env` into the checkout as `.env`.
2. **It takes a database backup and aborts if that fails.** Nothing else runs without a restore point.
3. `docker compose pull`, then builds `backend` and `frontend` (with one prune-and-retry on failure).
4. `docker compose up -d --force-recreate --remove-orphans backend frontend`.
1. `deploy.sh` links `/opt/job-tracker/shared/.env` into the checkout as `.env`, **and loads it into
its own shell.** The link is for docker compose; the script needs the values itself to pick the
right backup. Values already in the environment (CI's `APP_VERSION` and friends) win.
2. **It validates the deployment configuration**, before anything is built, stopped or replaced:
`DATABASE_PROVIDER` (required, no default), the connection string when that provider needs one,
`AI_SERVICE_TOKEN` and `AUTH_JWT_KEY`. A missing variable aborts the deploy with the running stack
untouched. Names are printed, never values.
3. **It takes a database backup and aborts if that fails.** Nothing else runs without a restore point.
The provider chooses the backup: `mariadb`/`mysql` gives a `.sql.gz` dump, `sqlite` gives a
`.tar.gz` of the data volume. Each is verified against its own format — the dump must contain
`CREATE TABLE` and the `Dump completed` trailer; the archive must contain `jobtracker.db`.
4. `docker compose pull`, then builds `backend` and `frontend` (with one prune-and-retry on failure).
5. `docker compose up -d --force-recreate --remove-orphans backend frontend`.
**There is no `compose down`** — containers are replaced in place, so the window is short.
5. On backend start, `InitializeJobTrackerAsync` runs: **reconcile → `Database.Migrate()` → reconcile**.
6. On backend start, `InitializeJobTrackerAsync` runs: **reconcile → `Database.Migrate()` → reconcile**.
Every Phase 4/5 migration is a no-op; the reconciler creates those tables with correct per-provider
DDL. `Migrate()` throws on failure, so a schema problem exits the container rather than limping on.
6. `deploy.sh` waits, then fails the deploy if `backend` is not running, and runs a public smoke check
against `APP_PUBLIC_BASE_URL` if it is set.
7. `deploy.sh` waits, then fails the deploy if `backend` is not running, and runs a public smoke check
against `APP_PUBLIC_BASE_URL`.
---
@@ -31,12 +40,17 @@ Verified by reading `deploy/deploy.sh` and `JobTrackerApi/Program.cs`:
restored is a hypothesis.
- [ ] **Disk space checked.** `df -h` on the host. You need room for the backup, two image sets during
the build, and the build cache. `docker system df` shows what Docker is holding.
- [ ] **Environment variables present.** Check `/opt/job-tracker/shared/.env` contains:
- `AI_SERVICE_TOKEN`**compose refuses to start without it**
- [ ] **Environment variables present.** `deploy.sh` now checks these itself and aborts before it
builds or replaces anything, so a miss costs an aborted deploy rather than a broken one. Check
first anyway and skip the round trip — `/opt/job-tracker/shared/.env` must contain:
- `DATABASE_PROVIDER=mariadb` (or `mysql`) — **required, no default.** An absent value used to
mean "sqlite", which silently produced the wrong backup; it now stops the deploy
- `JOBTRACKER_CONNECTION_STRING` — see the host-resolution note below
- `AI_SERVICE_TOKEN` — compose refuses to start without it
- `AUTH_JWT_KEY` — with `Auth__Require=true`, a blank key **throws at startup** (this is good;
it fails loud rather than silently invalidating every session on restart)
- `DATABASE_PROVIDER=mysql`**defaults to `sqlite` if absent**
- `JOBTRACKER_CONNECTION_STRING` — see the host-resolution note below
- `APP_PUBLIC_BASE_URL` — optional; without it the post-deploy public smoke check is skipped,
and the script prints that it is skipping
- `AUTH_ADMIN_EMAIL` / `AUTH_ADMIN_PASSWORD` only if you want admin seeding on this boot
- [ ] **Connection string host resolves from inside the container.** `Server=127.0.0.1` means *the
backend container*, not the host — this bit me during validation. Use the host's LAN address, a
@@ -65,8 +79,12 @@ Automatic — `deploy.sh` runs it first and aborts on failure. Confirm afterward
ls -lt /opt/job-tracker/backups | head -3
```
Expect a new `jobtracker-<db>-<UTC timestamp>.sql.gz`. The script already rejected it if it were empty
or missing `CREATE TABLE`.
Expect a new `jobtracker-<db>-<UTC timestamp>.sql.gz`. The script already rejected it if it were empty,
not valid gzip, missing `CREATE TABLE`, or missing the `Dump completed` trailer.
**Check the filename, not just that a file appeared.** A `jobtracker-sqlite-<stamp>.tar.gz` on a
MariaDB deploy means the environment is wrong — that is the exact failure the provider check exists to
prevent, and it should now abort rather than reach this point.
### 2. Pull code
@@ -105,7 +123,7 @@ docker compose logs -f backend
| `Unhandled exception ... Table '...' doesn't exist` | Reconciler ordering problem |
| `no such table: INFORMATION_SCHEMA.TABLES` | `DATABASE_PROVIDER=mysql` but the connection string is **empty** — verified failure mode |
| `Unable to connect to any of the specified MySQL hosts` | Connection string host unreachable from inside the container |
| `Auth is required but Auth:JwtKey is not configured` | `AUTH_JWT_KEY` missing from `.env` |
| `Auth is required but Auth:JwtKey is not configured` | `AUTH_JWT_KEY` missing from `.env` — `deploy.sh` should now catch this before the build |
### 8. Health verification
@@ -236,6 +254,11 @@ Against MariaDB 11 containers, no production data:
| Backup against a seeded MariaDB | ✅ verified dump written |
| Restore into a clean MariaDB | ✅ rows identical |
| Backup failure paths | ✅ bad credentials and missing connection string both abort, no partial file |
| Shared `.env` loaded into the deploy shell | ✅ MariaDB path selected from the file alone; dump contains schema, rows and trailer |
| `DATABASE_PROVIDER` missing or unrecognised | ✅ deploy stops before build/replace, names the variable, leaves no backup file |
| `AI_SERVICE_TOKEN` / `AUTH_JWT_KEY` missing | ✅ both reported in one pass, deploy stops |
| SQLite volume backup, and its failure paths | ✅ valid archive verified; a volume with no `jobtracker.db`, and a volume name that does not exist, both abort |
| Secret leakage in deploy output | ✅ zero occurrences of any password, token or key across every test |
| Empty connection string with `provider=mysql` | ✅ fails loudly (`no such table: INFORMATION_SCHEMA.TABLES`) rather than silently serving an empty database |
| Backend with unreachable database | ✅ exits, reported `unhealthy` |