From 2c89ed497889b16800de4deb45be1a0a585a3e88 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:06:57 +0200 Subject: [PATCH 01/37] feat(cli): add pg_dump snapshot, restore, and retention helpers --- bayanat | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/bayanat b/bayanat index 378e143df..54b05d6d6 100755 --- a/bayanat +++ b/bayanat @@ -85,6 +85,104 @@ swap_symlink() { mv -Tf "$CURRENT_LINK.tmp" "$CURRENT_LINK" } +# --- Snapshot helpers --- + +SNAPSHOT_RETENTION_DAYS="${BAYANAT_SNAPSHOT_RETENTION_DAYS:-30}" +SNAPSHOT_RETENTION_COUNT=5 + +_pg_env() { + # Emits pg_dump / pg_restore env from shared/.env. All callers must + # `eval "$(_pg_env)"` in a subshell. + local env_file="$SHARED_DIR/.env" + [[ -f "$env_file" ]] || die "missing $env_file" + grep -E '^(POSTGRES_HOST|POSTGRES_PORT|POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB)=' \ + "$env_file" | sed 's/^/export /' +} + +snapshot_pg_dump() { + # $1 = previous tag, $2 = target tag + # Writes shared/backups/pre--to--.dump via .partial rename. + local prev="$1" target="$2" + local ts name partial final + ts=$(date -u +%Y%m%d-%H%M) + name="pre-${prev}-to-${target}-${ts}.dump" + partial="$SHARED_DIR/backups/${name}.partial" + final="$SHARED_DIR/backups/${name}" + log "Taking pre-update snapshot: $name" + ( + eval "$(_pg_env)" + PGPASSWORD="${POSTGRES_PASSWORD:-}" \ + pg_dump -Fc \ + -h "${POSTGRES_HOST:-localhost}" \ + -p "${POSTGRES_PORT:-5432}" \ + -U "$POSTGRES_USER" \ + -f "$partial" \ + "$POSTGRES_DB" + ) + mv -Tf "$partial" "$final" + echo "$name" +} + +prune_snapshots() { + # Keep last $SNAPSHOT_RETENTION_COUNT snapshots OR last + # $SNAPSHOT_RETENTION_DAYS days, whichever is greater. + local backups="$SHARED_DIR/backups" + [[ -d "$backups" ]] || return 0 + local cutoff_epoch + cutoff_epoch=$(date -d "-${SNAPSHOT_RETENTION_DAYS} days" +%s 2>/dev/null \ + || date -v-"${SNAPSHOT_RETENTION_DAYS}"d +%s) + local idx=0 name path mtime + while IFS= read -r path; do + idx=$((idx + 1)) + name=$(basename "$path") + mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path") + if [[ "$idx" -le "$SNAPSHOT_RETENTION_COUNT" ]]; then + continue + fi + if [[ "$mtime" -lt "$cutoff_epoch" ]]; then + log "Pruning snapshot: $name" + rm -f "$path" + fi + done < <(ls -1t "$backups"/pre-*.dump 2>/dev/null || true) +} + +list_snapshots() { + local backups="$SHARED_DIR/backups" + [[ -d "$backups" ]] || { echo "No snapshots directory."; return; } + printf '%-60s %10s %20s\n' "NAME" "SIZE" "AGE" + local name size mtime age + for path in $(ls -1t "$backups"/pre-*.dump 2>/dev/null); do + name=$(basename "$path") + size=$(du -h "$path" | cut -f1) + mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path") + age="$(( ($(date +%s) - mtime) / 3600 ))h" + printf '%-60s %10s %20s\n' "$name" "$size" "$age" + done +} + +restore_pg() { + # $1 = snapshot basename (no path). Stops services, restores, starts services. + local name="$1" + local path="$SHARED_DIR/backups/$name" + [[ -f "$path" ]] || die "snapshot not found: $path" + log "Restoring $name (this will DROP and RECREATE tables)" + read -r -p "Type 'yes' to confirm: " reply + [[ "$reply" == "yes" ]] || die "aborted" + systemctl stop bayanat bayanat-celery + ( + eval "$(_pg_env)" + PGPASSWORD="${POSTGRES_PASSWORD:-}" \ + pg_restore --clean --if-exists \ + -h "${POSTGRES_HOST:-localhost}" \ + -p "${POSTGRES_PORT:-5432}" \ + -U "$POSTGRES_USER" \ + -d "$POSTGRES_DB" \ + "$path" + ) + systemctl start bayanat bayanat-celery + log "Restore complete" +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From 8539c7f89852fe44feb928de6a07c794b121706c Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:11:43 +0200 Subject: [PATCH 02/37] fix(cli): restart services if pg_restore fails during bayanat restore --- bayanat | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bayanat b/bayanat index 54b05d6d6..e5a646efb 100755 --- a/bayanat +++ b/bayanat @@ -169,7 +169,7 @@ restore_pg() { read -r -p "Type 'yes' to confirm: " reply [[ "$reply" == "yes" ]] || die "aborted" systemctl stop bayanat bayanat-celery - ( + if ! ( eval "$(_pg_env)" PGPASSWORD="${POSTGRES_PASSWORD:-}" \ pg_restore --clean --if-exists \ @@ -178,7 +178,10 @@ restore_pg() { -U "$POSTGRES_USER" \ -d "$POSTGRES_DB" \ "$path" - ) + ); then + systemctl start bayanat bayanat-celery + die "pg_restore failed; services restarted on existing DB" + fi systemctl start bayanat bayanat-celery log "Restore complete" } From aa77971a51468255c967fb2643757242d8f42b8f Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:11:59 +0200 Subject: [PATCH 03/37] refactor(cli): use while-read pattern in list_snapshots (matches prune_snapshots) --- bayanat | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bayanat b/bayanat index e5a646efb..07744fc67 100755 --- a/bayanat +++ b/bayanat @@ -151,13 +151,13 @@ list_snapshots() { [[ -d "$backups" ]] || { echo "No snapshots directory."; return; } printf '%-60s %10s %20s\n' "NAME" "SIZE" "AGE" local name size mtime age - for path in $(ls -1t "$backups"/pre-*.dump 2>/dev/null); do + while IFS= read -r path; do name=$(basename "$path") size=$(du -h "$path" | cut -f1) mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path") age="$(( ($(date +%s) - mtime) / 3600 ))h" printf '%-60s %10s %20s\n' "$name" "$size" "$age" - done + done < <(ls -1t "$backups"/pre-*.dump 2>/dev/null || true) } restore_pg() { From a1b8f4b54a5d780179830e90e7a1bfd85af015d9 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:12:09 +0200 Subject: [PATCH 04/37] refactor(cli): make SNAPSHOT_RETENTION_COUNT readonly --- bayanat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bayanat b/bayanat index 07744fc67..c80b6a2a2 100755 --- a/bayanat +++ b/bayanat @@ -88,7 +88,7 @@ swap_symlink() { # --- Snapshot helpers --- SNAPSHOT_RETENTION_DAYS="${BAYANAT_SNAPSHOT_RETENTION_DAYS:-30}" -SNAPSHOT_RETENTION_COUNT=5 +readonly SNAPSHOT_RETENTION_COUNT=5 _pg_env() { # Emits pg_dump / pg_restore env from shared/.env. All callers must From f3ead095db24c85c9f7d743f64a1d684394ec1d0 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:14:23 +0200 Subject: [PATCH 05/37] feat(cli): add update state file and PID lock primitives --- bayanat | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/bayanat b/bayanat index c80b6a2a2..d776e2e76 100755 --- a/bayanat +++ b/bayanat @@ -186,6 +186,76 @@ restore_pg() { log "Restore complete" } +# --- Update state + lock --- + +STATE_DIR="$BAYANAT_ROOT/state" +STATE_FILE="$STATE_DIR/update.json" +LOCK_FILE="$STATE_DIR/update.lock" + +_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +write_state() { + # write_state + # Reads STATE_TARGET / STATE_PREVIOUS / STATE_SNAPSHOT / + # STATE_STARTED_AT / STATE_PROGRESS / STATE_ERROR_JSON from environment. + local phase="$1" label="$2" + mkdir -p "$STATE_DIR" + chown "$APP_USER:$APP_USER" "$STATE_DIR" 2>/dev/null || true + local tmp="$STATE_FILE.tmp" + cat > "$tmp" </dev/null || true +} + +clear_state() { + rm -f "$STATE_FILE" +} + +read_phase() { + [[ -f "$STATE_FILE" ]] || { echo IDLE; return; } + python3 -c "import json,sys; print(json.load(open('$STATE_FILE')).get('phase','IDLE'))" \ + 2>/dev/null || echo IDLE +} + +read_field() { + # $1 = field name + [[ -f "$STATE_FILE" ]] || return 1 + python3 -c "import json,sys; print(json.load(open('$STATE_FILE')).get('$1',''))" 2>/dev/null +} + +acquire_lock() { + mkdir -p "$STATE_DIR" + if [[ -f "$LOCK_FILE" ]]; then + local pid + pid=$(cat "$LOCK_FILE" 2>/dev/null || echo 0) + if [[ "$pid" -gt 0 ]] && kill -0 "$pid" 2>/dev/null; then + die "another update is running (pid $pid)" + fi + log "Removing stale lock (pid $pid)" + rm -f "$LOCK_FILE" + fi + echo $$ > "$LOCK_FILE" + chown "$APP_USER:$APP_USER" "$LOCK_FILE" 2>/dev/null || true +} + +release_lock() { + rm -f "$LOCK_FILE" +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From bfd5b315a72098b9f31de9051e4510cc8d9926c1 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:15:02 +0200 Subject: [PATCH 06/37] feat(cli): add recover_state phase-dispatch for update crash recovery --- bayanat | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/bayanat b/bayanat index d776e2e76..3e938eb0a 100755 --- a/bayanat +++ b/bayanat @@ -256,6 +256,60 @@ release_lock() { rm -f "$LOCK_FILE" } +# --- Recovery dispatch --- + +recover_state() { + local phase + phase=$(read_phase) + case "$phase" in + IDLE) + return 0 + ;; + PREPARE_DONE) + log "Recovery: PREPARE_DONE — cleaning partial release and clearing state" + local target + target=$(read_field target || true) + if [[ -n "$target" ]]; then + rm -rf "$RELEASES_DIR/$target.partial" "$RELEASES_DIR/$target" + fi + clear_state + release_lock + ;; + MIGRATE_DONE) + log "Recovery: MIGRATE_DONE — starting services on previous release" + systemctl start bayanat bayanat-celery + if _wait_healthy 60; then + log "Recovery OK; operator should re-run update to finish" + clear_state + release_lock + else + STATE_ERROR_JSON='"MIGRATE_DONE recovery: previous release unhealthy"' + write_state NEEDS_INTERVENTION "Services unhealthy after recovery; restore snapshot manually" + exit 2 + fi + ;; + SWITCH_DONE) + log "Recovery: SWITCH_DONE — running code rollback" + rollback_code + ;; + SUCCESS|ROLLED_BACK) + clear_state + release_lock + ;; + NEEDS_INTERVENTION) + local snap prev + snap=$(read_field snapshot || true) + prev=$(read_field previous || true) + die "Operator intervention required. Snapshot: $snap Previous tag: $prev See: sudo -u $APP_USER bayanat snapshots" + ;; + *) + warn "Unknown phase '$phase' — clearing and starting fresh" + clear_state + release_lock + ;; + esac +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From ebfbdfce1c135f9d7dd84ae1ed47de84ef5da0e4 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:18:30 +0200 Subject: [PATCH 07/37] refactor(cli): mark state/lock file constants readonly --- bayanat | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bayanat b/bayanat index 3e938eb0a..04d2e53d8 100755 --- a/bayanat +++ b/bayanat @@ -188,9 +188,9 @@ restore_pg() { # --- Update state + lock --- -STATE_DIR="$BAYANAT_ROOT/state" -STATE_FILE="$STATE_DIR/update.json" -LOCK_FILE="$STATE_DIR/update.lock" +readonly STATE_DIR="$BAYANAT_ROOT/state" +readonly STATE_FILE="$STATE_DIR/update.json" +readonly LOCK_FILE="$STATE_DIR/update.lock" _now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } From c21913d4384f1aedc9340f1f893b24217102a8f6 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:19:11 +0200 Subject: [PATCH 08/37] feat(cli): add update health probe helpers (socket + db + redis) --- bayanat | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/bayanat b/bayanat index 04d2e53d8..3e76b972f 100755 --- a/bayanat +++ b/bayanat @@ -310,6 +310,41 @@ recover_state() { esac } +# --- Health probe --- + +_socket_health() { + # curl the Flask /health over the unix socket. Returns 0 on HTTP 200. + curl -s --unix-socket "$CURRENT_LINK/bayanat.sock" \ + --max-time 3 -o /dev/null -w '%{http_code}' \ + http://localhost/health 2>/dev/null | grep -q '^200$' +} + +_db_ping() { + ( + eval "$(_pg_env)" + PGPASSWORD="${POSTGRES_PASSWORD:-}" \ + psql -h "${POSTGRES_HOST:-localhost}" \ + -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -c 'SELECT 1' -t >/dev/null 2>&1 + ) +} + +_redis_ping() { + redis-cli ping 2>/dev/null | grep -q '^PONG$' +} + +_wait_healthy() { + # $1 = deadline in seconds (default 60) + local deadline=$(( $(date +%s) + ${1:-60} )) + while (( $(date +%s) < deadline )); do + if _socket_health && _db_ping && _redis_ping; then + return 0 + fi + sleep 1 + done + return 1 +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From f73b3950ac6084f84b89f114a615b5c87b4bbab8 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:21:04 +0200 Subject: [PATCH 09/37] feat(cli): update pipeline PREPARE phase --- bayanat | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/bayanat b/bayanat index 3e76b972f..0d0eab9a9 100755 --- a/bayanat +++ b/bayanat @@ -345,6 +345,61 @@ _wait_healthy() { return 1 } +# --- Update pipeline --- + +update_preflight() { + preflight_checks + _verify_service_health + command -v pg_dump >/dev/null || die "pg_dump not in PATH" + command -v pg_restore >/dev/null || die "pg_restore not in PATH" + # pg_dump major version must match Postgres server major version + local client_major server_major + client_major=$(pg_dump --version | awk '{print $3}' | cut -d. -f1) + server_major=$( + eval "$(_pg_env)" + PGPASSWORD="${POSTGRES_PASSWORD:-}" \ + psql -h "${POSTGRES_HOST:-localhost}" \ + -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -c 'SHOW server_version_num' -t 2>/dev/null \ + | tr -d ' ' | cut -c1-2 + ) + [[ -n "$client_major" && -n "$server_major" ]] \ + || die "could not determine pg_dump/server versions" + [[ "$client_major" == "$server_major" ]] \ + || die "pg_dump major ($client_major) != server major ($server_major)" + # Disk in backups (need >= 2 GB for snapshot headroom) + local backups_free_kb + backups_free_kb=$(df --output=avail "$SHARED_DIR/backups" | tail -1 | tr -d ' ') + [[ "$backups_free_kb" -ge 2097152 ]] \ + || die "need >= 2GB free in $SHARED_DIR/backups for snapshot" + # Schema aligned with models + flask_run "$(current_version)" check-db-alignment >/dev/null \ + || die "schema drift detected; run 'flask check-db-alignment' for details" + # Flask doctor + flask_run "$(current_version)" doctor >/dev/null \ + || die "flask doctor failed" +} + +do_prepare() { + # $1 = target tag + local target="$1" + local current + current=$(current_version || echo 0) + [[ "$target" != "$current" ]] || die "already on $target" + log "PREPARE: fetching $target" + update_preflight + _clone_release "$target" + _install_deps "$target" + _link_shared "$target" + acquire_lock + export STATE_TARGET="$target" + export STATE_PREVIOUS="$current" + export STATE_STARTED_AT + STATE_STARTED_AT="$(_now_iso)" + export STATE_PROGRESS="Fetched $target, ready to migrate" + write_state PREPARE_DONE "Prepared $target, ready to migrate" +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From 820c658d6c033d6664ce5e56d97b33e05e7474e8 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:22:02 +0200 Subject: [PATCH 10/37] feat(cli): update pipeline MIGRATE phase --- bayanat | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bayanat b/bayanat index 0d0eab9a9..a5bf37c3c 100755 --- a/bayanat +++ b/bayanat @@ -400,6 +400,33 @@ do_prepare() { write_state PREPARE_DONE "Prepared $target, ready to migrate" } +do_migrate() { + local target="$STATE_TARGET" + local prev="$STATE_PREVIOUS" + log "MIGRATE: stopping services" + export STATE_PROGRESS="Stopping services" + write_state MIGRATE "Stopping services for maintenance window" + systemctl stop bayanat bayanat-celery + log "MIGRATE: pruning old snapshots" + prune_snapshots + export STATE_PROGRESS="Taking pre-update snapshot" + write_state MIGRATE "Taking pre-update snapshot" + export STATE_SNAPSHOT + STATE_SNAPSHOT=$(snapshot_pg_dump "$prev" "$target") + log "MIGRATE: running migrations" + export STATE_PROGRESS="Running migrations" + write_state MIGRATE "Running migrations" + if ! flask_run "$target" db upgrade; then + export STATE_ERROR_JSON='"db upgrade failed"' + write_state NEEDS_INTERVENTION "Migration failed; previous release remains linked" + systemctl start bayanat bayanat-celery || true + release_lock + exit 2 + fi + export STATE_PROGRESS="Migration complete" + write_state MIGRATE_DONE "Migration complete, switching to new release" +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From d7dd110f5d7390a1808fc50209564fce4208d807 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:22:55 +0200 Subject: [PATCH 11/37] feat(cli): update pipeline SWITCH, VERIFY, and ROLLBACK_CODE --- bayanat | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/bayanat b/bayanat index a5bf37c3c..32e765179 100755 --- a/bayanat +++ b/bayanat @@ -427,6 +427,53 @@ do_migrate() { write_state MIGRATE_DONE "Migration complete, switching to new release" } +do_switch_verify() { + local target="$STATE_TARGET" + export STATE_PROGRESS="Swapping to $target" + write_state SWITCH "Swapping current -> $target" + swap_symlink "$RELEASES_DIR/$target" + systemctl start bayanat bayanat-celery + write_state SWITCH_DONE "Verifying new release" + export STATE_PROGRESS="Waiting for health probe" + if _wait_healthy 60; then + export STATE_PROGRESS="Update successful" + write_state SUCCESS "Update to $target complete" + clear_state + release_lock + log "SUCCESS: running $target" + return 0 + else + warn "Health probe failed; rolling back code" + rollback_code + return 1 + fi +} + +rollback_code() { + local prev="$STATE_PREVIOUS" + if [[ -z "$prev" ]]; then + export STATE_ERROR_JSON='"cannot roll back: no previous tag recorded"' + write_state NEEDS_INTERVENTION "Cannot roll back: no previous tag recorded" + exit 2 + fi + write_state ROLLBACK "Reverting symlink to $prev" + systemctl stop bayanat bayanat-celery || true + swap_symlink "$RELEASES_DIR/$prev" + systemctl start bayanat bayanat-celery + if _wait_healthy 60; then + export STATE_PROGRESS="Rolled back to $prev" + write_state ROLLED_BACK "Rolled back to $prev; snapshot retained" + clear_state + release_lock + log "ROLLED_BACK: on $prev; snapshot: ${STATE_SNAPSHOT:-none}" + exit 1 + else + export STATE_ERROR_JSON='"code rollback failed health probe"' + write_state NEEDS_INTERVENTION "Rollback to $prev unhealthy; restore snapshot ${STATE_SNAPSHOT:-} manually" + exit 2 + fi +} + # --- Step functions (each is idempotent) --- _install_system_packages() { From 473b04713786142731e6242a751a8b0017718604 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:24:49 +0200 Subject: [PATCH 12/37] feat(cli): wire update/snapshots/restore subcommands and extend status --- bayanat | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/bayanat b/bayanat index 32e765179..fc90a2862 100755 --- a/bayanat +++ b/bayanat @@ -837,6 +837,43 @@ _verify_service_health() { warn "Application not responding on socket after $((retries * delay))s (may still be starting)" } +# --- Update --- + +cmd_update() { + local flag="${1:-}" + case "$flag" in + --check) + local cur latest + cur=$(current_version || echo "not installed") + latest=$(latest_remote_tag | sed 's/^v//') + echo "current: $cur" + echo "latest: $latest" + if [[ "$cur" == "$latest" ]]; then + echo "up to date" + else + echo "update available" + fi + return 0 + ;; + --recover) + require_root + recover_state + return 0 + ;; + esac + require_root + recover_state + local target="${1:-}" + if [[ -z "$target" ]]; then + target=$(latest_remote_tag | sed 's/^v//') + else + target="${target#v}" + fi + do_prepare "$target" + do_migrate + do_switch_verify +} + # --- Status --- cmd_status() { @@ -863,6 +900,17 @@ cmd_status() { for svc in bayanat bayanat-celery caddy; do printf " %-20s %s\n" "$svc" "$(systemctl is-active "$svc" 2>/dev/null || echo 'unknown')" done + + echo "" + local phase + phase=$(read_phase) + echo "Update state: $phase" + if [[ "$phase" != "IDLE" ]]; then + echo " target: $(read_field target || echo '')" + echo " previous: $(read_field previous || echo '')" + echo " snapshot: $(read_field snapshot || echo '')" + echo " updated_at: $(read_field updated_at || echo '')" + fi } # --- Usage --- @@ -875,18 +923,27 @@ Usage: bayanat [options] Commands: install [domain] Install Bayanat (default: localhost) - status Show version and service status + update [] Update Bayanat to (default: latest release) + update --check Show current vs latest; no changes + update --recover Recover from a stuck update state file + snapshots List pre-update snapshots + restore Restore a pre-update snapshot (prompts confirmation) + status Show version, services, and update state Environment: - BAYANAT_REPO GitHub repo (default: sjacorg/bayanat) + BAYANAT_REPO GitHub repo (default: sjacorg/bayanat) + BAYANAT_SNAPSHOT_RETENTION_DAYS Snapshot retention floor (default: 30) EOF } # --- Main --- case "${1:-}" in - install) shift; cmd_install "$@" ;; - status) cmd_status ;; + install) shift; cmd_install "$@" ;; + update) shift; cmd_update "$@" ;; + snapshots) require_root; list_snapshots ;; + restore) require_root; [[ -n "${2:-}" ]] || die "usage: bayanat restore "; restore_pg "$2" ;; + status) cmd_status ;; -h|--help|help) usage ;; *) usage; exit 1 ;; esac From f2e3c43b41519db593c12d0fed79e683cb582b77 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:25:57 +0200 Subject: [PATCH 13/37] feat(installer): create /opt/bayanat/state directory for update CLI --- bayanat | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bayanat b/bayanat index fc90a2862..742b928df 100755 --- a/bayanat +++ b/bayanat @@ -559,7 +559,8 @@ _setup_database() { _create_directories() { mkdir -p "$RELEASES_DIR" "$SHARED_DIR/media" "$SHARED_DIR/backups" \ - "$LOGS_DIR" + "$LOGS_DIR" "$STATE_DIR" + chown -R "$APP_USER:$APP_USER" "$STATE_DIR" } _clone_release() { From ea544b988b87a41ae5233675f2777bf04dfe45a7 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:26:14 +0200 Subject: [PATCH 14/37] feat(installer): install bayanat-start-update wrapper and new sudoers grants --- bayanat | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bayanat b/bayanat index 742b928df..bc48222b3 100755 --- a/bayanat +++ b/bayanat @@ -738,11 +738,29 @@ EOF _install_sudoers() { cat > /etc/sudoers.d/bayanat << 'EOF' -bayanat ALL=(root) NOPASSWD: /usr/local/bin/bayanat update +bayanat ALL=(root) NOPASSWD: /usr/local/sbin/bayanat-start-update bayanat ALL=(root) NOPASSWD: /usr/local/bin/bayanat status +bayanat ALL=(root) NOPASSWD: /usr/local/bin/bayanat snapshots bayanat ALL=(root) NOPASSWD: /usr/bin/systemctl restart bayanat-celery EOF chmod 440 /etc/sudoers.d/bayanat + visudo -cf /etc/sudoers.d/bayanat >/dev/null || die "sudoers syntax invalid" +} + +_install_update_wrapper() { + # Root-owned wrapper. Launches `bayanat update` as a transient systemd + # unit so the update outlives Flask restart, SSH disconnect, and + # browser close. Must be in sudoers at this exact path. + install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/bayanat-start-update <<'EOF' +#!/bin/bash +# Installed root:root 0755 by `bayanat install`. Do not edit. +set -euo pipefail +exec /usr/bin/systemd-run \ + --unit=bayanat-update \ + --collect \ + --property=Restart=no \ + /usr/local/bin/bayanat update +EOF } # --- Install --- @@ -794,6 +812,7 @@ cmd_install() { _install_systemd _configure_caddy "$domain" _install_sudoers + _install_update_wrapper chown -R "$APP_USER:$APP_USER" "$BAYANAT_ROOT" systemctl daemon-reload From 06a8b61018f77ec494ab20044f2ffd13b32c5f5d Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:28:00 +0200 Subject: [PATCH 15/37] feat(api): add /health readiness endpoint for bayanat updater --- enferno/public/views.py | 34 +++++++++++++++++++++++++++++++--- tests/test_health.py | 13 +++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/test_health.py diff --git a/enferno/public/views.py b/enferno/public/views.py index 23cec85d8..92175efd2 100755 --- a/enferno/public/views.py +++ b/enferno/public/views.py @@ -1,8 +1,12 @@ -from flask import request, redirect, Blueprint, send_from_directory, Response, jsonify +import os from typing import Optional -from enferno.utils.logging_utils import get_logger -from enferno.extensions import db, limiter + +from flask import Blueprint, Response, jsonify, redirect, request, send_from_directory from flask_wtf.csrf import generate_csrf +from sqlalchemy import text + +from enferno.extensions import db, limiter, rds +from enferno.utils.logging_utils import get_logger bp_public = Blueprint("public", __name__, static_folder="../static") @@ -29,6 +33,30 @@ def get_csrf_token() -> Response: return jsonify({"csrf_token": token}) +@bp_public.route("/health") +@limiter.exempt +def health() -> Response: + """Readiness probe used by the bayanat updater. Touches DB and Redis.""" + try: + db.session.execute(text("SELECT 1")) + rds.ping() + except Exception as e: + logger.error(f"health check failed: {e}") + return jsonify({"status": "error", "error": str(e)[:120]}), 503 + version = os.environ.get("BAYANAT_VERSION") or _read_version_from_pyproject() + return jsonify({"status": "ok", "version": version}) + + +def _read_version_from_pyproject() -> Optional[str]: + try: + import tomllib + + with open("pyproject.toml", "rb") as fh: + return tomllib.load(fh).get("project", {}).get("version") + except Exception: + return None + + @bp_public.teardown_app_request def shutdown_global_session(exception: Optional[Exception] = None) -> None: """Remove database session at the end of each request.""" diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 000000000..20121b8ec --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +def test_health_returns_ok(anonymous_client): + resp = anonymous_client.get("/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "ok" + assert "version" in data + + +def test_health_is_exempt_from_rate_limit(anonymous_client): + # 20 rapid requests should all succeed (limiter.exempt). + for _ in range(20): + resp = anonymous_client.get("/health") + assert resp.status_code == 200 From bacd7cae7a877fd763098cdf33ec2cbb2abfabe8 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:29:25 +0200 Subject: [PATCH 16/37] feat(config): add AUTO_APPLY_PATCH_UPDATES default (off) --- enferno/utils/config_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/enferno/utils/config_utils.py b/enferno/utils/config_utils.py index 7e40a505d..7cd720ce8 100644 --- a/enferno/utils/config_utils.py +++ b/enferno/utils/config_utils.py @@ -166,6 +166,7 @@ class ConfigManager: "twitter.com", ], "YTDLP_COOKIES": "", + "AUTO_APPLY_PATCH_UPDATES": False, "NOTIFICATIONS": NOTIFICATIONS_DEFAULT_CONFIG, # Import from notification_config.py } ) @@ -240,6 +241,7 @@ class ConfigManager: "YTDLP_PROXY": "Proxy URL to use with Web Import", "YTDLP_ALLOWED_DOMAINS": "Allowed Domains for Web Import", "YTDLP_COOKIES": "Cookies to use with Web Import", + "AUTO_APPLY_PATCH_UPDATES": "Auto-apply patch releases", "NOTIFICATIONS": "Notifications", } ) From 211d36245bc14219b2186c88a369ac13fa11f93d Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:31:36 +0200 Subject: [PATCH 17/37] feat(tasks): periodic update check with opt-in patch auto-apply --- enferno/tasks/__init__.py | 12 ++++ enferno/tasks/maintenance.py | 103 ++++++++++++++++++++++++++++++++++- tests/test_update_check.py | 28 ++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/test_update_check.py diff --git a/enferno/tasks/__init__.py b/enferno/tasks/__init__.py index e96e07b13..b7a7bd309 100644 --- a/enferno/tasks/__init__.py +++ b/enferno/tasks/__init__.py @@ -140,6 +140,16 @@ def setup_periodic_tasks(sender: Any, **kwargs: dict[str, Any]) -> None: f"Backup periodic task is set up. Backups will run at 3:00 every {cfg.BACKUP_INTERVAL} day(s)." ) + # Update-check periodic task (every 6 hours) + from enferno.tasks.maintenance import check_for_updates + + sender.add_periodic_task( + 6 * 60 * 60, + check_for_updates.s(), + name="Check for Updates", + ) + logger.info("Update-check periodic task is set up (every 6h).") + # session cleanup task sender.add_periodic_task(24 * 60 * 60, session_cleanup.s(), name="Session Cleanup Cron") @@ -175,6 +185,7 @@ def setup_periodic_tasks(sender: Any, **kwargs: dict[str, Any]) -> None: from enferno.tasks.graph import generate_graph # noqa: E402 from enferno.tasks.maintenance import ( # noqa: E402 activity_cleanup_cron, + check_for_updates, daily_backup_cron, regenerate_locations, reload_app, @@ -224,6 +235,7 @@ def setup_periodic_tasks(sender: Any, **kwargs: dict[str, Any]) -> None: "generate_graph", # maintenance "activity_cleanup_cron", + "check_for_updates", "daily_backup_cron", "regenerate_locations", "reload_app", diff --git a/enferno/tasks/maintenance.py b/enferno/tasks/maintenance.py index c3f512085..615687373 100644 --- a/enferno/tasks/maintenance.py +++ b/enferno/tasks/maintenance.py @@ -1,17 +1,118 @@ # -*- coding: utf-8 -*- +import json import os -from datetime import date, timedelta +import subprocess +from datetime import date, datetime, timedelta, timezone +import requests +from packaging.version import Version + +from enferno.admin.constants import Constants from enferno.admin.models import Activity, Location +from enferno.admin.models.Notification import Notification from enferno.extensions import db, rds from enferno.tasks import celery, cfg from enferno.user.models import Session from enferno.utils.backup_utils import pg_dump, upload_to_s3 +from enferno.utils.config_utils import ConfigManager from enferno.utils.date_helper import DateHelper from enferno.utils.logging_utils import get_logger logger = get_logger("celery.tasks.maintenance") +GITHUB_LATEST_URL = "https://api.github.com/repos/sjacorg/bayanat/releases/latest" +UPDATE_CACHE_KEY = "bayanat:update:available" +UPDATE_NOTIFIED_KEY = "bayanat:update:available:notified" + + +def _strip_v(tag: str) -> str: + return tag[1:] if tag.startswith("v") else tag + + +def _redis_get_str(key: str): + val = rds.get(key) + if val is None: + return None + return val.decode() if isinstance(val, (bytes, bytearray)) else val + + +def _current_version() -> str: + try: + import tomllib + + with open("pyproject.toml", "rb") as fh: + return tomllib.load(fh).get("project", {}).get("version", "0.0.0") + except Exception: + return "0.0.0" + + +def _is_patch_bump(current: str, target: str) -> bool: + try: + c, t = Version(current), Version(target) + except Exception: + return False + if t <= c: + return False + return c.major == t.major and c.minor == t.minor + + +@celery.task +def check_for_updates(): + """Poll GitHub releases. Cache latest. Notify admins on new tag. Optionally auto-apply patch.""" + try: + resp = requests.get(GITHUB_LATEST_URL, timeout=10) + resp.raise_for_status() + release = resp.json() + except Exception as e: + logger.warning(f"update check failed: {e}") + return + + latest_tag = _strip_v(release.get("tag_name", "")) + if not latest_tag: + return + + rds.set( + UPDATE_CACHE_KEY, + json.dumps( + { + "latest": latest_tag, + "release_notes_url": release.get("html_url"), + "checked_at": datetime.now(timezone.utc).isoformat(), + } + ), + ) + + current = _current_version() + if latest_tag == current: + return + if _redis_get_str(UPDATE_NOTIFIED_KEY) == latest_tag: + return + + try: + auto_apply = ConfigManager.get_config("AUTO_APPLY_PATCH_UPDATES", False) + except Exception: + auto_apply = False + + if auto_apply and _is_patch_bump(current, latest_tag): + logger.info(f"auto-applying patch update {current} -> {latest_tag}") + try: + subprocess.run( + ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"], + check=True, + timeout=10, + ) + rds.set(UPDATE_NOTIFIED_KEY, latest_tag) + return + except Exception as e: + logger.warning(f"auto-apply failed, falling back to notification: {e}") + + Notification.create_for_admins( + title=f"Update available: {latest_tag}", + message=f"A new Bayanat release is available. {release.get('html_url', '')}", + category=Constants.NotificationCategories.UPDATE.value, + ) + rds.set(UPDATE_NOTIFIED_KEY, latest_tag) + @celery.task def activity_cleanup_cron() -> None: diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 000000000..f328e4821 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,28 @@ +from enferno.tasks.maintenance import _strip_v, _is_patch_bump + + +def test_strip_v_prefix(): + assert _strip_v("v4.1.0") == "4.1.0" + assert _strip_v("4.1.0") == "4.1.0" + assert _strip_v("") == "" + + +def test_is_patch_bump_true(): + assert _is_patch_bump("4.1.0", "4.1.1") is True + assert _is_patch_bump("4.1.2", "4.1.10") is True + + +def test_is_patch_bump_false_for_minor(): + assert _is_patch_bump("4.1.0", "4.2.0") is False + + +def test_is_patch_bump_false_for_major(): + assert _is_patch_bump("4.1.0", "5.0.0") is False + + +def test_is_patch_bump_false_for_same(): + assert _is_patch_bump("4.1.0", "4.1.0") is False + + +def test_is_patch_bump_false_for_downgrade(): + assert _is_patch_bump("4.1.5", "4.1.3") is False From 639c6fa63a7c8ad4f9a0c7ef86af1e14bec6c6b7 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:33:15 +0200 Subject: [PATCH 18/37] feat(api): admin endpoints for update availability, start, and status --- enferno/admin/views/system.py | 81 ++++++++++++++++++++++++++++++++++ tests/test_update_endpoints.py | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/test_update_endpoints.py diff --git a/enferno/admin/views/system.py b/enferno/admin/views/system.py index 5496f1db9..c217aea96 100644 --- a/enferno/admin/views/system.py +++ b/enferno/admin/views/system.py @@ -184,3 +184,84 @@ def get_data(table: str) -> list[dict[str, Any]] | list[dict[str, dict[str, Any return [{"en": item.title, "tr": item.title_tr or ""} for item in items] return None + + +import json +import subprocess +from pathlib import Path + +from enferno.extensions import rds +from enferno.tasks.maintenance import UPDATE_CACHE_KEY, _current_version + +UPDATE_STATE_FILE = "/opt/bayanat/state/update.json" +TERMINAL_PHASES = {"SUCCESS", "ROLLED_BACK", "NEEDS_INTERVENTION", "IDLE"} + + +def _idle_status(current): + return { + "phase": "IDLE", + "phase_label": "No update in progress", + "running": False, + "target": None, + "previous": None, + "snapshot": None, + "started_at": None, + "updated_at": None, + "progress_text": None, + "error": None, + "current": current, + } + + +@admin.get("/api/updates/available") +@roles_required("Admin") +def api_updates_available() -> Response: + """Return the latest cached GitHub release info.""" + raw = rds.get(UPDATE_CACHE_KEY) + cached = {} + if raw: + try: + cached = json.loads(raw.decode() if isinstance(raw, (bytes, bytearray)) else raw) + except Exception: + cached = {} + payload = { + "current": _current_version(), + "latest": cached.get("latest"), + "release_notes_url": cached.get("release_notes_url"), + "checked_at": cached.get("checked_at"), + } + return HTTPResponse.success(data=payload) + + +@admin.post("/api/updates/start") +@roles_required("Admin") +def api_updates_start() -> Response: + """Launch `bayanat update` out-of-process via the sudoers-granted wrapper.""" + try: + subprocess.run( + ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"], + check=True, + timeout=10, + ) + except subprocess.TimeoutExpired: + return HTTPResponse.error("Update start timed out", status=504) + except subprocess.CalledProcessError as e: + return HTTPResponse.error(f"Failed to start update: {e}", status=500) + return HTTPResponse.success(data={"status": "started"}) + + +@admin.get("/api/updates/status") +@roles_required("Admin") +def api_updates_status() -> Response: + """Return the current update state (from the CLI-written JSON file).""" + current = _current_version() + path = Path(UPDATE_STATE_FILE) + if not path.exists(): + return HTTPResponse.success(data=_idle_status(current)) + try: + state = json.loads(path.read_text()) + except Exception: + return HTTPResponse.success(data=_idle_status(current)) + state["running"] = state.get("phase") not in TERMINAL_PHASES + state["current"] = current + return HTTPResponse.success(data=state) diff --git a/tests/test_update_endpoints.py b/tests/test_update_endpoints.py new file mode 100644 index 000000000..71bd82aa1 --- /dev/null +++ b/tests/test_update_endpoints.py @@ -0,0 +1,78 @@ +import json +from unittest.mock import patch + + +def test_available_returns_cached(admin_client): + from enferno.extensions import rds + + rds.set( + "bayanat:update:available", + json.dumps( + { + "latest": "4.1.1", + "release_notes_url": "https://example.com", + "checked_at": "2026-04-16T00:00:00+00:00", + } + ), + ) + resp = admin_client.get("/admin/api/updates/available") + assert resp.status_code == 200 + data = resp.get_json()["data"] + assert data["latest"] == "4.1.1" + assert data["release_notes_url"] == "https://example.com" + + +def test_available_returns_empty_when_no_cache(admin_client): + from enferno.extensions import rds + + rds.delete("bayanat:update:available") + resp = admin_client.get("/admin/api/updates/available") + assert resp.status_code == 200 + data = resp.get_json()["data"] + assert data["latest"] is None + + +def test_status_idle_when_no_state_file(admin_client, tmp_path, monkeypatch): + monkeypatch.setattr( + "enferno.admin.views.system.UPDATE_STATE_FILE", + str(tmp_path / "missing.json"), + ) + resp = admin_client.get("/admin/api/updates/status") + assert resp.status_code == 200 + data = resp.get_json()["data"] + assert data["phase"] == "IDLE" + assert data["running"] is False + + +def test_status_running_when_midway(admin_client, tmp_path, monkeypatch): + state = tmp_path / "update.json" + state.write_text(json.dumps({"phase": "MIGRATE", "target": "4.1.1"})) + monkeypatch.setattr("enferno.admin.views.system.UPDATE_STATE_FILE", str(state)) + resp = admin_client.get("/admin/api/updates/status") + data = resp.get_json()["data"] + assert data["phase"] == "MIGRATE" + assert data["running"] is True + + +def test_status_terminal_when_success(admin_client, tmp_path, monkeypatch): + state = tmp_path / "update.json" + state.write_text(json.dumps({"phase": "SUCCESS", "target": "4.1.1"})) + monkeypatch.setattr("enferno.admin.views.system.UPDATE_STATE_FILE", str(state)) + resp = admin_client.get("/admin/api/updates/status") + data = resp.get_json()["data"] + assert data["running"] is False + + +def test_start_calls_wrapper(admin_client): + with patch("enferno.admin.views.system.subprocess.run") as run: + resp = admin_client.post("/admin/api/updates/start") + assert resp.status_code == 200 + run.assert_called_once() + args = run.call_args.args[0] + assert args == ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"] + + +def test_non_admin_cannot_start(da_client): + resp = da_client.post("/admin/api/updates/start") + # roles_required returns 403 (Forbidden) for wrong-role users + assert resp.status_code in (401, 403) From a928922deb47e27c9aa6752d850765d3fade3281 Mon Sep 17 00:00:00 2001 From: level09 Date: Sat, 18 Apr 2026 00:37:20 +0200 Subject: [PATCH 19/37] feat(ui): UpdateBanner and UpdateProgressDialog components Adds nav-bar chip that polls /admin/api/updates/available every 6h and shows a confirm dialog to trigger an update. Adds polling progress dialog that opens on update-started event and tracks /admin/api/updates/status at 2s intervals until the run completes. --- enferno/admin/templates/nav-bar.html | 2 + enferno/static/js/components/UpdateBanner.js | 89 +++++++++++++++++ .../js/components/UpdateProgressDialog.js | 98 +++++++++++++++++++ enferno/static/js/mixins/global-mixin.js | 2 + enferno/templates/layout.html | 2 + 5 files changed, 193 insertions(+) create mode 100644 enferno/static/js/components/UpdateBanner.js create mode 100644 enferno/static/js/components/UpdateProgressDialog.js diff --git a/enferno/admin/templates/nav-bar.html b/enferno/admin/templates/nav-bar.html index 3a724aae3..a6be982fd 100644 --- a/enferno/admin/templates/nav-bar.html +++ b/enferno/admin/templates/nav-bar.html @@ -11,6 +11,8 @@