| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- #!/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}"
|