Skip to main content

max / makenotwork

6.6 KB · 122 lines History Blame Raw
1 #!/usr/bin/env bash
2 # Rebuild and restart sandod itself to a target commit.
3 #
4 # Runs as ROOT, invoked by the oneshot unit `sando-update@<sha>.service` (the
5 # sha is the instance name, passed here as $1). sandod cannot do this itself:
6 # it runs User=sando with NoNewPrivileges + ProtectSystem=strict, so it can
7 # neither write /usr/local/bin/sandod nor restart its own service. sandod only
8 # *triggers* this unit (authorized for the sando user by a scoped polkit rule);
9 # the actual privileged work lives here.
10 #
11 # Build runs as the unprivileged build user (the sando user already carries a
12 # rustup toolchain at /srv/sando/.cargo/bin); only the install + restart run as
13 # root. The build uses a dedicated checkout, never the operator's dev tree.
14 #
15 # Config via environment (defaults shown), set in the unit or /etc/sando/sando.env:
16 # SANDO_SELF_UPDATE_DIR /srv/sando/self-update build checkout parent (build-user-owned)
17 # SANDO_UPSTREAM_URL /srv/sando/mnw.git source repo (see "Source" below)
18 # SANDO_BUILD_USER sando
19 # SANDO_BIN /usr/local/bin/sandod install destination
20 # SANDO_DEPLOY_BRANCH main the only branch a self-update sha may live on
21 # SANDO_DAEMON_CONFIG /etc/sando/sando-daemon.toml config the --check-config self-test loads
22 #
23 # Source: build from the LOCAL bare repo sandod already maintains
24 # (/srv/sando/mnw.git), not a remote fetch. The self-update sha is one that was
25 # already deployed as a server release, so `/rebuild` has force-updated
26 # refs/heads/main in the bare repo to include it — the sha and a current, trusted
27 # main are both present locally. Building from a remote (git@ssh.makenot.work)
28 # gave the `sando` user no git creds and broke the moment git hosting was down —
29 # it blocked the self-update during the 2026-07-09 deploy (postmortem #7). The
30 # local bare repo has no such external dependency.
31 #
32 # Provenance: sandod (bearer-gated) only *triggers* this unit with a hex sha; it
33 # does not prove the sha is a commit anyone intended to deploy. Without a check,
34 # the deploy token would be root code-exec on this host. So after fetch we REFUSE
35 # any sha that is not an ancestor of origin/$SANDO_DEPLOY_BRANCH: a feature-branch
36 # tip, an unknown sha, or a commit not on the deploy branch never reaches the
37 # build/install lines (exit 4). Only sandod writes the bare repo (fetching main
38 # from the authenticated upstream), so its main is a trustworthy provenance seal.
39 # A signed-tag check is the planned follow-up once release signing exists.
40 #
41 # Safety net: a clean build (no stale incremental cache) plus a --check-config
42 # self-test of the freshly built binary against the LIVE config gate the install.
43 # A stale incremental object once produced a sandod that could not parse its own
44 # node_health config and crash-looped (postmortem #6); either guard alone stops
45 # that binary from ever being installed.
46 set -euo pipefail
47
48 SHA="${1:-}"
49 if [[ ! "$SHA" =~ ^[0-9a-f]{7,40}$ ]]; then
50 echo "sando-self-update: refusing non-hex sha: '$SHA'" >&2
51 exit 2
52 fi
53
54 SELF_DIR="${SANDO_SELF_UPDATE_DIR:-/srv/sando/self-update}"
55 UPSTREAM_URL="${SANDO_UPSTREAM_URL:-/srv/sando/mnw.git}"
56 BUILD_USER="${SANDO_BUILD_USER:-sando}"
57 BIN="${SANDO_BIN:-/usr/local/bin/sandod}"
58 DEPLOY_BRANCH="${SANDO_DEPLOY_BRANCH:-main}"
59 DAEMON_CONFIG="${SANDO_DAEMON_CONFIG:-/etc/sando/sando-daemon.toml}"
60 REPO_DIR="$SELF_DIR/MNW"
61 BUILD_HOME="$(getent passwd "$BUILD_USER" | cut -d: -f6)"
62
63 echo "sando-self-update: building sandod @ $SHA as $BUILD_USER (provenance: origin/$DEPLOY_BRANCH)"
64
65 # Fetch + provenance check + checkout + build, all as the unprivileged build
66 # user. The clone is created once; thereafter we just fetch the new sha. Detached
67 # checkout so the dedicated tree never carries a branch to drift.
68 install -d -o "$BUILD_USER" -g "$BUILD_USER" "$SELF_DIR"
69 runuser -u "$BUILD_USER" -- env \
70 HOME="$BUILD_HOME" \
71 PATH="$BUILD_HOME/.cargo/bin:/usr/local/bin:/usr/bin:/bin" \
72 bash -euo pipefail -c "
73 if [[ ! -d '$REPO_DIR/.git' ]]; then
74 git clone '$UPSTREAM_URL' '$REPO_DIR'
75 fi
76 cd '$REPO_DIR'
77 # Pin origin to the configured source every run, so switching
78 # SANDO_UPSTREAM_URL (e.g. remote -> local bare repo) takes effect on an
79 # already-cloned checkout instead of silently keeping the old remote.
80 git remote set-url origin '$UPSTREAM_URL'
81 git fetch --prune origin
82 # Provenance seal: the sha must be reachable from the deploy branch in the
83 # source repo. --is-ancestor exits 1 for a non-ancestor and >1 for a
84 # bad/unresolvable ref, so any non-deploy-branch sha is refused fail-closed
85 # before a single line is built or installed.
86 if ! git merge-base --is-ancestor '$SHA' 'origin/$DEPLOY_BRANCH'; then
87 echo \"sando-self-update: refusing sha '$SHA' — not an ancestor of origin/$DEPLOY_BRANCH\" >&2
88 exit 4
89 fi
90 git checkout --detach '$SHA'
91 cd sando
92 # Clean build: wipe the workspace target so no stale incremental object
93 # survives across shas. A reused pre-node_health Gate enum object once
94 # produced a sandod that crash-looped on the current config (postmortem
95 # #6). Self-updates are rare, so a full recompile is a cheap insurance.
96 cargo clean
97 cargo build --release --locked -p sando-daemon
98 "
99
100 # Workspace shares one target dir at sando/target; -p sando-daemon avoids
101 # compiling the TUI on the build host.
102 NEW_BIN="$REPO_DIR/sando/target/release/sandod"
103 [[ -x "$NEW_BIN" ]] || { echo "sando-self-update: build produced no binary at $NEW_BIN" >&2; exit 3; }
104
105 # Self-test the fresh binary against the LIVE config BEFORE the swap: prove it can
106 # load + parse the exact daemon config + topology sandod will boot against. Run as
107 # the build user (not root) so the readability check matches the running daemon's
108 # identity. A binary that can't parse the current config (the postmortem #6
109 # brick) fails here and is never installed — sandod keeps running on the old one.
110 echo "sando-self-update: self-testing $NEW_BIN against $DAEMON_CONFIG"
111 if ! runuser -u "$BUILD_USER" -- env SANDO_CONFIG="$DAEMON_CONFIG" "$NEW_BIN" --check-config; then
112 echo "sando-self-update: new binary FAILED --check-config against $DAEMON_CONFIG; refusing to install (sandod left running on the current binary)" >&2
113 exit 5
114 fi
115
116 # Install + restart as root. install is atomic (writes a temp then renames), so
117 # a concurrent exec of $BIN never sees a half-written file.
118 echo "sando-self-update: installing $NEW_BIN -> $BIN and restarting sandod"
119 install -m 0755 "$NEW_BIN" "$BIN"
120 systemctl restart sandod
121 echo "sando-self-update: done ($SHA live)"
122