update.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. #!/usr/bin/env bash
  2. set -Eeuo pipefail
  3. INSTALL_DIR="${INSTALL_DIR:-/opt/bambuddy}"
  4. SERVICE_NAME="${SERVICE_NAME:-bambuddy}"
  5. BRANCH="${BRANCH:-}"
  6. VENV_PIP="${VENV_PIP:-$INSTALL_DIR/venv/bin/pip}"
  7. FRONTEND_DIR="${FRONTEND_DIR:-$INSTALL_DIR/frontend}"
  8. BACKUP_DIR="${BACKUP_DIR:-$INSTALL_DIR/backups}"
  9. BAMBUDDY_API_URL="${BAMBUDDY_API_URL:-http://127.0.0.1:8000/api/v1}"
  10. BAMBUDDY_API_KEY="${BAMBUDDY_API_KEY:-}"
  11. BACKUP_MODE="${BACKUP_MODE:-auto}" # auto|require|skip
  12. BACKUP_KEEP_COUNT=5
  13. FORCE="${FORCE:-0}"
  14. SERVICE_STOPPED=0
  15. CODE_UPDATED=0
  16. old_commit=""
  17. log() {
  18. printf '[bambuddy-update] %s\n' "$*"
  19. }
  20. warn() {
  21. printf '[bambuddy-update] WARNING: %s\n' "$*" >&2
  22. }
  23. die() {
  24. printf '[bambuddy-update] ERROR: %s\n' "$*" >&2
  25. exit 1
  26. }
  27. require_cmd() {
  28. command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
  29. }
  30. cleanup_old_backups() {
  31. local -a backup_files
  32. local max_count="$1"
  33. [ "$max_count" -gt 0 ] || return 0
  34. mapfile -t backup_files < <(ls -1t "$BACKUP_DIR"/bambuddy-backup-*.zip 2>/dev/null || true)
  35. if [ "${#backup_files[@]}" -le "$max_count" ]; then
  36. return 0
  37. fi
  38. for old_file in "${backup_files[@]:$max_count}"; do
  39. rm -f "$old_file"
  40. done
  41. log "Pruned old backups, kept newest $max_count file(s)"
  42. }
  43. # Restore the --loop asyncio pin on a unit file written before it existed (#3001).
  44. #
  45. # install.sh has pinned the loop since 2026-07-05 (#1896), but this script has
  46. # never rewritten a unit file, and nothing else does either -- so an install
  47. # created before that date still runs on uvloop today no matter how many times
  48. # it has been updated. uvloop has been in every native venv since
  49. # uvicorn[standard] landed on 2025-11-28, and uvicorn's --loop auto prefers it,
  50. # so that is a seven-month window of installs still on the wrong loop. It cost
  51. # them every RTSP camera on 1.2.5.4 (#3001), and before that it exposed them to
  52. # Virtual Printer FTP uploads being silently truncated (#1896). Neither is
  53. # discoverable from the outside, which is why this repairs rather than reports.
  54. #
  55. # Only ever inserts the one flag. Everything else in the unit -- hardening,
  56. # environment, ExecStartPre, a hand-edited port -- is left byte-identical, and
  57. # the file is copied aside first.
  58. repair_loop_flag() {
  59. local fragment exec_line backup
  60. # The effective ExecStart, so a drop-in that already pins the loop counts.
  61. if systemctl show "$SERVICE_NAME" --property=ExecStart --value 2>/dev/null | grep -q -- '--loop'; then
  62. return 0
  63. fi
  64. fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)"
  65. if [ -z "$fragment" ] || [ ! -f "$fragment" ]; then
  66. warn "Cannot locate the unit file for $SERVICE_NAME; not repairing the --loop flag."
  67. return 0
  68. fi
  69. # A drop-in, not the fragment, may be what defines ExecStart. Editing the
  70. # fragment would then change nothing while reporting success.
  71. if [ -n "$(systemctl show "$SERVICE_NAME" --property=DropInPaths --value 2>/dev/null || true)" ]; then
  72. warn "$SERVICE_NAME has systemd drop-ins; add '--loop asyncio' to its uvicorn command by hand. See #1896."
  73. return 0
  74. fi
  75. # Anything but exactly one single-line uvicorn ExecStart is someone else's
  76. # arrangement -- a wrapper script, a continuation, several ExecStart lines --
  77. # and is described rather than edited.
  78. if [ "$(grep -c '^ExecStart=' "$fragment")" -ne 1 ]; then
  79. warn "$fragment has no single ExecStart line; add '--loop asyncio' to its uvicorn command by hand. See #1896."
  80. return 0
  81. fi
  82. exec_line="$(grep '^ExecStart=' "$fragment")"
  83. case "$exec_line" in
  84. *uvicorn*) ;;
  85. *)
  86. warn "$fragment does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896."
  87. return 0
  88. ;;
  89. esac
  90. case "$exec_line" in
  91. *\\)
  92. warn "$fragment continues its ExecStart onto another line; add '--loop asyncio' by hand. See #1896."
  93. return 0
  94. ;;
  95. esac
  96. if [ ! -w "$fragment" ]; then
  97. warn "$fragment is not writable; add '--loop asyncio' to its uvicorn command by hand. See #1896."
  98. return 0
  99. fi
  100. backup="$fragment.bak-$(date +%Y%m%d-%H%M%S)"
  101. cp -p "$fragment" "$backup" || {
  102. warn "Could not back up $fragment; leaving it alone."
  103. return 0
  104. }
  105. # Appended, not spliced: uvicorn accepts its options in any order after the
  106. # app path, and appending cannot disturb a value already on the line.
  107. if ! sed -i 's|^ExecStart=.*|& --loop asyncio|' "$fragment"; then
  108. warn "Failed to edit $fragment; restoring from $backup."
  109. cp -p "$backup" "$fragment" || true
  110. return 0
  111. fi
  112. log "Added the missing '--loop asyncio' flag to $fragment (was written before #1896; backup at $backup)"
  113. log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)."
  114. systemctl daemon-reload || warn "systemctl daemon-reload failed; the new flag applies after the next reload."
  115. }
  116. on_error() {
  117. local exit_code="$1"
  118. if [ "$SERVICE_STOPPED" -eq 1 ]; then
  119. if [ "$CODE_UPDATED" -eq 1 ] && [ -n "$old_commit" ]; then
  120. warn "Update failed after code change, attempting rollback to $old_commit"
  121. git reset --hard "$old_commit" || warn "Rollback reset failed"
  122. fi
  123. warn "Update failed, attempting to restart service: $SERVICE_NAME"
  124. systemctl start "$SERVICE_NAME" || true
  125. fi
  126. exit "$exit_code"
  127. }
  128. trap 'on_error $?' ERR
  129. create_backup() {
  130. local ts backup_file
  131. local -a auth_args=()
  132. if [ "$BACKUP_MODE" = "skip" ]; then
  133. log "Skipping backup (BACKUP_MODE=skip)"
  134. return 0
  135. fi
  136. if ! systemctl is-active --quiet "$SERVICE_NAME"; then
  137. if [ "$BACKUP_MODE" = "require" ]; then
  138. die "Service is not running; cannot call built-in backup API."
  139. fi
  140. warn "Service is not running; skipping built-in backup API call."
  141. return 0
  142. fi
  143. mkdir -p "$BACKUP_DIR"
  144. ts="$(date +%Y%m%d-%H%M%S)"
  145. backup_file="$BACKUP_DIR/bambuddy-backup-$ts.zip"
  146. [ -n "$BAMBUDDY_API_KEY" ] && auth_args=(-H "X-API-Key: $BAMBUDDY_API_KEY")
  147. log "Creating built-in backup via API: $backup_file"
  148. if curl --silent --show-error --fail --location \
  149. --connect-timeout 5 --max-time 900 \
  150. "${auth_args[@]}" \
  151. "$BAMBUDDY_API_URL/settings/backup" \
  152. --output "$backup_file"; then
  153. log "Backup created successfully"
  154. cleanup_old_backups "$BACKUP_KEEP_COUNT"
  155. return 0
  156. fi
  157. rm -f "$backup_file"
  158. if [ "$BACKUP_MODE" = "require" ]; then
  159. die "Built-in backup API call failed (BACKUP_MODE=require)."
  160. fi
  161. warn "Built-in backup API call failed. Continuing because BACKUP_MODE=auto."
  162. }
  163. [ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root (or with sudo)."
  164. case "$BACKUP_MODE" in
  165. auto|require|skip) ;;
  166. *) die "Invalid BACKUP_MODE '$BACKUP_MODE' (expected: auto, require, skip)." ;;
  167. esac
  168. require_cmd git
  169. require_cmd systemctl
  170. require_cmd curl
  171. [ -d "$INSTALL_DIR" ] || die "Install directory not found: $INSTALL_DIR"
  172. cd "$INSTALL_DIR"
  173. if [ ! -d .git ]; then
  174. cat >&2 <<EOF
  175. [bambuddy-update] ERROR: No .git directory found in $INSTALL_DIR.
  176. This update script requires a git-based install. If you installed by
  177. downloading a ZIP or tarball from GitHub, reinstall from scratch:
  178. 1. Back up your data:
  179. sudo systemctl stop $SERVICE_NAME
  180. sudo tar czf ~/bambuddy-backup.tgz -C $INSTALL_DIR \\
  181. data bambuddy.db bambuddy.db-shm bambuddy.db-wal \\
  182. virtual_printer archive projects icons .env 2>/dev/null || true
  183. 2. Remove the old install and reinstall via install.sh:
  184. sudo rm -rf $INSTALL_DIR
  185. curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh \\
  186. -o /tmp/install.sh && sudo bash /tmp/install.sh --path $INSTALL_DIR
  187. 3. Restore your data:
  188. sudo systemctl stop $SERVICE_NAME
  189. sudo tar xzf ~/bambuddy-backup.tgz -C $INSTALL_DIR
  190. sudo systemctl start $SERVICE_NAME
  191. EOF
  192. exit 1
  193. fi
  194. if [ -z "$BRANCH" ]; then
  195. BRANCH="$(git rev-parse --abbrev-ref HEAD)"
  196. [ "$BRANCH" = "HEAD" ] && BRANCH="main"
  197. fi
  198. load_state="$(systemctl show "$SERVICE_NAME" --property=LoadState --value 2>/dev/null || true)"
  199. if [ -z "$load_state" ] || [ "$load_state" = "not-found" ]; then
  200. die "Service not found: ${SERVICE_NAME}.service"
  201. fi
  202. old_commit="$(git rev-parse --short HEAD || true)"
  203. log "Fetching latest code from origin/$BRANCH"
  204. git fetch --prune origin
  205. remote_commit="$(git rev-parse --short "origin/$BRANCH" || true)"
  206. log "Current commit: ${old_commit:-unknown}"
  207. log "Remote commit: ${remote_commit:-unknown}"
  208. if git diff --quiet HEAD "origin/$BRANCH"; then
  209. log "You are already running the latest version of Bambuddy."
  210. read -r -p "Do you want to run the update process anyway? [y/N]: " run_anyway
  211. case "${run_anyway:-}" in
  212. y|Y|yes|YES) ;;
  213. *) exit 0 ;;
  214. esac
  215. else
  216. read -r -p "An update for Bambuddy is available. Install now? [y/N]: " install_now
  217. case "${install_now:-}" in
  218. y|Y|yes|YES) ;;
  219. *) exit 0 ;;
  220. esac
  221. fi
  222. if [ -n "$(git status --porcelain)" ]; then
  223. if [ "$FORCE" != "1" ]; then
  224. read -r -p "Local edits were detected in your installation. Updating now will overwrite those edits. Continue? [y/N]: " answer
  225. case "${answer:-}" in
  226. y|Y|yes|YES) ;;
  227. *) die "Update cancelled by user." ;;
  228. esac
  229. else
  230. warn "Proceeding without prompt because FORCE=1."
  231. fi
  232. fi
  233. create_backup
  234. log "Stopping service: $SERVICE_NAME"
  235. systemctl stop "$SERVICE_NAME"
  236. SERVICE_STOPPED=1
  237. log "Updating code to origin/$BRANCH"
  238. git reset --hard "origin/$BRANCH"
  239. CODE_UPDATED=1
  240. if [ -x "$VENV_PIP" ] && [ -f requirements.txt ]; then
  241. log "Updating Python dependencies"
  242. "$VENV_PIP" install -r requirements.txt
  243. else
  244. warn "Skipping Python dependency update (venv pip or requirements.txt missing)."
  245. fi
  246. if [ -f "$FRONTEND_DIR/package.json" ]; then
  247. if command -v npm >/dev/null 2>&1; then
  248. log "Building frontend"
  249. (
  250. cd "$FRONTEND_DIR"
  251. npm ci
  252. npm run build
  253. )
  254. else
  255. warn "Skipping frontend build (npm not installed)."
  256. fi
  257. else
  258. warn "Skipping frontend build (frontend/package.json not found)."
  259. fi
  260. repair_loop_flag
  261. log "Starting service: $SERVICE_NAME"
  262. systemctl start "$SERVICE_NAME"
  263. SERVICE_STOPPED=0
  264. systemctl --no-pager --lines=8 status "$SERVICE_NAME"
  265. new_commit="$(git rev-parse --short HEAD || true)"
  266. log "Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}"