feat(ops): protect complete pre-deploy recovery set
CI and Deploy / test (push) Successful in 6m56s
CI and Deploy / deploy (push) Successful in 1m10s

This commit is contained in:
cesnimda
2026-09-01 20:06:25 +02:00
parent bf3dc2d06e
commit bd7fa05b8c
6 changed files with 223 additions and 11 deletions
+21
View File
@@ -312,10 +312,13 @@ jobs:
steps:
- name: Run remote deploy
uses: appleboy/ssh-action@v1.0.3
env:
MAINTENANCE_GITEA_TOKEN: ${{ secrets.MAINTENANCE_GITEA_TOKEN }}
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
envs: MAINTENANCE_GITEA_TOKEN
command_timeout: 40m
script: |
set -euo pipefail
@@ -324,6 +327,24 @@ jobs:
exit 1
fi
cd /opt/job-tracker/app
if [ -z "${MAINTENANCE_GITEA_TOKEN:-}" ]; then
echo "MAINTENANCE_GITEA_TOKEN is not configured as a repository Actions secret."
exit 1
fi
shared_env=/opt/job-tracker/shared/.env
if [ ! -f "$shared_env" ]; then
echo "Expected deployment environment at $shared_env."
exit 1
fi
env_tmp="$(mktemp)"
trap 'rm -f "$env_tmp"' EXIT
grep -v '^MAINTENANCE_GITEA_TOKEN=' "$shared_env" > "$env_tmp"
printf 'MAINTENANCE_GITEA_TOKEN=%s\n' "$MAINTENANCE_GITEA_TOKEN" >> "$env_tmp"
chmod --reference="$shared_env" "$env_tmp"
cat "$env_tmp" > "$shared_env"
rm -f "$env_tmp"
trap - EXIT
unset MAINTENANCE_GITEA_TOKEN
if ! git fetch --all --prune; then
echo "git fetch failed on server. Check remote auth/URL for /opt/job-tracker/app."
exit 1
+5
View File
@@ -193,7 +193,11 @@ deploy if the backup fails**. Nothing else in the deploy runs without a restore
- **Selection:** driven solely by `DATABASE_PROVIDER`, which must be set.
- `mariadb` / `mysql` → SQL dump, `jobtracker-<database>-<UTC timestamp>.sql.gz`, e.g.
`jobtracker-jobtracker-20260719T153759Z.sql.gz`
- MariaDB deployments also archive `jobtracker_data`, which contains attachments, imported CVs,
generated exports and Data Protection keys.
- `sqlite` → data volume archive, `jobtracker-sqlite-<UTC timestamp>.tar.gz`
- every deployment archives the separate account-deletion tombstone volume when it exists;
restore it independently and never replace it with an older application-data snapshot
- anything else → the deploy stops
- **The filename tells you which path ran.** If you expect a MariaDB deploy and find a
`jobtracker-sqlite-*.tar.gz`, the environment is wrong — that is the exact failure this check exists
@@ -215,6 +219,7 @@ like a restore point.
|---|---|
| MariaDB | non-empty; valid gzip; contains `CREATE TABLE`; contains the `Dump completed` trailer that `mariadb-dump` writes last, so a dump that died partway through is rejected |
| SQLite | non-empty; valid gzip; the archive actually contains `jobtracker.db` |
| Recovery volumes | non-empty valid gzip archives; a missing required application-data volume aborts the deploy |
A failed check deletes the file rather than leaving something that looks like a backup.
+52 -4
View File
@@ -265,6 +265,7 @@ validate_deploy_config() {
# exactly when a restore point matters. See deploy/README.md.
# ---------------------------------------------------------------------------
BACKUP_DIR="${BACKUP_DIR:-/opt/job-tracker/backups}"
BACKUP_STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
backup_database() {
if [ "${DEPLOY_SKIP_DB_BACKUP:-false}" = "true" ]; then
@@ -276,8 +277,6 @@ backup_database() {
local provider
provider="$(printf '%s' "$DATABASE_PROVIDER" | tr '[:upper:]' '[:lower:]')"
local stamp
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$BACKUP_DIR"
if [ ! -w "$BACKUP_DIR" ]; then
echo "Backup directory ${BACKUP_DIR} is not writable; repairing ownership via Docker."
@@ -304,7 +303,7 @@ backup_database() {
return 1
fi
local target="$BACKUP_DIR/jobtracker-sqlite-${stamp}.tar.gz"
local target="$BACKUP_DIR/jobtracker-sqlite-${BACKUP_STAMP}.tar.gz"
echo "Backing up SQLite data volume '${volume}' to ${target}"
# The archive path is built inside the container: passing /backup/... as an argument
# gets rewritten by MSYS path translation when the script is run from Git Bash.
@@ -341,7 +340,7 @@ backup_database() {
return 1
fi
local target="$BACKUP_DIR/jobtracker-${db_name}-${stamp}.sql.gz"
local target="$BACKUP_DIR/jobtracker-${db_name}-${BACKUP_STAMP}.sql.gz"
echo "Backing up MariaDB database '${db_name}' on ${db_host}:${db_port} to ${target}"
# The password goes via MYSQL_PWD, never on the command line, so it cannot leak
@@ -469,6 +468,51 @@ verify_volume_backup() {
report_backup "$target"
}
backup_named_volume() {
local volume="$1" label="$2" required="$3"
if ! docker volume inspect "$volume" >/dev/null 2>&1; then
if [ "$required" = "true" ]; then
echo "Required recovery volume '${volume}' does not exist. Aborting deploy."
return 1
fi
echo "Recovery volume '${volume}' does not exist yet; nothing to preserve."
return 0
fi
local target="$BACKUP_DIR/jobtracker-${label}-${BACKUP_STAMP}.tar.gz"
echo "Backing up recovery volume '${volume}' to ${target}"
if ! docker run --rm \
-v "$volume":/data:ro \
-v "$BACKUP_DIR":/backup \
-e ARCHIVE_NAME="$(basename "$target")" \
alpine:3 sh -c 'tar czf "/backup/$ARCHIVE_NAME" -C /data .'; then
echo "Recovery volume backup FAILED. Aborting deploy."
rm -f "$target"
return 1
fi
verify_backup_file_shape "$target" "archive" || return 1
report_backup "$target"
}
backup_recovery_volumes() {
local project_name="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" provider
provider="$(printf '%s' "$DATABASE_PROVIDER" | tr '[:upper:]' '[:lower:]')"
# With MariaDB, the database dump does not include attachments, imported CVs,
# exports or Data Protection keys stored in /data. SQLite already archives this
# exact volume in backup_database, so avoid writing the same archive twice.
if [ "$provider" != "sqlite" ]; then
backup_named_volume "${project_name}_jobtracker_data" "files-${project_name}" true || return 1
fi
# Tombstones are restored independently from application data so an older data
# restore cannot resurrect an erased identity. The volume may be absent on the
# first deployment of the deletion lifecycle.
backup_named_volume "${project_name}_jobtracker_deletion_tombstones" \
"deletion-tombstones-${project_name}" false
}
resolve_existing_web_proxy_subnet
if ! validate_deploy_config; then
@@ -479,6 +523,10 @@ if ! backup_database; then
exit 1
fi
if ! backup_recovery_volumes; then
exit 1
fi
previous_backend_ref=""
previous_frontend_ref=""
capture_previous_core_image() {
+9 -7
View File
@@ -86,9 +86,10 @@ MYSQL_PWD='<pw>' mariadb --host=<host> --user=<user> --default-character-set=utf
## Production access
A sanitized read-only host inventory was completed on 2026-08-15. It verified the integrity of the
existing compressed database dumps without reading private rows or restoring data. The newest
observed dump was dated 2026-08-02, and the set is database-only: it does not prove recovery of owned
files, data-protection keys, configuration, or account-deletion tombstones.
then-existing compressed database dumps without reading private rows or restoring data. The newest
observed dump was dated 2026-08-02, and that set was database-only. Current deployments now create a
verified database backup plus application-data and deletion-tombstone volume archives before any
build or replacement. A scratch restore is still required before recovery is considered proven.
**Authorized production steps still required:**
@@ -98,10 +99,11 @@ files, data-protection keys, configuration, or account-deletion tombstones.
- table count (~42) and row counts for `AspNetUsers`, `JobApplications`, `Companies`,
`CareerProfiles` match production;
- a real record containing `æ`/`ø`/`å` reads back correctly (the check above).
3. Build and restore a complete recovery bundle covering the `jobtracker_data` volume and deployment
data-protection keys as well as MariaDB. Preserve the separately mounted
`jobtracker_deletion_tombstones` volume across application-data restores; never overwrite it with
an older backup capable of resurrecting a deleted identity.
3. Restore the matching `jobtracker_data` archive alongside the database dump. It contains owner
files and deployment Data Protection keys.
4. Verify the separately archived `jobtracker_deletion_tombstones` ledger, but preserve the newest
ledger across application-data restores; never overwrite it with an older backup capable of
resurrecting a deleted identity.
Until that is done, backup/restore is proven **on the mechanism and on synthetic Norwegian data**, not
on the production dataset.
+3
View File
@@ -1,5 +1,8 @@
# Stripe activation
For a dashboard-by-dashboard operator walkthrough, use
[`stripe-billing-setup-walkthrough.md`](stripe-billing-setup-walkthrough.md).
The application code is complete. Activate it in Stripe test mode first; test and live mode have
different API keys, price ids, webhook endpoints/signing secrets, and customer data.
@@ -0,0 +1,133 @@
# Stripe billing setup walkthrough
This closes production blocker 2. Complete the test-mode checklist first, prove the full lifecycle,
then repeat it in live mode. Stripe test and live objects are separate: keys, prices, webhook secrets,
customers and subscriptions cannot be mixed.
The current production misconfiguration is specific: `STRIPE_PRICE_PREMIUM` contains a Product ID
(`prod_...`). JobTracker needs the recurring Price ID attached to that product (`price_...`). The API
deliberately treats the Product ID as disabled instead of sending an invalid Checkout request.
## What you need
- Access to the Stripe account that will receive JobTracker subscriptions.
- Access to `/opt/job-tracker/shared/.env` on the production host.
- One disposable JobTracker user for the test purchase.
- A decision on the monthly Pro price and currency. Start with one monthly price; annual billing can
be added after the monthly lifecycle is proven.
Never paste Stripe keys or webhook signing secrets into Git, chat, screenshots, tickets or CI logs.
## Part 1 — create the test subscription price
1. Sign in to the Stripe Dashboard and enable **Test mode**.
2. Open **Product catalog** and either open the existing JobTracker Pro product or create one named
`JobTracker Pro`.
3. Add a price with:
- pricing model: standard/flat rate;
- billing: recurring;
- interval: monthly;
- currency and amount: your chosen commercial terms.
4. Save the price.
5. Open the new price and copy its identifier. It must start with `price_`, not `prod_`.
The product describes what is sold. The price describes how it is billed. Checkout needs the latter.
## Part 2 — configure the customer portal
1. In Stripe, open the customer-portal settings.
2. Enable the portal in test mode.
3. Allow customers to update payment details and cancel subscriptions.
4. Decide whether cancellation is immediate or at the end of the billing period. The application
follows Stripe's resulting subscription status; Stripe remains authoritative.
5. Save the portal configuration.
## Part 3 — create the test webhook
1. Open Stripe's webhook/event-destination area in test mode.
2. Add this public HTTPS endpoint:
```text
https://jobs.cesnimda.uk/api/billing/webhook
```
3. Subscribe it to exactly these events:
```text
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
```
4. Save the endpoint.
5. Reveal and copy that endpoint's signing secret. It starts with `whsec_`.
The endpoint signing secret belongs to this specific webhook endpoint and mode. It is not the API
secret key shown on Stripe's API keys page.
## Part 4 — install the three test values
On the production host, edit `/opt/job-tracker/shared/.env` and set:
```text
STRIPE_SECRET_KEY=<test secret key beginning sk_test_>
STRIPE_PRICE_PREMIUM=<test recurring price id beginning price_>
STRIPE_WEBHOOK_SECRET=<test endpoint secret beginning whsec_>
```
Before saving, verify all three values came from test mode. Do not mix a live key with a test price or
test webhook secret. Redeploy after saving so the backend receives the new environment.
## Part 5 — prove the test lifecycle
Use a disposable authenticated JobTracker account:
1. Open account/settings and confirm billing is available.
2. Select **Upgrade**. Stripe-hosted Checkout must open for the expected monthly amount and currency.
3. Complete Checkout using a Stripe test card.
4. Confirm Stripe redirects to `/settings?billing=success`.
5. In Stripe's webhook delivery log, confirm the subscription event received a successful `2xx`
response.
6. Refresh JobTracker and confirm the user has Pro/Premium entitlements.
7. Open **Manage billing** and confirm the Stripe customer portal opens.
8. Cancel the test subscription using the portal.
9. Confirm the updated/deleted subscription webhook succeeds and JobTracker removes Premium after
the subscription is no longer active or trialling.
10. Replay one already-successful event from Stripe. JobTracker's stored state and role must remain
correct; replay must not duplicate or corrupt billing state.
Do not continue to live mode if any webhook delivery is red, Checkout shows the wrong amount, or the
role does not follow Stripe's subscription state.
## Part 6 — live-mode cutover
1. Disable Test mode in Stripe.
2. Create or open the live JobTracker Pro product and create a live recurring monthly price.
3. Configure the live customer portal.
4. Create the live webhook endpoint with the same URL and three subscription events.
5. Replace all three production environment values together:
- live `sk_live_...` secret key;
- live `price_...` recurring Price ID;
- live endpoint-specific `whsec_...` signing secret.
6. Redeploy.
7. Complete one low-risk real subscription, portal visit and cancellation.
8. Confirm every live webhook delivery is successful and the JobTracker role follows the live
subscription state.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| Billing appears unavailable | `STRIPE_PRICE_PREMIUM` must begin `price_`; all three settings must be present. |
| Checkout says the price does not exist | The API key and Price ID probably come from different Stripe modes or accounts. |
| Webhook signature fails | Use the endpoint's `whsec_...` secret, not an API key or a Stripe CLI temporary secret. |
| Checkout succeeds but Premium is absent | Inspect the webhook delivery response; JobTracker provisions from signed subscription events. |
| Portal does not open | Enable and save the customer portal in the same Stripe mode as the customer. |
| Cancellation does not remove Premium | Check the resulting Stripe status and replay the authoritative subscription event. |
## Safe rollback
Removing all three Stripe environment values and redeploying disables new Checkout and portal
sessions without deleting billing history. Do not manually remove Premium while Stripe still reports
an active subscription. Repair webhook delivery and replay the authoritative event instead.