From ef8856598bcf05715e4f209658d7ec482a90d79e Mon Sep 17 00:00:00 2001 From: Timothy Kim Date: Sat, 6 Jun 2026 13:19:26 -0400 Subject: [PATCH] add docker update script --- scripts/update-docker.sh | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100755 scripts/update-docker.sh diff --git a/scripts/update-docker.sh b/scripts/update-docker.sh new file mode 100755 index 0000000..e07b01e --- /dev/null +++ b/scripts/update-docker.sh @@ -0,0 +1,241 @@ +#!/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 '') + 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 ]