#!/usr/bin/env bash # # Weekly image refresh for the compose stack in /home/connor. # # Why this exists: every service in compose.yml is pinned to `:latest` (bar the # two Immich images, which carry digests), podman-auto-update.timer is disabled # and nothing else pulled. The tag made the stack look current while the # running images were up to twelve months old -- jellyseerr, the personal-site # pair and searxng's valkey had not moved since 2025. `:latest` without a # puller is not a rolling tag, it is a snapshot with a misleading name. # # Recreation is deliberate rather than pull-only. Stale images are the larger # standing risk, `restart: unless-stopped` plus the nightly restic snapshot # make a bad pull recoverable, and podman keeps the previous image so a # rollback is `podman tag` away. Set APPLY=0 below to downgrade this to # pull-and-notify if that trade ever stops being worth it. set -euo pipefail umask 077 APPLY=1 PROJECT_DIR=/home/connor NTFY_URL=https://ntfy.rcjohnstone.com/infra NTFY_ENV=/etc/ntfy/publish.env notify() { # notify <body> local u p [ -r "$NTFY_ENV" ] || return 0 # PARSED, not sourced: the bot password contains ` and &, so sourcing it # dies with a syntax error. Same reason the restic scripts use sed. u=$(sed -n 's/^NTFY_USER=//p' "$NTFY_ENV" | head -1) p=$(sed -n 's/^NTFY_PASS=//p' "$NTFY_ENV" | head -1) [ -n "$u" ] && [ -n "$p" ] || return 0 curl -fsS --max-time 20 -u "$u:$p" \ -H "Title: $3" -H "Priority: $1" -H "Tags: $2" \ -d "$4" "$NTFY_URL" >/dev/null || true } # Trap installed before anything can fail, so a crash still reports. The # restic script learned this the hard way: its failure-log code sat above the # first thing that could die, and the one failure it was written to capture # happened before it was reached. LOG=$(mktemp /tmp/podman-stack-update.XXXXXX) FAILLOG=/var/log/podman-stack-update.failed.log cleanup() { local rc=$? if [ "$rc" -ne 0 ]; then cp -f "$LOG" "$FAILLOG" 2>/dev/null || true notify urgent rotating_light "Stack update FAILED on $(uname -n)" \ "exit $rc"$'\n\n'"$(tail -n 25 "$LOG")" fi rm -f "$LOG" } trap cleanup EXIT cd "$PROJECT_DIR" # Image IDs before, so the report names what actually moved rather than # everything that was pulled. declare -A before while read -r ref id; do before["$ref"]=$id; done < <( podman images --format '{{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null) echo "=== pull ===" >>"$LOG" podman-compose pull >>"$LOG" 2>&1 changed=() while read -r ref id; do [ "$ref" = "<none>:<none>" ] && continue if [ "${before[$ref]:-none}" != "$id" ]; then changed+=("$ref"); fi done < <(podman images --format '{{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null) if [ "${#changed[@]}" -eq 0 ]; then echo "no image changes" >>"$LOG" notify low white_check_mark "Stack update: no changes" "All images already current." exit 0 fi if [ "$APPLY" -ne 1 ]; then notify default package "Stack update: ${#changed[@]} image(s) available" \ "$(printf '%s\n' "${changed[@]}")"$'\n\n'"APPLY=0, not recreated." exit 0 fi echo "=== up -d ===" >>"$LOG" podman-compose up -d >>"$LOG" 2>&1 # A container that is not running 30s after recreation is the failure mode # worth shouting about -- a bad image starts, crashes, and without this the # run still looks like a success. sleep 30 notrunning=$(podman ps -a --filter 'label=io.podman.compose.project=connor' \ --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -v '^\S* Up' || true) if [ -n "$notrunning" ]; then notify urgent rotating_light "Stack update: containers DOWN on $(uname -n)" \ "Updated:"$'\n'"$(printf '%s\n' "${changed[@]}")"$'\n\n'"Not running:"$'\n'"$notrunning" exit 1 fi notify default package "Stack update OK on $(uname -n)" \ "${#changed[@]} image(s) updated:"$'\n'"$(printf '%s\n' "${changed[@]}")"