deploy/update.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Updates this Docker Compose install to the stable version, with a way back:
#
# ./update.sh the stable version (the newest release until a first version is made stable)
# ./update.sh --version X.Y.Z one released version
# ./update.sh --check say what would change, and change nothing
# ./update.sh --rollback put back the version and the data from before the last update (asks first; --yes)
# ./update.sh --allow-older go ahead when the version asked for is older than, or no newer than, the one running
# ./update.sh --dir PATH the install's folder, when it is not this one (the installer's: /opt/openmausbot)
#
# It runs on the server itself, beside docker-compose.yml, because a server cannot replace its own container. The steps
# are the ones the MausBot connector runs for a connected server (connector/src/home.ts `update`): download the new
# image by digest, stop the server and check that it stopped, copy its data into a new Docker volume and check the copy
# file by file, start the new image, and wait until it answers and can still start bots. When it does not, the old image
# and the copy are put back. The copy from the update before is removed only once the new copy has been checked.
#
# Each step is written to .update/journal before it starts. A closed terminal does not stop an update: it carries on,
# and says how it ended in .update/update.log. An update this run interrupts (Ctrl-C) is undone at once. One an earlier
# run left unfinished (a reboot, a kill) is kept when its new version is healthy; otherwise the next run asks before it
# puts the old data back, and says what that loses.
#
# Exit codes, the same ones the connector's update runner reads (connector/host/mausbot-update-runner.sh):
# 0 done, or nothing to do
# 1 the update failed, and the old version and its data are back
# 2 the old version or its data could not be brought back: see what it said; ./update.sh --rollback tries again
# 3 another update is running here
# 4 nothing was changed: refused, could not download, the folder cannot be written, or a question was not answered
# 5 nothing was changed: the version asked for is older than the one running, or either version cannot be told
# 130 interrupted (Ctrl-C) before the new version started: nothing was changed, and the server was started again
set -uo pipefail
# An SSH session that drops sends SIGHUP, and a `| tee` that dies with it would send SIGPIPE: neither may stop an
# update half-way. Output to a terminal that has gone is lost; .update/update.log keeps it.
trap '' HUP PIPE
REPO="ghcr.io/milind-soni/openmausbot"
PINNED="^ghcr\.io/milind-soni/openmausbot@sha256:[0-9a-f]{64}$"
STABLE_URL=${OMB_STABLE_URL:-https://raw.githubusercontent.com/milind-soni/OpenMausBot/channels/stable.json}
NEWEST_URL=${OMB_NEWEST_URL:-https://api.github.com/repos/milind-soni/OpenMausBot/releases/latest}
SERVICE=${OMB_UPDATE_SERVICE:-omb}
# A healthy server answers within seconds of starting; two minutes leaves room for a slow first start after an update,
# and is how long the server is down before a version that never answers is rolled back. OMB_UPDATE_WAIT_S for more.
WAIT_S=${OMB_UPDATE_WAIT_S:-120}
ENGINE_WAIT_S=${OMB_UPDATE_ENGINE_WAIT_S:-120}
POLL_S=${OMB_UPDATE_POLL_S:-5}
KEEP_TAG="openmausbot-before-update:local"
BACKUP_ROLE="com.mausbot.role=update-backup"
# The install: --dir, else the folder this script is in when that holds a Compose file, else where the installer
# (deploy/install.sh) puts one.
DIR=${OMB_INSTALL_DIR:-}
want_dir=no
for arg in "$@"; do
if [ "$want_dir" = yes ]; then DIR=$arg; want_dir=no; elif [ "$arg" = --dir ]; then want_dir=yes; fi
done
if [ -z "$DIR" ]; then
DIR=$(cd "$(dirname "$0")" && pwd)
if [ ! -f "$DIR/docker-compose.yml" ] && [ ! -f "$DIR/compose.yaml" ] && [ -f /opt/openmausbot/compose.yaml ]; then DIR=/opt/openmausbot; fi
fi
STATE="$DIR/.update"
JOURNAL="$STATE/journal"
LAST="$STATE/last"
LOCK="$STATE/lock"
LOG="$STATE/update.log"
cd "$DIR" 2>/dev/null || { printf '%s\n' "There is no folder $DIR."; exit 4; }
# The pin is read from .env, never from the shell that happens to run this.
unset OMB_IMAGE
# A step interrupted while its output goes to the log still speaks to the terminal.
exec 3>&1
# The answers to its questions are read from fd 4, and nothing else this script starts sees them: `docker compose exec`
# forwards its standard input to the container and would swallow a `yes` typed or piped ahead of the question.
exec 4<&0 </dev/null
say() {
printf '%s\n' "$*" >&3 2>/dev/null
if [ -d "$STATE" ]; then printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$LOG" 2>/dev/null; fi
return 0
}
refuse() {
say "$*"
exit 4
}
# What was typed after `question`, or nothing when there is no one to answer (no terminal, end of input).
ask() {
local answer=""
printf '%s' "$1" >&3 2>/dev/null
read -r answer <&4 || answer=""
# A terminal echoes the Enter that ends the answer; anything else does not.
[ -t 4 ] || printf '\n' >&3 2>/dev/null
printf '%s' "$answer"
}
compose() { docker compose "$@"; }
container() { compose ps -aq "$SERVICE" 2>/dev/null | head -n 1; }
inspect() { docker inspect -f "$1" "$2" 2>/dev/null; }
image_id() { docker image inspect -f '{{.Id}}' "$1" 2>/dev/null; }
published_ref() { docker image inspect -f '{{range .RepoDigests}}{{println .}}{{end}}' "$1" 2>/dev/null | grep "^$REPO@" | head -n 1; }
# The released version an image is labelled with; nothing for a build of main, whose label is the branch.
image_version() { docker image inspect -f '{{index .Config.Labels "org.opencontainers.image.version"}}' "$1" 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p'; }
name_of() { if [ -n "${2:-}" ]; then printf '%s' "$2"; else printf '%s' "$1" | sed 's/^.*sha256:\(.\{12\}\).*$/image \1/'; fi; }
old_name() { name_of "${J_previous:-$J_previous_id}" "$J_previous_version"; }
new_name() { name_of "$J_target" "$J_version"; }
is_version() { printf '%s' "$1" | grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+'; }
# True when version $1 is older than version $2 (both X.Y.Z).
older_than() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | head -n 1)" = "$1" ]; }
# The string value of a top-level key of the JSON object on stdin, when the object has that key exactly once and it is
# a string. Nested objects and arrays (a record's `history`) are stepped over, never matched.
json_top() {
LC_ALL=C awk -v want="$1" '
{ text = text $0 "\n" }
END {
n = length(text)
depth = 0
expect = ""
key = ""
found = 0
value = ""
for (i = 1; i <= n; i++) {
c = substr(text, i, 1)
if (c == "\"") {
start = i + 1
for (i++; i <= n; i++) {
c = substr(text, i, 1)
if (c == "\\") i++
else if (c == "\"") break
}
s = substr(text, start, i - start)
if (depth == 1 && expect == "value") {
if (key == want) { found++; value = s }
expect = ""
} else if (depth == 1) key = s
} else if (c == ":") {
if (depth == 1) expect = "value"
} else if (c == "{" || c == "[") {
if (depth == 1 && expect == "value") { if (key == want) found += 2; expect = "" }
depth++
} else if (c == "}" || c == "]") {
depth--
} else if (c == ",") {
if (depth == 1) expect = ""
} else if (c !~ /[ \t\r\n]/ && depth == 1 && expect == "value") {
if (key == want) found += 2
expect = ""
}
}
if (found != 1) exit 1
print value
}'
}
# GET $1 into BODY. Returns 0 with the body, 1 when there is no such file (HTTP 404, or a file:// path that is not
# there), 2 when it could not be asked (no network, a timeout, any other answer).
fetch() {
local url=$1 got code rc headers
BODY=""
if command -v curl >/dev/null 2>&1; then
got=$(curl -sSL --max-time 20 -w '\n%{http_code}' "$url" 2>/dev/null)
rc=$?
code=${got##*$'\n'}
BODY=${got%$'\n'*}
case "$rc:$code" in
0:200 | 0:000) return 0 ;;
0:404 | 37:*) BODY=""; return 1 ;;
*) BODY=""; return 2 ;;
esac
fi
headers=$(mktemp) || return 2
BODY=$(wget -S -T 20 -qO- "$url" 2>"$headers")
rc=$?
if [ "$rc" = 0 ]; then rm -f "$headers"; return 0; fi
BODY=""
if grep -q 'HTTP/[0-9.]* 404' "$headers"; then rm -f "$headers"; return 1; fi
rm -f "$headers"
return 2
}
# The journal: one `key=value` per line, written whole and renamed into place.
J_phase="" J_target="" J_target_id="" J_version="" J_previous="" J_previous_id="" J_previous_version=""
J_data="" J_backup="" J_engine="" J_caddy="" J_running="" J_started="" J_project=""
JOURNAL_KEYS="phase target target_id version previous previous_id previous_version data backup engine caddy running started project"
write_record() {
local file=$1 key
for key in $JOURNAL_KEYS; do eval "printf '%s=%s\n' \"$key\" \"\$J_$key\""; done >"$file.tmp" && mv "$file.tmp" "$file"
}
read_record() {
local file=$1 key value
[ -f "$file" ] || return 1
# shellcheck disable=SC2034 # `value` is read by the eval
while IFS='=' read -r key value; do
case " $JOURNAL_KEYS " in *" $key "*) eval "J_$key=\$value" ;; esac
done <"$file"
}
phase() {
J_phase=$1
write_record "$JOURNAL"
}
take_lock() {
if ! mkdir -p "$STATE" 2>/dev/null || ! chmod 700 "$STATE" 2>/dev/null || [ ! -w "$STATE" ] || [ ! -w "$DIR" ]; then
printf '%s\n' "$DIR cannot be written by this user, so the update could not keep its journal or change .env. Nothing was changed. Run it as the user who owns $DIR, or with sudo."
exit 4
fi
if ! mkdir "$LOCK" 2>/dev/null; then
local other
other=$(cat "$LOCK/pid" 2>/dev/null || true)
if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then
printf '%s\n' "An update is already running here (process $other). Wait for it to finish."
exit 3
fi
rm -rf "$LOCK"
mkdir "$LOCK" 2>/dev/null || { printf '%s\n' "Another update took the lock in $LOCK just now. Wait for it to finish."; exit 3; }
fi
echo $$ >"$LOCK/pid"
trap 'rm -rf "$LOCK"' EXIT
}
# Helpers run in a short-lived container from the server's own image, as root and without a network.
helper() { # image, then `-v` mounts as source:target[:ro], then `--` and the shell script
local image=$1 mounts=()
shift
while [ "$1" != -- ]; do mounts+=(-v "$1"); shift; done
shift
docker run --rm --network none --user 0:0 --label com.mausbot.role=update-helper "${mounts[@]}" --entrypoint sh "$image" -c "$1"
}
# `same A B` succeeds when the two folders hold the same entries and every file has the same checksum.
VERIFY='manifest() { (cd "$1" && find . -print | LC_ALL=C sort && find . -type f -exec sha256sum {} + | LC_ALL=C sort); }
same() {
a=$(manifest "$1" | sha256sum) && b=$(manifest "$2" | sha256sum) || return 1
if [ "$a" != "$b" ]; then echo "the copy differs from the original"; return 1; fi
echo "checked: $(cd "$2" && find . -print | wc -l) entries, every file by checksum"
}
'
# A new volume for the copy, named for this update, so the copy from the update before stays until this one is checked.
new_backup_name() {
local base name at=1
base="${J_project:-deploy}_data-before-update-$(date -u +%Y%m%dT%H%M%SZ)"
name=$base
while docker volume inspect "$name" >/dev/null 2>&1; do at=$((at + 1)); name="$base-$at"; done
printf '%s' "$name"
}
make_backup_volume() { # name image
docker volume create --label "$BACKUP_ROLE" --label "com.mausbot.project=$J_project" --label "com.mausbot.data=$J_data" --label "com.mausbot.image=$2" "$1" >/dev/null
}
copy_aside() { # data backup image: copy, then check the copy against the data, file by file
docker run --rm --network none --user 0:0 --label com.mausbot.role=update-helper -v "$1:/from:ro" -v "$2:/to" --entrypoint cp "$3" -a /from/. /to/ || return 1
helper "$3" "$1:/from:ro" "$2:/to:ro" -- "$VERIFY"'same /from /to'
}
# Every copy of this install's data but the one this update made: dropped once that one has been checked.
drop_older_copies() {
local volume
for volume in $(docker volume ls -q --filter "label=$BACKUP_ROLE" --filter "label=com.mausbot.data=$J_data" 2>/dev/null); do
[ "$volume" = "$J_backup" ] || docker volume rm -f "$volume" >>"$LOG" 2>&1
done
}
# Puts the copy back. The copy goes into a new folder in the data first and is checked there; only then are the data's
# entries swapped for it, by renames on one filesystem. A restore that fails before the swap leaves the data as it was.
put_back() { # backup data image
helper "$3" "$1:/from:ro" "$2:/to" -- "$VERIFY"'set -u
stage=/to/.omb-restore
old=/to/.omb-replaced
rm -rf "$stage" "$old"
mkdir "$stage" || exit 1
if ! cp -a /from/. "$stage"/ || ! same /from "$stage"; then rm -rf "$stage"; exit 1; fi
mkdir "$old" || exit 1
find /to -mindepth 1 -maxdepth 1 ! -name .omb-restore ! -name .omb-replaced -exec mv {} "$old"/ \; || exit 1
find "$stage" -mindepth 1 -maxdepth 1 -exec mv {} /to/ \; || exit 1
rmdir "$stage" && rm -rf "$old"'
}
room_for_copy() { # data backup image → 0 when there is room where the copy goes, or when it cannot be measured
local sizes used free
# The copy lands in a Docker volume, under Docker's own folder, which for a bind-mounted data folder is another disk.
sizes=$(helper "$3" "$1:/from:ro" "$2:/to" -- 'du -sk /from | cut -f1; df -Pk /to | tail -n 1 | tr -s " " | cut -d" " -f4' 2>/dev/null) || return 0
used=$(printf '%s\n' "$sizes" | sed -n 1p)
free=$(printf '%s\n' "$sizes" | sed -n 2p)
case "$used$free" in '' | *[!0-9]*) return 0 ;; esac
NEEDED_MB=$(((used + used / 10) / 1024 + 1024))
FREE_MB=$((free / 1024))
[ "$FREE_MB" -ge "$NEEDED_MB" ]
}
# After a rollback .env is exactly as it was, unless it named a tag that moves: the installer's `stable`, `latest`, or no
# image at all (the Compose file's `latest`). Such a tag may have moved since, and a later run of the installer or
# `docker compose pull` would follow it and undo the rollback, so the version that ran before is pinned by its digest
# instead. A digest or a released version's own tag (`:v0.1.150`) never moves, and stays as it was. A version built on
# this machine has no digest to pin.
pin_previous() {
local named
case "$J_previous" in "$REPO@sha256:"*) ;; *) return 0 ;; esac
named=$(sed -n 's/^OMB_IMAGE=//p' .env 2>/dev/null | tail -n 1)
case "$named" in
*@sha256:* | *:v[0-9]*.[0-9]*.[0-9]*) return 0 ;;
esac
pin_image "$J_previous"
}
pin_image() { # writes OMB_IMAGE=<ref> into .env, keeping every other line and the file's mode
local tmp="$DIR/.env.update.tmp"
if [ -f .env ]; then cp -p .env "$tmp" && grep -v '^OMB_IMAGE=' .env >"$tmp"; else : >"$tmp" && chmod 600 "$tmp"; fi
printf 'OMB_IMAGE=%s\n' "$1" >>"$tmp" && mv "$tmp" .env
}
keep_env() {
rm -f "$STATE/env.before" "$STATE/env.absent" "$STATE/compose.yaml.before" "$STATE/Caddyfile.before"
if [ -f .env ]; then cp -p .env "$STATE/env.before" && chmod 600 "$STATE/env.before"; else : >"$STATE/env.absent"; fi
}
# The settings go back with the data, and with them the installer's files when this update moved them.
restore_env() {
if [ -f "$STATE/env.before" ]; then cp -p "$STATE/env.before" "$DIR/.env.update.tmp" && mv "$DIR/.env.update.tmp" .env
elif [ -f "$STATE/env.absent" ]; then rm -f .env; fi
local file
for file in compose.yaml Caddyfile; do
if [ -f "$STATE/$file.before" ]; then cp -p "$STATE/$file.before" "$DIR/$file.update.tmp" && mv "$DIR/$file.update.tmp" "$file"; fi
done
}
# An install the installer (deploy/install.sh) made before Caddy got its own container. Caddy shared the server's
# network then, and stayed in the old, dead one whenever the server restarted without it: HTTPS refused while both
# containers looked healthy. The installer rewrites its own two files at every run; an update does the same, with the
# copies below, which are the installer's own (scripts/server-installer.test.ts keeps them equal).
INSTALLER_MARK="# Written by MausBot's installer (deploy/install.sh)"
old_installer_layout() { [ -f compose.yaml ] && grep -qF "$INSTALLER_MARK" compose.yaml && grep -q '^ *network_mode: "service:omb"' compose.yaml; }
# A Compose file of this repository's (path B) with Caddy in the server's network: `git pull` brings the current one.
old_repository_layout() { [ -f docker-compose.yml ] && grep -q '^ *network_mode: "service:omb"' docker-compose.yml; }
move_installer_layout() {
cp -p compose.yaml "$STATE/compose.yaml.before" && cp -p Caddyfile "$STATE/Caddyfile.before" || return 1
installer_compose >compose.yaml.update.tmp && mv compose.yaml.update.tmp compose.yaml &&
installer_caddyfile >Caddyfile.update.tmp && mv Caddyfile.update.tmp Caddyfile
}
installer_compose() {
cat <<'COMPOSE'
# Written by MausBot's installer (deploy/install.sh), and written again each time it runs: put your own settings in
# .env beside this file, which the installer keeps. The server only listens on loopback inside its container, and on
# two Unix sockets in the `edge` volume, which Caddy, in its own container, reaches it through. A request on a socket is
# always a remote one to the server. Caddy does not share the server's network: a server that restarted on its own came
# back in a new one, and left Caddy refusing HTTPS in the old one.
name: openmausbot
services:
omb:
image: ${OMB_IMAGE:?run the installer again}
restart: unless-stopped
init: true
shm_size: 512m
volumes:
- ./data:/data
- edge:/run/openmausbot
environment:
OMB_PORT: "8799"
OMB_WEBHOOK_PORT: "8800"
OMB_TUNNEL_SOCKET: /run/openmausbot/http.sock
OMB_WEBHOOK_SOCKET: /run/openmausbot/hooks.sock
OMB_WEBHOOK_PUBLIC_URL: https://${DOMAIN}
OMB_PUBLIC_URL: https://${DOMAIN}
OMB_SERVER_NAME: ${OMB_SERVER_NAME:-${DOMAIN}}
# Passed only when .env sets them: an empty one would still be set, and Settings' own lists are used when not.
OMB_SIGNIN_EMAILS:
OMB_SIGNIN_MEMBER_EMAILS:
OMB_SIGNIN_SMTP_URL:
OMB_SIGNIN_SMTP_FROM:
# "1": phones pair by QR through MausBot Relay. Sign in once: docker compose exec omb openmausbot login --relay
OMB_PHONE_RELAY:
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8799/api/health | grep -q openmausbot"]
interval: 30s
timeout: 5s
retries: 3
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
# Started with the server, not after it is healthy: Caddy reaches it over the socket
# whenever it answers, so `up` never waits on the server's health.
depends_on:
omb:
condition: service_started
environment:
DOMAIN: ${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- edge:/run/openmausbot
- ./caddy/data:/data
- ./caddy/config:/config
volumes:
# The two sockets: in memory, made afresh by the server at each start. Not under /data, which every bot's sandbox
# sees; the sandbox has its own /run.
edge:
driver_opts:
type: tmpfs
device: tmpfs
o: mode=1777,size=1m
COMPOSE
}
installer_caddyfile() {
cat <<'CADDY'
{$DOMAIN} {
encode zstd gzip
# Webhook ingress is designed to be reachable by third parties (it has its
# own per-hook secrets), so it sits outside any login. The path is passed
# through unchanged: the receiver routes on /hooks/<id>.
handle /hooks/* {
reverse_proxy unix//run/openmausbot/hooks.sock
}
handle {
# Optional second wall in front of pairing (a shared password):
# basic_auth {
# {$BASIC_AUTH_USER} {$BASIC_AUTH_HASH}
# }
# and set both variables in .env (hash with `caddy hash-password`,
# every `$` doubled for Compose).
reverse_proxy unix//run/openmausbot/http.sock {
# SSE: stream events as they arrive
flush_interval -1
# Compression is `encode`'s job above; the server would gzip its
# JSON on its socket otherwise, as for the phone tunnel.
header_up -Accept-Encoding
}
}
}
CADDY
}
# Stops the whole project and checks that the server's container is no longer running: no copy is taken of live data.
stop_server() {
local cid
compose stop >>"$LOG" 2>&1 || return 1
cid=$(container)
[ -z "$cid" ] || [ "$(inspect '{{.State.Running}}' "$cid")" = false ]
}
answers() { compose exec -T "$SERVICE" sh -c 'curl -sf --max-time 5 "http://127.0.0.1:${OMB_PORT:-8799}/api/health"' 2>/dev/null | grep -q openmausbot; }
reported_version() {
compose exec -T "$SERVICE" sh -c 'curl -sf --max-time 5 "http://127.0.0.1:${OMB_PORT:-8799}/.well-known/openmausbot/environment"' 2>/dev/null | json_top version
}
# How many engines can start a bot, as the server counts them (GET /api/health/engines, which a hardened server answers
# a local caller without a session); nothing from a version without that count.
engine_count() {
compose exec -T "$SERVICE" sh -c 'curl -sf --max-time 10 "http://127.0.0.1:${OMB_PORT:-8799}/api/health/engines"' 2>/dev/null |
sed -n 's/.*"engines":\([0-9][0-9]*\).*/\1/p' | head -n 1
}
# At least one engine can start a bot, within $1 seconds: the server's count, or `openmausbot doctor` on a version that
# has no count (it needs the owner's trust, so on a hardened version without the count this cannot tell).
engine_ready() {
local deadline=$(($(date +%s) + ${1:-0})) count
while :; do
count=$(engine_count)
if [ -n "$count" ]; then
[ "$count" -gt 0 ] && return 0
elif compose exec -T "$SERVICE" openmausbot doctor >/dev/null 2>&1; then
return 0
fi
[ "$(date +%s)" -lt "$deadline" ] || return 1
sleep "$POLL_S"
done
}
caddy_runs() { compose ps --status running --services 2>/dev/null | grep -qx caddy; }
last_words() { compose logs --no-color --no-log-prefix --tail 40 "$SERVICE" 2>/dev/null | grep -v '^[[:space:]]*$' | tail -n 1 | cut -c 1-300; }
# The server runs image $1, answers /api/health three times in a row, and (with $2) says it is version $2. Sets WHY.
wait_healthy() {
local want=$1 version=${2:-} deadline=$(($(date +%s) + WAIT_S)) streak=0 cid state status restarts image said
WHY=""
while :; do
cid=$(container)
if [ -z "$cid" ]; then WHY="its container is gone"; return 1; fi
state=$(inspect '{{.State.Status}} {{.RestartCount}} {{.Image}}' "$cid")
status=${state%% *}
restarts=$(printf '%s' "$state" | cut -d' ' -f2)
image=${state##* }
if [ "$image" != "$want" ]; then WHY="the server is not running the image it should"; return 1; fi
case "$status" in
exited | dead) WHY="it stopped as it started ($(last_words))"; return 1 ;;
esac
if [ "${restarts:-0}" -ge 3 ] 2>/dev/null; then WHY="it kept stopping as it started ($(last_words))"; return 1; fi
if [ "$status" = running ] && answers; then streak=$((streak + 1)); else streak=0; fi
[ "$streak" -ge 3 ] && break
if [ "$(date +%s)" -ge "$deadline" ]; then WHY="it did not answer within $WAIT_S seconds"; return 1; fi
sleep "$POLL_S"
done
if [ -n "$version" ]; then
said=$(reported_version)
if [ "$said" != "$version" ]; then WHY="it says it is version ${said:-unknown}, not $version"; return 1; fi
fi
}
# What worked before the update still works: an engine that could start a bot, and Caddy in front.
wait_whole() {
local tries=0
if [ "$J_engine" = ready ] && ! engine_ready "$ENGINE_WAIT_S"; then WHY="no engine can start a bot on it, and one could before"; return 1; fi
if [ "$J_caddy" = running ]; then
until caddy_runs; do
tries=$((tries + 1))
if [ "$tries" -ge 12 ]; then WHY="Caddy, which serves it to the internet, did not start again"; return 1; fi
sleep "$POLL_S"
done
fi
}
finish() {
cp "$JOURNAL" "$LAST.tmp" && mv "$LAST.tmp" "$LAST"
rm -f "$JOURNAL"
say "Updated from $(old_name) to $(new_name)."
if old_repository_layout; then
say "Note: this docker-compose.yml runs Caddy in the server's network, so HTTPS goes down whenever the server restarts without Caddy (a crash, docker compose restart). git pull brings the current one, then: docker compose up -d"
fi
say "The data from before the update is kept in the Docker volume $J_backup until the next update."
say "To go back to it: ./update.sh --rollback"
}
# Puts back the settings, the data and the image from before the update, then says what happened and exits. The journal
# stays until the data is back, so a rollback that could not finish is tried again, never forgotten.
roll_back() { # why, exit code when the old version is back
local why=$1 code=$2 helper_image=$J_previous_id back=no
trap '' INT TERM
phase rollback
say "$why"
say "Putting back the version and the data from before the update…"
if ! stop_server; then
say "The server could not be stopped (see $LOG), so its data was left as it is. The update's journal is kept: run ./update.sh --rollback again once docker compose stop works."
exit 2
fi
if ! docker volume inspect "$J_backup" >/dev/null 2>&1; then
restore_env
pin_previous
compose up -d --no-build >>"$LOG" 2>&1
rm -f "$JOURNAL" "$LAST"
say "The copy of the data from before the update ($J_backup) is missing, so the data was left as it is. The server runs $(old_name) again."
exit 2
fi
# The settings go back with the data, never before it: an old version is not started on data a newer one left.
if ! put_back "$J_backup" "$J_data" "$helper_image" >>"$LOG" 2>&1; then
say "The data could not be put back from the Docker volume $J_backup (see $LOG). Neither was changed: the data is as $(new_name) left it, and the volume still holds the copy. The server is stopped."
say "Once the cause is fixed (often disk space), ./update.sh --rollback tries again. To run the server on the data as it is instead: docker compose up -d (that starts $(new_name) again)."
exit 2
fi
restore_env
pin_previous
compose up -d --no-build >>"$LOG" 2>&1
if [ "$(inspect '{{.Image}}' "$(container)")" != "$J_previous_id" ]; then
# .env named a tag that has moved since: the image kept aside at the swap is the one that ran.
pin_image "$KEEP_TAG"
compose up -d --no-build >>"$LOG" 2>&1
fi
if wait_healthy "$J_previous_id"; then back=yes; fi
rm -f "$JOURNAL" "$LAST"
if [ "$back" = no ]; then
say "$(old_name) and its data are back, but it did not become healthy either ($WHY). See: docker compose logs $SERVICE"
exit 2
fi
say "Your server runs $(old_name) again, with its data as it was before the update."
exit "$code"
}
# An update an earlier run left in its swap, wait or rollback step. Kept when its new version is healthy; otherwise
# nothing happens to the data until someone who can see what it costs says so.
earlier_update() {
local answer wait=60
[ "$WAIT_S" -lt 60 ] 2>/dev/null && wait=$WAIT_S
if [ "$J_phase" != rollback ] && [ "$MODE" != rollback ] && WAIT_S=$wait wait_healthy "$J_target_id" "$J_version" && wait_whole; then
say "The last update was interrupted after the new version had started, and it is healthy."
finish
return 0
fi
if [ "$J_phase" = rollback ]; then
say "An earlier update to $(new_name) was being undone, and putting its data back did not finish."
elif [ "$MODE" = rollback ]; then
say "An earlier update to $(new_name), started ${J_started:-earlier}, did not finish."
else
say "An earlier update to $(new_name), started ${J_started:-earlier}, did not finish, and $(new_name) is not healthy now ($WHY)."
fi
say "Going back puts $(old_name) and the data as it was at ${J_started:-that update} back, from the Docker volume $J_backup. Everything made on this server since then (chats, files, settings) is lost."
if [ "$MODE" = rollback ] && [ "$ASSUME_YES" = yes ]; then
answer=yes
elif [ "$MODE" = rollback ]; then
answer=$(ask "Type yes to go back: ")
else
answer=$(ask "Type yes to go back, or keep to leave the server as it is and forget that update: ")
fi
case "$MODE:$answer" in
rollback:yes) roll_back "Going back, as asked." 0 ;;
update:yes) roll_back "Going back, as asked." 1 ;;
update:keep)
cp "$JOURNAL" "$LAST.tmp" && mv "$LAST.tmp" "$LAST"
rm -f "$JOURNAL"
say "Left as it is. The copy from before that update stays in the Docker volume $J_backup; ./update.sh --rollback puts it back."
;;
*) refuse "Nothing was changed. The unfinished update stays recorded in $JOURNAL, and the next ./update.sh asks again." ;;
esac
}
# Finishes or undoes an update that was cut short. `signal`: this run was interrupted; otherwise an earlier one was.
recover() {
local how=${1:-}
read_record "$JOURNAL" || return 0
case "$J_phase" in
pull)
# Only this update's own, new copy is removed; the copy from the update before is never touched here.
[ -n "$J_backup" ] && docker volume rm -f "$J_backup" >/dev/null 2>&1
rm -f "$JOURNAL"
[ -n "$how" ] && say "Stopped before anything was changed."
;;
backup)
docker volume rm -f "$J_backup" >/dev/null 2>&1
[ "$J_running" = yes ] && compose up -d --no-build >>"$LOG" 2>&1
rm -f "$JOURNAL"
say "The update stopped while it copied the data aside. Nothing was changed, and the server was started again."
;;
swap | wait | rollback)
if [ -n "$how" ]; then roll_back "The update to $(new_name) was interrupted." 1; fi
earlier_update
;;
*) rm -f "$JOURNAL" ;;
esac
}
# Ctrl-C undoes only what this run started. While it looks at or asks about an earlier run's update, nothing changes.
THIS_RUN=no
interrupted() {
trap '' INT TERM
say ""
say "Interrupted."
if [ "$THIS_RUN" = yes ]; then recover signal; else say "Nothing was changed."; fi
exit 130
}
usage() {
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
MODE=update WANT_VERSION="" WANT_IMAGE="" ASSUME_YES=no REPLACE_BUILD=no ALLOW_OLDER=no
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || usage 4; WANT_VERSION=${2#v}; shift ;;
--image) [ $# -ge 2 ] || usage 4; WANT_IMAGE=$2; shift ;;
--dir) [ $# -ge 2 ] || usage 4; shift ;;
--check) MODE=check ;;
--rollback) MODE=rollback ;;
--yes | -y) ASSUME_YES=yes ;;
--replace-local-build) REPLACE_BUILD=yes ;;
--allow-older) ALLOW_OLDER=yes ;;
-h | --help) usage 0 ;;
*) printf 'Unknown option: %s\n\n' "$1"; usage 4 ;;
esac
shift
done
if [ -n "$WANT_VERSION" ] && ! is_version "$WANT_VERSION"; then refuse "--version takes a released version such as 0.1.151."; fi
docker compose version >/dev/null 2>&1 || refuse "This needs Docker with the Compose plugin (docker compose version)."
[ -f docker-compose.yml ] || [ -f compose.yaml ] || [ -n "${COMPOSE_FILE:-}" ] || refuse "There is no docker-compose.yml or compose.yaml in $DIR. Name the install's folder: ./update.sh --dir PATH"
if [ "$MODE" != check ]; then
take_lock
trap interrupted INT TERM
recover
fi
if [ "$MODE" = rollback ]; then
read_record "$LAST" || refuse "There is no earlier update to go back from."
if [ "$ASSUME_YES" != yes ]; then
say "This puts back $(old_name) and the data as it was before the last update${J_started:+ (taken $J_started)}."
[ "$(ask "Everything made since then (chats, files, settings) is lost. Type yes to go on: ")" = yes ] || refuse "Nothing was changed."
fi
roll_back "Going back, as asked." 0
fi
# A fresh record for this update: nothing of a journal read above carries over.
J_phase="" J_target="" J_target_id="" J_version="" J_engine="" J_caddy="" J_started=""
CID=$(container)
[ -n "$CID" ] || refuse "This install has no server yet. Start it once first: docker compose up -d"
J_previous_id=$(inspect '{{.Image}}' "$CID")
J_previous=$(published_ref "$J_previous_id")
J_previous_version=$(image_version "$J_previous_id")
J_data=$(inspect '{{range .Mounts}}{{if eq .Destination "/data"}}{{if eq .Type "volume"}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' "$CID")
J_project=$(inspect '{{index .Config.Labels "com.docker.compose.project"}}' "$CID")
[ -n "$J_data" ] || refuse "The server's container has no /data volume, so its data cannot be copied aside. Nothing was changed."
if [ "$(inspect '{{.State.Running}}' "$CID")" = true ]; then J_running=yes; else J_running=no; fi
# The version the server says it is decides what counts as older: a build of main (`latest`) is labelled with the
# branch, not a version, and says the version of the release it was built after.
RUNNING_VERSION=""
[ "$J_running" = yes ] && RUNNING_VERSION=$(reported_version)
is_version "$RUNNING_VERSION" || RUNNING_VERSION=$J_previous_version
CHANNEL=""
if [ -n "$WANT_IMAGE" ]; then
J_target=$WANT_IMAGE
elif [ -n "$WANT_VERSION" ]; then
J_target="$REPO:v$WANT_VERSION"
J_version=$WANT_VERSION
else
fetch "$STABLE_URL"
case $? in
0)
CHANNEL=stable
J_target=$(printf '%s' "$BODY" | json_top image)
J_version=$(printf '%s' "$BODY" | json_top version)
printf '%s' "$J_target" | grep -Eq "$PINNED" && is_version "$J_version" || refuse "The stable channel names no image this script accepts. Nothing was changed."
;;
1)
# No version has been made stable yet: the newest release, as the server's own update check says then.
fetch "$NEWEST_URL" || refuse "Could not find the newest release ($NEWEST_URL). Nothing was changed. To name a version yourself: ./update.sh --version X.Y.Z"
J_version=$(printf '%s' "$BODY" | json_top tag_name)
J_version=${J_version#v}
is_version "$J_version" || refuse "The newest release names no version this script accepts. Nothing was changed."
CHANNEL=newest
J_target="$REPO:v$J_version"
;;
*) refuse "Could not read the stable channel ($STABLE_URL). Nothing was changed. To name a version yourself: ./update.sh --version X.Y.Z" ;;
esac
fi
# Why the version asked for may not replace the one running without --allow-older, or nothing.
older_reason() {
if [ -z "$J_version" ]; then
printf '%s' "$J_target names no released version, so this cannot tell whether it is older than the version running."
elif [ -z "$RUNNING_VERSION" ]; then
printf '%s' "This cannot tell which version the server runs (it does not answer, and its image names no released version), so it cannot tell whether $J_version is older."
elif older_than "$J_version" "$RUNNING_VERSION"; then
printf '%s' "This server runs $RUNNING_VERSION, which is newer than $J_version. An older version may not read what a newer one wrote."
elif [ "$J_version" = "$RUNNING_VERSION" ] && [ -z "$J_previous_version" ]; then
printf '%s' "This server runs a build of main that says $RUNNING_VERSION, and may hold changes made after $J_version was released. It moves to stable by itself once a version newer than $RUNNING_VERSION is stable."
fi
}
if [ "$MODE" = check ]; then
[ -n "$J_version" ] || J_version=$(image_version "$J_target")
if [ -n "$RUNNING_VERSION" ] && [ "$RUNNING_VERSION" != "$J_previous_version" ]; then printf '%s\n' "This server runs $(old_name), which says it is $RUNNING_VERSION."
else printf '%s\n' "This server runs $(old_name)."; fi
[ "$CHANNEL" = newest ] && printf '%s\n' "No version has been made stable yet, so ./update.sh takes the newest release, $J_version."
[ -f "$JOURNAL" ] && printf '%s\n' "An earlier update did not finish. ./update.sh first says what it would cost to go back, and asks."
if [ -n "$J_previous" ] && [ "$J_previous" = "$J_target" ]; then printf '%s\n' "That is the version asked for. Nothing to do."
elif [ "$ALLOW_OLDER" != yes ] && [ -n "$(older_reason)" ]; then printf '%s\n' "$(older_reason)" "./update.sh would change nothing without --allow-older."
else printf '%s\n' "./update.sh would update it to $(new_name)."; fi
if old_installer_layout; then printf '%s\n' "It would also move Caddy to its own container: in the server's network, HTTPS goes down whenever the server restarts without it."
elif old_repository_layout; then printf '%s\n' "This docker-compose.yml runs Caddy in the server's network, where HTTPS goes down whenever the server restarts without it: git pull brings the current one."; fi
exit 0
fi
if [ -z "$J_previous" ] && [ "$REPLACE_BUILD" != yes ]; then
refuse "This server runs an image built on this machine (docker compose up --build), with whatever ENGINES you baked into it. To stay on your own build: git pull && docker compose up -d --build. To move to the published image instead: ./update.sh --replace-local-build"
fi
J_backup=$(new_backup_name)
# Gives up before anything was changed: this update's journal and its own, new copy go; nothing else does.
BACKUP_MADE=no
abandon() {
[ "$BACKUP_MADE" = yes ] && docker volume rm -f "$J_backup" >/dev/null 2>&1
rm -f "$JOURNAL"
}
THIS_RUN=yes
phase pull
[ "$CHANNEL" = newest ] && say "No version has been made stable yet, so this takes the newest release, $J_version."
say "Downloading $(new_name)…"
if ! docker pull -q "$J_target" >>"$LOG" 2>&1 && [ -z "$(image_id "$J_target")" ]; then
abandon
refuse "Could not download $J_target (see $LOG). Nothing was changed."
fi
J_target_id=$(image_id "$J_target")
case "$J_target" in
*@sha256:*) ;;
*) PUBLISHED=$(published_ref "$J_target_id"); [ -n "$PUBLISHED" ] && J_target=$PUBLISHED ;;
esac
[ -n "$J_version" ] || J_version=$(image_version "$J_target_id")
if [ "$J_target_id" = "$J_previous_id" ]; then
abandon
say "This server already runs $(new_name). Nothing to do."
exit 0
fi
if [ "$ALLOW_OLDER" != yes ] && [ -n "$(older_reason)" ]; then
abandon
say "$(older_reason)"
say "Nothing was changed. To go ahead anyway, with the data copied first: ./update.sh --allow-older${WANT_VERSION:+ --version $WANT_VERSION}${WANT_IMAGE:+ --image $WANT_IMAGE}"
exit 5
fi
if ! OMB_IMAGE="$J_target" docker compose config 2>/dev/null | grep -qF "image: $J_target"; then
abandon
refuse "The Compose file in $DIR does not take its image from OMB_IMAGE. Get the current one (git pull, or run the installer again), then run this again. Nothing was changed."
fi
BACKUP_MADE=yes
if ! make_backup_volume "$J_backup" "$J_previous_id" || ! room_for_copy "$J_data" "$J_backup" "$J_previous_id"; then
abandon
refuse "There is not enough free disk for a copy of the data: it needs about ${NEEDED_MB:-?} MB and ${FREE_MB:-?} MB are free. Free some space, then run this again. Nothing was changed."
fi
J_engine=unknown
J_caddy=stopped
if [ "$J_running" = yes ]; then
engine_ready && J_engine=ready
caddy_runs && J_caddy=running
fi
J_started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
phase backup
say "Stopping the server and copying its data aside…"
copied=no
if stop_server; then
if copy_aside "$J_data" "$J_backup" "$J_previous_id" >>"$LOG" 2>&1; then copied=yes; fi
why="Copying the data aside failed, or the copy did not match the data (see $LOG)."
else
why="The server could not be stopped (see $LOG), so its data was not copied."
fi
if [ "$copied" != yes ]; then
docker volume rm -f "$J_backup" >/dev/null 2>&1
[ "$J_running" = yes ] && compose up -d --no-build >>"$LOG" 2>&1
rm -f "$JOURNAL"
refuse "$why Nothing was changed, and the server was started again."
fi
keep_env
docker tag "$J_previous_id" "$KEEP_TAG" >/dev/null 2>&1
phase swap
# The new copy is checked, and from here on an interruption puts it back: the copy from the update before may go now,
# and with it the way back to that one.
rm -f "$LAST"
drop_older_copies
say "Starting $(new_name)…"
if old_installer_layout; then
if move_installer_layout; then say "Caddy moves to its own container, so HTTPS stays up whenever the server restarts on its own."
else roll_back "The installer's compose.yaml and Caddyfile could not be rewritten (see $LOG)." 1; fi
fi
pin_image "$J_target"
# Where Caddy waits for a healthy server (a Compose file from before Caddy had its own container), `up` itself waits,
# and fails when the new version never gets there; the wait below then says why. The current files start Caddy with
# the server, so `up` returns at once and the wait below is the only one.
if ! compose up -d --no-build >>"$LOG" 2>&1 && [ "$(inspect '{{.Image}}' "$(container)")" != "$J_target_id" ]; then
roll_back "Docker could not start $(new_name) (see $LOG)." 1
fi
phase wait
say "Waiting for it to answer and to be able to start bots…"
if ! wait_healthy "$J_target_id" "$J_version" || ! wait_whole; then
roll_back "The update to $(new_name) did not work: $WHY." 1
fi
finish