worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | #!/bin/bash |
| 2 | # Install a freshly built binary as the running service, and undo it if the service |
| 3 | # does not come back healthy. |
| 4 | # |
| 5 | # Started by root, not called by CI. CI drops a binary and a request file in a |
| 6 | # staging directory; a systemd.path unit notices the request and runs this. Nothing |
| 7 | # the CI user writes becomes an argument, and CI never gains a privilege — it can |
| 8 | # only ask. |
| 9 | # |
| 10 | # The alternative was a sudoers rule, which does not work here anyway: the build |
| 11 | # instance runs with NoNewPrivileges=yes, and sudo is setuid. Unhardening the one |
| 12 | # unit that executes arbitrary pushed code to let it call sudo is the wrong trade. |
| 13 | # |
| 14 | # Understand what this still grants: whoever can push to the source repository |
| 15 | # decides which binary runs as this host's service. That is what continuous |
| 16 | # deployment is, and no amount of care in this script changes it. It is acceptable |
| 17 | # because push access to that repository is the same trust as root on this host. If |
| 18 | # that ever stops being true, this arrangement has to go. |
| 19 | # |
| 20 | # Install: |
| 21 | # sudo install -o root -g root -m 0755 zuka-deploy /usr/local/bin/zuka-deploy |
| 22 | # sudo install -o root -g root -m 0644 zuka-deploy.path zuka-deploy.service \ |
| 23 | # /etc/systemd/system/ |
| 24 | # sudo systemctl enable --now zuka-deploy.path |
| 25 | |
| 26 | set -euo pipefail |
| 27 | |
| 28 | readonly SERVICE="zuka" |
| 29 | readonly JOBS="zuka-jobs" |
| 30 | readonly STAGING="/var/lib/zuka-build/staging" |
| 31 | readonly PRIVATE="/var/lib/zuka-deploy" |
| 32 | readonly INSTALLED="/usr/local/bin/zuka" |
| 33 | readonly HEALTH="http://127.0.0.1:8790/healthz" |
| 34 | readonly HEALTH_TIMEOUT=90 |
| 35 | |
| 36 | log() { printf '[deploy] %s\n' "$*" >&2; } |
| 37 | |
| 38 | # The result file is how CI learns whether this worked, so it must be written on |
| 39 | # every exit path — including the ones nobody thought about. Without the trap, a |
| 40 | # script that dies early leaves CI polling until it times out, reporting "deploy |
| 41 | # timed out" for what was actually a crash on line three. |
| 42 | result="" |
| 43 | finish() { |
| 44 | local code=$? |
| 45 | [[ -n $result ]] || result="FAIL unknown deploy failure (exit $code)" |
| 46 | printf '%s\n' "$result" >"$STAGING/deploy.result.tmp" |
| 47 | chmod 644 "$STAGING/deploy.result.tmp" |
| 48 | mv "$STAGING/deploy.result.tmp" "$STAGING/deploy.result" |
| 49 | log "$result" |
| 50 | } |
| 51 | trap finish EXIT |
| 52 | |
| 53 | die() { |
| 54 | result="FAIL $*" |
| 55 | exit 1 |
| 56 | } |
| 57 | |
| 58 | [[ $EUID -eq 0 ]] || die "must run as root" |
| 59 | |
| 60 | # Everything in the staging directory is writable by the CI user, so anything checked |
| 61 | # about a file there can be made untrue before it is used. Copy into a root-only |
| 62 | # directory and never look at the original again. |
| 63 | mkdir -p "$PRIVATE" |
| 64 | chmod 700 "$PRIVATE" |
| 65 | [[ -f $STAGING/zuka ]] || die "no staged binary" |
| 66 | |
| 67 | readonly CANDIDATE="$PRIVATE/candidate" |
| 68 | cp "$STAGING/zuka" "$CANDIDATE" |
| 69 | chmod 755 "$CANDIDATE" |
| 70 | |
| 71 | # A binary that cannot report its own version will not serve traffic either, and |
| 72 | # finding out here costs nothing while finding out after the restart costs an outage. |
| 73 | version_output="$("$CANDIDATE" version 2>/dev/null)" || die "staged binary does not run" |
| 74 | # `version` prints "<name> <build>". |
| 75 | readonly BUILD="${version_output##* }" |
| 76 | log "candidate $BUILD" |
| 77 | |
| 78 | if [[ -x $INSTALLED ]]; then |
| 79 | current="$("$INSTALLED" version 2>/dev/null || echo 'unknown unknown')" |
| 80 | if [[ ${current##* } == "$BUILD" ]]; then |
| 81 | result="OK $BUILD (already running)" |
| 82 | exit 0 |
| 83 | fi |
| 84 | cp "$INSTALLED" "$PRIVATE/previous" |
| 85 | else |
| 86 | rm -f "$PRIVATE/previous" |
| 87 | fi |
| 88 | |
| 89 | # Health alone is not proof: systemd could have restarted the old binary and reported |
| 90 | # perfectly good health. The build it reports has to be the one just installed. |
| 91 | healthy() { |
| 92 | local body |
| 93 | body="$(curl -fsS --max-time 3 "$HEALTH" 2>/dev/null)" || return 1 |
| 94 | [[ $body == *"$BUILD"* ]] |
| 95 | } |
| 96 | |
| 97 | install_binary() { |
| 98 | # Same filesystem, so the rename is atomic. systemd may restart at any moment and |
| 99 | # must never find a half-written file at this path. |
| 100 | cp "$1" "$INSTALLED.new" |
| 101 | chmod 755 "$INSTALLED.new" |
| 102 | mv "$INSTALLED.new" "$INSTALLED" |
| 103 | # Both units run this binary. Restarting only the web unit would leave |
| 104 | # maintenance running the old code against the new data directory. |
| 105 | systemctl restart "$SERVICE" "$JOBS" |
| 106 | } |
| 107 | |
| 108 | wait_for() { |
| 109 | local waited=0 |
| 110 | while ((waited < HEALTH_TIMEOUT)); do |
| 111 | "$1" && return 0 |
| 112 | sleep 2 |
| 113 | waited=$((waited + 2)) |
| 114 | done |
| 115 | return 1 |
| 116 | } |
| 117 | |
| 118 | log "installing $BUILD and restarting" |
| 119 | install_binary "$CANDIDATE" |
| 120 | if wait_for healthy; then |
| 121 | result="OK $BUILD" |
| 122 | exit 0 |
| 123 | fi |
| 124 | |
| 125 | log "not healthy on $BUILD after ${HEALTH_TIMEOUT}s — rolling back" |
| 126 | [[ -f $PRIVATE/previous ]] || die "$BUILD is unhealthy and there is no previous binary; $SERVICE is DOWN" |
| 127 | |
| 128 | install_binary "$PRIVATE/previous" |
| 129 | responds() { curl -fsS --max-time 3 "$HEALTH" >/dev/null 2>&1; } |
| 130 | if wait_for responds; then |
| 131 | die "$BUILD did not become healthy; rolled back, the host is serving the previous build" |
| 132 | fi |
| 133 | |
| 134 | die "$BUILD did not become healthy AND the rollback did not restore it; $SERVICE is DOWN" |