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 | # Run by CI through a single narrow sudoers rule. It takes no arguments, because an |
| 6 | # argument is a thing the caller controls and this runs as root. |
| 7 | # |
| 8 | # zuka-build ALL=(root) NOPASSWD: /usr/local/bin/zuka-deploy |
| 9 | # |
| 10 | # This script must be root-owned and not writable by the CI user. It is deliberately |
| 11 | # NOT deployed by CI: a script that CI can rewrite and then invoke under sudo is a |
| 12 | # root escalation with extra steps. Update it by hand. |
| 13 | # |
| 14 | # Understand what this grants: whoever can push to the source repository decides what |
| 15 | # binary runs as this host's service. That is what continuous deployment is. It is |
| 16 | # acceptable here because push access to that repository is the same trust as root on |
| 17 | # this host — if that ever stops being true, this script has to go. |
| 18 | |
| 19 | set -euo pipefail |
| 20 | |
| 21 | # Hardcoded, not an argument: the sudoers rule above permits this command with no |
| 22 | # arguments, so an argument could never arrive, and pretending otherwise would leave |
| 23 | # a parameter that looks configurable and is not. |
| 24 | readonly SERVICE="zuka" |
| 25 | readonly JOBS="zuka-jobs" |
| 26 | readonly STAGED="/var/lib/zuka-build/staging/zuka" |
| 27 | readonly PRIVATE="/var/lib/zuka-deploy" |
| 28 | readonly INSTALLED="/usr/local/bin/zuka" |
| 29 | readonly HEALTH="http://127.0.0.1:8790/healthz" |
| 30 | readonly HEALTH_TIMEOUT=60 |
| 31 | |
| 32 | log() { printf '[deploy] %s\n' "$*" >&2; } |
| 33 | die() { log "FAILED: $*"; exit 1; } |
| 34 | |
| 35 | [[ $EUID -eq 0 ]] || die "must run as root" |
| 36 | |
| 37 | # The staged path is writable by the CI user, so anything checked about the file |
| 38 | # there can be made untrue before it is installed. Copy it into a root-only directory |
| 39 | # first and never look at the original again. |
| 40 | mkdir -p "$PRIVATE" |
| 41 | chmod 700 "$PRIVATE" |
| 42 | [[ -f $STAGED ]] || die "no staged binary at $STAGED" |
| 43 | |
| 44 | readonly CANDIDATE="$PRIVATE/candidate" |
| 45 | cp "$STAGED" "$CANDIDATE" |
| 46 | chmod 755 "$CANDIDATE" |
| 47 | |
| 48 | # A binary that cannot report its own version will not serve traffic either. Cheaper |
| 49 | # to find out now than after the restart. |
| 50 | candidate_build="$("$CANDIDATE" version 2>/dev/null)" || die "staged binary does not run" |
| 51 | log "candidate: $candidate_build" |
| 52 | |
| 53 | if [[ -x $INSTALLED ]]; then |
| 54 | current_build="$("$INSTALLED" version 2>/dev/null || echo unknown)" |
| 55 | log "current: $current_build" |
| 56 | if [[ $candidate_build == "$current_build" ]]; then |
| 57 | log "already running this build; nothing to do" |
| 58 | exit 0 |
| 59 | fi |
| 60 | cp "$INSTALLED" "$PRIVATE/previous" |
| 61 | else |
| 62 | rm -f "$PRIVATE/previous" |
| 63 | fi |
| 64 | |
| 65 | healthy() { |
| 66 | local body |
| 67 | body="$(curl -fsS --max-time 3 "$HEALTH" 2>/dev/null)" || return 1 |
| 68 | # Health alone is not enough: systemd could have restarted the *old* binary and |
| 69 | # reported perfectly good health. The running build has to be the new one. |
| 70 | # `version` prints "<name> <build>"; the build is the last field. |
| 71 | [[ $body == *"${candidate_build##* }"* ]] |
| 72 | } |
| 73 | |
| 74 | install_and_wait() { |
| 75 | # Same filesystem, so the rename is atomic: no window where the path is a |
| 76 | # half-written file, which matters because systemd may restart at any moment. |
| 77 | cp "$1" "$INSTALLED.new" |
| 78 | chmod 755 "$INSTALLED.new" |
| 79 | mv "$INSTALLED.new" "$INSTALLED" |
| 80 | # Both units run this binary. Restarting only the web unit would leave |
| 81 | # maintenance running last week's code against this week's data directory. |
| 82 | systemctl restart "$SERVICE" "$JOBS" |
| 83 | |
| 84 | local waited=0 |
| 85 | while ((waited < HEALTH_TIMEOUT)); do |
| 86 | if healthy; then return 0; fi |
| 87 | sleep 2 |
| 88 | waited=$((waited + 2)) |
| 89 | done |
| 90 | return 1 |
| 91 | } |
| 92 | |
| 93 | log "installing and restarting $SERVICE" |
| 94 | if install_and_wait "$CANDIDATE"; then |
| 95 | log "healthy on $candidate_build" |
| 96 | exit 0 |
| 97 | fi |
| 98 | |
| 99 | log "did not become healthy in ${HEALTH_TIMEOUT}s — rolling back" |
| 100 | if [[ ! -f $PRIVATE/previous ]]; then |
| 101 | die "no previous binary to roll back to; service is down" |
| 102 | fi |
| 103 | |
| 104 | cp "$PRIVATE/previous" "$INSTALLED.new" |
| 105 | chmod 755 "$INSTALLED.new" |
| 106 | mv "$INSTALLED.new" "$INSTALLED" |
| 107 | systemctl restart "$SERVICE" "$JOBS" |
| 108 | |
| 109 | waited=0 |
| 110 | while ((waited < HEALTH_TIMEOUT)); do |
| 111 | if curl -fsS --max-time 3 "$HEALTH" >/dev/null 2>&1; then |
| 112 | log "rolled back; the bad build was not deployed" |
| 113 | # Non-zero: the deploy failed, even though the host is fine. CI must go red. |
| 114 | exit 1 |
| 115 | fi |
| 116 | sleep 2 |
| 117 | waited=$((waited + 2)) |
| 118 | done |
| 119 | |
| 120 | # Rollback did not restore health. The host needs a human. |
| 121 | die "rollback did not restore health — $SERVICE is DOWN" |