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:
@@ -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` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user