#!/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 ]