#!/usr/bin/env bash # # update.sh -- keep the Ergo stack current: container updates with a DB snapshot # and health-checked rollback, plus the Caddy -> Ergo TLS certificate sync. # Companion to deploy.sh; installed in $STACK_DIR next to ergolib.sh. # # Subcommands: # check (default) current vs latest, advisories, cert state; changes nothing # run what the daily schedule runs: apply UPDATE_POLICY, cert sync, expiry check # update update now (to latest, or TARGET_VERSION=x.y.z); honours FORCE_UPDATE # certsync copy Caddy's cert into ircd/ and rehash if it changed (15-min schedule) # caddy pull a newer Caddy image and recreate it (health-checked) # install write /etc/ergo-update.conf + schedule the jobs # uninstall remove the schedule # # Policy (UPDATE_POLICY): # latest update to the newest release whenever one exists (default) # security update ONLY when a published GitHub security advisory covers the # running version, or a release between current and latest has a # "### Security" section in its notes # off never change the running version (check/notify only) # # Safety rails (all policies): # * releases whose notes announce "Compatibility breaks" are HELD (notify # only) unless FORCE_UPDATE=1 -- review the notes, then `update update` # * the new image must load the current ircd/ (ergo run --smoke on a copy, # which also dry-runs any DB schema upgrade) before the live server is touched # * users get a NOTICE and UPDATE_GRACE seconds before the restart # * ircd.db is snapshotted with Ergo stopped (buntdb is append-only; a live # copy can miss the last second of writes) # * health = compose healthcheck (IRC-level) + a registration handshake; on # failure the previous tag is restored and, if the new version bumped the # DB schema, the snapshot is put back # # Env, in precedence order: environment > /etc/ergo-update.conf > the stack's # .env > the built-in default. # STACK_DIR=/srv/ergo UPDATE_POLICY=latest FORCE_UPDATE=0 UPDATE_GRACE=60 # CADDY_AUTOUPDATE=0 NOTIFY=1 SSH_NOTIFY_CONF=/etc/ssh-notify.conf DRY_RUN=0 # TARGET_VERSION= GH_REPO=ergochat/ergo LOG=/var/log/ergo-update.log # SCHEDULE_UPDATES=1 (install: 0 schedules certsync only, no daily update) # VERBOSE= (certsync: also log "nothing to do" outcomes) set -euo pipefail SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" SELF_DIR="$(dirname "$SELF")" log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } # shellcheck source=ergolib.sh . "$SELF_DIR/ergolib.sh" load_conf : "${STACK_DIR:=$SELF_DIR}" ergo_set_paths # .env is the stack's own settings file and documents these knobs, so honour it # for anything the environment and /etc/ergo-update.conf did not already set. env_defaults UPDATE_POLICY FORCE_UPDATE UPDATE_GRACE CADDY_AUTOUPDATE : "${UPDATE_POLICY:=latest}" : "${FORCE_UPDATE:=0}" : "${UPDATE_GRACE:=60}" : "${CADDY_AUTOUPDATE:=0}" : "${TARGET_VERSION:=}" : "${SCHEDULE_UPDATES:=1}" : "${LOG:=/var/log/ergo-update.log}" # --------------------------------------------------------------------------- # Release metadata (GitHub). Unauthenticated: 60 requests/hour -- a run uses 2-3. # --------------------------------------------------------------------------- RELEASES_JSON="" # Fetch the release list ONCE per process. This must be called as a plain # statement, never inside a pipeline or $(): the assignment would land in a # subshell and every use would re-download the list (and burn the 60/hour # anonymous API budget). load_releases() { [[ -n "$RELEASES_JSON" ]] && return 0 RELEASES_JSON="$(fetch "https://api.github.com/repos/${GH_REPO}/releases?per_page=50" || true)" printf '%s' "$RELEASES_JSON" | jq -e 'type=="array"' >/dev/null 2>&1 || RELEASES_JSON="[]" return 0 } # Bodies of stable releases with cur < version <= upto, newest first: # "=== " header lines followed by the body. release_notes_between() { # local cur="$1" upto="$2" tag v load_releases while IFS= read -r tag; do [[ -n "$tag" ]] || continue v="$(normver "$tag")"; [[ -n "$v" ]] || continue ver_gt "$v" "$cur" || continue ver_le "$v" "$upto" || continue printf '=== %s\n' "$tag" printf '%s' "$RELEASES_JSON" | jq -r --arg t "$tag" '.[] | select(.tag_name==$t) | .body // ""' printf '\n' done < <(printf '%s' "$RELEASES_JSON" | jq -r '.[] | select(.prerelease==false and .draft==false) | .tag_name' 2>/dev/null) } # Published GitHub security advisories covering . Prints "GHSA-... " # lines. Ranges look like "<= v2.19.0" or ">= v2.15.0, < v2.19.1". ghsa_covering() { # local cur="$1" json id rng patched part op ver ok json="$(fetch "https://api.github.com/repos/${GH_REPO}/security-advisories" || true)" printf '%s' "$json" | jq -e 'type=="array"' >/dev/null 2>&1 || return 0 while IFS=$'\t' read -r id rng patched; do [[ -n "$rng" ]] || continue ok=1 IFS=',' read -r -a parts <<< "$rng" for part in "${parts[@]}"; do part="$(printf '%s' "$part" | tr -d ' ')" op="$(printf '%s' "$part" | grep -oE '^(<=|>=|==|<|>|=)' || echo '=')" ver="$(normver "$part")"; [[ -n "$ver" ]] || { ok=0; break; } case "$op" in '<') ver_gt "$ver" "$cur" || ok=0 ;; '<=') ver_ge "$ver" "$cur" || ok=0 ;; '>') ver_gt "$cur" "$ver" || ok=0 ;; '>=') ver_ge "$cur" "$ver" || ok=0 ;; *) [[ "$(ver_cmp "$ver" "$cur")" == "0" ]] || ok=0 ;; esac (( ok )) || break done (( ok )) && printf '%s %s\n' "$id" "$(normver "$patched")" done < <(printf '%s' "$json" | jq -r '.[] | select(.state=="published") | .ghsa_id as $id | (.vulnerabilities // [])[] | [$id, (.vulnerable_version_range // ""), ((.patched_versions // "") | tostring)] | @tsv' 2>/dev/null) } # --------------------------------------------------------------------------- # Decide what to do. Sets globals (not via stdout -- a $() would lose them): # TARGET (x.y.z or ""), REASON, HOLD (1 = compat break, notify only), # NOTES (one-line summary for the notification), DBCHANGE (0/1) # --------------------------------------------------------------------------- TARGET=""; REASON=""; HOLD=0; NOTES=""; DBCHANGE=0 resolve_target() { # local cur="$1" latest notes ghsa ids TARGET=""; REASON=""; HOLD=0; NOTES=""; DBCHANGE=0 latest="$(normver "$(ergo_latest_tag || true)")" if [[ -n "$TARGET_VERSION" ]]; then TARGET="$(normver "$TARGET_VERSION")"; REASON="target override ${TARGET}" else case "$UPDATE_POLICY" in off) REASON="policy=off (no changes)"; return 0 ;; security) ghsa="$(ghsa_covering "$cur")" if [[ -n "$ghsa" ]]; then ids="$(printf '%s\n' "$ghsa" | awk '{print $1}' | paste -sd, - || true)" # NF>=2: an advisory with no patched version yields only an id. TARGET="$(printf '%s\n' "$ghsa" | awk 'NF>=2 {print $2}' | tail -n1 || true)" [[ -n "$TARGET" ]] || TARGET="$latest" REASON="security advisory covers ${cur}: ${ids}" elif [[ -n "$latest" ]] && ver_gt "$latest" "$cur" \ && grep -qiE '^#+ *security|security vulnerabilit' <<< "$(release_notes_between "$cur" "$latest")"; then TARGET="$latest"; REASON="a release since ${cur} carries a Security section" else REASON="no security advisory or security release covers ${cur}"; return 0 fi ;; latest|*) [[ -n "$latest" ]] || { REASON="could not resolve the latest release"; return 0; } TARGET="$latest"; REASON="policy=latest" ;; esac fi [[ -n "$TARGET" ]] || return 0 notes="$(release_notes_between "$cur" "$TARGET")" # Feed grep from a here-string, not a pipe: `printf | grep -q` on notes # larger than the pipe buffer makes printf die of SIGPIPE, and pipefail # then reports "no match" for what was in fact a match. if grep -qiE 'compatibility break|not backwards[- ]compatible|backwards[- ]incompatible' <<< "$notes"; then HOLD=1 fi if grep -qiE 'database change|database file format changes|schema' <<< "$notes" \ && ! grep -qiE 'no changes to the config file format or database file format' <<< "$notes"; then DBCHANGE=1 fi NOTES="$(grep -iE 'config file format|database (file )?format|database change|compatibility break' <<< "$notes" \ | head -n 3 | sed 's/^[ *-]*//' | tr '\n' ' ' | cut -c1-300 || true)" return 0 } # --------------------------------------------------------------------------- # Apply an update: pre-flight on a copy, warn users, stop, snapshot, up, verify; # roll back (tag + DB snapshot when needed) on failure. # --------------------------------------------------------------------------- notify_users() { # -- best effort, only when Ergo is up and we can oper ergo_running || return 0 irc_raw --oper --quiet 1 -- "NOTICE \$\$* :$1" >/dev/null 2>&1 || true } apply_update() { # local from="$1" to="$2" reason="$3" image_base new_image snap ts cause prev_tag image_base="$(env_get ERGO_IMAGE)"; image_base="${image_base:-ghcr.io/ergochat/ergo}" new_image="${image_base}:v${to}" # Roll back to the tag that is actually pinned right now. Reconstructing # "v${from}" would write a bare "v" when the running version is unknown # (e.g. .env still holds the 'stable' fallback and the container is down). prev_tag="$(ergo_tag)" log "Updating Ergo ${from:-?} -> ${to} (${reason})..." if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: pull ${new_image}; validate on a copy; NOTICE users; stop; snapshot ircd.db; ERGO_TAG=v${to}; up -d --no-deps ergo" return 0 fi cp -a "$ENV_FILE" "${ENV_FILE}.bak.$(date -u +%Y%m%d%H%M%S)" 2>/dev/null || true log "Pulling ${new_image}..." if ! docker pull -q "$new_image" >/dev/null 2>&1; then send_notice "Ergo update FAILED" "high" "pull of ${new_image} failed; staying on ${from}" return 1 fi log "Pre-flight: loading the current ircd/ with ${new_image} (throwaway container)..." if ! ergo_validate_config "$new_image"; then cause="$(tail -n 5 "$VALIDATE_LOG" 2>/dev/null | tr '\n' ' ' | cut -c1-300)" warn "Pre-flight failed: $cause" send_notice "Ergo update HELD" "high" "${to} cannot load the current config/DB: ${cause}. Staying on ${from}; run 'ergoctl update check'." return 1 fi rm -f "$VALIDATE_LOG" if (( UPDATE_GRACE > 0 )) && ergo_running; then log "Warning connected users (${UPDATE_GRACE}s grace)..." notify_users "Server restarting for an upgrade to Ergo ${to} in ${UPDATE_GRACE} seconds -- you will be disconnected briefly." sleep "$UPDATE_GRACE" fi log "Stopping Ergo and snapshotting ircd.db..." dc stop -t 15 ergo >/dev/null 2>&1 || true ts="$(date -u +%Y%m%d%H%M%S)" install -d -m 0700 "$BACKUP_DIR" snap="$BACKUP_DIR/ircd.db.${from:-unknown}.${ts}" # -P: never follow a symlink in the uid-owned ircd/ (see ergolib's note). if [[ -f "$IRCD_DIR/ircd.db" ]] && refuse_symlink "$IRCD_DIR/ircd.db" "ircd/ircd.db"; then cp -Pp "$IRCD_DIR/ircd.db" "$snap" && chmod 0600 "$snap" else snap="" fi # The message history is a separate store; snapshot it too so a rollback is # not silently lossy. case "$(history_backend)" in sqlite) [[ -f "$IRCD_DIR/ergo_history.db" ]] && cp -Pp "$IRCD_DIR/ergo_history.db" "$BACKUP_DIR/ergo_history.db.${from:-unknown}.${ts}" 2>/dev/null || true ;; postgres) dc up -d --no-deps postgres >/dev/null 2>&1 || true if pg_check; then dc exec -T postgres pg_dump -U "$(env_get POSTGRES_USER)" -d "$(env_get POSTGRES_DB)" \ > "$BACKUP_DIR/history.sql.${from:-unknown}.${ts}" 2>/dev/null \ || { rm -f "$BACKUP_DIR/history.sql.${from:-unknown}.${ts}"; warn "pg_dump failed; the rollback would not restore message history."; } fi ;; esac env_set ERGO_TAG "v${to}" dc up -d --no-deps ergo >/dev/null 2>&1 || true if wait_healthy ergo 120 && irc_probe; then log "Ergo ${to} is healthy." state_rm failed record_template "$new_image" "$from" "$to" prune_snapshots local b="updated ${from:-?} -> ${to} (${reason})" [[ -n "$NOTES" ]] && b="${b}. Notes: ${NOTES}" [[ -n "$TEMPLATE_NOTE" ]] && b="${b}. ${TEMPLATE_NOTE}" send_notice "Ergo updated" "default" "$b" return 0 fi cause="$(dc logs --tail 60 ergo 2>/dev/null | grep -E 'Config file did not load|Database requires update|Could not load server|failed to|fatal|panic' | tail -n 2 | cut -c1-200 | tr '\n' ' ' || true)" warn "Ergo ${to} did not become healthy (${cause:-no diagnostic in the log}); rolling back to ${prev_tag}." dc stop -t 10 ergo >/dev/null 2>&1 || true env_set ERGO_TAG "$prev_tag" dc up -d --no-deps ergo >/dev/null 2>&1 || true if ! wait_healthy ergo 90; then # The old binary refuses a newer schema ("Database requires update") -- # only then does the snapshot go back (otherwise keep the live DB). if [[ -n "$snap" ]] && { [[ "$DBCHANGE" == 1 ]] || dc logs --tail 40 ergo 2>/dev/null | grep -q 'Database requires update'; }; then warn "Old version rejects the upgraded database; restoring the snapshot ${snap}." dc stop -t 10 ergo >/dev/null 2>&1 || true install -m 0600 -o "$(ergo_uid)" -g "$(ergo_gid)" "$snap" "$IRCD_DIR/ircd.db" dc up -d --no-deps ergo >/dev/null 2>&1 || true wait_healthy ergo 90 || true fi fi prune_snapshots # Remember the target that failed so the daily run does not repeat this # whole disruptive cycle (warn users, stop, swap, roll back) every night. # A manual `ergoctl update update` still forces a retry. state_set failed "$to" if [[ "$(svc_state ergo)" == "running healthy" ]]; then send_notice "Ergo update FAILED" "high" "${to} unhealthy (${cause:-see docker compose logs ergo}); rolled back to ${prev_tag}. Snapshot: ${snap:-none}. Will not retry automatically; run 'ergoctl update update' after investigating." else send_notice "Ergo DOWN after failed update" "urgent" "${to} failed (${cause:-?}) and ${prev_tag} did not come back healthy. Snapshot: ${snap:-none}. Run 'ergoctl status' / 'docker compose logs ergo'." fi return 1 } # Keep the image's default.yaml per version and diff it against the previous one # so template drift (new options, renamed keys) is visible after an upgrade. TEMPLATE_NOTE="" record_template() { # local image="$1" from="$2" to="$3" new old diff added removed TEMPLATE_NOTE="" install -d -m 0700 "$TEMPLATES_DIR" new="$TEMPLATES_DIR/default.yaml.v${to}" ergo_extract_template "$image" "$new" 2>/dev/null || { rm -f "$new"; return 0; } chmod 0600 "$new" old="$TEMPLATES_DIR/default.yaml.v${from}" [[ -f "$old" ]] || return 0 diff="$TEMPLATES_DIR/diff-v${from}-v${to}.txt" if diff -u "$old" "$new" > "$diff" 2>/dev/null; then rm -f "$diff"; return 0; fi chmod 0600 "$diff" 2>/dev/null || true added="$(grep -cE '^\+[^+]' "$diff" || true)"; removed="$(grep -cE '^-[^-]' "$diff" || true)" TEMPLATE_NOTE="default config changed upstream (+${added}/-${removed} lines): review ${diff} against ircd/ircd.yaml" log "$TEMPLATE_NOTE" } prune_snapshots() { # keep the last 5 of each snapshot kind, and 5 .env backups local f n=0 pat for pat in 'ircd.db.*' 'ergo_history.db.*' 'history.sql.*'; do n=0 for f in $(ls -1t "$BACKUP_DIR"/$pat 2>/dev/null); do n=$((n + 1)); (( n > 5 )) && rm -f "$f" done done n=0 for f in $(ls -1t "$STACK_DIR"/.env.bak.* 2>/dev/null); do n=$((n + 1)); (( n > 5 )) && rm -f "$f" done return 0 } # The pinned tag must be an immutable vX.Y.Z: deploy.sh falls back to 'stable' # when GitHub is unreachable, and a floating tag silently changes the running # version on any pull, with no pre-flight, snapshot or rollback. Pin it to # whatever is running as soon as we can see a version (no restart needed). pin_floating_tag() { # local cur="$1" tag tag="$(ergo_tag)" [[ -n "$cur" ]] || return 0 case "$tag" in v[0-9]*) return 0 ;; esac env_set ERGO_TAG "v${cur}" log "Pinned ERGO_TAG=v${cur} (was '${tag}', which floats)." } # --------------------------------------------------------------------------- # Cert sync + expiry watch # --------------------------------------------------------------------------- do_certsync() { local rc=0 prev ergo_certsync || rc=$? prev="$(state_get certsync)" case "$rc" in 0) [[ "$CERTSYNC_MSG" == "up to date" ]] || log "certsync: $CERTSYNC_MSG" # rc 0 also covers the "renewal in progress, retrying later" cases, # which synced nothing -- clearing a stored failure on those would # send a false all-clear while the real problem persists. if [[ -n "$prev" && ( "$CERTSYNC_MSG" == "up to date" || "$CERTSYNC_MSG" == installed\ cert* ) ]]; then state_rm certsync; send_notice "Ergo TLS recovered" "default" "cert sync ok: ${CERTSYNC_MSG}" fi ;; 2) [[ -n "${VERBOSE:-}" ]] && log "certsync: $CERTSYNC_MSG" return 0 ;; *) warn "certsync: $CERTSYNC_MSG" if [[ "$prev" != "$CERTSYNC_MSG" ]]; then state_set certsync "$CERTSYNC_MSG"; send_notice "Ergo TLS sync FAILED" "high" "$CERTSYNC_MSG"; fi return 1 ;; esac return 0 } cert_expiry_check() { # daily: warn once per day if the served cert is self-signed or expiring local f="$IRCD_DIR/fullchain.pem" today msg="" [[ -f "$f" ]] || return 0 today="$(date -u +%F)" if cert_is_selfsigned "$f"; then msg="Ergo is serving a SELF-SIGNED certificate on 6697 (Caddy has not issued one yet: check DNS for $(ergo_domain) and ports 80/443)." elif cert_expires_within "$f" $((14 * 86400)); then msg="Ergo's TLS cert expires soon ($(cert_enddate "$f")) and certsync has not replaced it -- is the 15-minute job running? ($SELF certsync)" fi [[ -n "$msg" ]] || { state_rm certexpiry; return 0; } warn "$msg" [[ "$(state_get certexpiry)" == "$today" ]] && return 0 state_set certexpiry "$today" send_notice "Ergo TLS attention" "high" "$msg" } # --------------------------------------------------------------------------- # Caddy: pull + recreate when the image moved (drops web-client websockets) # --------------------------------------------------------------------------- do_caddy() { preflight local tag before after tag="$(env_get CADDY_TAG)"; tag="${tag:-2-alpine}" before="$(docker inspect --format '{{.Image}}' ergo-caddy 2>/dev/null | head -n1 || true)" log "Pulling caddy:${tag}..." docker pull -q "caddy:${tag}" >/dev/null 2>&1 || { warn "pull failed"; return 1; } after="$(docker image inspect --format '{{.Id}}' "caddy:${tag}" 2>/dev/null | head -n1 || true)" if [[ -n "$before" && "$before" == "$after" ]]; then log "Caddy is current (${tag})."; return 0; fi [[ "$DRY_RUN" == "1" ]] && { echo "DRY: docker compose up -d --no-deps caddy"; return 0; } log "Recreating Caddy (web-client websockets will reconnect)..." dc up -d --no-deps caddy >/dev/null 2>&1 || true if wait_healthy caddy 60; then send_notice "Caddy updated" "min" "caddy:${tag} recreated (image $(printf '%s' "$after" | cut -c8-19))" return 0 fi send_notice "Caddy update FAILED" "high" "caddy:${tag} is not healthy after recreate; check docker compose logs caddy" return 1 } # --------------------------------------------------------------------------- # Subcommands # --------------------------------------------------------------------------- preflight() { [[ $EUID -eq 0 ]] || die "Run as root." [[ -f "$STACK_DIR/docker-compose.yml" ]] || die "No stack at $STACK_DIR (set STACK_DIR)." command -v docker >/dev/null 2>&1 || die "docker not found." command -v jq >/dev/null 2>&1 || die "jq is required (release metadata); install it." } # With HISTORY=postgres, Ergo will not start if its history backend is down -- # so an update must not stop a healthy server until the database is confirmed up. # The config validator cannot check this: it runs with no network on purpose. pg_gate() { # pg_enabled || return 0 pg_check && return 0 warn "PostgreSQL is not ready ($PG_CHECK_MSG); skipping $1 -- Ergo would not restart." send_notice "Ergo update skipped" "high" "PostgreSQL not ready (${PG_CHECK_MSG}); ${1} was not attempted." return 1 } do_check() { preflight local cur latest ghsa held="" prio="min" note cur="$(ergo_version_running)" pin_floating_tag "$cur" latest="$(normver "$(ergo_latest_tag || true)")" log "Running: ${cur:-unknown} (pinned $(ergo_tag)) | latest release: ${latest:-unknown} | policy: ${UPDATE_POLICY}" ghsa="$(ghsa_covering "$cur" || true)" if [[ -n "$ghsa" ]]; then note="VULNERABLE: $(printf '%s\n' "$ghsa" | awk '{print $1}' | paste -sd, -) covers ${cur}"; prio="high"; warn "$note" else note="no published advisory covers ${cur}"; log "$note" fi if [[ -n "$cur" && -n "$latest" ]] && ver_gt "$latest" "$cur"; then resolve_target "$cur" log "A newer release is available: ${cur} -> ${latest}" [[ "$prio" == "min" ]] && prio="default" note="${note}; newer release ${latest} available" if [[ "$HOLD" == 1 ]]; then held=" -- HELD: release notes announce compatibility breaks (review, then FORCE_UPDATE=1 or 'ergoctl update update')"; warn "${held# -- }"; fi [[ -n "$NOTES" ]] && log "Release notes: $NOTES" send_notice "Ergo check" "$prio" "${note}${held}" fi local f="$IRCD_DIR/fullchain.pem" if [[ -f "$f" ]]; then if cert_is_selfsigned "$f"; then warn "TLS: serving a SELF-SIGNED cert (no Let's Encrypt cert from Caddy yet)." else log "TLS: $(cert_subject_cn "$f"), expires $(cert_enddate "$f") (served: $( [[ "$(served_fingerprint)" == "$(cert_fingerprint "$f")" ]] && echo matches || echo DIFFERS -- rehash needed ))"; fi fi } do_run() { preflight if [[ "$DRY_RUN" != "1" ]]; then # Create the log directory only when it is missing. `install -d -m` also # re-modes an EXISTING directory, and this resolves to /var/log, which on # Debian/Ubuntu ships 0775 root:syslog so rsyslog (running as `syslog`) # can create files there -- re-moding it to 0755 silently breaks logging. [[ -d "$(dirname "$LOG")" ]] || install -d -m 0755 "$(dirname "$LOG")" 2>/dev/null || true echo "=== ergo-update $(date -u +%FT%TZ) ===" >> "$LOG" # Scheduled runs have nowhere to send stdout (busybox crond would try to # mail it and these hosts have no MTA), so keep the whole run in the log. [[ -t 1 ]] || exec >>"$LOG" 2>&1 fi do_certsync || true cert_expiry_check || true local cur cur="$(ergo_version_running)" pin_floating_tag "$cur" resolve_target "$cur" log "current=${cur:-?} | ${REASON}" if [[ -n "$TARGET" && -n "$cur" ]] && ver_gt "$TARGET" "$cur"; then if [[ "$(state_get failed)" == "$TARGET" && "$FORCE_UPDATE" != "1" ]]; then log "Skipping ${TARGET}: it already failed here and was rolled back. Investigate, then run 'ergoctl update update' (or set FORCE_UPDATE=1)." elif ! pg_gate "the update to ${TARGET}"; then : # postgres is down; pg_gate already warned and notified elif [[ "$HOLD" == 1 && "$FORCE_UPDATE" != "1" ]]; then warn "HELD: ${TARGET} announces compatibility breaks. Review the release notes, then 'ergoctl update update' (or FORCE_UPDATE=1)." if [[ "$(state_get held)" != "$TARGET" ]]; then state_set held "$TARGET" send_notice "Ergo update HELD" "default" "${cur} -> ${TARGET} announces compatibility breaks; not applied automatically. Notes: ${NOTES:-see GitHub release}. Apply with 'ergoctl update update'." fi else state_rm held apply_update "$cur" "$TARGET" "$REASON" || true fi else log "No update to apply." fi if [[ "$CADDY_AUTOUPDATE" == "1" ]]; then do_caddy || true; fi } do_update() { preflight local cur cur="$(ergo_version_running)" [[ -n "$cur" ]] || die "Cannot tell which version is running (container down and ERGO_TAG='$(ergo_tag)' is not a version). Start Ergo, or pin ERGO_TAG=vX.Y.Z in $ENV_FILE first." pin_floating_tag "$cur" if [[ -z "$TARGET_VERSION" && "$UPDATE_POLICY" == "off" ]]; then UPDATE_POLICY=latest; fi resolve_target "$cur" [[ -n "$TARGET" ]] || die "Could not determine a target version (${REASON})." if [[ -n "$cur" ]] && ! ver_gt "$TARGET" "$cur" && [[ -z "$TARGET_VERSION" ]]; then log "Already on ${cur} (latest ${TARGET}); nothing to do." return 0 fi if [[ "$HOLD" == 1 && "$FORCE_UPDATE" != "1" ]]; then warn "Release notes between ${cur} and ${TARGET} announce compatibility breaks: ${NOTES}" warn "Proceeding because you asked explicitly (this is 'update update'). Set FORCE_UPDATE=1 to also auto-apply such releases." fi pg_gate "the update to ${TARGET}" || die "Start PostgreSQL first (docker compose up -d postgres), then retry." apply_update "$cur" "$TARGET" "${REASON} (manual)" } # Only what the scheduled job needs to FIND the stack lives here. The update # knobs (UPDATE_POLICY, FORCE_UPDATE, UPDATE_GRACE, CADDY_AUTOUPDATE) are read # from the stack's .env so there is a single place to edit them; setting one # here (or in the environment) still overrides .env for that run. write_conf() { cat > "$ERGO_UPDATE_CONF" < /etc/periodic/15min/ergo-certsync chmod +x /etc/periodic/15min/ergo-certsync if [[ "$SCHEDULE_UPDATES" == "1" ]]; then printf '#!/bin/sh\nexec bash "%s" run\n' "$SELF" > /etc/periodic/daily/ergo-update chmod +x /etc/periodic/daily/ergo-update else rm -f /etc/periodic/daily/ergo-update fi if command -v rc-update >/dev/null 2>&1; then rc-update add crond default >/dev/null 2>&1 || true rc-service crond status >/dev/null 2>&1 || rc-service crond start >/dev/null 2>&1 || true fi log "Scheduled: /etc/periodic/15min/ergo-certsync$( [[ "$SCHEDULE_UPDATES" == "1" ]] && printf ', /etc/periodic/daily/ergo-update (policy=%s)' "$UPDATE_POLICY" )." ;; *) cat > /etc/systemd/system/ergo-certsync.service < /etc/systemd/system/ergo-certsync.timer < /etc/systemd/system/ergo-update.service < /etc/systemd/system/ergo-update.timer </dev/null 2>&1 || true if [[ "$SCHEDULE_UPDATES" == "1" ]]; then systemctl enable --now ergo-update.timer >/dev/null 2>&1 || true else systemctl disable --now ergo-update.timer >/dev/null 2>&1 || true; fi log "Scheduled: ergo-certsync.timer (15 min)$( [[ "$SCHEDULE_UPDATES" == "1" ]] && printf ', ergo-update.timer (daily, policy=%s)' "$UPDATE_POLICY" )." ;; esac } do_uninstall() { [[ $EUID -eq 0 ]] || die "Run as root." rm -f /etc/periodic/15min/ergo-certsync /etc/periodic/daily/ergo-update if command -v systemctl >/dev/null 2>&1; then systemctl disable --now ergo-certsync.timer ergo-update.timer >/dev/null 2>&1 || true rm -f /etc/systemd/system/ergo-certsync.{timer,service} /etc/systemd/system/ergo-update.{timer,service} systemctl daemon-reload >/dev/null 2>&1 || true fi log "Removed the scheduled Ergo jobs (config kept at $ERGO_UPDATE_CONF)." } case "${1:-check}" in check) do_check ;; run) do_run ;; update) do_update ;; certsync) preflight; do_certsync ;; caddy) do_caddy ;; install) do_install ;; uninstall) do_uninstall ;; *) die "Usage: update.sh [check|run|update|certsync|caddy|install|uninstall]" ;; esac