2cc1e69f5d
Closes #25. Retires the biggest risk in #18 — losing the owner's reading history — on a copy, before production is anywhere near it. ## What was run A throwaway generator (python3 stdlib `sqlite3`, ~20 lines, **not committed**) read a copy of `bookmarks-20260807-213515.db` and emitted plain SQL: 29 distinct Series first, then 29 Bookmarks referencing them, each `INSERT ... SELECT id FROM owner` so the reader id is resolved rather than hardcoded. The target was a scratch Postgres whose schema and owner Reader were built by the real binary (`go run .` against a throwaway container), not by hand-written DDL. Production was not touched. ## Verified | check | result | |---|---| | Bookmarks total | 29 | | reading / archived / other | 18 / 11 / 0 | | Series | 29, equal to the distinct `(site, series_id)` count in the source | | Readers | 1; Bookmarks not owned by the owner: 0 | | Field-by-field diff, all 29 rows x 15 columns | 0 differences | | `GET /bookmarks` over the real read path | 29 rows, values match source | | `TRUNCATE bookmarks, series;` then re-apply | clean, 29 again | The spot-check the ticket asked for was widened to a full row-by-row comparison — 29 rows is small enough that sampling was the more expensive option. ## What is committed `CUTOVER.md` only, plus two cross-links from `REDEPLOY.md`. The generator stays out of the repository: its output is the owner's reading history, and it reads SQLite, which the backend module dropped in ADR-0001. So the runbook specifies the transformation — column mapping, ordering, nullability, quoting, the temp-table ownership trick — rather than shipping a script. `backend/go.mod` gains nothing. ## Review Two-axis review ran on the diff; six findings applied, all in the runbook: - Six source columns (`title`, `series_url`, `cover`, `last_chapter`, `last_chapter_url`, `last_chapter_num`) are nullable in SQLite but `NOT NULL` in Postgres and must be coalesced — the opposite of `latest_chapter_num`, the one column where `NULL` is meaningful. The 2026-08-07 export had none; a fresh one is not promised the same. - `CREATE TEMP TABLE ... ON COMMIT DROP` must sit *inside* the transaction, or psql's autocommit drops it instantly. - The ownership check now resolves the Reader by Discord id; comparing against `ORDER BY id LIMIT 1` was true by construction and could never fail. - The spot-check now samples archived and favourite rows explicitly instead of hoping they fall inside `ORDER BY updated_at DESC LIMIT 5`. - `git pull --ff-only` before `up -d --build`, or a pre-cutover server rebuilds the SQLite image. - `python3` and `jq` named as prerequisites; column count corrected to sixteen. Every query in the runbook was executed against the scratch database as written. `go vet`, `CGO_ENABLED=0 go build ./...` and `go test ./...` all pass — no Go code changed. Reviewed-on: #33 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
402 lines
18 KiB
Markdown
402 lines
18 KiB
Markdown
# Redeploy runbook
|
||
|
||
Shipping new code to a server that is already running. First-time setup (DNS,
|
||
`.env`, Traefik, installing the userscript) is `DEPLOY.md` — this file assumes
|
||
all of that exists and picks up at "there is a running stack and I want it to
|
||
run the new commit."
|
||
|
||
Whole thing is ~5 minutes, most of it waiting on `docker build`. Order matters:
|
||
**back up before you pull.** A backup taken after a bad migration is a backup of
|
||
the damage.
|
||
|
||
Paths below assume the checkout is at `/opt/bookmarkmanager`; substitute your own. The
|
||
one absolute rule about paths: **backups live in `../bookmarkmanager-backups/`**, a
|
||
sibling of the project directory (`/opt/bookmarkmanager-backups`), never inside it. It
|
||
sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside
|
||
the checkout cannot take the backups with them.
|
||
|
||
```
|
||
/opt/
|
||
├── bookmarkmanager/ <- the checkout (this repo)
|
||
└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.dump
|
||
```
|
||
|
||
---
|
||
|
||
## 0. Preflight
|
||
|
||
```bash
|
||
cd /opt/bookmarkmanager
|
||
|
||
# Both -f flags, every time. The prod override is not standalone.
|
||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||
|
||
$COMPOSE ps # bookmark-api should be Up
|
||
git status --short # expect empty
|
||
git log --oneline -1 # note this hash — it is your rollback target
|
||
df -h /var/lib/docker | tail -1 # a build needs room
|
||
```
|
||
|
||
If `git status` is dirty, someone edited files on the server. Decide before you
|
||
pull: `git stash` to keep it, `git checkout -- .` to discard. A `git pull` onto a
|
||
dirty tree fails halfway and leaves you in a worse spot than either.
|
||
|
||
Create the backup directory once, and make sure it is a sibling, not a child:
|
||
|
||
```bash
|
||
mkdir -p ../bookmarkmanager-backups
|
||
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups" # absolute — Docker needs it
|
||
echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups
|
||
```
|
||
|
||
---
|
||
|
||
## 1. Back up the database
|
||
|
||
The database is Postgres, running as the `postgres` service on the named volume
|
||
`postgres-data`. It has **no published port** — nothing outside the internal `db`
|
||
network can reach it — so every command below goes in through the container:
|
||
|
||
```bash
|
||
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c '\dt'
|
||
# -> bookmarks, schema_migrations, series
|
||
```
|
||
|
||
Inside the container that connects over the local socket as the `bookmarks`
|
||
superuser, so no password is needed anywhere in this section. `-T` is not
|
||
optional: without it Compose allocates a TTY, which rewrites `\n` to `\r\n` and
|
||
silently corrupts any binary stream flowing back out — see the dump below.
|
||
|
||
### Preferred: hot dump, no downtime
|
||
|
||
`pg_dump` runs in a single repeatable-read transaction, so it writes one
|
||
point-in-time-consistent snapshot while the API keeps serving. No stopping, no
|
||
WAL to worry about — that is the server's problem, not yours.
|
||
|
||
```bash
|
||
STAMP=$(date -u +%Y%m%d-%H%M%S) # UTC, sorts chronologically as text
|
||
$COMPOSE exec -T postgres pg_dump -U bookmarks -d bookmarks -Fc \
|
||
> "$BACKUP_DIR/bookmarks-$STAMP.dump"
|
||
|
||
ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.dump
|
||
```
|
||
|
||
`-Fc` is the custom archive format rather than plain SQL: it is compressed, and
|
||
`pg_restore` can inspect and replay it selectively — list its table of contents,
|
||
restore one table, restore schema without data, reorder. A plain `.sql` dump can
|
||
only be piped into `psql` whole, and gives you no way to check what is in it
|
||
short of reading it.
|
||
|
||
`$STAMP` is the "time in the name" — `bookmarks-20260730-014233.dump`. UTC, so
|
||
the files sort in real order and never collide across a DST shift.
|
||
|
||
Verify it before you trust it. An unreadable backup is worse than none, because
|
||
you will act as though you have one:
|
||
|
||
```bash
|
||
# 1. The dump parses and contains the tables. Uses the same image compose
|
||
# already pulls, so nothing new to install.
|
||
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
|
||
pg_restore --list "/backup/bookmarks-$STAMP.dump" | grep 'TABLE DATA'
|
||
# -> 1234; 0 0 TABLE DATA public bookmarks bookmarks
|
||
# -> 1235; 0 0 TABLE DATA public schema_migrations bookmarks
|
||
# -> 1236; 0 0 TABLE DATA public series series
|
||
|
||
# 2. Sanity-check the live row count you just captured.
|
||
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks \
|
||
-c 'select count(*) from bookmarks'
|
||
# -> 37
|
||
```
|
||
|
||
A custom-format archive stores row counts nowhere, so step 1 proves the file is
|
||
a readable archive with the right tables in it, not that the rows are there;
|
||
step 2 is the number those rows should be. It should match what the web UI
|
||
shows. Zero on a server you know has bookmarks means the API and your `psql`
|
||
are looking at different databases — check `DATABASE_URL`.
|
||
|
||
### Fallback: cold volume archive
|
||
|
||
Use this when you want the whole data directory rather than a logical dump — a
|
||
like-for-like restore of the same Postgres major version onto the same host.
|
||
|
||
**The stack must be stopped first.** A running Postgres has dirty pages in
|
||
shared buffers and WAL that has not been replayed into the data files, and `tar`
|
||
walks the directory over several seconds while the server keeps writing to it.
|
||
The archive you get is torn: files from different instants, possibly a
|
||
half-written page. It may restore, start, and be quietly wrong. Online
|
||
filesystem-level backup is `pg_basebackup`'s job, not `tar`'s; with the
|
||
container stopped the shutdown checkpoint has already flushed everything and a
|
||
plain archive of the volume is consistent.
|
||
|
||
```bash
|
||
VOL=$(docker volume ls --filter name=postgres-data -q | head -1)
|
||
echo "$VOL" # -> bookmarkmanager_postgres-data
|
||
|
||
$COMPOSE stop
|
||
docker run --rm -v "$VOL":/from:ro -v "$BACKUP_DIR":/to alpine \
|
||
tar czf "/to/postgres-data-$STAMP.tgz" -C /from .
|
||
$COMPOSE start
|
||
|
||
ls -lh "$BACKUP_DIR"/postgres-data-$STAMP.tgz
|
||
```
|
||
|
||
Costs ~15 seconds of downtime. Read-only on the source is safe here precisely
|
||
because nothing is running against it. Restoring this variant means untarring it
|
||
back into an *empty* `postgres-data` volume with the stack down — it is a whole
|
||
data directory, not a file you can drop next to the live one, and it will only
|
||
start under `postgres:17`.
|
||
|
||
### Retention
|
||
|
||
Keep a month, drop the rest — a bookmark database this small compresses the
|
||
decision to "disk is free, but not infinite":
|
||
|
||
```bash
|
||
ls -1t "$BACKUP_DIR"/bookmarks-*.dump | tail -n +31 | xargs -r rm -v
|
||
```
|
||
|
||
### A note on the old `bookmarks-data` volume
|
||
|
||
`bookmarks-data` is the **pre-migration SQLite volume**. It is deliberately not
|
||
declared in `docker-compose.yml` any more, which is what keeps `docker compose
|
||
down -v` from taking it with the rest of the stack. It is not the live database
|
||
and nothing reads it — the one-way move out of it is `CUTOVER.md`. Once the
|
||
Postgres data has been trusted for a while, remove it by hand — nothing else will:
|
||
|
||
```bash
|
||
docker volume rm bookmarkmanager_bookmarks-data
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Pull the new code
|
||
|
||
```bash
|
||
git pull --ff-only
|
||
git log --oneline -3
|
||
```
|
||
|
||
`--ff-only` so a diverged history fails loudly instead of opening a merge you
|
||
did not plan on the production box.
|
||
|
||
Check whether `.env` needs anything new. New config lands in `.env.example`, and
|
||
compose fails at start for a missing required var — better to find out now:
|
||
|
||
```bash
|
||
git diff HEAD@{1} HEAD -- .env.example docker-compose.yml docker-compose.prod.yml
|
||
```
|
||
|
||
If a variable was added there, add it to `.env` before continuing.
|
||
|
||
---
|
||
|
||
## 3. Rebuild and restart
|
||
|
||
```bash
|
||
$COMPOSE up -d --build
|
||
```
|
||
|
||
**A rebuild is mandatory for any UI change.** The HTML templates, CSS,
|
||
JavaScript and fonts are compiled into the binary by `//go:embed`, so editing
|
||
them on the server — or pulling them — changes nothing until the image is
|
||
rebuilt. The one exception is `userscript/manga-bookmark.user.js`, which is
|
||
bindmounted read-only and read fresh per request.
|
||
|
||
```bash
|
||
$COMPOSE ps # bookmark-api Up; postgres Up (healthy)
|
||
docker logs bookmark-api --tail 20 # -> "listening on :8080 ..."
|
||
```
|
||
|
||
Nothing in the log about the database, the migrations or the poller failing.
|
||
`bookmark-api` waits on `postgres` reporting healthy before it starts and the
|
||
binary applies any pending migration before it listens, so an API that never
|
||
says "listening" is usually the database, not the code — `$COMPOSE logs
|
||
postgres` first. The image is tagged
|
||
`bookmarkmanager-backend:latest`, so the previous image is still on disk untagged —
|
||
that is what makes the rollback in §6 quick.
|
||
|
||
---
|
||
|
||
## 4. Verify the deploy
|
||
|
||
Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names once:
|
||
|
||
```bash
|
||
API=https://bookmark-api.violetcrown.my.id
|
||
WEB=https://bookmark.violetcrown.my.id
|
||
# During the grace window the retired global credential still resolves to the
|
||
# owner; afterwards it is 401 like any other wrong credential.
|
||
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
|
||
|
||
curl -s $API/healthz # -> ok
|
||
curl -s -o /dev/null -w '%{http_code}\n' $API/bookmarks # -> 401
|
||
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||
# -> your data, not []
|
||
curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
|
||
-H 'Access-Control-Request-Method: PUT' \
|
||
$API/bookmarks/x | grep -i access-control # -> allow-origin echoed
|
||
```
|
||
|
||
`[]` from the third call is the alarm that matters: you are talking to an empty
|
||
database, which means the API found a *different* Postgres than the one holding
|
||
your data — a renamed project directory, a fresh `postgres-data`, or a
|
||
`DATABASE_URL` override in `.env` pointing elsewhere. Stop and check, before
|
||
touching anything else:
|
||
|
||
```bash
|
||
$COMPOSE config --volumes # -> postgres-data
|
||
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c 'select count(*) from bookmarks'
|
||
```
|
||
|
||
Web UI and its assets:
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code}\n' $WEB/ # -> 200 (login page)
|
||
curl -s -o /dev/null -w '%{http_code}\n' $WEB/static/style.css # -> 200
|
||
|
||
# The fonts are self-hosted; this is what tells you they shipped and are typed.
|
||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||
$WEB/static/fonts/instrument-serif-400-latin.woff2
|
||
# -> 200 font/woff2
|
||
|
||
# The served userscript, whose @version is its mtime — a pull bumps it.
|
||
curl -s $API/u/$TOKEN/manga-bookmark.user.js | grep '@version'
|
||
```
|
||
|
||
`404` on the font means `static/fonts/` did not make it into the image; the UI
|
||
will still render, in Georgia, which is easy to miss on a phone. `application/
|
||
octet-stream` instead of `font/woff2` means an older binary is running.
|
||
|
||
Then open `$WEB` in a browser and confirm, in one glance:
|
||
|
||
- Serif brand and serif row titles — not the system sans fallback.
|
||
- A series with an unread chapter has a **crimson title on an ember underline**;
|
||
everything else is cool grey. That single detail exercises the whole design
|
||
path (template class, CSS, and the poller's `latest_chapter`).
|
||
- Tapping the pencil opens the chapter form; Save closes it and the row keeps
|
||
its place in the list.
|
||
|
||
---
|
||
|
||
## 5. Smoke-test the full loop
|
||
|
||
The API answering is not the same as the product working. Do this on the phone,
|
||
against the real sites — it is the only check that covers the userscript, CORS
|
||
and the shared store together:
|
||
|
||
1. Open a series on **asurascans.com**, open the panel, **+ Bookmark this**.
|
||
2. On the server: `curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks`
|
||
→ the series is in the JSON.
|
||
3. Open a chapter of it, reopen the panel → last-read shows that chapter, and
|
||
opening an *older* chapter does not move it backwards.
|
||
4. Open **demonicscans.org**, open the panel → the Asura bookmark is listed
|
||
there too. Different origin, one store — this is the whole point of the
|
||
backend, and the check that fails first when CORS or TLS regressed.
|
||
5. Open `$WEB` → the same series appears, with the same chapter.
|
||
6. Turn airplane mode on, tap ★ on a row, turn it off, reopen the panel → the
|
||
star stuck. That exercises the retry queue.
|
||
|
||
If 1–5 pass, the deploy is good.
|
||
|
||
---
|
||
|
||
## 6. Rollback
|
||
|
||
Two independent things can be wrong, so undo only what broke.
|
||
|
||
**Bad code, database fine** — go back to the previous commit and rebuild:
|
||
|
||
```bash
|
||
git log --oneline -5
|
||
git checkout <previous-hash>
|
||
$COMPOSE up -d --build
|
||
```
|
||
|
||
**Database damaged** — restore the dump from §1. Stop **only the API**, not the
|
||
whole stack: `pg_restore` needs the server up to restore into, and it needs
|
||
`bookmark-api`'s connection pool gone, because `--clean` cannot drop a table
|
||
other sessions are holding open.
|
||
|
||
```bash
|
||
$COMPOSE stop bookmark-api
|
||
|
||
$COMPOSE exec -T postgres pg_restore -U bookmarks -d bookmarks --clean --if-exists \
|
||
< "$BACKUP_DIR/bookmarks-<STAMP>.dump"
|
||
|
||
$COMPOSE start bookmark-api
|
||
docker logs bookmark-api --tail 20
|
||
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||
```
|
||
|
||
Three things here are easy to skip and all three bite:
|
||
|
||
- **`--clean --if-exists`.** Without `--clean` the dump's rows land *on top of*
|
||
what is already there and you get primary-key collisions half way through, a
|
||
partially restored database, and a non-zero exit you may not notice.
|
||
`--if-exists` only suppresses the "does not exist" noise when the target is
|
||
already empty; it is not the part doing the work.
|
||
- **`-T` again.** Feeding a custom-format archive into a TTY-allocated `exec`
|
||
corrupts it in flight and `pg_restore` fails with a garbled-header error on a
|
||
file that is perfectly fine on disk.
|
||
- **Stop the API, not Postgres.** `$COMPOSE stop` (everything) leaves you with
|
||
nothing to restore into; leaving `bookmark-api` running leaves connections
|
||
that block the drops *and* lets the poller write into a half-restored table.
|
||
|
||
No ownership fixing is needed any more — the Postgres image owns `postgres-data`
|
||
itself and `pg_restore` writes through the server, not the filesystem.
|
||
`schema_migrations` is inside the dump, so the database comes back at whatever
|
||
schema version the backup was taken at; the migration runner applies anything
|
||
newer the next time `bookmark-api` starts.
|
||
|
||
---
|
||
|
||
## 7. The whole thing, as one block
|
||
|
||
For a routine redeploy where nothing needs deciding:
|
||
|
||
```bash
|
||
cd /opt/bookmarkmanager
|
||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups"; mkdir -p "$BACKUP_DIR"
|
||
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||
|
||
$COMPOSE exec -T postgres pg_dump -U bookmarks -d bookmarks -Fc \
|
||
> "$BACKUP_DIR/bookmarks-$STAMP.dump" &&
|
||
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
|
||
pg_restore --list "/backup/bookmarks-$STAMP.dump" > /dev/null &&
|
||
git pull --ff-only &&
|
||
$COMPOSE up -d --build &&
|
||
sleep 5 &&
|
||
curl -sf https://bookmark-api.violetcrown.my.id/healthz && echo " deploy ok"
|
||
```
|
||
|
||
The `&&` chain is deliberate: if the dump or its `pg_restore --list` check
|
||
fails, nothing is pulled and nothing is rebuilt. A failed dump still leaves a
|
||
short or empty `.dump` behind — the shell creates the file before `pg_dump`
|
||
runs — so delete it rather than letting it sit in the backup directory looking
|
||
like a backup. Then still do §5 by hand — no shell command can tell you the
|
||
panel works on the phone.
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Cause / fix |
|
||
|---|---|
|
||
| `/bookmarks` returns `[]` after redeploy | You are on an empty Postgres. Check `$COMPOSE config --volumes` lists `postgres-data`, that you passed both `-f` files, and that `.env` has no stray `DATABASE_URL` override. Do **not** re-bookmark; the data is still in the volume. |
|
||
| UI looks like plain Georgia / system sans | `static/fonts/` missing from the image, or the browser cached an old `style.css`. `/static/*` is served `max-age=3600`, so hard-reload or wait an hour. |
|
||
| CSS or template change did not appear | You restarted without `--build`. Assets are `//go:embed`ed. |
|
||
| Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. |
|
||
| Everyone logged out of the web UI | The `sessions` table was wiped; sessions are database rows, not signed cookies. Expected after a deliberate revoke. |
|
||
| `compose` errors about `BOOKMARK_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. |
|
||
| Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. `@version` comes from the file's mtime, so confirm the pull actually touched it. |
|
||
| `bookmark-api` crash-loops, log says `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` in `.env` no longer matches the one burned into `postgres-data` at first init — Postgres reads that variable only when initialising an empty volume. Put the old value back, or reset the role: `$COMPOSE exec postgres psql -U bookmarks -d bookmarks -c '\password bookmarks'` (prompts, so nothing lands in shell history) and then match `.env` to it. |
|
||
| `compose` errors `set POSTGRES_PASSWORD in .env` | Unset. Compose builds the backend's `DATABASE_URL` out of it, so it is required even though you never write that URL yourself. Run from the directory holding `.env`. |
|
||
| `postgres` never leaves `starting`; `bookmark-api` never starts either | The healthcheck (`pg_isready`) is failing and `bookmark-api` waits on it. `$COMPOSE logs postgres` — usually `postgres-data` was initialised by a different major version ("database files are incompatible with server"), or the disk is full. |
|
||
| `pg_restore`: `cannot drop … other objects depend on it` / `being accessed by other users` | Live connections block `--clean`. `$COMPOSE stop bookmark-api` first (§6). If they persist: `$COMPOSE exec -T postgres psql -U bookmarks -d postgres -c "select pg_terminate_backend(pid) from pg_stat_activity where datname='bookmarks' and pid <> pg_backend_pid()"`. |
|
||
| Dump is 0 bytes, or `pg_restore`: `did not find magic string in file header` | You ran `exec` without `-T`. The allocated TTY rewrites newlines in the binary stream and corrupts the archive in flight (§1). |
|
||
|
||
Full first-time setup: `DEPLOY.md`. The one-off SQLite→Postgres move:
|
||
`CUTOVER.md`. Config reference and endpoints: `README.md`.
|
||
UI conventions: `docs/design-system.md`.
|