Files
argento/scripts/update-docker.sh
T
2026-06-06 13:19:26 -04:00

242 lines
8.7 KiB
Bash
Executable File

#!/bin/bash
# update-docker.sh — pull/build the latest images for every app under docker/,
# recreate only the containers whose image changed, validate they come back
# healthy, and run Nextcloud's post-upgrade occ steps. Prints a per-app summary
# (updated / no update / failed) and exits non-zero if anything failed.
#
# In-place by design: it never runs a global `docker compose down`, because the
# `shared` network is created by the nginx compose file and every other app
# joins it as external. Tearing it all down at once would break that network and
# cause needless downtime. `docker compose up -d` recreates only the changed
# containers and leaves the rest (and the network) alone.
#
# Usage:
# ./update-docker.sh # update every app under docker/
# ./update-docker.sh nextcloud nginx # update only the named apps
# ./update-docker.sh --prune # also reclaim dangling images afterward
#
# Env overrides:
# DOCKER_DIR=/path/to/docker # default: ../docker relative to this script
# HEALTH_TIMEOUT=180 # seconds to wait for containers to be healthy
#
# Runs on the server (Rocky Linux 9), where each app's .env is present so compose
# can resolve secrets. Run it from anywhere; it cd's into each app dir itself.
set -uo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
DOCKER_DIR="${DOCKER_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)/docker}"
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-180}"
PRUNE=false
REQUESTED=()
for arg in "$@"; do
case "$arg" in
--prune) PRUNE=true ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*) REQUESTED+=("$arg") ;;
esac
done
# `docker compose pull --ignore-buildable` cleanly skips services with a build
# section (added in Compose v2.30). Without it, pulling minecraft would error on
# its local-only image tag — harmless here since we treat pull failures as
# non-fatal and rebuild buildable services anyway, but the flag keeps output clean.
PULL_FLAGS=""
if docker compose pull --help 2>/dev/null | grep -q -- '--ignore-buildable'; then
PULL_FLAGS="--ignore-buildable"
fi
UPDATED=()
NOUPDATE=()
FAILED=()
# Stable "ref=imageID" listing of every image the project resolves to, so we can
# tell whether a pull/build actually changed anything.
image_signature() {
local img id
while IFS= read -r img; do
[ -z "$img" ] && continue
id=$(docker image inspect -f '{{.Id}}' "$img" 2>/dev/null || echo '<none>')
printf '%s=%s\n' "$img" "$id"
done < <(docker compose config --images 2>/dev/null) | sort
}
compose_has_build() {
docker compose config 2>/dev/null | grep -qE '^[[:space:]]+build:'
}
# Wait until every container in the project is running, and (if it has a
# healthcheck) healthy. Returns non-zero on timeout.
validate_app() {
local deadline cid cname state health all_ok pending
deadline=$(( SECONDS + HEALTH_TIMEOUT ))
while :; do
all_ok=true
pending=""
while IFS= read -r cid; do
[ -z "$cid" ] && continue
cname=$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's#^/##')
state=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null)
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null)
if [ "$state" != "running" ]; then
all_ok=false; pending+=" $cname=$state"
elif [ "$health" = "starting" ] || [ "$health" = "unhealthy" ]; then
all_ok=false; pending+=" $cname=health:$health"
fi
done < <(docker compose ps -a -q)
$all_ok && return 0
if [ "$SECONDS" -ge "$deadline" ]; then
echo " validation timed out after ${HEALTH_TIMEOUT}s:$pending"
return 1
fi
sleep 5
done
}
# Nextcloud post-upgrade steps.
#
# The official image's entrypoint already runs `occ upgrade` on start when it
# detects a version change, and holds the instance in maintenance mode while it
# does (https://github.com/nextcloud/docker README). The old upgrade.sh failed
# because it ran `occ upgrade` after a fixed `sleep 10` — i.e. while the
# entrypoint was still mid-upgrade. So here we first wait for maintenance mode to
# clear, then run occ ourselves.
#
# Exit codes from core/Command/Upgrade.php (current master): 0 = success AND
# "no upgrade required" (ERROR_UP_TO_DATE == 0), 2 = maintenance mode,
# 5 = failure. So a non-zero from `occ upgrade` is a genuine problem, not the
# benign "already up to date" it used to be in older versions (which returned 3).
nextcloud_occ() {
local rc=0 deadline
deadline=$(( SECONDS + HEALTH_TIMEOUT ))
echo " occ: waiting for the entrypoint's auto-upgrade / maintenance mode to clear"
while docker compose exec -T -u www-data nextcloud php occ maintenance:mode 2>/dev/null \
| grep -q 'currently enabled'; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo " still in maintenance mode after ${HEALTH_TIMEOUT}s — aborting occ steps"
return 1
fi
sleep 5
done
# Safety-net upgrade. Normally a no-op (the entrypoint already migrated), so
# this should print "No upgrade required" and return 0. A non-zero exit is real.
echo " occ: upgrade"
docker compose exec -T -u www-data nextcloud php occ upgrade 2>&1 | sed 's/^/ /'
local up=${PIPESTATUS[0]}
if [ "$up" -ne 0 ]; then
echo " occ upgrade exited $up (2=maintenance, 5=failure) — see output above"
rc=1
fi
# Documented post-upgrade index optimization; idempotent and safe to repeat.
echo " occ: add missing DB indices"
docker compose exec -T -u www-data nextcloud php occ db:add-missing-indices 2>&1 | sed 's/^/ /'
[ "${PIPESTATUS[0]}" -ne 0 ] && rc=1
echo " occ: status"
docker compose exec -T -u www-data nextcloud php occ status 2>&1 | sed 's/^/ /'
[ "${PIPESTATUS[0]}" -ne 0 ] && rc=1
return $rc
}
update_app() {
local name="$1"
local dir="$DOCKER_DIR/$name"
if ! cd "$dir" 2>/dev/null; then
FAILED+=("$name (cannot cd into $dir)"); return
fi
echo "==> $name: checking for updates"
local before after
before=$(image_signature)
# Pull registry images. Lenient: a warning/error here (e.g. an image that is
# only built locally) shouldn't abort the run — validation catches real breakage.
docker compose pull $PULL_FLAGS 2>&1 | sed 's/^/ /'
if compose_has_build; then
echo " rebuilding local images against latest base"
docker compose build --pull 2>&1 | sed 's/^/ /'
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
FAILED+=("$name (build failed)"); return
fi
fi
after=$(image_signature)
if [ "$before" = "$after" ]; then
echo " no update needed"
NOUPDATE+=("$name"); return
fi
echo " new image(s) detected — recreating containers"
docker compose up -d 2>&1 | sed 's/^/ /'
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
FAILED+=("$name (up -d failed)"); return
fi
if ! validate_app; then
FAILED+=("$name (unhealthy after update)"); return
fi
if [ "$name" = "nextcloud" ]; then
if ! nextcloud_occ; then
FAILED+=("$name (occ steps failed)"); return
fi
fi
echo " updated and validated"
UPDATED+=("$name")
}
# Build the work list: every dir under docker/ that has a compose file, with
# nginx first so the shared network owner is touched before its dependents.
all_apps=()
while IFS= read -r d; do
name=$(basename "$d")
if compgen -G "$d/compose.y*ml" >/dev/null || compgen -G "$d/docker-compose.y*ml" >/dev/null; then
all_apps+=("$name")
fi
done < <(find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -type d | sort)
if [ "${#REQUESTED[@]}" -gt 0 ]; then
# Validate requested names against what actually exists.
apps=()
for req in "${REQUESTED[@]}"; do
found=false
for a in "${all_apps[@]}"; do [ "$a" = "$req" ] && found=true; done
if $found; then apps+=("$req"); else
echo "no such app under docker/: $req" >&2; exit 2
fi
done
else
apps=("${all_apps[@]}")
fi
# nginx first, the rest after.
ordered=()
for a in "${apps[@]}"; do [ "$a" = nginx ] && ordered+=("nginx"); done
for a in "${apps[@]}"; do [ "$a" != nginx ] && ordered+=("$a"); done
for name in "${ordered[@]}"; do
update_app "$name"
done
echo
echo "================ summary ================"
echo "updated: ${UPDATED[*]:-none}"
echo "no update: ${NOUPDATE[*]:-none}"
echo "failed: ${FAILED[*]:-none}"
if $PRUNE && [ "${#UPDATED[@]}" -gt 0 ]; then
echo
echo "pruning dangling images"
docker image prune -f 2>&1 | sed 's/^/ /'
fi
[ "${#FAILED[@]}" -eq 0 ]