#!/bin/bash
# Install a freshly built binary as the running service, and undo it if the service
# does not come back healthy.
#
# Run by CI through a single narrow sudoers rule. It takes no arguments, because an
# argument is a thing the caller controls and this runs as root.
#
#   zuka-build ALL=(root) NOPASSWD: /usr/local/bin/zuka-deploy
#
# This script must be root-owned and not writable by the CI user. It is deliberately
# NOT deployed by CI: a script that CI can rewrite and then invoke under sudo is a
# root escalation with extra steps. Update it by hand.
#
# Understand what this grants: whoever can push to the source repository decides what
# binary runs as this host's service. That is what continuous deployment is. It is
# acceptable here because push access to that repository is the same trust as root on
# this host — if that ever stops being true, this script has to go.

set -euo pipefail

# Hardcoded, not an argument: the sudoers rule above permits this command with no
# arguments, so an argument could never arrive, and pretending otherwise would leave
# a parameter that looks configurable and is not.
readonly SERVICE="zuka"
readonly JOBS="zuka-jobs"
readonly STAGED="/var/lib/zuka-build/staging/zuka"
readonly PRIVATE="/var/lib/zuka-deploy"
readonly INSTALLED="/usr/local/bin/zuka"
readonly HEALTH="http://127.0.0.1:8790/healthz"
readonly HEALTH_TIMEOUT=60

log() { printf '[deploy] %s\n' "$*" >&2; }
die() { log "FAILED: $*"; exit 1; }

[[ $EUID -eq 0 ]] || die "must run as root"

# The staged path is writable by the CI user, so anything checked about the file
# there can be made untrue before it is installed. Copy it into a root-only directory
# first and never look at the original again.
mkdir -p "$PRIVATE"
chmod 700 "$PRIVATE"
[[ -f $STAGED ]] || die "no staged binary at $STAGED"

readonly CANDIDATE="$PRIVATE/candidate"
cp "$STAGED" "$CANDIDATE"
chmod 755 "$CANDIDATE"

# A binary that cannot report its own version will not serve traffic either. Cheaper
# to find out now than after the restart.
candidate_build="$("$CANDIDATE" version 2>/dev/null)" || die "staged binary does not run"
log "candidate: $candidate_build"

if [[ -x $INSTALLED ]]; then
	current_build="$("$INSTALLED" version 2>/dev/null || echo unknown)"
	log "current:   $current_build"
	if [[ $candidate_build == "$current_build" ]]; then
		log "already running this build; nothing to do"
		exit 0
	fi
	cp "$INSTALLED" "$PRIVATE/previous"
else
	rm -f "$PRIVATE/previous"
fi

healthy() {
	local body
	body="$(curl -fsS --max-time 3 "$HEALTH" 2>/dev/null)" || return 1
	# Health alone is not enough: systemd could have restarted the *old* binary and
	# reported perfectly good health. The running build has to be the new one.
	# `version` prints "<name> <build>"; the build is the last field.
	[[ $body == *"${candidate_build##* }"* ]]
}

install_and_wait() {
	# Same filesystem, so the rename is atomic: no window where the path is a
	# half-written file, which matters because systemd may restart at any moment.
	cp "$1" "$INSTALLED.new"
	chmod 755 "$INSTALLED.new"
	mv "$INSTALLED.new" "$INSTALLED"
	# Both units run this binary. Restarting only the web unit would leave
	# maintenance running last week's code against this week's data directory.
	systemctl restart "$SERVICE" "$JOBS"

	local waited=0
	while ((waited < HEALTH_TIMEOUT)); do
		if healthy; then return 0; fi
		sleep 2
		waited=$((waited + 2))
	done
	return 1
}

log "installing and restarting $SERVICE"
if install_and_wait "$CANDIDATE"; then
	log "healthy on $candidate_build"
	exit 0
fi

log "did not become healthy in ${HEALTH_TIMEOUT}s — rolling back"
if [[ ! -f $PRIVATE/previous ]]; then
	die "no previous binary to roll back to; service is down"
fi

cp "$PRIVATE/previous" "$INSTALLED.new"
chmod 755 "$INSTALLED.new"
mv "$INSTALLED.new" "$INSTALLED"
systemctl restart "$SERVICE" "$JOBS"

waited=0
while ((waited < HEALTH_TIMEOUT)); do
	if curl -fsS --max-time 3 "$HEALTH" >/dev/null 2>&1; then
		log "rolled back; the bad build was not deployed"
		# Non-zero: the deploy failed, even though the host is fine. CI must go red.
		exit 1
	fi
	sleep 2
	waited=$((waited + 2))
done

# Rollback did not restore health. The host needs a human.
die "rollback did not restore health — $SERVICE is DOWN"
