Files
maziggy 0dfcff5925 Keep the RTSPS proxy's handler set off the server object (issue #3001)
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.
2026-08-30 08:01:42 +02:00

320 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/bambuddy}"
SERVICE_NAME="${SERVICE_NAME:-bambuddy}"
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)"
}
# Restore the --loop asyncio pin on a unit file written before it existed (#3001).
#
# install.sh has pinned the loop since 2026-07-05 (#1896), but this script has
# never rewritten a unit file, and nothing else does either -- so an install
# created before that date still runs on uvloop today no matter how many times
# it has been updated. uvloop has been in every native venv since
# uvicorn[standard] landed on 2025-11-28, and uvicorn's --loop auto prefers it,
# so that is a seven-month window of installs still on the wrong loop. It cost
# them every RTSP camera on 1.2.5.4 (#3001), and before that it exposed them to
# Virtual Printer FTP uploads being silently truncated (#1896). Neither is
# discoverable from the outside, which is why this repairs rather than reports.
#
# Only ever inserts the one flag. Everything else in the unit -- hardening,
# environment, ExecStartPre, a hand-edited port -- is left byte-identical, and
# the file is copied aside first.
repair_loop_flag() {
local fragment exec_line backup
# The effective ExecStart, so a drop-in that already pins the loop counts.
if systemctl show "$SERVICE_NAME" --property=ExecStart --value 2>/dev/null | grep -q -- '--loop'; then
return 0
fi
fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)"
if [ -z "$fragment" ] || [ ! -f "$fragment" ]; then
warn "Cannot locate the unit file for $SERVICE_NAME; not repairing the --loop flag."
return 0
fi
# A drop-in, not the fragment, may be what defines ExecStart. Editing the
# fragment would then change nothing while reporting success.
if [ -n "$(systemctl show "$SERVICE_NAME" --property=DropInPaths --value 2>/dev/null || true)" ]; then
warn "$SERVICE_NAME has systemd drop-ins; add '--loop asyncio' to its uvicorn command by hand. See #1896."
return 0
fi
# Anything but exactly one single-line uvicorn ExecStart is someone else's
# arrangement -- a wrapper script, a continuation, several ExecStart lines --
# and is described rather than edited.
if [ "$(grep -c '^ExecStart=' "$fragment")" -ne 1 ]; then
warn "$fragment has no single ExecStart line; add '--loop asyncio' to its uvicorn command by hand. See #1896."
return 0
fi
exec_line="$(grep '^ExecStart=' "$fragment")"
case "$exec_line" in
*uvicorn*) ;;
*)
warn "$fragment does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896."
return 0
;;
esac
case "$exec_line" in
*\\)
warn "$fragment continues its ExecStart onto another line; add '--loop asyncio' by hand. See #1896."
return 0
;;
esac
if [ ! -w "$fragment" ]; then
warn "$fragment is not writable; add '--loop asyncio' to its uvicorn command by hand. See #1896."
return 0
fi
backup="$fragment.bak-$(date +%Y%m%d-%H%M%S)"
cp -p "$fragment" "$backup" || {
warn "Could not back up $fragment; leaving it alone."
return 0
}
# Appended, not spliced: uvicorn accepts its options in any order after the
# app path, and appending cannot disturb a value already on the line.
if ! sed -i 's|^ExecStart=.*|& --loop asyncio|' "$fragment"; then
warn "Failed to edit $fragment; restoring from $backup."
cp -p "$backup" "$fragment" || true
return 0
fi
log "Added the missing '--loop asyncio' flag to $fragment (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)."
systemctl daemon-reload || warn "systemctl daemon-reload failed; the new flag applies after the next reload."
}
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"
systemctl start "$SERVICE_NAME" || 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 ! systemctl is-active --quiet "$SERVICE_NAME"; 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[@]}" \
"$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."
}
[ "${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 systemctl
require_cmd curl
[ -d "$INSTALL_DIR" ] || die "Install directory not found: $INSTALL_DIR"
cd "$INSTALL_DIR"
if [ ! -d .git ]; then
cat >&2 <<EOF
[bambuddy-update] ERROR: No .git directory found in $INSTALL_DIR.
This update script requires a git-based install. If you installed by
downloading a ZIP or tarball from GitHub, reinstall from scratch:
1. Back up your data:
sudo systemctl stop $SERVICE_NAME
sudo tar czf ~/bambuddy-backup.tgz -C $INSTALL_DIR \\
data bambuddy.db bambuddy.db-shm bambuddy.db-wal \\
virtual_printer archive projects icons .env 2>/dev/null || true
2. Remove the old install and reinstall via install.sh:
sudo rm -rf $INSTALL_DIR
curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh \\
-o /tmp/install.sh && sudo bash /tmp/install.sh --path $INSTALL_DIR
3. Restore your data:
sudo systemctl stop $SERVICE_NAME
sudo tar xzf ~/bambuddy-backup.tgz -C $INSTALL_DIR
sudo systemctl start $SERVICE_NAME
EOF
exit 1
fi
if [ -z "$BRANCH" ]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
[ "$BRANCH" = "HEAD" ] && BRANCH="main"
fi
load_state="$(systemctl show "$SERVICE_NAME" --property=LoadState --value 2>/dev/null || true)"
if [ -z "$load_state" ] || [ "$load_state" = "not-found" ]; then
die "Service not found: ${SERVICE_NAME}.service"
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"
systemctl stop "$SERVICE_NAME"
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"
systemctl start "$SERVICE_NAME"
SERVICE_STOPPED=0
systemctl --no-pager --lines=8 status "$SERVICE_NAME"
new_commit="$(git rev-parse --short HEAD || true)"
log "Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}"