docs: correct the ClickHouse rotation runbook -- it had it backwards

The runbook opened with `ALTER USER default IDENTIFIED BY ...` and
claimed the image entrypoint reads CLICKHOUSE_PASSWORD only when it
initialises an empty data volume. Both claims were wrong, and following
them would have failed at the first command.

`SELECT name, storage FROM system.users` reports `default | users_xml`:
the user is defined by a config file, and ClickHouse rejects ALTER USER
against that storage. There is no in-database password to change. The
password lives in /etc/clickhouse-server/users.d/default-user.xml, which
the entrypoint regenerates on *every* container start -- that path is in
the container filesystem, and only /var/lib/clickhouse is a volume. The
file's mtime matches the container's StartedAt to the second, before and
after a rotation.

So the env var is the source of truth and recreating the container is
what applies it -- close to the inverse of what was written.

/opt/sentry's own docker-compose.override.yml header had recorded the
correct mechanism during the 2026-08-19 rotation. The runbook was
drafted without reading it. Noted at the top of the new Verification
status section, since a runbook that was wrong once should say so.

Procedure rewritten around what was actually executed against proto on
2026-08-23 and verified: back up first, generate hex on the box so the
value never reaches a terminal or transcript, sed only indented KEY:
value lines so the header comment is untouched, `docker compose config
-q` before touching anything live, then recreate. Verification now
checks that the OLD password is rejected -- read from the backup rather
than retyped -- not merely that the new one works.

