Admin migration runbook
This runbook covers the deploy that flips the Packyard admin surface from
the legacy loopback model (127.0.0.1:8088, no auth) to the new
OAuth-gated public host (https://admin.pkg.example.org/).
The implementation work is already merged. This runbook is the operational checklist for cutting over a live deployment. Run it in order; do not skip the snapshot or the rehearsal.
0. Pre-flight assumptions
- You have shell access to the production VM as
deploy. - You have control of the DNS zone for
pkg.example.org. - You have admin rights in the GitHub organisation (and / or Microsoft Entra tenant) that will gate operator access.
- You have read both Production deployment §3 (Secrets & Environment) and Operator onboarding §1 (Register the IdP apps).
1. Size the migration window
The legacy schema had subscription keys without an account_id column.
The deployed migration creates a single synthetic "legacy" account and
backfills every key with account_id = <legacy id>. The migration is
done inside one serialisable transaction and is bounded by the active key
count.
Capture the count from production:
ssh deploy@pkg.example.org -- \
docker compose exec -T auth sqlite3 /data/db/auth.db \
"SELECT COUNT(*) FROM subscription_key WHERE active = 1;"
Record the number in this runbook (commit the update before the deploy):
Active key count (pre-deploy): ____
Migration window estimate: < N seconds (target: under 5 s for any count below 100k)
For reference: the legacy-account backfill is a single UPDATE ... plus a
schema rebuild for the NOT-NULL constraint. SQLite with WAL mode + single
writer handles ~50k rows per second on the production VM hardware, so any
plausible count finishes well inside the deploy's restart window.
2. Verify the implementation
The migration is already coded — these steps confirm it's present on the release tag you are about to deploy. Verify observable behaviour rather than source-internal symbol names (which a future refactor can rename without changing the runtime contract):
# 1. The auth binary advertises the migration on `--version`.
docker compose run --rm auth --version
# Expect: build metadata including the release tag you're deploying.
# 2. The /admin/login route serves the SPA shell over the admin host.
curl -sSI "https://admin.pkg.example.org/admin/login" | head -1
# Expect: HTTP/2 200.
# 3. The /api/v1/accounts surface responds (401 is correct here — no
# operator session yet; the goal is to confirm the route exists,
# not to call it authenticated).
curl -sSI "https://admin.pkg.example.org/api/v1/accounts" | head -1
# Expect: HTTP/2 401 (NOT 404 — 404 would indicate routing regression).
If any check returns a different status, the release tag is stale or
the deploy hasn't fully picked up the new container — rebuild from
main and redeploy before continuing.
3. Snapshot the database
Take a fresh backup before any migration step. The existing daily
backup container produces a snapshot every 24 hours; do not rely on
it — take an on-demand snapshot at the start of the deploy window.
ssh deploy@pkg.example.org -- bash <<'EOF'
cd ~/packyard
TS=$(date -u +%Y%m%d-%H%M%S)
docker compose exec -T auth sqlite3 /data/db/auth.db ".backup '/data/backup/auth.db.pre-admin-migration.${TS}'"
ls -lh /data/backup/auth.db.pre-admin-migration.*
EOF
The snapshot lives in the auth-backup volume. If anything goes wrong
during the migration, restore via the existing
Restore keystore runbook.
4. Verify the OAuth apps
Both providers must be reachable and reachable's redirect URIs must exactly match the production callback URLs. Pre-deploy, before flipping DNS for the new admin host:
GitHub
curl -sI "https://github.com/login/oauth/authorize?client_id=${PACKYARD_GITHUB_CLIENT_ID}"
# Expect: HTTP/2 302 with a `Location:` header that includes the configured
# callback URI ("redirect_uri=https://admin.pkg.example.org/api/v1/auth/callback/github").
Cross-check the OAuth App settings:
- Authorization callback URL is exactly
https://admin.pkg.example.org/api/v1/auth/callback/github. - The app has access to query the configured
PACKYARD_GITHUB_ORG's membership (read:orgscope, org-owner approval if your org's third-party app policy requires it).
Microsoft Entra
# Inspect the OpenID Connect discovery document to confirm the tenant is
# reachable.
curl -s "https://login.microsoftonline.com/${PACKYARD_MICROSOFT_TENANT_ID}/v2.0/.well-known/openid-configuration" \
| jq '.issuer, .authorization_endpoint, .token_endpoint'
Cross-check the Entra app registration:
- Redirect URI (Web) is exactly
https://admin.pkg.example.org/api/v1/auth/callback/microsoft. - Tenant id matches
PACKYARD_MICROSOFT_TENANT_ID. - Delegated permissions for
openid,profile,emailare added and admin consent is granted.
Record the verification results:
GitHub OAuth app reachable: [ ]
GitHub callback URL exact match: [ ]
GitHub org `read:org` approved: [ ]
Entra OpenID discovery reachable: [ ]
Entra callback URL exact match: [ ]
Entra admin consent granted: [ ]
5. Seed the bootstrap operator
Pick the human who will allowlist the rest of the operators. They MUST have an account on whichever provider you're starting with (GitHub or Microsoft) and their primary verified email on that provider MUST be the exact email you set below.
Add to the production .env:
PACKYARD_BOOTSTRAP_OPERATOR_EMAIL=ops@example.org
The behaviour is idempotent: the env var is honoured only when the
operators table is empty. Once any operator exists (allowlist or
bootstrap), the variable is ignored. You can leave it set forever; it does
nothing on subsequent deploys.
Add ADMIN_DOMAIN and the OAuth env-var sets at the same time per
Production deployment §3.
6. Deploy
Standard deploy sequence. The migration runs at auth-service startup; the loopback admin entrypoint is removed by the same Compose / Traefik config change.
ssh deploy@pkg.example.org -- bash <<'EOF'
cd ~/packyard
git fetch
git checkout <release-tag>
docker compose pull
docker compose up -d --remove-orphans
# Tail the auth + traefik logs for the next 60 seconds.
docker compose logs auth traefik -f --since=60s
EOF
Expected log lines (auth):
loaded components from database— component snapshot loaded.bootstrap operator created(only the first timePACKYARD_BOOTSTRAP_OPERATOR_EMAILruns on an empty table).oauth provider registered provider=github(and / ormicrosoft).starting packyard-auth ... db=/data/db/auth.db.
Expected log lines (traefik):
Certificate obtained successfully domain=admin.pkg.example.org(if ACME has not yet seen this hostname).
7. Post-deploy verification
Every check below must pass before declaring the migration complete.
7.1 Schema migration
ssh deploy@pkg.example.org -- \
docker compose exec -T auth sqlite3 /data/db/auth.db <<'EOF'
.schema subscription_key
SELECT COUNT(*) AS keys_without_account FROM subscription_key WHERE account_id IS NULL;
SELECT COUNT(*) AS legacy_account_keys
FROM subscription_key sk JOIN accounts a ON a.id = sk.account_id
WHERE a.email = 'legacy@packyard.local';
EOF
Pass criteria:
keys_without_accountis0.account_idcolumn isNOT NULLin the printed schema.legacy_account_keysequals the active-key count recorded in §1.
7.2 Loopback entrypoint is gone
ssh deploy@pkg.example.org -- ss -tlnp | grep ':8088 ' || echo OK
Pass criteria: OK is printed (no process is listening on 8088).
7.3 Admin host serves the SPA
From an external host:
curl -sI https://admin.pkg.example.org/admin/login
# Expect: HTTP/2 200, Content-Type: text/html
curl -sI https://admin.pkg.example.org/api/v1/operators
# Expect: HTTP/2 401, body: {"code":"UNAUTHORIZED",...}
7.4 OAuth round-trip on each provider
For each configured provider, sign in via a browser at
https://admin.pkg.example.org/admin/login and confirm:
- The IdP prompts you (and on subsequent runs, silent-auths).
- The redirect lands at
/admin/accountswith the operator chrome showing your email + role. - DevTools → Application → Cookies shows
packyard_sessionwithHttpOnly,Secure,SameSite=Strict.
Repeat for the second provider if both are configured.
7.5 Audit log captured the verification logins
ssh deploy@pkg.example.org -- \
docker compose exec -T auth sqlite3 /data/db/auth.db \
"SELECT ts, action, details FROM audit_log
WHERE action IN ('login.success','login.failure')
ORDER BY id DESC LIMIT 10;"
Pass criteria: at least one login.success row per provider you tested,
with the IdP captured in details.
7.6 Subscriber surface unaffected
The subscriber path is independent of the admin migration but worth a smoke test:
# From an external host
curl -sI -u subscriber:${TEST_KEY} \
https://pkg.example.org/rpm/core/2025/el9-x86_64/repodata/repomd.xml
# Expect: HTTP/2 200 (or 404 if no artifacts) — NOT 401.
8. Rollback
If §7 fails on a check that cannot be fixed in-place within the deploy window:
ssh deploy@pkg.example.org -- bash <<'EOF'
cd ~/packyard
git checkout <previous-release-tag>
docker compose pull
docker compose stop auth
# Restore the snapshot taken in §3.
# NOTE: the auth image is distroless (no shell). `docker compose exec auth
# sh` will fail — use a one-shot alpine container against the named volume
# for any file operations on the DB volume.
TS_BACKUP=<filename from §3>
docker run --rm -v packyard_auth-db:/db alpine \
rm -f /db/auth.db /db/auth.db-wal /db/auth.db-shm
docker run --rm -v packyard_auth-db:/db -v packyard_auth-backup:/backup alpine \
cp /backup/${TS_BACKUP} /db/auth.db
docker compose up -d --remove-orphans
EOF
After rollback, the loopback admin entrypoint returns (the previous release still ships it). Subscribers are unaffected — their keys and forward-auth paths do not change between the legacy and new implementations.
Open an incident issue documenting which §7 check failed and why; the migration can be retried after the issue is fixed.
9. Sign-off
Migration window: ____ to ____ (UTC).
Operator who ran the migration: ____
Verification results: §7.1 ☐ §7.2 ☐ §7.3 ☐ §7.4 ☐ §7.5 ☐ §7.6 ☐
Snapshot retained until: ____ (recommended: ≥ 30 days post-deploy).