max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+332 insertions,
-101 deletions
| @@ -16,6 +16,7 @@ | |||
| 16 | 16 | #[derive(Parser)] | |
| 17 | 17 | #[command( | |
| 18 | 18 | name = "pom", | |
| 19 | + | version, | |
| 19 | 20 | about = "Peace of Mind: health checks and test orchestration" | |
| 20 | 21 | )] | |
| 21 | 22 | struct Cli { |
| @@ -1,0 +1,48 @@ | |||
| 1 | + | # How Bento releases pom. Lives here rather than in the daemon's config so it is | |
| 2 | + | # versioned with the code it describes. | |
| 3 | + | # | |
| 4 | + | # A service, not an app or a library: pom is run rather than distributed. There | |
| 5 | + | # is no bundle for a user to download and no crate to publish, so the release | |
| 6 | + | # ends at `deploy` -- the binary lands on the hosts that run it and their units | |
| 7 | + | # restart. | |
| 8 | + | kind = "service" | |
| 9 | + | ||
| 10 | + | # Two arches, two machines, built natively on each. Sando cannot do this job: | |
| 11 | + | # sando-daemon.toml sets build_host = "fw13" and build::run refuses to compile | |
| 12 | + | # anywhere else, which is the never-build-on-prod invariant and also makes Sando | |
| 13 | + | # single-architecture. pom needs an aarch64 binary for astra, so Sando could | |
| 14 | + | # never build half of this release without breaking its own rule. | |
| 15 | + | targets = ["linux/x86_64", "linux/aarch64"] | |
| 16 | + | ||
| 17 | + | # Workspace root carries the version. | |
| 18 | + | version_path = "Cargo.toml" | |
| 19 | + | ||
| 20 | + | # Neither instance ships without the other. pom watches production, and an | |
| 21 | + | # aarch64 build that quietly failed while x86_64 shipped leaves half the mesh on | |
| 22 | + | # an old binary with nothing saying so. | |
| 23 | + | require_all_targets = true | |
| 24 | + | ||
| 25 | + | # Where each target lands. The recipe never names a machine -- it calls | |
| 26 | + | # deploy(), and the target it is already building for decides where that goes. | |
| 27 | + | # | |
| 28 | + | # install_path is /usr/local/bin/pom, which is what pom.service actually | |
| 29 | + | # ExecStarts and what both live hosts have today. Not /opt/pom/pom: that was | |
| 30 | + | # copied from mnw-cli's companion shape and matches nothing on either box. | |
| 31 | + | ||
| 32 | + | [[deploy]] | |
| 33 | + | target = "linux/x86_64" | |
| 34 | + | # Reached over Tailscale SSH on 22 as root. NOT port 2200 -- that is what the | |
| 35 | + | # old deploy.sh used and it refuses this machine's key for both root and max, | |
| 36 | + | # which is why the 0.4.0 deploy was done by hand. | |
| 37 | + | host = "root@100.120.174.96" | |
| 38 | + | install_path = "/usr/local/bin/pom" | |
| 39 | + | service = "pom.service" | |
| 40 | + | health_url = "http://100.120.174.96:9100/api/health" | |
| 41 | + | ||
| 42 | + | [[deploy]] | |
| 43 | + | target = "linux/aarch64" | |
| 44 | + | # astra builds this one and astra runs it, so the binary never leaves the box. | |
| 45 | + | host = "astra" | |
| 46 | + | install_path = "/usr/local/bin/pom" | |
| 47 | + | service = "pom.service" | |
| 48 | + | health_url = "http://100.106.221.39:9100/api/health" |
| @@ -1,0 +1,75 @@ | |||
| 1 | + | # Deploying pom | |
| 2 | + | ||
| 3 | + | pom ships through **Bento**, not Sando, and not by hand. | |
| 4 | + | ||
| 5 | + | Sando cannot do this job. `sando-daemon.toml` sets `build_host = "fw13"` and | |
| 6 | + | `build::run` refuses to compile anywhere else, which is the never-build-on-prod | |
| 7 | + | invariant and also makes Sando single-architecture. pom runs on astra (aarch64) | |
| 8 | + | and on the Hetzner box (x86_64), so Sando could never build half of this release | |
| 9 | + | without breaking its own rule. Bento already fans out native builds across both | |
| 10 | + | hosts, so pom is a Bento recipe. | |
| 11 | + | ||
| 12 | + | `deploy.sh` is gone. It cross-compiled with `cargo zigbuild` (against the | |
| 13 | + | native-per-architecture rule, with a cargo-zigbuild that is not installed on | |
| 14 | + | fw13), reached Hetzner on port 2200 which refuses this machine's key, and copied | |
| 15 | + | config and the unit file over the live ones with no diff and no backup. It did | |
| 16 | + | not work as written when 0.4.0 went out; that deploy was done by hand. | |
| 17 | + | ||
| 18 | + | ## Running a deploy | |
| 19 | + | ||
| 20 | + | Bump the version in `Cargo.toml`, commit, tag `v<version>`, push, then trigger | |
| 21 | + | the build through Bento for both targets. The `/deploy` skill has the procedure. | |
| 22 | + | ||
| 23 | + | What the recipe does, per target (`dist/recipes/linux.rhai`): | |
| 24 | + | ||
| 25 | + | 1. Pin the checkout to the release tag, and check both hosts report one commit. | |
| 26 | + | 2. `cargo clippy -D warnings` and `cargo test`, on that target's own build host. | |
| 27 | + | 3. `cargo build --release`. | |
| 28 | + | 4. Compare the binary's highest `GLIBC_` symbol against the service host's | |
| 29 | + | `ldd --version`, and assert `pom --version` matches the tag. | |
| 30 | + | 5. Stage the binary on the service host and call the privileged installer. | |
| 31 | + | 6. Poll `/api/health` until the restarted instance answers, then assert the | |
| 32 | + | running binary reports the version that was just installed. | |
| 33 | + | ||
| 34 | + | The two instances go one at a time, and `require_all_targets` keeps the release | |
| 35 | + | from counting as done until both are green. pom watches its own deploy, so the | |
| 36 | + | restart takes one watcher down for a moment; the other stays up and keeps | |
| 37 | + | watching. Never both at once. | |
| 38 | + | ||
| 39 | + | ## What a deploy does not touch | |
| 40 | + | ||
| 41 | + | **Config.** `pom-astra.toml` and `pom-hetzner.toml` differ per instance, and the | |
| 42 | + | live config on prod once carried a `[targets.mnw.tests]` block this repo did not | |
| 43 | + | have. `deploy.sh` would have silently deleted it. Config is a separate, | |
| 44 | + | deliberate act; the installer refuses to go near it. | |
| 45 | + | ||
| 46 | + | **The unit file.** Same reasoning. A hardened unit that has drifted from the | |
| 47 | + | repo is a question for a human, not something a binary deploy overwrites. | |
| 48 | + | ||
| 49 | + | ## One-time host setup | |
| 50 | + | ||
| 51 | + | Each host that runs pom needs the installer and its scoped sudo grant: | |
| 52 | + | ||
| 53 | + | ``` | |
| 54 | + | sudo install -d /usr/local/lib/bento | |
| 55 | + | sudo install -m 0755 install-service.sh /usr/local/lib/bento/install-service.sh | |
| 56 | + | sudo install -m 0440 bento-deploy.sudoers /etc/sudoers.d/bento-deploy | |
| 57 | + | sudo visudo -cf /etc/sudoers.d/bento-deploy | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | Edit the sudoers file first so the user matches how Bento reaches that host: | |
| 61 | + | `max` on astra, `root` over Tailscale SSH on the Hetzner box. The grant covers | |
| 62 | + | one script and nothing else; the script bounds its own arguments (source under | |
| 63 | + | `/var/tmp/bento-deploy`, destination under `/usr/local/bin`, a bare `*.service` | |
| 64 | + | unit), so it is a script-guarded grant rather than a broad | |
| 65 | + | `install` + `systemctl` one. | |
| 66 | + | ||
| 67 | + | ## Rollback | |
| 68 | + | ||
| 69 | + | The installer keeps the previous binary as `<install-path>.prev`. Rolling back | |
| 70 | + | is putting it back and restarting: | |
| 71 | + | ||
| 72 | + | ``` | |
| 73 | + | sudo install -m 0755 /usr/local/bin/pom.prev /usr/local/bin/pom | |
| 74 | + | sudo systemctl restart pom.service | |
| 75 | + | ``` |
| @@ -1,0 +1,21 @@ | |||
| 1 | + | # Bento service-deploy grant. Install on each host that runs a Bento-deployed | |
| 2 | + | # service (currently astra and alpha-west-1, both running pom): | |
| 3 | + | # | |
| 4 | + | # sudo install -m 0440 bento-deploy.sudoers /etc/sudoers.d/bento-deploy | |
| 5 | + | # sudo visudo -cf /etc/sudoers.d/bento-deploy # validate before trusting it | |
| 6 | + | # | |
| 7 | + | # The deploy user -- the same user Bento's executor SSHes as -- may run ONLY the | |
| 8 | + | # service installer, with any arguments. The arguments are bounded by the script | |
| 9 | + | # itself (src under /var/tmp/bento-deploy, dst under /usr/local/bin, service a | |
| 10 | + | # bare *.service unit), so this is a script-guarded grant rather than a broad | |
| 11 | + | # install + systemctl one. Sando's mnw-companion.sudoers is the same line for | |
| 12 | + | # the same reason. | |
| 13 | + | # | |
| 14 | + | # Uncomment the line matching how Bento reaches this host. Both are listed | |
| 15 | + | # because pom's two instances are reached as different users: astra over the | |
| 16 | + | # tailnet as max, and the Hetzner box over Tailscale SSH as root. A root deploy | |
| 17 | + | # user does not need the grant at all -- the line is harmless there and keeps | |
| 18 | + | # the two hosts configured identically. | |
| 19 | + | ||
| 20 | + | max ALL=(root) NOPASSWD: /usr/local/lib/bento/install-service.sh * | |
| 21 | + | # root ALL=(root) NOPASSWD: /usr/local/lib/bento/install-service.sh * |
| @@ -1,101 +1,0 @@ | |||
| 1 | - | #!/usr/bin/env bash | |
| 2 | - | # | |
| 3 | - | # STALE. Did not work as written when pom 0.4.0 went out on 2026-07-29, and the | |
| 4 | - | # 0.4.0 deploy was done by hand instead. Read this before trusting it. | |
| 5 | - | # | |
| 6 | - | # Three problems, in order of how much they matter: | |
| 7 | - | # | |
| 8 | - | # 1. It cross-compiles, which the ecosystem rule forbids: builds are native per | |
| 9 | - | # architecture. `cargo zigbuild --target aarch64-...` from fw13 is a cross | |
| 10 | - | # build for astra, and cargo-zigbuild is not installed here anyway (only zig | |
| 11 | - | # itself). What 0.4.0 actually did: build on astra for astra, build on fw13 | |
| 12 | - | # for hetzner. Both ends are now x86_64 glibc 2.39 for the hetzner leg, so | |
| 13 | - | # that one needs nothing but `cargo build --release`; confirm with | |
| 14 | - | # `objdump -T target/release/pom | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1` | |
| 15 | - | # against the target's `ldd --version` before shipping. | |
| 16 | - | # 2. It reaches hetzner on port 2200, which refused this machine's key for both | |
| 17 | - | # root and max. The route that worked is Tailscale SSH on 22 as root. | |
| 18 | - | # 3. It copies pom-<host>.toml over /etc/pom/pom.toml and pom.service over the | |
| 19 | - | # unit, with no diff and no backup. That is how a live-only config block gets | |
| 20 | - | # deleted: prod's config carried a [targets.mnw.tests] block this repo did | |
| 21 | - | # not have, so running this would have silently dropped prod's remote CI | |
| 22 | - | # check. Fixed in MNW@f92eb501, but the hazard is structural, not that one | |
| 23 | - | # block. Diff live against the file before installing either. | |
| 24 | - | # | |
| 25 | - | # What 0.4.0 did instead, per host: back up the binary, /etc/pom/pom.toml, and | |
| 26 | - | # pom.db + WAL; `systemctl stop pom`; install the binary; start; then confirm | |
| 27 | - | # migrations in the journal and that /api/health, /status.json and /api/versions | |
| 28 | - | # all answer 200. | |
| 29 | - | # | |
| 30 | - | set -euo pipefail | |
| 31 | - | ||
| 32 | - | ASTRA_HOST="max@100.106.221.39" | |
| 33 | - | HETZNER_HOST="root@100.120.174.96" | |
| 34 | - | ||
| 35 | - | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| 36 | - | PROJECT_DIR="$(dirname "$SCRIPT_DIR")" | |
| 37 | - | ||
| 38 | - | deploy_target() { | |
| 39 | - | local name="$1" | |
| 40 | - | local host target sudo_prefix="" ssh_opts="" scp_opts="" | |
| 41 | - | ||
| 42 | - | case "$name" in | |
| 43 | - | astra) | |
| 44 | - | host="$ASTRA_HOST" | |
| 45 | - | target="aarch64-unknown-linux-gnu" | |
| 46 | - | sudo_prefix="sudo" | |
| 47 | - | ;; | |
| 48 | - | hetzner) | |
| 49 | - | host="$HETZNER_HOST" | |
| 50 | - | target="x86_64-unknown-linux-gnu" | |
| 51 | - | ssh_opts="-p 2200" | |
| 52 | - | scp_opts="-P 2200" | |
| 53 | - | ;; | |
| 54 | - | *) | |
| 55 | - | echo "Unknown target: $name (use astra, hetzner, or all)" | |
| 56 | - | exit 1 | |
| 57 | - | ;; | |
| 58 | - | esac | |
| 59 | - | ||
| 60 | - | echo "=== Building pom for $name ($target) ===" | |
| 61 | - | cargo zigbuild --release --target "$target" --manifest-path "$PROJECT_DIR/Cargo.toml" | |
| 62 | - | ||
| 63 | - | local binary="$PROJECT_DIR/target/$target/release/pom" | |
| 64 | - | ||
| 65 | - | local config_file="$SCRIPT_DIR/pom-${name}.toml" | |
| 66 | - | if [ ! -f "$config_file" ]; then | |
| 67 | - | echo "Config not found: $config_file" | |
| 68 | - | exit 1 | |
| 69 | - | fi | |
| 70 | - | ||
| 71 | - | echo "=== Deploying to $name ($host) ===" | |
| 72 | - | ssh $ssh_opts "$host" "$sudo_prefix mkdir -p /etc/pom" | |
| 73 | - | scp $scp_opts "$binary" "$host:/tmp/pom" | |
| 74 | - | scp $scp_opts "$config_file" "$host:/tmp/pom.toml" | |
| 75 | - | scp $scp_opts "$SCRIPT_DIR/pom.service" "$host:/tmp/pom.service" | |
| 76 | - | ||
| 77 | - | ssh $ssh_opts "$host" "$sudo_prefix mv /tmp/pom /usr/local/bin/pom && $sudo_prefix chmod +x /usr/local/bin/pom && $sudo_prefix mv /tmp/pom.toml /etc/pom/pom.toml && $sudo_prefix mv /tmp/pom.service /etc/systemd/system/pom.service" | |
| 78 | - | ||
| 79 | - | # Create env file with Postmark token if it doesn't exist | |
| 80 | - | ssh $ssh_opts "$host" "if [ ! -f /etc/pom/env ]; then echo 'POM_POSTMARK_TOKEN=SET_ME' | $sudo_prefix tee /etc/pom/env > /dev/null && $sudo_prefix chmod 600 /etc/pom/env; fi" | |
| 81 | - | ||
| 82 | - | ssh $ssh_opts "$host" "$sudo_prefix systemctl daemon-reload && $sudo_prefix systemctl enable pom && $sudo_prefix systemctl restart pom" | |
| 83 | - | ||
| 84 | - | echo "=== $name: deployed ===" | |
| 85 | - | ssh $ssh_opts "$host" "$sudo_prefix systemctl status pom --no-pager" | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | if [ $# -eq 0 ]; then | |
| 89 | - | echo "Usage: $0 <astra|hetzner|all>" | |
| 90 | - | exit 1 | |
| 91 | - | fi | |
| 92 | - | ||
| 93 | - | case "$1" in | |
| 94 | - | all) | |
| 95 | - | deploy_target astra | |
| 96 | - | deploy_target hetzner | |
| 97 | - | ;; | |
| 98 | - | *) | |
| 99 | - | deploy_target "$1" | |
| 100 | - | ;; | |
| 101 | - | esac |
| @@ -1,0 +1,105 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Install a staged service binary and restart its unit. Runs as ROOT, invoked by | |
| 3 | + | # Bento's deploy step over the host's executor as: | |
| 4 | + | # | |
| 5 | + | # sudo /usr/local/lib/bento/install-service.sh <src> <dst> <service> | |
| 6 | + | # | |
| 7 | + | # where <src> is the binary Bento staged under /var/tmp/bento-deploy/<app>/, | |
| 8 | + | # <dst> is the unit's ExecStart path (/usr/local/bin/pom), and <service> is the | |
| 9 | + | # systemd unit to restart (pom.service). | |
| 10 | + | # | |
| 11 | + | # This wrapper exists so the deploy user's sudo grant is ONE auditable script | |
| 12 | + | # rather than a broad install/systemctl grant on a box running production. Same | |
| 13 | + | # shape, and the same reasoning, as sando/deploy/install-companion.sh. | |
| 14 | + | # | |
| 15 | + | # Install (one-time per host, as root): | |
| 16 | + | # sudo install -d /usr/local/lib/bento | |
| 17 | + | # sudo install -m 0755 install-service.sh /usr/local/lib/bento/install-service.sh | |
| 18 | + | # # then add the scoped sudoers line -- see bento-deploy.sudoers | |
| 19 | + | # | |
| 20 | + | # What this deliberately does NOT touch: | |
| 21 | + | # | |
| 22 | + | # Config. pom-astra.toml and pom-hetzner.toml differ per instance, and the | |
| 23 | + | # live config on prod once carried a [targets.mnw.tests] block the repo did | |
| 24 | + | # not have. The old deploy.sh copied config over the top with no diff and no | |
| 25 | + | # backup, which is how that block would have been silently deleted. A deploy | |
| 26 | + | # ships the binary; config is a separate, deliberate act. | |
| 27 | + | # | |
| 28 | + | # The unit file. Same reasoning: a hardened unit that drifted from the repo is | |
| 29 | + | # a question for a human, not something a binary deploy overwrites. | |
| 30 | + | # | |
| 31 | + | # The install is atomic (install(1) writes a temp then renames), so a running | |
| 32 | + | # service never execs a half-written file. The previous binary is kept next to | |
| 33 | + | # the new one as <dst>.prev, which is the whole rollback: put it back and | |
| 34 | + | # restart. The unit is restarted only after a successful install, so a failed | |
| 35 | + | # install leaves the service on the binary it already had. | |
| 36 | + | set -euo pipefail | |
| 37 | + | ||
| 38 | + | if [[ $# -ne 3 ]]; then | |
| 39 | + | echo "usage: install-service.sh <src-binary> <dst-path> <service>" >&2 | |
| 40 | + | exit 2 | |
| 41 | + | fi | |
| 42 | + | ||
| 43 | + | STAGING_ROOT=/var/tmp/bento-deploy | |
| 44 | + | ||
| 45 | + | SRC="$1" | |
| 46 | + | DST="$2" | |
| 47 | + | SERVICE="$3" | |
| 48 | + | ||
| 49 | + | # Guard rails. These bound what a caller -- already behind the single sudoers | |
| 50 | + | # grant -- can install and restart. Being the ONLY control on a NOPASSWD grant, | |
| 51 | + | # they have to actually hold, so normalise BEFORE testing any prefix: a | |
| 52 | + | # glob-only test against the raw argument is something `..` walks straight out | |
| 53 | + | # of. `/var/tmp/bento-deploy/../../../etc/shadow` matches a naive src pattern, | |
| 54 | + | # and `/usr/local/bin/../../../etc/systemd/system/x` a naive dst one -- i.e. | |
| 55 | + | # `install -m 0755` as root to anywhere on the filesystem. `realpath -m` | |
| 56 | + | # resolves `..` and symlinks without requiring the path to exist (the dst does | |
| 57 | + | # not on a first install). The roots are resolved too, so the comparison still | |
| 58 | + | # holds if either is a symlink. | |
| 59 | + | SRC="$(realpath -m -- "$SRC")" | |
| 60 | + | DST="$(realpath -m -- "$DST")" | |
| 61 | + | STAGING_ROOT="$(realpath -m -- "$STAGING_ROOT")" | |
| 62 | + | BIN_ROOT="$(realpath -m -- /usr/local/bin)" | |
| 63 | + | ||
| 64 | + | case "$SRC" in | |
| 65 | + | "$STAGING_ROOT"/*) : ;; | |
| 66 | + | *) echo "install-service: refusing src outside $STAGING_ROOT: $SRC" >&2; exit 3 ;; | |
| 67 | + | esac | |
| 68 | + | case "$DST" in | |
| 69 | + | "$BIN_ROOT"/*) : ;; | |
| 70 | + | *) echo "install-service: refusing dst outside $BIN_ROOT: $DST" >&2; exit 3 ;; | |
| 71 | + | esac | |
| 72 | + | case "$SERVICE" in | |
| 73 | + | *[/[:space:]]*|"") echo "install-service: bad service name: $SERVICE" >&2; exit 3 ;; | |
| 74 | + | *.service) : ;; | |
| 75 | + | *) echo "install-service: service must end in .service: $SERVICE" >&2; exit 3 ;; | |
| 76 | + | esac | |
| 77 | + | ||
| 78 | + | if [[ ! -f "$SRC" ]]; then | |
| 79 | + | echo "install-service: no binary at $SRC" >&2 | |
| 80 | + | exit 4 | |
| 81 | + | fi | |
| 82 | + | ||
| 83 | + | # Refuse a binary that cannot run here. Bento checks this too, before it stages | |
| 84 | + | # anything, but that check reads the build host's objdump output; this one is | |
| 85 | + | # the target actually trying to load it. A unit restarted onto a binary it | |
| 86 | + | # cannot exec is down until someone notices. | |
| 87 | + | if ! "$SRC" --version >/dev/null 2>&1; then | |
| 88 | + | echo "install-service: staged binary at $SRC will not run on this host" >&2 | |
| 89 | + | "$SRC" --version || true | |
| 90 | + | exit 5 | |
| 91 | + | fi | |
| 92 | + | ||
| 93 | + | if [[ -f "$DST" ]]; then | |
| 94 | + | echo "install-service: keeping the current binary as $DST.prev" | |
| 95 | + | cp -p -- "$DST" "$DST.prev" | |
| 96 | + | fi | |
| 97 | + | ||
| 98 | + | # Installs the normalised paths, not the raw arguments, so there is no gap | |
| 99 | + | # between what was checked and what is written. | |
| 100 | + | echo "install-service: installing $SRC -> $DST" | |
| 101 | + | install -m 0755 -- "$SRC" "$DST" | |
| 102 | + | ||
| 103 | + | echo "install-service: restarting $SERVICE" | |
| 104 | + | systemctl restart "$SERVICE" | |
| 105 | + | echo "install-service: done ($SERVICE live on $(basename -- "$DST"))" |
| @@ -1,0 +1,82 @@ | |||
| 1 | + | // pom: Linux release recipe for Bento (x86_64 on fw13, aarch64 on astra). | |
| 2 | + | // | |
| 3 | + | // One recipe serves both arches. build_host() resolves to whichever native host | |
| 4 | + | // the topology assigns the target and deploy() resolves to whichever machine | |
| 5 | + | // bento.toml says that target runs on, so there is no cross-compilation and no | |
| 6 | + | // hard-coded host name anywhere in here. That matters more than usual: the two | |
| 7 | + | // legs of this release are different architectures on different machines, and a | |
| 8 | + | // recipe that named hosts could install the aarch64 binary on the x86_64 box. | |
| 9 | + | // | |
| 10 | + | // Replaces deploy/deploy.sh, which cross-compiled with cargo zigbuild (against | |
| 11 | + | // the native-per-architecture rule, and with a cargo-zigbuild that is not | |
| 12 | + | // installed on fw13 anyway), reached Hetzner on a port that refuses this | |
| 13 | + | // machine's key, and copied config and the unit file over the live ones with no | |
| 14 | + | // diff and no backup. It did not work as written when 0.4.0 went out; that | |
| 15 | + | // deploy was done by hand. | |
| 16 | + | ||
| 17 | + | let h = build_host(); | |
| 18 | + | let v = version(); | |
| 19 | + | let r = repo(); | |
| 20 | + | ||
| 21 | + | step("checkout"); | |
| 22 | + | // Pin to the release tag v<version>, not whatever main is at pull time. The | |
| 23 | + | // daemon runs the same pin as a cross-host barrier before either target builds, | |
| 24 | + | // so both arches come from one commit rather than from two machines' idea of | |
| 25 | + | // main. | |
| 26 | + | let sha = checkout_sha(h); | |
| 27 | + | log("pinned " + h + " to v" + v + " @ " + sha); | |
| 28 | + | ||
| 29 | + | // Gate: nothing is installed on a host that watches production from code that | |
| 30 | + | // fails clippy or its tests. It runs on this target's own build host, so a | |
| 31 | + | // break confined to one architecture is caught where it would have shipped. | |
| 32 | + | step("prebuild"); | |
| 33 | + | sh_ok(h, "cd " + r + " && cargo clippy --workspace --all-targets " + feature_flags() + " -- -D warnings"); | |
| 34 | + | sh_ok(h, "cd " + r + " && cargo test --workspace " + feature_flags()); | |
| 35 | + | ||
| 36 | + | step("build"); | |
| 37 | + | sh_ok(h, "cd " + r + " && cargo build --release " + feature_flags()); | |
| 38 | + | let binary = resolve_artifact(h, r + "/target/release/pom"); | |
| 39 | + | ||
| 40 | + | step("verify"); | |
| 41 | + | // Native-per-arch removed the cross-compile hazard the old script was written | |
| 42 | + | // against, but not this one: fw13 tracks a newer glibc than the Ubuntu 24.04 | |
| 43 | + | // box in Hetzner, so a binary built here can reference a symbol version that | |
| 44 | + | // box does not have and fail at exec -- after the unit has already restarted | |
| 45 | + | // onto it. This compares the two and fails the step if the build host got | |
| 46 | + | // ahead. The installer checks the same thing again on the far side, by actually | |
| 47 | + | // running the binary. | |
| 48 | + | log(glibc_check(binary)); | |
| 49 | + | // The version that is about to ship is the version in the tag. A binary | |
| 50 | + | // reporting something else means the checkout and the release disagree. | |
| 51 | + | sh_ok(h, binary + " --version | grep -qw " + v); | |
| 52 | + | ||
| 53 | + | step("deploy"); | |
| 54 | + | // Ships the binary and restarts the unit, through the root installer the host | |
| 55 | + | // holds a scoped sudo grant for. Config is deliberately untouched: | |
| 56 | + | // pom-astra.toml and pom-hetzner.toml differ per instance, and prod's carried a | |
| 57 | + | // [targets.mnw.tests] block this repo did not have. A deploy that copies config | |
| 58 | + | // over the top is how that block gets silently deleted. | |
| 59 | + | log(deploy(binary)); | |
| 60 | + | ||
| 61 | + | // pom watches its own deploy, so the restart above takes the watcher down with | |
| 62 | + | // it for a moment. The two instances are deployed one at a time (Bento runs one | |
| 63 | + | // target per host at a time, and require_all_targets keeps the release from | |
| 64 | + | // being called done until both are green), so the other one stays up and keeps | |
| 65 | + | // watching while this one comes back. | |
| 66 | + | // | |
| 67 | + | // Assert the restarted instance is actually serving rather than trusting | |
| 68 | + | // systemctl's opinion that the unit started: pom answering /api/health is what | |
| 69 | + | // "pom is up" means. Retries because a restart is not instant, and a single | |
| 70 | + | // immediate probe would just be a race. | |
| 71 | + | sh_ok(deploy_host(), | |
| 72 | + | "for i in $(seq 1 30); do " + | |
| 73 | + | "curl -fsS -o /dev/null --max-time 5 " + health_url() + " && exit 0; " + | |
| 74 | + | "sleep 2; done; " + | |
| 75 | + | "echo 'pom did not answer " + health_url() + " within 60s after restart'; " + | |
| 76 | + | "systemctl status " + service_name() + " --no-pager || true; exit 1"); | |
| 77 | + | ||
| 78 | + | // And that what is serving is what was just installed, rather than an old | |
| 79 | + | // process that survived the restart. | |
| 80 | + | sh_ok(deploy_host(), install_path() + " --version | grep -qw " + v); | |
| 81 | + | ||
| 82 | + | log("pom " + v + " live on " + deploy_host() + " (" + target() + ")"); |