Cockpit Database Upgrade Runbook
How to take a Spring or Flask code change that touches the database from a merged PR to production on cockpit, without losing a row. Every procedure here has been run against production; the last runs are listed at the bottom.
.env file on cockpit — never paste a password into a command, a chat, or this page.
Answer first
Which procedure do I need?
Decide from what the incoming commits change, not from how big they are. The two apps differ because their databases differ:
- Spring runs on the SQLite file
~/open/spring/volumes/sqlite.db(DB_URLis commented out in its.env). Hibernate runs withddl-auto=none, so it never changes the schema on its own — new code against an old schema fails at runtime. - Flask runs on MySQL in AWS RDS (database
user_management). SQLAlchemy'screate_all()can add missing tables but never columns.
| App | What the incoming commits change | Use |
|---|---|---|
| Both | No entity / model changes | 03 · code-only |
| Spring | Any @Entity change: new entity, field, column, join table |
01 · backup → init → restore |
| Flask | New models (new tables) only | 02 · db_upgrade.py |
| Flask | New column on an existing table | 02b · ALTER, then 02 |
| Flask | Column type changes, renames, or anything an ALTER cannot express |
02c · full rebuild |
Do one app at a time. Finish Spring — up, smoke-tested — before starting Flask.
Before the next upgrade
Merging the fixes this depends on
Four fixes came out of the 2026-10-21 upgrade. Until they are merged, parts of this runbook fail on production. They are independent of each other — none conflicts with another, and all three Flask PRs were test-merged together — so the order below is about risk, not about breaking the build.
| # | PR | Needs | Without it | After pulling on cockpit |
|---|---|---|---|---|
| 1 | spring#218 — runner image compiles before switching user | nothing | Breaks prod every docker compose up --build of spring fails at javac Runner.java |
docker compose up -d --build; no DB change |
| 2 | flask#105 — restore keeps active / last_verified |
nothing | Data loss a full rebuild (02c) brings every account back inactive | git pull && docker compose up -d --build; no DB change. Must be deployed before any 02c run |
| 3 | flask#106 — db_init.py mysqldump works on RDS |
nothing | Breaks prod db_init.py cannot take its backup on RDS, so 02c stops at step 1 |
git pull; script only |
| 4 | flask#107 — scripts/db_upgrade.py |
nothing as documented here; PR 3 only if you run it where mysqldump exists without --backup-file |
Risk procedure 02 falls back to a hand-typed create_all() (shown in 02) |
git pull && docker compose up -d --build; then --check should say Up to date |
| 5 | this page | reads best after 1–4 | — | — |
Recommended order: 1 (unblocks every Spring deploy), then 2 + 3 + 4 together on Flask, deployed in one git pull && docker compose up -d --build. Each PR description has its own "How to merge" section with the check to run afterwards.
Phase 00
Preflight (both apps)
-
01
Get a shell on cockpitdev machine
ssh cockpit # Host cockpit → cockpit.opencodingsociety.com, user ubuntuIf SSH hangs with
Connection timed out during banner exchangewhile the sites still load, sshd is wedged, not the box. Use the Cockpit web console instead: cockpit.opencodingsociety.com → Terminal. Every command below works there. -
02
Confirm the checkout is clean and see what is comingcockpit · open/spring and open/flask
cd ~/open/spring # or ~/open/flask; branch is master / main git status -sb # modified tracked files? stop and find out why before pulling git fetch git log --oneline HEAD..@{u}Untracked files (
??) are fine. Modified tracked files (M) are local edits someone made on the server; check that the incoming commits do not touch them (git diff --name-only HEAD @{u}), orgit pull --ff-onlywill refuse. -
03
Decide which procedure — read the schema deltacockpit
Spring — list incoming files that are (or were) JPA entities:
cd ~/open/spring for f in $(git diff --name-only HEAD @{u} -- '*.java'); do (git show @{u}:$f 2>/dev/null; git show HEAD:$f 2>/dev/null) | grep -qE '@Entity|@Embeddable' && echo "$f" done git diff HEAD @{u} -- '*.java' | grep -E '^\+\+\+|^[+-]\s*@(Entity|Table|Column|JoinColumn|JoinTable|ManyToOne|OneToMany|ManyToMany|ElementCollection)|^[+-]\s*private [A-Za-z<>, ]+ [a-zA-Z]+;'Flask — list column and table definitions that change:
cd ~/open/flask git diff --stat HEAD @{u} -- model/ git diff HEAD @{u} -- model/ | grep -E '^[+-].*(db\.Column|__tablename__|db\.Table\()'Nothing printed → code-only. Then pick from the table in Which procedure.
-
04
Pick a quiet time and tell people—
Spring is fully down for the whole of procedure 01 (a few minutes;
initboots the app). Flask stays up in 02 / 02b and is down for most of 02c.
Phase 01 · any Spring entity change
Spring: backup → init → restore
init rebuilds an empty schema from the entities of the code you just pulled; restore loads the backup into it, copying every column both schemas share. New columns take their defaults, new tables start empty, and every existing row comes back — including the *_seq id tables and the HT_*/HTE_* tables, which Hibernate will not recreate.
init and restore.
In that window the database holds only the handful of demo users ModelInit seeds. Starting the app then makes production a demo site. That is why step 06 runs both as one nohup command joined by &&.
Rehearse first (dev machine, no production data)
Production data stays on cockpit. Rehearse the exact commit on a copy of your local dev database instead — it has the same 160-odd tables, so it proves init builds and restore reconciles on the new code:
cd spring && git fetch upstream
git worktree add --detach ../spring-rehearsal upstream/master
cp .env ../spring-rehearsal/.env # DB_URL must stay commented out → SQLite
mkdir -p ../spring-rehearsal/volumes/backups
python3 -c "import sqlite3; s=sqlite3.connect('volumes/sqlite.db'); d=sqlite3.connect('../spring-rehearsal/volumes/sqlite.db'); s.backup(d)"
cd ../spring-rehearsal
python3 scripts/db_migrate.py backup
B=$(ls -t volumes/backups/sqlite_backup_*.db | head -1)
FORCE_YES=true python3 scripts/db_migrate.py init && python3 scripts/db_migrate.py restore --backup-file "$B" --force
It must end with Rows restored equal to Rows in backup and All tables verified. Port 8585 must be free locally (./dev.sh stop spring). Remove the worktree afterwards with git worktree remove ../spring-rehearsal.
On production
-
01
Confirm the target and note the before-picturecockpit · open/spring
cd ~/open/spring python3 scripts/db_migrate.py status # must say Mode: SQLITE; note Tables and Rows -
02
Stop Spring and pullcockpit · open/spring
docker compose down git pull --ff-only git log --oneline -1 # the commit you rehearsedPull before
init, orinitrebuilds the old schema. The app must be down:initneeds port 8585, and the backup must not miss writes made after it. -
03
Back up the live databasecockpit · open/spring
python3 scripts/db_migrate.py backupUses SQLite's online backup API — never
cpthe file, it runs in WAL mode. If it fails withunable to open database file,volumes/backups/is root-owned:sudo chown -R $(id -u):$(id -g) volumes/and run it again. -
04
Gate — the backup is completegate
It must end with All tables and all rows accounted for. Pin the file in a variable so the path cannot be mistyped later:
B=volumes/backups/sqlite_backup_YYYYMMDD_HHMMSS.db # the file it printed ls -l "$B" || echo "MISSING - STOP" -
05
Rebuild the schema and reload the datacockpit · open/spring
nohup bash -c "FORCE_YES=true python3 scripts/db_migrate.py init && python3 scripts/db_migrate.py restore --backup-file '$B' --force" \ > ~/upgrade_$(date +%Y%m%d).log 2>&1 & tail -f ~/upgrade_$(date +%Y%m%d).log&&means a failedinitnever reachesrestore;nohupmeans a dropped SSH or web-console session cannot leave it half done.initboots the app with Maven, so expect a few minutes of Waiting for Spring Boot.... -
06
Gate — every row came backgate
DATABASE RESET COMPLETE Rows in backup: N Rows restored: N # must equal the line above All tables verified: target row counts match the backup.All four lines, or do not start the app. If
initsucceeded butrestorefailed, re-run only the restore:python3 scripts/db_migrate.py restore --backup-file "$B" --force. Never re-runinitat this point. -
07
Start Springcockpit · open/spring
docker compose up -d --buildIf the build stops at
RUN javac Runner.javawith error while writing Runner, PR 1 (spring#218) is not merged yet. The database is fine; start with the previous runner image instead:docker compose build web && docker compose up -d --no-build. -
08
Smoke testgate
docker compose ps # web and code_runner Up curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8585/ # 302 (login redirect) = up python3 -c " import sqlite3; c = sqlite3.connect('file:volumes/sqlite.db?mode=ro', uri=True) print('tables', c.execute(\"select count(*) from sqlite_master where type='table'\").fetchone()[0]) print('person', c.execute('select count(*) from person').fetchone()[0]) print('integrity', c.execute('pragma quick_check').fetchone()[0])" docker compose logs web --since 10m | grep -iE "Schema-validation|APPLICATION FAILED|Exception" | tailTable count and
personmust match the before-picture (plus any new tables); integrity must beok. Then log in through the site and open a page that uses the changed entity.
DB_URL is set, db_migrate.py switches to its MySQL code paths and the flow becomes backup → down → pull → init → restore --keep-target-schema → up. The spring README, section "Database Management Workflow with Scripts", has the exact commands. The RDS springdatabase is a stale April snapshot — do not restore it over the SQLite file.
Phase 02 · new Flask models only
Flask: create the new tables in place
Nothing is dropped, nothing is reloaded, user ids do not change, and Flask stays up. This is how the nine presence tables went to production on 2026-10-21.
-
01
Back up RDS from the hostcockpit · open/flask
cd ~/open/flask OUT=instance/backups/user_management_$(date +%Y%m%d_%H%M%S)_pre_upgrade.sql ( eval "$(grep -E '^(DB_ENDPOINT|DB_USERNAME|DB_PASSWORD)=' .env)" MYSQL_PWD="$DB_PASSWORD" mysqldump --host="$DB_ENDPOINT" --port=3306 --user="$DB_USERNAME" \ --single-transaction --set-gtid-purged=OFF --routines --triggers user_management > "$OUT" ) ls -l "$OUT"; tail -1 "$OUT"The web container has no
mysqldump, so this runs on the host. It loads only the threeDB_*lines from.env—source .envbreaks on other lines in that file — and the subshell keeps them out of your session. -
02
Gate — the dump is completegate
The last line must be
-- Dump completed on …. Ifmysqldumpinstead saysFLUSH TABLES WITH READ LOCK … Access denied, the--set-gtid-purged=OFFflag is missing. Delete any partial file before retrying. -
03
Deploy the codecockpit · open/flask
git pull --ff-only && git log --oneline -1 docker compose up -d --buildSafe before the schema: Flask only creates tables when told to. Pages for the new models error until step 05; everything else keeps working.
-
04
Gate — only tables to addgate
docker compose exec web python scripts/db_upgrade.py --checkExit 2 with a list under Tables to create → continue. MISSING COLUMNS → stop and do 02b first. Unused tables / columns (left in place) are harmless leftovers (for example
users.kasm_server_needed). -
05
Create the tablescockpit · open/flask
docker compose exec web python scripts/db_upgrade.py --backup-file "$OUT"instance/is mounted into the container at the same relative path, so$OUTresolves inside it. The script refuses a missing or truncated dump, creates the tables, and re-counts every existing table.Before PR 4 (flask#107) is merged, the equivalent by hand is:
docker compose exec web python -c "from main import app, db; app.app_context().push(); db.create_all()" -
06
Gate and smoke testgate
The script must end with All N existing tables kept their row counts and Upgrade complete; a second
--checkmust say Up to date. Then:curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8587/ # 200 docker compose logs web --since 10m | grep -iE "error|traceback" | tailLog in as a real (non-seed) user and open a page that uses the new model.
Phase 02b · a new column on an existing Flask table
Flask: add the column, then 02
create_all() never alters an existing table, so a new column is one ALTER TABLE, applied before the code that reads it is deployed. The precedent is in model/user.py, next to active and last_verified: the PR that adds a column should carry its ALTER (and any backfill UPDATE) in a comment or its description.
-
01
Back up — exactly 02 step 01, and pass its gatecockpit · open/flask
-
02
Apply the ALTER from the PRcockpit · open/flask
( eval "$(grep -E '^(DB_ENDPOINT|DB_USERNAME|DB_PASSWORD)=' .env)" MYSQL_PWD="$DB_PASSWORD" mysql --host="$DB_ENDPOINT" --user="$DB_USERNAME" user_management \ -e "ALTER TABLE users ADD COLUMN example_flag BOOLEAN NOT NULL DEFAULT 0;" )A
NOT NULLcolumn needs aDEFAULTor MySQL rejects it on a table that has rows. Think about what existing rows should hold — whenactivewas added, every existing account was set to active with an explicitUPDATE, otherwise everyone would have been locked out. -
03
Continue with 02 from step 03cockpit · open/flask
Deploy, then
db_upgrade.py --checkmust no longer list MISSING COLUMNS.
Phase 02c · last resort
Flask: full rebuild (db_init + restore)
db_init.py cannot take its RDS backup and stops. It also gives every user a new id. Prefer 02 / 02b whenever they can express the change.
This drops every table on RDS and round-trips all data through the admin export/import API, so production's code and your local checkout must be the same commit. The full step list, with its gates, is in the flask README, section "Database Management Workflow with Scripts". In short:
- On cockpit:
git pull && docker compose up -d --build(code first — the pull needs production's new export endpoints). - On your machine, with the production admin in
.env(ADMIN_UID/ADMIN_PASSWORD):python scripts/db_migrate-prod2sqlite.py— must exit 0, every tableok. - Test locally against that data.
- On cockpit, in a Python environment with the app's requirements:
python scripts/db_init.py— takes themysqldump, thendrop_all(),create_all(), seed data. Note the dump path. - On your machine:
python scripts/db_restore-sqlite2prod.py— must report a clean reconciliation;+N extraare seed rows,MISSINGis a failure. - Check that real accounts are still active: log in as a student and as a teacher.
The pulled SQLite file is the only complete copy of production between steps 4 and 5. Do not delete it until step 5 reconciles.
Phase 03 · no entity / model changes
Code-only upgrade (both apps)
Still take a backup — it costs a minute and a bad release can write bad data.
# Spring
cd ~/open/spring
python3 scripts/db_migrate.py backup # must end "All tables and all rows accounted for."
git pull --ff-only && docker compose up -d --build
# Flask
cd ~/open/flask
# 02 step 01 (mysqldump), pass its gate, then:
git pull --ff-only && docker compose up -d --build
Smoke test as in 01 step 08 / 02 step 06. Rolling back is git checkout <previous commit> && docker compose up -d --build, because the database did not change.
If it goes wrong
Rollback
Spring — the backup file is a complete database
cd ~/open/spring
docker compose down
git checkout <previous commit> # the old code matches the old schema
cp "$B" volumes/sqlite.db
rm -f volumes/sqlite.db-wal volumes/sqlite.db-shm # or the old WAL replays on top
docker compose up -d --build
Do not start the new code on the old database: with ddl-auto=none the missing columns break every query that touches them.
Flask — load the dump back into RDS
cd ~/open/flask
( eval "$(grep -E '^(DB_ENDPOINT|DB_USERNAME|DB_PASSWORD)=' .env)"
MYSQL_PWD="$DB_PASSWORD" mysql --host="$DB_ENDPOINT" --user="$DB_USERNAME" user_management < "$OUT" )
git checkout <previous commit> && docker compose up -d --build
After 02 alone, rolling back the code is usually enough — the new tables are unused by the old code. Load the dump when data was changed or lost.
Last line of defence
An RDS snapshot from the AWS console, taken before a 02c rebuild, restores the whole instance. Slow, and it discards everything written since.
Learned the hard way
Pitfalls we have hit
| Impact | What happens | Avoid it by |
|---|---|---|
| Data loss | cp sqlite.db of a WAL database misses everything still in sqlite.db-wal. |
db_migrate.py backup only |
| Data loss | Spring started between init and restore serves the seeded demo users as production. |
01 step 05 as one nohup … && … |
| Data loss | Flask full rebuild deactivates every account (before flask#105). | 02 / 02b, or merge PR 2 first |
| Breaks prod | New Spring code started on the old schema: missing columns, errors on every affected endpoint. | pull → init → restore → up, in that order |
| Breaks prod | docker compose up --build fails on javac Runner.java after compose down — Spring stays down. |
merge spring#218; meanwhile --no-build for the runner |
| Risk | mysqldump on RDS: FLUSH TABLES WITH READ LOCK … Access denied, no backup. |
--set-gtid-purged=OFF |
| Risk | source .env fails part-way on an unquoted URL line, and exports every secret into your shell. |
the eval "$(grep -E '^(DB_…)=' .env)" subshell |
| Risk | SSH hangs at banner exchange; a dropped session mid-init strands Spring half-migrated. |
Cockpit web console; nohup for long steps |
| Risk | db_init.py run under sudo once leaves /tmp/db_init.log root-owned; every later run dies before Spring starts. |
never sudo the scripts; rm /tmp/db_init.log |
Record
Upgrade log
| Date | App | Change | Procedure | Result |
|---|---|---|---|---|
| 2026-09-21 | Spring | peer and self assessment tables | 01 | 63,698 rows preserved |
| 2026-09-28 | Spring | direct messages (157 → 160 tables) | 01 | 71,360 rows preserved |
| 2026-10-21 | Spring | 1f9fb561 → e5a7650b: assignment.hack_weights, assignment_submission.graded_at | 01 | 101,548 / 101,548 rows restored, 162 tables, integrity ok. Runner image reused (spring#218). |
| 2026-10-21 | Flask | dac2786 → b58c2f6: 9 presence tables, users.kasm_server_needed retired | 02 (by hand) | 19 → 28 tables on RDS, all 676 users still active, ids unchanged |
Add a row each time — date, commit range, procedure, and the reconciliation numbers.