Files
jobtrackingapp/docs/operations/production-backup-verification.md
T
cesnimda f7312f4ba6
CI and Deploy / test (pull_request) Successful in 5m35s
CI and Deploy / deploy (pull_request) Has been skipped
feat(ai): add safe benchmark harness
2026-08-15 19:24:02 +02:00

155 lines
8.7 KiB
Markdown

# Backup and restore verification
> **2026-07-19 19:32 UTC.** Full backup → verify → restore → start-the-app rehearsal of the
> `deploy/deploy.sh` backup path, exercised end to end against MariaDB 11.
>
> **This is a rehearsal, not a verification of production data.** See *Limitations* — that section is
> the most important part of this document, and the checklist at the end is what actually closes the
> gap.
## Scope — read this first
| | |
|---|---|
| **What was verified** | The backup *mechanism*: the real `backup_database` function from `deploy.sh`, against a real MariaDB 11 database carrying the real 42-table schema, restored into a separate clean MariaDB 11 container, with the real application started against the result |
| **What was NOT verified** | **Your production database.** No production host was contacted, no production credentials were used, and no production data was read, copied or restored |
| **Why** | The machine this ran on has no route to production: no `/opt/job-tracker`, no `DATABASE_PROVIDER` or `JOBTRACKER_CONNECTION_STRING` in its `.env`, and the local stack runs SQLite. The production host, user and key are CI secrets (`PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`) that are not available here |
The commands below are the ones to run against production. They are recorded so the owner can execute
the same sequence with production values substituted.
## 2026-08-15 read-only production checkpoint
The earlier access statement above remains historically accurate for the 2026-07-19 rehearsal, but access is now available. A strictly read-only production inventory found:
- 21 MariaDB `.sql.gz` dumps (about 5.2 MB combined), all passing `gzip -t`;
- oldest observed dump: 2026-07-19 21:41 UTC; newest: 2026-08-02 17:32 UTC;
- no JobTracker systemd timer or current-user cron entry;
- 1.4 TiB free on the Docker/data filesystem and 36 GiB free on the 83%-used root filesystem;
- database-only dump files in the backup directory: no owner-file volume, data-protection-key, protected tombstone, or configuration recovery bundle;
- no restore, row/content read, count comparison, non-ASCII check, or production mutation.
This upgrades production dump *presence and compressed-stream integrity* from unknown to observed, but it does not close the restore/RPO/completeness checklist. The newest observed dump was 13 days old at capture. See `docs/production/production-ai-hardware-assessment.md` and `docs/production/production-ai-rollout-and-rollback.md`.
## 1. Backup configuration
Read from `deploy/deploy.sh` and `docker-compose.yml`:
- `deploy.sh` loads `/opt/job-tracker/shared/.env` into its own shell before any decision.
- `validate_deploy_config` runs **before** the backup, and therefore before any build, stop or replace.
It requires `DATABASE_PROVIDER` (no default), `JOBTRACKER_CONNECTION_STRING` when the provider is
MariaDB, plus `AI_SERVICE_TOKEN` and `AUTH_JWT_KEY`.
- Backups land in `/opt/job-tracker/backups` (override with `BACKUP_DIR`), UTC-timestamped, gzipped.
Nothing is ever overwritten and nothing is ever auto-deleted.
- The password travels via `MYSQL_PWD`, never on the command line.
## 2. Provider resolution
`DATABASE_PROVIDER=mariadb` selected the MariaDB dump path. Confirmed by the script's own output:
```
Deployment configuration validated (database provider: mariadb).
Backing up MariaDB database 'jobtracker' on <host>:<port> to <dir>/jobtracker-jobtracker-20260719T193022Z.sql.gz
Backup verified: <dir>/jobtracker-jobtracker-20260719T193022Z.sql.gz (8.0K)
```
**The filename is the check that matters.** `jobtracker-<database>-<stamp>.sql.gz` means the MariaDB
path ran. A `jobtracker-sqlite-<stamp>.tar.gz` on a MariaDB host would mean the environment is wrong.
## 3. Commands used
Schema built by the real application, not by hand — `Database.Migrate()` plus the startup reconciler
against an empty MariaDB, producing 42 tables. Representative rows were then seeded across users,
companies, applications, career profile and CV variants.
```bash
# Backup — the real function from deploy.sh, not a hand-written dump
DATABASE_PROVIDER=mariadb \
JOBTRACKER_CONNECTION_STRING='Server=<host>;Port=<port>;Database=jobtracker;User Id=<user>;Password=<password>;' \
BACKUP_DIR=/opt/job-tracker/backups \
deploy/deploy.sh # takes the backup first and aborts the deploy if it fails
# Integrity and content
gzip -t "$BACKUP" # valid archive
gzip -dc "$BACKUP" | grep -c 'CREATE TABLE' # schema present
gzip -dc "$BACKUP" | tail -1 # "-- Dump completed on ..." trailer
# Restore into a SEPARATE, empty database — never over a live one
gzip -dc "$BACKUP" | MYSQL_PWD='<password>' mariadb --host=<host> --user=<user> jobtracker
```
Passwords are supplied via `MYSQL_PWD` so they never reach the process list or the shell history.
## 4. Backup verification result
| Check | Result |
|---|---|
| Correct backup type | ✅ `.sql.gz` MariaDB dump, not a volume archive |
| File integrity | ✅ `gzip -t` passed |
| Schema markers | ✅ 42 `CREATE TABLE` statements |
| Dump trailer | ✅ `-- Dump completed on 2026-07-19 19:30:23` — proves the dump was not truncated |
| Expected tables | ✅ `AspNetUsers`, `Companies`, `JobApplications`, `CareerProfiles`, `CvVariants`, `JobEvents`, `ApplicationChecklistItems`, `AiInteractions` all present |
| Data present | ✅ 7 `INSERT INTO` statements |
## 5. Restore result
Restored into a **separate, empty** MariaDB 11 container (0 tables before, 42 after).
| Entity | Source | Restored | |
|---|---|---|---|
| Users | 1 | 1 | ✅ |
| Companies | 2 | 2 | ✅ |
| Applications | 2 | 2 | ✅ |
| Career profiles | 1 | 1 | ✅ |
| CV variants | 1 | 1 | ✅ |
Beyond counts:
- **All 42 tables compared** — table lists identical, and **every table's row count matched**.
- **Content survived, not just cardinality** — the restored applications resolve their company
foreign keys (`Senior Backend Engineer @ Northwind Consulting`), the CV variant kept its name and
`IsPublic` flag, and the career profile JSON still contained its languages.
- **The application starts against the restored database.** `/health` returned
`{"status":"ok","version":"restore-rehearsal"}`, the schema stayed at 42 tables (the reconciler
correctly found nothing to do) and rows were preserved. A restore that produces a database the app
cannot boot against is not a restore.
## Limitations
Read these before treating the deployment as backed up.
1. **No production data was touched.** Everything above ran against a locally built MariaDB with
seeded rows. It proves the mechanism; it proves nothing about your database.
2. **The seeded dataset is tiny** (7 rows). It does not exercise dump duration, disk headroom, lock
behaviour under load, or timeout limits on a real dataset. A production database large enough to
make `mariadb-dump` slow could behave differently.
3. **Character-set and collation fidelity was not stress-tested.** The seeded data was mostly ASCII.
Real CV content contains non-ASCII text — Norwegian `æøå`, accents, CJK. The dump defaults should
handle this, but it was not proven here.
4. **No restore was performed over a populated database.** The destination was empty. Restoring over
an existing database is a different operation with different failure modes.
5. **`deploy.sh` was exercised up to and including the backup**, not through the build and container
replacement, which would have required a full deployment.
6. **The `mariadb-dump` client came from a container** (`mariadb:11`). If the production host has its
own client installed, `deploy.sh` uses that instead, and version differences are possible.
## What the owner still needs to do
This is the checklist that turns a rehearsal into a verified backup.
- [ ] **Run one backup by hand against production** and confirm the filename is
`jobtracker-<database>-<stamp>.sql.gz`.
- [ ] **Check the dump size is plausible** for the amount of data you have. A suspiciously small file
is the signal worth catching.
- [ ] **Restore it into a scratch database** — never over the live one — and confirm row counts for
`AspNetUsers`, `JobApplications`, `Companies` and `CareerProfiles` match production.
- [ ] **Check non-ASCII text survived.** Open one CV or career profile containing `æ`, `ø` or `å` in
the restored copy and confirm it is not mangled. This is the most likely silent failure.
- [ ] **Confirm `/opt/job-tracker/backups` has disk headroom** for several dumps.
- [ ] **Note how long the dump takes.** It runs before every deploy and blocks it.
## Cleanup
Both MariaDB containers and all rehearsal files were removed. No test artefacts remain, and the
existing local `jobtracker-backend-1` / `jobtracker-frontend-1` stack was left untouched.