#!/usr/bin/env bash
# Pull the current image and put both halves of it in place.
#
# Two halves, because Caddy serves the frontend from a directory and the
# backend from a container -- so a deploy is "restart the container" *and*
# "refresh the directory", and they have to come from the same image or the
# thing v1 got wrong (a frontend calling an endpoint the backend does not have)
# comes straight back. Nothing here builds anything; the image is whatever CI
# pushed.
#
# Both halves live in root podman, because that is where the rest of the
# machine's stack lives and where Caddy can reach a container by name. This
# runs from a system timer for the same reason: run as a user, it deploys a
# backend into a rootless namespace nothing else can route to, which looks
# exactly like a successful deploy from in here.
#
# Safe to run when nothing has changed, and safe to run again after a run that
# failed half way: it stops only when the image it just pulled is the one it
# has a record of having deployed.

set -euo pipefail

IMAGE="${RUNWAY_IMAGE:-git.rcjohnstone.com/connor/runway:latest}"
COMPOSE_DIR="${RUNWAY_COMPOSE_DIR:-$HOME}"
SERVICE="${RUNWAY_SERVICE:-runway-backend}"
# `web/`, not `data/runway/` itself, because Caddy mounts the directory that
# *holds* `dist` and must not be shown the database next to it. See the
# directory swap below for why the mount is of the holder rather than of
# `dist`.
DIST="${RUNWAY_DIST:-$HOME/data/runway/web/dist}"

runtime() {
  if command -v podman >/dev/null 2>&1; then echo podman
  elif command -v docker >/dev/null 2>&1; then echo docker
  else echo "need podman or docker" >&2; exit 1
  fi
}
rt="$(runtime)"

# Checked before anything is pulled, because the failure it prevents is the
# quiet kind. Replacing the frontend means swapping one directory for another,
# which is a write to the directory *containing* them -- and that one is
# root-owned on a machine where v1 used to deploy by `sudo rsync`. Without this
# the script pulled an image, failed on `mktemp`, and exited 1 with nothing in
# the journal saying what was wrong.
holder="${DIST%/*}"

# The deploy replaces things inside this directory; it never builds the tree.
# So a holder that does not exist means the path is wrong, and the loudest
# case is running this by hand under `sudo`, where $HOME becomes /root and
# every default silently points at a directory nobody serves. Left to itself
# that would create /root/data/runway/web, deploy into it, and report success
# -- a deploy that does not happen, which is the failure this script has now
# had twice and should not get a third time.
if [ ! -d "$holder" ]; then
  echo "runway: $holder does not exist, so there is nothing to deploy into." >&2
  echo "        Set RUNWAY_DIST, or create it (see deploy/README.md). Running" >&2
  echo "        under sudo? \$HOME is $HOME here, which is likely the problem." >&2
  exit 1
fi

if [ ! -w "$holder" ]; then
  echo "runway: cannot write to $holder, so the frontend cannot be replaced." >&2
  echo "        It is owned by $(stat -c '%U' "$holder"), and this is running as $(id -un)." >&2
  echo "        Fix with:  sudo chown $(id -un):$(id -gn) $holder" >&2
  exit 1
fi

"$rt" pull --quiet "$IMAGE" >/dev/null
image="$("$rt" image inspect --format '{{.Id}}' "$IMAGE")"
image="${image#sha256:}"

# What is compared is the image that was last *deployed*, recorded here at the
# end of a run that got all the way through -- not the image that was last
# pulled. The difference is the whole point. This script used to compare the
# pull against the local image before it, which reads as "is there anything
# new?" and is wrong in the one case that matters: a run that pulls
# successfully and then fails. The image is local afterwards, so the next run
# sees nothing new and exits 0 with "nothing to do", for ever, while the thing
# on disk is whatever was there before. That is what happened -- the pull
# landed, the frontend swap failed on a root-owned directory, and once the
# directory was fixed the script would have gone on reporting success without
# ever deploying.
#
# Keyed on the deploy, every failure retries on the next tick and the retry
# needs no cleanup, because nothing is recorded until the work is done.
stamp="${DIST%/}.deployed"
deployed="$(cat "$stamp" 2>/dev/null || true)"

if [ "$deployed" = "$image" ]; then
  echo "runway: already on ${image:0:12}, nothing to do"
  exit 0
fi

if [ -n "$deployed" ]; then
  echo "runway: ${deployed:0:12} -> ${image:0:12}"
else
  echo "runway: deploying ${image:0:12}"
fi

# The frontend first. It is copied into place through a staging directory and
# moved, so that Caddy is never serving a half-written bundle -- `index.html`
# names hashed files, and a browser that fetches the new HTML and the old
# JavaScript gets a blank page.
staged="$(mktemp -d "${DIST%/*}/.dist.XXXXXX")"
container="$("$rt" create "$IMAGE")"
trap '"$rt" rm -f "$container" >/dev/null 2>&1 || true; rm -rf "$staged"' EXIT
"$rt" cp "$container:/srv/dist/." "$staged/"

# Kept rather than deleted: rolling the frontend back is then a `mv`, which is
# the kind of thing you want to be easy at the moment you need it.
previous="${DIST%/}.previous"
rm -rf "$previous"
# Spelled as an `if` and not `[ -d ... ] && mv`, because under `set -e` the
# second form exits the script the first time it runs, when there is nothing
# there yet -- and it would have looked like the deploy succeeded.
if [ -d "$DIST" ]; then
  mv "$DIST" "$previous"
fi
mv "$staged" "$DIST"
chmod -R a+rX "$DIST"

# Then the backend, which runs the migrations as it starts.
#
# `--force-recreate`, because the tag does not move. `compose up -d` decides
# whether to replace a container by comparing its *configuration*, and the
# configuration here is the string `runway:latest` either way -- so a service
# already running was left exactly where it was, every time, while the frontend
# beside it was replaced on every run. That is this script's third variation on
# the same theme and the worst of them: it does not fail, it half-succeeds, and
# what it leaves behind is the one state the single-image design exists to
# prevent -- a new frontend calling an API the running backend does not have.
( cd "$COMPOSE_DIR" && "$rt" compose up -d --force-recreate "$SERVICE" )

# So a half-deploy cannot be reported as a whole one. Everything above this
# either worked or the script has already exited; what is checked here is the
# thing no exit code covered -- that what is now running is what was just
# pulled.
running="$("$rt" inspect --format '{{.Image}}' "$("$rt" ps --filter "name=$SERVICE" --format '{{.Names}}' | head -1)" 2>/dev/null || true)"
running="${running#sha256:}"
if [ -n "$running" ] && [ "$running" != "$image" ]; then
  echo "runway: the frontend is on ${image:0:12} and the backend is on ${running:0:12}." >&2
  echo "        Not recording this as deployed, so the next run tries again." >&2
  exit 1
fi

# Last, so that anything above failing means the next run tries again. A hand
# rollback (see the README) leaves this pointing at the image you rolled away
# from, which is deliberate: the timer then leaves the rollback alone until a
# new image is actually pushed, rather than undoing it ten minutes later.
printf '%s\n' "$image" > "$stamp"

echo "runway: ${image:0:12} deployed"
