Files

171 lines
8.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Updates a docker compose install (the folder scripts/install.sh set up) to a release (08 §3.1).
# Installs from pulled images (Portainer and the like) don't use it: change the image tag and
# redeploy (admin guide, maintenance.md). Run from anywhere:
#
# Usage: scripts/update.sh [vX.Y.Z] [--yes] [--force]
# vX.Y.Z the release to update to (default: the newest that isn't a pre-release)
# --yes don't ask before starting
# --force go on with calls in progress, low disk space or a failed backup
#
# Checks first (Docker, an unchanged checkout, free disk, the release's changelog and new .env
# settings), backs up, compacts Asterisk's logs, builds the new images while the old ones still
# run, restarts, then checks the result. If the check fails it goes back: the old code and images,
# and the backup too if the database was changed. Each run is logged in data/update-logs/.
# Everything in { }: bash reads it all before running, so checking out the release (which can
# change this file) can't change what runs.
{
set -euo pipefail
cd "$(dirname "$0")/.."
target='' yes='' force=''
for a in "$@"; do
case $a in
--yes) yes=1 ;;
--force) force=1 ;;
v[0-9]*) target=$a ;;
*) sed -n '7,10s/^# \{0,1\}//p' "$0" >&2; exit 2 ;;
esac
done
mkdir -p data/update-logs
log=data/update-logs/update-$(date +%Y%m%d-%H%M%S).log
exec > >(tee -a "$log") 2>&1
say() { printf '\n== %s\n' "$*"; }
die() { echo "update: $*" >&2; exit 1; }
# A check --force can override.
need() { if [ -n "$force" ]; then echo "update: $1 (going on: --force)"; else die "$1"; fi; }
command -v docker >/dev/null && docker compose version >/dev/null 2>&1 \
|| die "Docker with the compose plugin is required"
command -v git >/dev/null || die "git is required"
[ -d .git ] && [ -f .env ] || die "run this in the SelfPhoneNG folder scripts/install.sh set up"
# The bundled Caddy is in a compose profile: keep it if it's running.
profile=()
[ -n "$(docker ps -q --filter label=com.docker.compose.project=selfphoneng \
--filter label=com.docker.compose.service=caddy)" ] && profile=(--profile caddy)
dc() { docker compose "${profile[@]}" "$@"; }
ast() { dc exec -T asterisk asterisk -rx "$1"; }
say "Checks"
changed=$(git status --porcelain --untracked-files=no)
[ -z "$changed" ] || die "files in this folder were changed; undo or save them first:
$changed"
for d in data "$(docker info -f '{{.DockerRootDir}}' 2>/dev/null || echo /)"; do
free=$(df -Pk "$d" 2>/dev/null | awk 'NR==2 {print $4}') || continue
[ "${free:-0}" -ge $((3 * 1024 * 1024)) ] \
|| need "under 3 GB free on $(df -P "$d" | awk 'NR==2 {print $6}') (backup + new images)"
done
git fetch --quiet --tags origin || die "can't reach the git remote ($(git remote get-url origin))"
current=$(dc exec -T api cat /app/VERSION 2>/dev/null || sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json)
[ -n "$target" ] || target=$(git tag -l 'v[0-9]*' | grep -v -- - | sort -V | tail -1)
git rev-parse -q --verify "refs/tags/$target" >/dev/null || die "no release $target"
[ "$target" != "v$current" ] || { echo "Already on $target."; exit 0; }
[ "$(printf '%s\n' "v$current" "$target" | sort -V | head -1)" = "v$current" ] \
|| die "$target is older than the running v$current. To go back, restore a backup (scripts/restore.sh)."
echo "v$current → $target"
echo
git show "$target:CHANGELOG.md" | awk -v stop="## v$current " 'index($0, stop) == 1 {exit} /^## v/ {p=1} p'
new_keys=$(comm -13 <(sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p' .env | sort -u) \
<(git show "$target:.env.example" | sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p' | sort -u))
[ -z "$new_keys" ] || echo "New settings in .env.example, not in your .env (defaults apply): $new_keys"
ast 'core show version' >/dev/null 2>&1 || die "Asterisk isn't running; start it first (docker compose up -d)"
calls=$(ast 'core show channels count' | sed -n 's/^\([0-9]*\) active call.*/\1/p' || true)
[ "${calls:-0}" -eq 0 ] || need "$calls call(s) in progress; try again later"
if [ -z "$yes" ]; then
read -r -p "Update to $target now? Calls drop for about a minute. Type yes: " ok
[ "$ok" = yes ] || die "stopped, nothing changed"
fi
# What the check compares against, and what going back needs.
# Going back means the running version's release, which isn't the checkout when that was moved
# ahead by hand (a folder without this script yet: git checkout <release>, then run it).
prev=$(git rev-parse -q --verify "refs/tags/v$current^{commit}" || git rev-parse HEAD)
regs_before=$(ast 'pjsip show registrations' | grep -cw Registered || true)
mig() { dc exec -T postgres psql -U selfphoneng -d selfphoneng -tAc 'select count(*) from drizzle.__drizzle_migrations'; }
mig_before=$(mig) || die "can't read the database; is postgres running?"
started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
say "Backup"
backup=''
if ! dc exec -T api true 2>/dev/null; then
need "the api isn't running, so there's no backup first"
elif dc exec -T api test -f dist/backup-cli.js; then
backup=$(dc exec -T api node dist/backup-cli.js | tail -1) || need "the backup failed"
[ -z "$backup" ] || echo "data/backups/$backup"
else
echo "v$current can't back up from here; the new version backs up before changing the database."
fi
say "Compacting Asterisk's logs"
# Each log gzipped next to itself and emptied in place (Asterisk appends); gone after 90 days.
dc exec -T asterisk sh -c 'cd /var/log/asterisk && t=$(date +%Y%m%d-%H%M%S) &&
for f in *.log; do [ -s "$f" ] || continue; gzip -9c "$f" > "${f%.log}-$t.log.gz" && : > "$f"; done
find . -maxdepth 1 -name "*.log.gz" -mtime +90 -delete; du -sh .'
say "Building $target (the running version stays up)"
git -c advice.detachedHead=false checkout -q "$target"
if ! { dc pull --ignore-buildable --quiet && dc build; }; then
git -c advice.detachedHead=false checkout -q "$prev"
die "building $target failed; nothing changed"
fi
say "Restarting"
dc up -d
# The api answers with the new version, Asterisk is up, and trunks registered before still are.
check() {
local want=$1 i
for i in $(seq 60); do
dc exec -T api node -e "fetch('http://127.0.0.1:'+(process.env.API_PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" \
2>/dev/null && break
[ "$i" -lt 60 ] || { echo "the api doesn't answer"; return 1; }
sleep 3
done
[ "$(dc exec -T api cat /app/VERSION)" = "$want" ] || { echo "the api isn't v$want"; return 1; }
for i in $(seq 20); do
ast 'core waitfullybooted' >/dev/null 2>&1 && break
[ "$i" -lt 20 ] || { echo "Asterisk didn't start"; return 1; }
sleep 3
done
for i in $(seq 30); do
[ "$(ast 'pjsip show registrations' | grep -cw Registered || true)" -ge "$regs_before" ] && break
[ "$i" -lt 30 ] || { echo "trunks registered before aren't now"; return 1; }
sleep 3
done
echo "api v$want answers, Asterisk is up, $regs_before trunk(s) registered"
}
say "Checking"
if check "${target#v}"; then
errors=$(dc logs --no-log-prefix --since "$started" api asterisk 2>&1 \
| grep -E '"level":(50|60)|ERROR\[' | tail -5 || true)
[ -z "$errors" ] || printf 'Errors logged since the update (look into them):\n%s\n' "$errors"
docker image prune -f >/dev/null
echo "$(date -Is) v$current → $target ok" >> data/update-logs/history
say "Updated to $target"
exit 0
fi
say "The check failed: going back to v$current"
# The new api's own backup, made just before it migrated, beats the one above.
own=$(dc logs --no-log-prefix --since "$started" api 2>&1 \
| sed -n 's/.*"backup":"\(selfphoneng-[0-9-]*\.tar\.gz\)".*backup before migrating made.*/\1/p' | tail -1)
echo "The api's last log lines:"
dc logs --no-log-prefix --tail 15 api 2>&1 || true
git -c advice.detachedHead=false checkout -q "$prev"
dc build
if [ "$(mig)" != "$mig_before" ]; then
restore=${own:-$backup}
[ -n "$restore" ] || die "the database was changed and there's no backup to go back to; see $log"
echo "The database was changed: restoring data/backups/$restore"
printf 'yes\n' | scripts/restore.sh "data/backups/$restore"
fi
dc up -d
if check "$current"; then result="failed, back on v$current"; else result="failed, and v$current didn't come back"; fi
echo "$(date -Is) v$current → $target $result" >> data/update-logs/history
die "update to $target $result; see $log"
}