Rollback rewritten to restore the backup, since there is no ALTER to
reverse. Every shell block syntax-checked with bash -n.
This commit is contained in:
2026-08-22 20:41:49 -07:00
parent cf2d0437d3
commit 5e6c0e918c
+109 -35
View File
@@ -3,7 +3,11 @@
Applies to the docker-compose deployments (`proto.cairnobs.org`, Applies to the docker-compose deployments (`proto.cairnobs.org`,
`demo.cairnobs.org`). For Helm deployments the value is a K8s `Secret` `demo.cairnobs.org`). For Helm deployments the value is a K8s `Secret`
(`/deploy/helm/cairnobs/templates/secrets.yaml`) and the procedure is a (`/deploy/helm/cairnobs/templates/secrets.yaml`) and the procedure is a
secret update plus a rollout restart — only steps 1 and 5 below apply. secret update plus a rollout restart. The `users_xml` mechanics in "The
trap" below apply there too — the chart's ClickHouse pod builds the same
`default-user.xml` from the same env var — so the shape is identical:
change the value, recreate the pod, verify. Only the sed-the-override
steps differ.
## What you are rotating ## What you are rotating
@@ -32,14 +36,33 @@ wider model.
do not assume a value recovered from one applies to the other. do not assume a value recovered from one applies to the other.
- **Rotate demo before proto**, matching the deploy risk order. - **Rotate demo before proto**, matching the deploy risk order.
## The trap ## The trap: do NOT reach for `ALTER USER`
`CLICKHOUSE_PASSWORD` is read by the official image's entrypoint **only The obvious move — change the password inside ClickHouse, then update
when it initialises an empty data volume**. On a box that has already the env to match — **does not work here, and fails loudly**:
been running, editing the env var and restarting does *not* change the
stored password — ClickHouse keeps the old one, the clients present the ```
new one, and every query starts returning 403. The password must be $ docker exec cairnobs-clickhouse clickhouse-client \
changed *inside* ClickHouse first. --query "SELECT name, storage FROM system.users"
default users_xml
```
`storage = users_xml` means the `default` user is defined by a config
file, not by SQL. ClickHouse treats that storage as read-only and
rejects `ALTER USER` against it. There is no in-database password to
change.
The password lives in `/etc/clickhouse-server/users.d/default-user.xml`,
which the official image's entrypoint **regenerates from
`CLICKHOUSE_PASSWORD` on every container start** — not only on first
init. That directory is part of the container filesystem; only
`/var/lib/clickhouse` (the data) is a volume, so the file is rebuilt
each time and the data survives untouched.
So the env var *is* the source of truth, and recreating the container is
what applies it. Verify by comparing the file's mtime against
`docker inspect <container> --format '{{.State.StartedAt}}'` — they
match to the second, before and after a rotation.
## Procedure ## Procedure
@@ -53,60 +76,111 @@ up` will run the migrate step with a stale password and fail.
`web`, `alerting`, `search`, `enterprise-auth`, `metadata-postgres`, and `web`, `alerting`, `search`, `enterprise-auth`, `metadata-postgres`, and
`redpanda` do not carry it and do not need restarting. `redpanda` do not carry it and do not need restarting.
**1. Generate a new value** and keep it somewhere you can paste from. **1. Back up the override file.** It is the only copy of these
credentials, and it is the rollback path.
```sh ```sh
openssl rand -base64 24 cd /opt/sentry # or /home/john/cairnobs-demo on demo
cp -p docker-compose.override.yml \
docker-compose.override.yml.bak.$(date +%Y%m%d-%H%M%S)
``` ```
**2. Change it inside ClickHouse first.** **2. Generate the value on the box, and never let it leave.** Use hex,
not base64: this string is interpolated into the generated
`default-user.xml`, and base64's `/`, `+`, and `=` are needless risk in
XML. Generating it remotely also keeps it out of your terminal
scrollback and out of any AI-assistant transcript.
```sh ```sh
docker exec cairnobs-clickhouse clickhouse-client \ NEW="$(openssl rand -hex 24)"
--query "ALTER USER default IDENTIFIED BY '<new-password>'"
``` ```
Existing connections keep working; this affects authentication of new **3. Update all four occurrences in the override file.** Match only
ones. Expect the next `api`/`ingest` query to fail until step 4. indented `KEY: value` lines, so the header comment (which mentions
`CLICKHOUSE_PASSWORD` in prose) is left alone:
**3. Update the override file**`docker-compose.override.yml` in the ```sh
deployment directory (`/opt/sentry` on proto, `/home/john/cairnobs-demo` sed -i -E "s|^( +)CLICKHOUSE_PASSWORD: .*|\1CLICKHOUSE_PASSWORD: \"${NEW}\"|" \
on demo). This file is gitignored precisely because it carries real docker-compose.override.yml
credentials; it is not in the repo and rsync will not overwrite it grep -cE '^ +CLICKHOUSE_PASSWORD: ' docker-compose.override.yml # expect 4
unless you tell it to. ```
Update every occurrence, not just the `clickhouse` service's — `api` and The file is gitignored precisely because it carries real credentials; it
`ingest` each set their own copy. is not in the repo, and an rsync deploy will not overwrite it unless you
explicitly tell it to.
**4. Restart the three services that carry it.** **4. Validate before touching anything live.**
```sh
docker compose config -q && echo OK
```
If this fails, restore the backup and stop.
**5. Recreate the three long-running services.** This is what applies
the new password — the entrypoint rewrites `default-user.xml` as
`clickhouse` comes up.
```sh ```sh
docker compose up -d clickhouse api ingest docker compose up -d clickhouse api ingest
``` ```
**5. Verify.** All three must pass: `clickhouse-migrate` and the other one-shots will run and exit cleanly
as dependencies; that is expected, and is why step 3 had to update their
copy too.
**6. Verify.** All four must pass:
```sh ```sh
# a) the credential works # a) the OLD password is now rejected -- read it from the backup rather
docker exec cairnobs-clickhouse clickhouse-client \ # than retyping it anywhere
--user default --password '<new-password>' --query "SELECT 1" OLD=$(grep -m1 -E '^ +CLICKHOUSE_PASSWORD: ' docker-compose.override.yml.bak.* \
| sed -E 's/.*: *"?([^"]*)"?$/\1/')
docker exec cairnobs-clickhouse clickhouse-client --user default \
--password "$OLD" --query "SELECT 1" && echo "FAILED: old still works"
# b) the app can still read logs (non-zero, and climbing on a live box) # b) the new password is accepted
docker exec cairnobs-clickhouse clickhouse-client --user default \
--password "$NEW" --query "SELECT 1"
# c) the app can still read logs, and the count climbs on a live box
docker exec cairnobs-clickhouse clickhouse-client \ docker exec cairnobs-clickhouse clickhouse-client \
--query "SELECT count() FROM cairnobs.logs" --query "SELECT count() FROM cairnobs.logs"
# c) no auth failures since the restart # d) no auth failures since the restart
docker compose logs --since 5m api ingest | grep -iE 'auth|403|denied' docker compose logs --since 5m api ingest | grep -icE '403|auth.*fail|denied'
``` ```
If (a) passes but (b) or (c) fail, the override file still holds the old If (b) passes but (c) or (d) fail, the override still holds the old value
value somewhere — recheck every service block in step 3. somewhere — recheck every service block from step 3.
## Rollback ## Rollback
Re-run step 2 with the previous password and restart the three services. Restore the backup from step 1 and recreate the same three services:
There is no schema or data change here, so rollback is symmetric and
carries no data-loss risk. ```sh
cp -p docker-compose.override.yml.bak.<timestamp> docker-compose.override.yml
docker compose config -q && docker compose up -d clickhouse api ingest
```
Nothing else has to be undone. The rotation changes no schema and no
data — `/var/lib/clickhouse` is a volume and is never rewritten by this
procedure — so rollback is symmetric and carries no data-loss risk.
## Verification status
This procedure was executed end-to-end against `proto.cairnobs.org` on
2026-08-23: old password confirmed rejected, new one accepted,
`default-user.xml` mtime matching the new `StartedAt`, zero auth
failures, and the ingest row count still climbing afterwards. No
downtime beyond the recreate of `clickhouse`, `api`, and `ingest`.
An earlier draft of this runbook had it backwards — it opened with
`ALTER USER` and claimed the entrypoint only reads `CLICKHOUSE_PASSWORD`
at volume-init. Both were wrong, and `/opt/sentry`'s own
`docker-compose.override.yml` header had already recorded the correct
mechanism during the 2026-08-19 rotation. Check that file before
trusting anything here.
## Known gap ## Known gap