mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
asyncio's Server has a __dict__ and uvloop's, a Cython cdef class, does not, so the attribute added in 1.2.5.4 raised AttributeError under uvloop. Every RTSP camera failed before opening a socket, which is the diagnostic's capture_exception at 0 ms. Our own unit files all pin --loop asyncio for #1896 and were never affected. The reports come from units we do not write: the Proxmox VE Helper-Scripts LXC pins no loop, and installs predating that fix never gained the flag because update.sh does not rewrite unit files. The loop is not ours to assume, so fix the code rather than add another flag. The set moves to a module-level WeakKeyDictionary, keyed weakly so an abandoned proxy retires its own entry rather than leaking one and later handing a new server a dead one's handlers. Pinned on a real uvloop loop and, for hosts without uvloop, against a __slots__ server; conftest builds its loop from the default policy, so nothing in the suite had ever run the branch that broke. Also routes the two external-camera teardowns through close_tls_proxy, which #2968 introduced and left them out of. ----- Say so at startup when running on uvloop (issue #3001) An install on the wrong loop had no way to find out it was. #3001 was loud enough to notice; the #1896 upload truncation it is also exposed to is silent, and shows up as a print failing from a file that was corrupt on arrival. One WARNING in the lifespan naming the loop, the risk and the flag to add. A warning and not a refusal: uvicorn has already chosen its loop by the time any application code runs, and a server that answers requests beats one that will not boot. Asks the running loop what it is rather than whether uvloop imports -- uvicorn[standard] installs uvloop everywhere, so its presence says nothing -- and matches on the module name so the question never imports uvloop on a host without it. ----- Repair a service file written before the --loop asyncio pin (issue #3001) install.sh has pinned the loop since #1896, but nothing has ever rewritten an existing service file, so every native install created between 2025-11-28 (when uvicorn[standard] brought uvloop into the venv) and 2026-07-05 still runs on uvloop no matter how often it is updated. Both update scripts now add the flag themselves while the service is stopped, so it takes effect on the same restart -- systemd via sed, launchd via PlistBuddy, each backing the file up first and inserting nothing but the flag. Refuses to edit and explains instead when the shape is not a plain single-line uvicorn unit: a wrapper script, a continued ExecStart, several of them, a read-only file, or a service with drop-ins, since a drop-in may be what defines ExecStart and editing the fragment would change nothing while reporting success. A deliberate --loop uvloop is left alone. Reads the effective ExecStart from systemd rather than the file, so it is idempotent.
271 lines
7.9 KiB
Bash
271 lines
7.9 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
INSTALL_DIR="${INSTALL_DIR:-/opt/bambuddy}"
|
|
SERVICE_NAME="${SERVICE_NAME:-com.bambuddy.app}"
|
|
PLIST_PATH="${PLIST_PATH:-$HOME/Library/LaunchAgents/com.bambuddy.app.plist}"
|
|
BRANCH="${BRANCH:-}"
|
|
VENV_PIP="${VENV_PIP:-$INSTALL_DIR/venv/bin/pip}"
|
|
FRONTEND_DIR="${FRONTEND_DIR:-$INSTALL_DIR/frontend}"
|
|
BACKUP_DIR="${BACKUP_DIR:-$INSTALL_DIR/backups}"
|
|
BAMBUDDY_API_URL="${BAMBUDDY_API_URL:-http://127.0.0.1:8000/api/v1}"
|
|
BAMBUDDY_API_KEY="${BAMBUDDY_API_KEY:-}"
|
|
BACKUP_MODE="${BACKUP_MODE:-auto}" # auto|require|skip
|
|
BACKUP_KEEP_COUNT=5
|
|
FORCE="${FORCE:-0}"
|
|
|
|
SERVICE_STOPPED=0
|
|
CODE_UPDATED=0
|
|
old_commit=""
|
|
|
|
log() {
|
|
printf '[bambuddy-update] %s\n' "$*"
|
|
}
|
|
|
|
warn() {
|
|
printf '[bambuddy-update] WARNING: %s\n' "$*" >&2
|
|
}
|
|
|
|
die() {
|
|
printf '[bambuddy-update] ERROR: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
require_cmd() {
|
|
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
|
}
|
|
|
|
cleanup_old_backups() {
|
|
local -a backup_files
|
|
local max_count="$1"
|
|
|
|
[ "$max_count" -gt 0 ] || return 0
|
|
|
|
mapfile -t backup_files < <(ls -1t "$BACKUP_DIR"/bambuddy-backup-*.zip 2>/dev/null || true)
|
|
if [ "${#backup_files[@]}" -le "$max_count" ]; then
|
|
return 0
|
|
fi
|
|
|
|
for old_file in "${backup_files[@]:$max_count}"; do
|
|
rm -f "$old_file"
|
|
done
|
|
|
|
log "Pruned old backups, kept newest $max_count file(s)"
|
|
}
|
|
|
|
is_service_active() {
|
|
launchctl list | grep -q "$SERVICE_NAME"
|
|
}
|
|
|
|
# Restore the --loop asyncio pin on a plist written before it existed (#3001).
|
|
#
|
|
# The macOS twin of the systemd repair in update.sh, and there for the same
|
|
# reason: install.sh has pinned the loop since 2026-07-05 (#1896), this script
|
|
# has never rewritten the plist, and nothing else does -- so an install created
|
|
# before that date still launches on uvloop today. uvloop reaches macOS as
|
|
# well, since uvicorn[standard] only excludes it on Windows. That costs every
|
|
# RTSP camera (#3001) and risks silently truncated Virtual Printer FTP uploads
|
|
# (#1896), neither of which is visible from outside the machine.
|
|
#
|
|
# PlistBuddy is used rather than sed because the plist is XML and
|
|
# ProgramArguments is an array; appending the two strings is safe because
|
|
# uvicorn accepts its options in any order after the app path.
|
|
repair_loop_flag() {
|
|
local plistbuddy="/usr/libexec/PlistBuddy" backup
|
|
|
|
[ -f "$PLIST_PATH" ] || return 0
|
|
if grep -q -- '--loop' "$PLIST_PATH"; then
|
|
return 0
|
|
fi
|
|
if [ ! -x "$plistbuddy" ]; then
|
|
warn "PlistBuddy not found; add '--loop' and 'asyncio' to ProgramArguments in $PLIST_PATH by hand. See #1896."
|
|
return 0
|
|
fi
|
|
# A plist that does not invoke uvicorn directly is someone else's
|
|
# arrangement and is described rather than edited.
|
|
if ! grep -q 'uvicorn' "$PLIST_PATH"; then
|
|
warn "$PLIST_PATH does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896."
|
|
return 0
|
|
fi
|
|
|
|
backup="$PLIST_PATH.bak-$(date +%Y%m%d-%H%M%S)"
|
|
cp -p "$PLIST_PATH" "$backup" || {
|
|
warn "Could not back up $PLIST_PATH; leaving it alone."
|
|
return 0
|
|
}
|
|
|
|
if ! "$plistbuddy" -c 'Add :ProgramArguments: string --loop' \
|
|
-c 'Add :ProgramArguments: string asyncio' "$PLIST_PATH" >/dev/null 2>&1; then
|
|
warn "Failed to edit $PLIST_PATH; restoring from $backup."
|
|
cp -p "$backup" "$PLIST_PATH" || true
|
|
return 0
|
|
fi
|
|
|
|
log "Added the missing '--loop asyncio' flag to $PLIST_PATH (was written before #1896; backup at $backup)"
|
|
log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)."
|
|
}
|
|
|
|
on_error() {
|
|
local exit_code="$1"
|
|
|
|
if [ "$SERVICE_STOPPED" -eq 1 ]; then
|
|
if [ "$CODE_UPDATED" -eq 1 ] && [ -n "$old_commit" ]; then
|
|
warn "Update failed after code change, attempting rollback to $old_commit"
|
|
git reset --hard "$old_commit" || warn "Rollback reset failed"
|
|
fi
|
|
|
|
warn "Update failed, attempting to restart service: $SERVICE_NAME"
|
|
launchctl load "$PLIST_PATH" || true
|
|
fi
|
|
|
|
exit "$exit_code"
|
|
}
|
|
trap 'on_error $?' ERR
|
|
|
|
create_backup() {
|
|
local ts backup_file
|
|
local -a auth_args=()
|
|
|
|
if [ "$BACKUP_MODE" = "skip" ]; then
|
|
log "Skipping backup (BACKUP_MODE=skip)"
|
|
return 0
|
|
fi
|
|
|
|
if ! is_service_active; then
|
|
if [ "$BACKUP_MODE" = "require" ]; then
|
|
die "Service is not running; cannot call built-in backup API."
|
|
fi
|
|
warn "Service is not running; skipping built-in backup API call."
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$BACKUP_DIR"
|
|
ts="$(date +%Y%m%d-%H%M%S)"
|
|
backup_file="$BACKUP_DIR/bambuddy-backup-$ts.zip"
|
|
|
|
[ -n "$BAMBUDDY_API_KEY" ] && auth_args=(-H "X-API-Key: $BAMBUDDY_API_KEY")
|
|
|
|
log "Creating built-in backup via API: $backup_file"
|
|
if curl --silent --show-error --fail --location \
|
|
--connect-timeout 5 --max-time 900 \
|
|
${auth_args:+${auth_args[@]}} \
|
|
"$BAMBUDDY_API_URL/settings/backup" \
|
|
--output "$backup_file"; then
|
|
log "Backup created successfully"
|
|
cleanup_old_backups "$BACKUP_KEEP_COUNT"
|
|
return 0
|
|
fi
|
|
|
|
rm -f "$backup_file"
|
|
if [ "$BACKUP_MODE" = "require" ]; then
|
|
die "Built-in backup API call failed (BACKUP_MODE=require)."
|
|
fi
|
|
warn "Built-in backup API call failed. Continuing because BACKUP_MODE=auto."
|
|
}
|
|
|
|
# NOTE: kept root check as-is (you can remove if desired)
|
|
#[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root (or with sudo)."
|
|
|
|
case "$BACKUP_MODE" in
|
|
auto|require|skip) ;;
|
|
*) die "Invalid BACKUP_MODE '$BACKUP_MODE' (expected: auto, require, skip)." ;;
|
|
esac
|
|
|
|
require_cmd git
|
|
require_cmd launchctl
|
|
require_cmd curl
|
|
|
|
[ -d "$INSTALL_DIR" ] || die "Install directory not found: $INSTALL_DIR"
|
|
[ -f "$PLIST_PATH" ] || die "Service plist not found: $PLIST_PATH"
|
|
|
|
cd "$INSTALL_DIR"
|
|
[ -d .git ] || die "No git repository found in: $INSTALL_DIR"
|
|
|
|
if [ -z "$BRANCH" ]; then
|
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
|
[ "$BRANCH" = "HEAD" ] && BRANCH="main"
|
|
fi
|
|
|
|
# replaced systemctl show check
|
|
if ! launchctl list | grep -q "$SERVICE_NAME" && [ ! -f "$PLIST_PATH" ]; then
|
|
die "Service not found: $SERVICE_NAME"
|
|
fi
|
|
|
|
old_commit="$(git rev-parse --short HEAD || true)"
|
|
|
|
log "Fetching latest code from origin/$BRANCH"
|
|
git fetch --prune origin
|
|
|
|
remote_commit="$(git rev-parse --short "origin/$BRANCH" || true)"
|
|
log "Current commit: ${old_commit:-unknown}"
|
|
log "Remote commit: ${remote_commit:-unknown}"
|
|
|
|
if git diff --quiet HEAD "origin/$BRANCH"; then
|
|
log "You are already running the latest version of Bambuddy."
|
|
read -r -p "Do you want to run the update process anyway? [y/N]: " run_anyway
|
|
case "${run_anyway:-}" in
|
|
y|Y|yes|YES) ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
else
|
|
read -r -p "An update for Bambuddy is available. Install now? [y/N]: " install_now
|
|
case "${install_now:-}" in
|
|
y|Y|yes|YES) ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
fi
|
|
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
if [ "$FORCE" != "1" ]; then
|
|
read -r -p "Local edits were detected in your installation. Updating now will overwrite those edits. Continue? [y/N]: " answer
|
|
case "${answer:-}" in
|
|
y|Y|yes|YES) ;;
|
|
*) die "Update cancelled by user." ;;
|
|
esac
|
|
else
|
|
warn "Proceeding without prompt because FORCE=1."
|
|
fi
|
|
fi
|
|
|
|
create_backup
|
|
|
|
log "Stopping service: $SERVICE_NAME"
|
|
launchctl unload "$PLIST_PATH"
|
|
SERVICE_STOPPED=1
|
|
|
|
log "Updating code to origin/$BRANCH"
|
|
git reset --hard "origin/$BRANCH"
|
|
CODE_UPDATED=1
|
|
|
|
if [ -x "$VENV_PIP" ] && [ -f requirements.txt ]; then
|
|
log "Updating Python dependencies"
|
|
"$VENV_PIP" install -r requirements.txt
|
|
else
|
|
warn "Skipping Python dependency update (venv pip or requirements.txt missing)."
|
|
fi
|
|
|
|
if [ -f "$FRONTEND_DIR/package.json" ]; then
|
|
if command -v npm >/dev/null 2>&1; then
|
|
log "Building frontend"
|
|
(
|
|
cd "$FRONTEND_DIR"
|
|
npm ci
|
|
npm run build
|
|
)
|
|
else
|
|
warn "Skipping frontend build (npm not installed)."
|
|
fi
|
|
else
|
|
warn "Skipping frontend build (frontend/package.json not found)."
|
|
fi
|
|
|
|
repair_loop_flag
|
|
|
|
log "Starting service: $SERVICE_NAME"
|
|
launchctl load "$PLIST_PATH"
|
|
SERVICE_STOPPED=0
|
|
launchctl list | grep "$SERVICE_NAME" || true
|
|
|
|
new_commit="$(git rev-parse --short HEAD || true)"
|
|
log "Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}"
|