max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+209 insertions,
-15 deletions
| @@ -64,6 +64,52 @@ | |||
| 64 | 64 | unit), so it is a script-guarded grant rather than a broad | |
| 65 | 65 | `install` + `systemctl` one. | |
| 66 | 66 | ||
| 67 | + | ## The test runner on astra | |
| 68 | + | ||
| 69 | + | astra is the only instance that runs test suites, and it runs them itself rather | |
| 70 | + | than over SSH. `pom.service` runs as the `pom` user with `ProtectHome`, so it can | |
| 71 | + | see neither max's checkouts nor max's toolchain, and astra has no sshd for a hop | |
| 72 | + | to reach (Tailscale SSH does not intercept a node connecting to itself, which is | |
| 73 | + | why the config's old `ssh = "max@<astra>"` failed `Connection refused` and no | |
| 74 | + | suite ever ran). `TestsConfig.ssh` is therefore optional; omitted, the command | |
| 75 | + | runs as a local child. | |
| 76 | + | ||
| 77 | + | Everything the runner needs lives under `/var/lib/pom`, the one path the hardened | |
| 78 | + | unit can write: | |
| 79 | + | ||
| 80 | + | | What | Where | Source | | |
| 81 | + | |------|-------|--------| | |
| 82 | + | | Runner script | `/var/lib/pom/staging/run-ci.sh` | `run-ci.sh` here | | |
| 83 | + | | Unit overrides | `/etc/systemd/system/pom.service.d/10-test-runner.conf` | `pom.service.d-10-test-runner.conf` here | | |
| 84 | + | | Clones | `/var/lib/pom/staging/{MNW,synckit,Apps/*}` | cloned from `/home/max/git-mirrors` | | |
| 85 | + | | Toolchain | `/var/lib/pom/.cargo`, `/var/lib/pom/.rustup` | rustup, as the `pom` user, plus `sqlx-cli` | | |
| 86 | + | ||
| 87 | + | The drop-in is not optional: the base unit's `MemoryMax=256M` would OOM-kill any | |
| 88 | + | cargo build, and `ProtectHome` has to be `read-only` rather than `true` so the | |
| 89 | + | clones can fetch from the mirrors. | |
| 90 | + | ||
| 91 | + | `staging/` mirrors the `~/Code` tree, `Apps/` included. Nothing in git records | |
| 92 | + | that layout, and both Tauri apps carry a symlink that reaches across it | |
| 93 | + | (`src-tauri/frontend/js/shared-updater.js` into `MNW/shared/tauri-updater-ui`). | |
| 94 | + | Flatten the clones and it dangles, and the app's `build.rs` panics on a read. | |
| 95 | + | ||
| 96 | + | Postgres notes, all of which cause failures that look like something else: | |
| 97 | + | `PGUSER=pom` is required because sqlx cannot resolve a username inside the | |
| 98 | + | sandbox and falls back to `whoami`'s `anonymous` placeholder; the harnesses need | |
| 99 | + | `TEST_DATABASE_URL` because they default to a TCP URL and astra's postgres is | |
| 100 | + | socket-only; and the MNW suite migrates its own `pom_ci_makenotwork` rather than | |
| 101 | + | compiling against the committed `.sqlx` cache, which goes stale silently whenever | |
| 102 | + | a migration lands without a `cargo sqlx prepare`. | |
| 103 | + | ||
| 104 | + | To reproduce a red suite by hand: | |
| 105 | + | ||
| 106 | + | ``` | |
| 107 | + | sudo -u pom /var/lib/pom/staging/run-ci.sh mnw | |
| 108 | + | ``` | |
| 109 | + | ||
| 110 | + | A failure that only appears under `systemd-run` with the unit's properties is a | |
| 111 | + | hardening problem, not a test problem. | |
| 112 | + | ||
| 67 | 113 | ## Rollback | |
| 68 | 114 | ||
| 69 | 115 | The installer keeps the previous binary as `<install-path>.prev`. Rolling back |
| @@ -48,6 +48,11 @@ | |||
| 48 | 48 | [targets.mnw.tls] | |
| 49 | 49 | host = "makenot.work" | |
| 50 | 50 | ||
| 51 | + | [targets.mnw.tests] | |
| 52 | + | command = "/var/lib/pom/staging/run-ci.sh mnw" | |
| 53 | + | timeout_secs = 5400 | |
| 54 | + | staleness_days = 7 | |
| 55 | + | ||
| 51 | 56 | [targets.mt] | |
| 52 | 57 | label = "Multithreaded Forum" | |
| 53 | 58 | expected_routes = ["/"] | |
| @@ -64,9 +69,8 @@ | |||
| 64 | 69 | host = "forums.makenot.work" | |
| 65 | 70 | ||
| 66 | 71 | [targets.mt.tests] | |
| 67 | - | ssh = "max@100.106.221.39" | |
| 68 | - | command = "cd /home/max/staging/multithreaded && cargo test --workspace 2>&1" | |
| 69 | - | timeout_secs = 300 | |
| 72 | + | command = "/var/lib/pom/staging/run-ci.sh mt" | |
| 73 | + | timeout_secs = 2700 | |
| 70 | 74 | staleness_days = 7 | |
| 71 | 75 | ||
| 72 | 76 | [targets.mnw-cli] | |
| @@ -112,36 +116,32 @@ | |||
| 112 | 116 | label = "GoingsOn" | |
| 113 | 117 | ||
| 114 | 118 | [targets.go.tests] | |
| 115 | - | ssh = "max@100.106.221.39" | |
| 116 | - | command = "cd /home/max/staging/goingson && cargo test --workspace 2>&1" | |
| 117 | - | timeout_secs = 600 | |
| 119 | + | command = "/var/lib/pom/staging/run-ci.sh go" | |
| 120 | + | timeout_secs = 2700 | |
| 118 | 121 | staleness_days = 7 | |
| 119 | 122 | ||
| 120 | 123 | [targets.bb] | |
| 121 | 124 | label = "Balanced Breakfast" | |
| 122 | 125 | ||
| 123 | 126 | [targets.bb.tests] | |
| 124 | - | ssh = "max@100.106.221.39" | |
| 125 | - | command = "cd /home/max/staging/balanced_breakfast && cargo test --workspace 2>&1" | |
| 126 | - | timeout_secs = 600 | |
| 127 | + | command = "/var/lib/pom/staging/run-ci.sh bb" | |
| 128 | + | timeout_secs = 2700 | |
| 127 | 129 | staleness_days = 7 | |
| 128 | 130 | ||
| 129 | 131 | [targets.af] | |
| 130 | 132 | label = "AudioFiles" | |
| 131 | 133 | ||
| 132 | 134 | [targets.af.tests] | |
| 133 | - | ssh = "max@100.106.221.39" | |
| 134 | - | command = "cd /home/max/staging/audiofiles && cargo test --workspace 2>&1" | |
| 135 | - | timeout_secs = 600 | |
| 135 | + | command = "/var/lib/pom/staging/run-ci.sh af" | |
| 136 | + | timeout_secs = 2700 | |
| 136 | 137 | staleness_days = 7 | |
| 137 | 138 | ||
| 138 | 139 | [targets.sk] | |
| 139 | 140 | label = "SyncKit SDK" | |
| 140 | 141 | ||
| 141 | 142 | [targets.sk.tests] | |
| 142 | - | ssh = "max@100.106.221.39" | |
| 143 | - | command = "cd /home/max/staging/synckit-client && cargo test 2>&1" | |
| 144 | - | timeout_secs = 300 | |
| 143 | + | command = "/var/lib/pom/staging/run-ci.sh sk" | |
| 144 | + | timeout_secs = 1200 | |
| 145 | 145 | staleness_days = 7 | |
| 146 | 146 | ||
| 147 | 147 | [peers.hetzner] |
| @@ -1,0 +1,22 @@ | |||
| 1 | + | # Room for the test suites PoM now schedules. | |
| 2 | + | # | |
| 3 | + | # The base unit caps the whole cgroup at 256M, which is generous for the daemon | |
| 4 | + | # and nowhere near a cargo build. The suites run as child processes in this same | |
| 5 | + | # cgroup, so the cap applies to them: left at 256M every scheduled run would be | |
| 6 | + | # OOM-killed and land in the dashboard as a failing suite. astra has 125G. | |
| 7 | + | # | |
| 8 | + | # 48G rather than 16G because the MNW server workspace blew through 16G and 20G | |
| 9 | + | # of swap before dying. run-ci.sh also caps cargo's job count: 96 cores means 96 | |
| 10 | + | # concurrent rustc processes by default, and the memory ceiling is the wrong | |
| 11 | + | # place to discover that. | |
| 12 | + | # | |
| 13 | + | # ProtectHome drops to read-only rather than staying true because the staging | |
| 14 | + | # clones fetch from the mirrors under /home/max/git-mirrors. Read-only is the | |
| 15 | + | # whole requirement: nothing here writes outside /var/lib/pom. | |
| 16 | + | [Service] | |
| 17 | + | MemoryMax=48G | |
| 18 | + | ProtectHome=read-only | |
| 19 | + | Environment=HOME=/var/lib/pom | |
| 20 | + | Environment=CARGO_HOME=/var/lib/pom/.cargo | |
| 21 | + | Environment=RUSTUP_HOME=/var/lib/pom/.rustup | |
| 22 | + | LimitNOFILE=65536 |
| @@ -1,0 +1,126 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # Test runner for PoM's scheduled suites on astra. | |
| 3 | + | # | |
| 4 | + | # pom.service calls this once a target's last run goes stale. Everything it | |
| 5 | + | # touches lives under /var/lib/pom, the one path the hardened unit can write: | |
| 6 | + | # the clones, the rust toolchain, and the build artifacts. That is deliberate. | |
| 7 | + | # The daemon runs as `pom` with ProtectHome, so max's checkouts and max's | |
| 8 | + | # toolchain are out of reach by design, and this script must not depend on them. | |
| 9 | + | # | |
| 10 | + | # The one exception is the git mirrors it fetches from, which are read-only. | |
| 11 | + | # | |
| 12 | + | # Usage: run-ci.sh <target> [filter] | |
| 13 | + | # target one of: mnw mt go bb af sk | |
| 14 | + | # filter optional cargo test filter, passed through by PoM | |
| 15 | + | # | |
| 16 | + | # Exit status is the suite's, which is what PoM records as pass/fail. | |
| 17 | + | ||
| 18 | + | set -eu | |
| 19 | + | ||
| 20 | + | STAGING="/var/lib/pom/staging" | |
| 21 | + | MIRRORS="/home/max/git-mirrors" | |
| 22 | + | ||
| 23 | + | # The pom user's own toolchain. /usr/bin/cargo is the system rustc, which | |
| 24 | + | # trails what these dependency trees need. | |
| 25 | + | export HOME="/var/lib/pom" | |
| 26 | + | export RUSTUP_HOME="/var/lib/pom/.rustup" | |
| 27 | + | export CARGO_HOME="/var/lib/pom/.cargo" | |
| 28 | + | export PATH="$CARGO_HOME/bin:$PATH" | |
| 29 | + | ||
| 30 | + | # Parallel test databases exhaust the default 1024 descriptors. | |
| 31 | + | ulimit -n 65536 2>/dev/null || true | |
| 32 | + | ||
| 33 | + | # Peer auth on the socket means postgres wants an OS username, and sqlx cannot | |
| 34 | + | # work one out inside the service sandbox: it falls back to the `whoami` crate's | |
| 35 | + | # placeholder and every connection is refused as user "anonymous". libpq tools | |
| 36 | + | # and sqlx both honour PGUSER, so say it once here. | |
| 37 | + | export PGUSER=pom | |
| 38 | + | ||
| 39 | + | # astra has 96 cores, and cargo's default job count is all of them. Ninety-six | |
| 40 | + | # concurrent rustc processes on the MNW server workspace peaked past 16G and 20G | |
| 41 | + | # of swap before the cgroup killed it. This is a memory cap wearing a job-count | |
| 42 | + | # costume; the wall-clock cost is small next to thrashing. | |
| 43 | + | export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-16}" | |
| 44 | + | ||
| 45 | + | # The mirrors are max's, this runs as pom, and git refuses a repo it considers | |
| 46 | + | # foreign. Without this every fetch below fails and the clones silently test | |
| 47 | + | # whatever commit they were created at, forever. Scoped to this process rather | |
| 48 | + | # than written into a global gitconfig. | |
| 49 | + | GIT_CONFIG_COUNT=1 | |
| 50 | + | GIT_CONFIG_KEY_0=safe.directory | |
| 51 | + | GIT_CONFIG_VALUE_0='*' | |
| 52 | + | export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 | |
| 53 | + | ||
| 54 | + | target="${1:-}" | |
| 55 | + | filter="${2:-}" | |
| 56 | + | ||
| 57 | + | # The layout under $STAGING mirrors ~/Code on purpose, Apps/ included. Nothing | |
| 58 | + | # in git records that tree, so a plain clone does not reproduce it, and the | |
| 59 | + | # Tauri apps have a symlink that reaches across it: | |
| 60 | + | # Apps/<app>/src-tauri/frontend/js/shared-updater.js | |
| 61 | + | # -> ../../../../../MNW/shared/tauri-updater-ui/updater.js | |
| 62 | + | # Flatten the tree and that dangles, and the app's build.rs panics on a read. | |
| 63 | + | case "$target" in | |
| 64 | + | mnw) dir="$STAGING/MNW/server" ;; | |
| 65 | + | mt) dir="$STAGING/MNW/multithreaded" ;; | |
| 66 | + | go) dir="$STAGING/Apps/goingson" ;; | |
| 67 | + | bb) dir="$STAGING/Apps/balanced_breakfast" ;; | |
| 68 | + | af) dir="$STAGING/Apps/audiofiles" ;; | |
| 69 | + | sk) dir="$STAGING/synckit/synckit-client" ;; | |
| 70 | + | *) | |
| 71 | + | echo "run-ci.sh: unknown target '${target}' (want: mnw mt go bb af sk)" >&2 | |
| 72 | + | exit 2 | |
| 73 | + | ;; | |
| 74 | + | esac | |
| 75 | + | ||
| 76 | + | if [ ! -d "$dir" ]; then | |
| 77 | + | echo "run-ci.sh: $dir does not exist, clone it from $MIRRORS first" >&2 | |
| 78 | + | exit 2 | |
| 79 | + | fi | |
| 80 | + | ||
| 81 | + | # Track the mirror so a run tests current main rather than whatever was cloned | |
| 82 | + | # months ago. A failed fetch is not fatal: testing a slightly old tree beats | |
| 83 | + | # reporting nothing, and the staleness clock keeps ticking either way. | |
| 84 | + | repo_root=$(cd "$dir" && git rev-parse --show-toplevel) | |
| 85 | + | if ! (cd "$repo_root" && git fetch --quiet origin && git reset --quiet --hard origin/HEAD); then | |
| 86 | + | echo "run-ci.sh: warning, could not update $repo_root, testing the existing checkout" >&2 | |
| 87 | + | fi | |
| 88 | + | ||
| 89 | + | cd "$dir" | |
| 90 | + | echo "run-ci.sh: $target at $(git rev-parse --short HEAD) in $dir" | |
| 91 | + | ||
| 92 | + | # Postgres on astra listens on a unix socket only, so the URLs carry no host. | |
| 93 | + | # Both harnesses default to a TCP URL, which fails to connect here and fails | |
| 94 | + | # every test in the suite within seconds rather than failing loudly once. | |
| 95 | + | # Do not add ?host=... : the harnesses split these on the last '/'. | |
| 96 | + | # Auth is peer on the socket, so the `pom` role needs LOGIN and CREATEDB. | |
| 97 | + | case "$target" in | |
| 98 | + | mnw) | |
| 99 | + | # Its own database, not max's `makenotwork`: the sqlx macros are | |
| 100 | + | # checked at compile time against whatever DATABASE_URL points at, so | |
| 101 | + | # CI needs a schema it owns and can migrate. Compiling against the | |
| 102 | + | # committed .sqlx cache instead is not an option worth taking, since | |
| 103 | + | # the cache is hand-maintained by `cargo sqlx prepare` and goes stale | |
| 104 | + | # silently the moment a migration lands without one. | |
| 105 | + | DATABASE_URL='postgres:///pom_ci_makenotwork' | |
| 106 | + | TEST_DATABASE_URL='postgres:///postgres' | |
| 107 | + | export DATABASE_URL TEST_DATABASE_URL | |
| 108 | + | ||
| 109 | + | psql "$TEST_DATABASE_URL" -tAc \ | |
| 110 | + | "SELECT 1 FROM pg_database WHERE datname = 'pom_ci_makenotwork'" \ | |
| 111 | + | | grep -q 1 || createdb pom_ci_makenotwork | |
| 112 | + | sqlx migrate run --source migrations --database-url "$DATABASE_URL" | |
| 113 | + | ;; | |
| 114 | + | mt) | |
| 115 | + | TEST_DATABASE_URL='postgres:///postgres' | |
| 116 | + | export TEST_DATABASE_URL | |
| 117 | + | ;; | |
| 118 | + | esac | |
| 119 | + | ||
| 120 | + | # --test-threads=4 rather than the default: at full parallelism the suites | |
| 121 | + | # intermittently exhaust postgres connection slots on this box. | |
| 122 | + | if [ -n "$filter" ]; then | |
| 123 | + | exec cargo test --workspace "$filter" -- --test-threads=4 | |
| 124 | + | else | |
| 125 | + | exec cargo test --workspace -- --test-threads=4 | |
| 126 | + | fi |