Files
LLeMbas/deploy/update.sh
T
Jaroslav Beneš 6cc262ea5a An update script that stopped where nobody could see it
Found by running it rather than by reading it. Under `set -euo pipefail` the tag
resolution added in the last commit dies when no release tag exists -- grep exits
1 when nothing matches, and `head -1` closing the pipe early can hand it a
SIGPIPE besides. That is every host until the first release is tagged, which is
every host today. It printed "== fetching ==" and stopped: fetched, not reset,
not restarted, and exit status swallowed by the pipe it was being read through.
The fallback comment two lines above claimed to handle exactly this case.

And the consequence of moving to SSH: install.sh takes REPO_URL from the running
checkout's origin, so whoever pushes over SSH now hands the deployment a URL the
service user cannot use -- it has no key and should not have one, being a
credential that can push to the repository sitting on a box to do a read-only
job. The clone would have failed loudly, with "Permission denied (publickey)"
from an account nobody was thinking about. It is refused up front with the fix
named instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:00:27 +02:00

186 lines
8.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pull the latest LLeMbas into the deployment and restart the service.
#
# Run this after pushing. It fetches, hard-resets the deployment checkout to the
# remote branch, reinstalls dependencies if they changed, and restarts. Nothing
# is ever edited in place under the deployment prefix, so a hard reset is safe
# and avoids merge conflicts from a dirty tree.
set -euo pipefail
SERVICE_USER="${SERVICE_USER:-lembas}"
PREFIX="${PREFIX:-/srv/lembas}"
BRANCH="${LEMBAS_BRANCH:-main}"
# `stable` deploys the newest release tag; `edge` deploys the branch tip. Stable
# is the default because a branch tip is not a release -- following one means
# deploying whatever was pushed five minutes ago. A host with no tags yet falls
# back to the branch and says so, rather than refusing to update at all.
CHANNEL="${LEMBAS_CHANNEL:-stable}"
APP="$PREFIX/app"
VENV="$PREFIX/venv"
if [[ ! -d "$APP/.git" ]]; then
echo "No deployment at $APP. Run deploy/install.sh first." >&2
exit 1
fi
git_as() { sudo -u "$SERVICE_USER" git -C "$APP" "$@"; }
before=$(git_as rev-parse HEAD)
echo "== fetching =="
# `--tags` and `--force`: without the first, the stable channel never learns
# about a release; without the second, a tag that was moved -- which happens to a
# release cut wrong -- is refused rather than updated, and the host sits on the
# old one with no sign of why.
git_as fetch --quiet --tags --force origin "$BRANCH"
# What to land on. A release tag on stable, the branch tip on edge. The tag
# pattern deliberately excludes anything with a suffix: `v1.1.0-rc1` sorts above
# `v1.1.0` under git's version sort, so accepting it would step a stable host
# onto a release candidate on the strength of a hyphen.
target="origin/$BRANCH"
if [[ "$CHANNEL" == "stable" ]]; then
# `|| true` is load-bearing under `set -euo pipefail`, and for two reasons:
# grep exits 1 when nothing matches -- which is every host until the first
# release is tagged -- and `head -1` closing the pipe early can hand grep a
# SIGPIPE. Either kills the script mid-update, after the fetch and before the
# reset, leaving the checkout fetched and unmoved with no error printed.
newest=$(git_as tag --list --sort=-v:refname \
| grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
if [[ -n "$newest" ]]; then
target="$newest"
else
echo " no release tags yet; following $BRANCH instead"
fi
fi
echo " channel $CHANNEL -> $target"
if [[ "$target" == "origin/$BRANCH" ]]; then
# Stays on the branch, which is what this always did.
git_as reset --hard --quiet "$target"
else
# Detached at the tag. A `reset --hard <tag>` while on `main` would move the
# local branch to it, which is a rewrite of a ref nobody asked to rewrite --
# and the deployment checkout is never developed in, so being at a commit
# rather than on a branch is the more honest state anyway.
git_as -c advice.detachedHead=false checkout --force --detach --quiet "$target"
fi
after=$(git_as rev-parse HEAD)
if [[ "$before" == "$after" ]]; then
echo " already at ${after:0:7}, nothing to pull"
else
echo " ${before:0:7} -> ${after:0:7}"
git_as --no-pager log --oneline "$before..$after" | sed 's/^/ /'
fi
# Cheap and idempotent; catches a dependency added since the last deploy.
# The extras a deployment gets. `search` because DuckDuckGo is the default web
# search provider and is meant to need no setup; `ssh` because agent chats reach
# their machine over it and a deployment without it offers the feature with an
# install hint instead. Listed here AND in install.sh -- an extra added to only
# one of them means existing deployments silently miss it.
LEMBAS_EXTRAS="${LEMBAS_EXTRAS:-search,ssh}"
echo "== dependencies =="
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]"
# The unit is NOT reinstalled automatically. An installed unit usually carries
# host-specific lines the template cannot know about -- an ordering dependency
# on whatever serves the models, a note about how the prefix is mounted -- and
# overwriting those on every update would be a worse surprise than drifting.
#
# So this compares the *template* against the one last applied here, not the
# template against the installed file. Comparing the files would warn forever
# about the local lines, and a warning that always fires is one nobody reads.
#
# The drift is worth catching: a change in the unit can be what makes a release
# work at all, and a host that pulled the code without it would run the new
# version under the old settings and fail confusingly.
STAMP="$PREFIX/.unit-applied"
current=$(sha256sum "$APP/deploy/lembas.service" | cut -d' ' -f1)
if [[ -f "$STAMP" && "$(cat "$STAMP")" != "$current" ]]; then
echo "== systemd unit ==" >&2
echo " deploy/lembas.service has changed since it was last applied here." >&2
echo " Review it and merge by hand, keeping this host's own lines:" >&2
echo " diff /etc/systemd/system/lembas.service <(sed \\" >&2
echo " -e 's|__PREFIX__|$PREFIX|g' -e 's|__SERVICE_USER__|$SERVICE_USER|g' \\" >&2
echo " $APP/deploy/lembas.service)" >&2
echo " Then: sudo systemctl daemon-reload && sudo systemctl restart lembas" >&2
echo " And record it as applied: echo $current | sudo tee $STAMP" >&2
elif [[ ! -f "$STAMP" ]]; then
# First run after this check was added. Assume what is installed is current;
# there is nothing to compare against and crying wolf on every host once is
# not worth it.
echo "$current" | sudo tee "$STAMP" >/dev/null
fi
# The same argument for the vhost, and the failure is worse. A stale unit at
# least says something in the journal; a stale vhost breaks a feature two layers
# away, and the only symptom is a panel that says it could not connect. The
# terminal is a WebSocket, and a `location` that does not pass an upgrade
# through fails every handshake while every test in the suite still passes.
VHOST_STAMP="$PREFIX/.vhost-applied"
vhost_now=$(sha256sum "$APP/deploy/nginx-vhost.conf" | cut -d' ' -f1)
site_host=""; app_port=""
# Written by install.sh, and absent on every deployment that predates it --
# which is the case that most needs the one-time check below, so the port is
# recovered from the environment file and the vhost found by what it proxies to.
# Guessing "your-host" instead would have skipped the check on exactly the hosts
# it was added for.
if [[ -f "$PREFIX/.deploy-env" ]]; then
. "$PREFIX/.deploy-env"
site_host="$SITE_HOST"; app_port="$APP_PORT"
fi
if [[ -z "$app_port" && -f "$PREFIX/lembas.env" ]]; then
app_port=$(sed -n 's/^LEMBAS_PORT=//p' "$PREFIX/lembas.env" | tail -1)
fi
app_port="${app_port:-8080}"
installed_vhost=""
if [[ -n "$site_host" && -f "/etc/nginx/conf.d/$site_host.conf" ]]; then
installed_vhost="/etc/nginx/conf.d/$site_host.conf"
else
installed_vhost=$(grep -ls "proxy_pass http://127.0.0.1:$app_port" \
/etc/nginx/conf.d/*.conf 2>/dev/null | head -1)
fi
vhost_stale=""
if [[ -f "$VHOST_STAMP" ]]; then
[[ "$(cat "$VHOST_STAMP")" != "$vhost_now" ]] && vhost_stale="the template has changed"
elif [[ -n "$installed_vhost" ]]; then
# First run with this check, so there is no stamp to compare against. Rather
# than assume what is installed is current -- which is what the unit check
# does, and would hide exactly the change this was added for -- look for the
# one thing that must be there. Everything else is left to the stamp.
grep -q 'lembas_connection_upgrade' "$installed_vhost" \
|| vhost_stale="the installed vhost does not pass WebSocket upgrades through, so the terminal cannot connect"
fi
if [[ -n "$vhost_stale" ]]; then
echo "== nginx vhost ==" >&2
echo " $vhost_stale." >&2
echo " Review and reinstall it:" >&2
echo " diff ${installed_vhost:-/etc/nginx/conf.d/your-host.conf} <(sed \\" >&2
echo " -e 's|__SITE_HOST__|${site_host:-your-host}|g' -e 's|__APP_PORT__|$app_port|g' \\" >&2
echo " $APP/deploy/nginx-vhost.conf)" >&2
echo " Then: sudo nginx -t && sudo systemctl reload nginx" >&2
echo " And record it as applied: echo $vhost_now | sudo tee $VHOST_STAMP" >&2
elif [[ ! -f "$VHOST_STAMP" ]]; then
echo "$vhost_now" | sudo tee "$VHOST_STAMP" >/dev/null
fi
echo "== restart =="
sudo systemctl restart lembas
sleep 2
if systemctl is-active --quiet lembas; then
echo " lembas is running"
else
echo " lembas FAILED to start:" >&2
sudo journalctl -u lembas -n 30 --no-pager >&2
exit 1
fi