#!/usr/bin/env bash # # bt-update - OTA update with automatic rollback for ButtonTask. # # Usage: # bt-update stage # deploy + health-check + commit/rollback # bt-update rollback # revert to the previous version # # Layout (see paths.py): # $ROOT/versions// deployed versions (ButtonTask + webconfig/) # $ROOT/current -> versions/ # $ROOT/run/update-status.json progress for the web UI # $ROOT/run/qt.alive heartbeat written by the Qt UI # $ROOT/.previous path of the previously active version # # Launched via sudo by the web service. On first entry re-execs itself through # systemd-run so it lives outside buttontask-web.service's cgroup and survives # the web service restart performed mid-update (setsid alone is not enough). set -uo pipefail ROOT="${BUTTONTASK_ROOT:-/opt/buttontask}" # Re-exec in a transient unit unless we are already running there. if [ -z "${BT_UPDATE_DETACHED:-}" ]; then unit="buttontask-update-$(date +%s)" exec systemd-run --no-block --collect \ "--unit=${unit}" \ "--setenv=BT_UPDATE_DETACHED=1" \ "--setenv=BUTTONTASK_ROOT=${ROOT}" \ "$0" "$@" fi VERSIONS="$ROOT/versions" CURRENT="$ROOT/current" RUN="$ROOT/run" STATUS="$RUN/update-status.json" HEARTBEAT="$RUN/qt.alive" PREV_MARK="$ROOT/.previous" LOG="$RUN/update.log" HEALTH_TIMEOUT="${BUTTONTASK_HEALTH_TIMEOUT:-30}" mkdir -p "$RUN" "$VERSIONS" log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG" } write_status() { local state="$1"; local msg="${2:-}" msg="${msg//\"/}" log "status: $state ${msg}" printf '{"state":"%s","message":"%s","ts":%s}\n' "$state" "$msg" "$(date +%s)" > "$STATUS" } web_port() { python3 - <<'PY' 2>/dev/null || echo 8080 import json, os root = os.environ.get("BUTTONTASK_ROOT", "/opt/buttontask") try: with open(os.path.join(root, "config", "config.json")) as f: print(int(json.load(f).get("settings", {}).get("webPort", 8080))) except Exception: print(8080) PY } restart_services() { log "restart buttontask" systemctl restart buttontask 2>/dev/null || true log "restart buttontask-web" systemctl restart buttontask-web 2>/dev/null || true } # Returns 0 when the freshly started version looks healthy. health_check() { local port deadline now hb_age port="$(web_port)" deadline=$(( $(date +%s) + HEALTH_TIMEOUT )) while [ "$(date +%s)" -lt "$deadline" ]; do # 1) web answers /healthz if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then # 2) Qt UI heartbeat is fresh (file touched in the last 20s) if [ -f "$HEARTBEAT" ]; then now=$(date +%s) hb_age=$(( now - $(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0) )) if [ "$hb_age" -le 20 ]; then # 3) Qt service is active if systemctl is-active --quiet buttontask 2>/dev/null; then return 0 fi fi fi fi sleep 2 done return 1 } switch_to() { # Atomically repoint the current symlink. ln -sfn "$1" "$CURRENT" } # Install privileged helpers + sudoers from the staged package so OTA can # deliver new bt-* scripts (e.g. bt-brightness) without a full redeploy. sync_helpers() { local dest="$1" local src="$dest/webconfig/scripts" local s name tmp_sudoers [ -d "$src" ] || { log "sync_helpers: no $src"; return 0; } mkdir -p "$ROOT/scripts" for s in "$src"/bt-*; do [ -f "$s" ] || continue name="$(basename "$s")" install -m 0755 "$s" "$ROOT/scripts/$name" chown root:root "$ROOT/scripts/$name" 2>/dev/null || true sed -i 's/\r$//' "$ROOT/scripts/$name" 2>/dev/null || true log "synced helper $name" done if [ -f "$src/buttontask.sudoers" ]; then tmp_sudoers="$(mktemp)" cp "$src/buttontask.sudoers" "$tmp_sudoers" sed -i 's/\r$//' "$tmp_sudoers" 2>/dev/null || true chmod 0440 "$tmp_sudoers" if visudo -cf "$tmp_sudoers" >/dev/null 2>&1; then install -m 0440 "$tmp_sudoers" /etc/sudoers.d/buttontask log "synced sudoers" else log "sudoers validation failed — kept previous /etc/sudoers.d/buttontask" fi rm -f "$tmp_sudoers" fi } cmd_stage() { local pkg="$1" [ -f "$pkg" ] || { write_status error "package not found"; exit 1; } write_status staging "распаковка" local tmp ver dest tmp="$(mktemp -d "$VERSIONS/.stage.XXXXXX")" if ! tar -xzf "$pkg" -C "$tmp"; then rm -rf "$tmp"; write_status error "bad archive"; exit 1 fi # Some packages wrap content in a single top dir; flatten if so. if [ ! -f "$tmp/manifest.json" ]; then local inner inner="$(find "$tmp" -maxdepth 2 -name manifest.json | head -n1)" [ -n "$inner" ] && tmp="$(dirname "$inner")" fi [ -f "$tmp/manifest.json" ] || { rm -rf "$tmp"; write_status error "manifest.json missing"; exit 1; } [ -x "$tmp/ButtonTask" ] || chmod +x "$tmp/ButtonTask" 2>/dev/null || true ver="$(python3 -c "import json,sys;print(json.load(open('$tmp/manifest.json')).get('version','unknown'))" 2>/dev/null || echo unknown)" dest="$VERSIONS/$ver" # Remember the current version for rollback. if [ -L "$CURRENT" ]; then readlink -f "$CURRENT" > "$PREV_MARK" fi # Avoid clobbering the running version directory. if [ "$(readlink -f "$CURRENT" 2>/dev/null)" = "$(readlink -f "$dest" 2>/dev/null)" ]; then dest="${dest}-$(date +%s)" fi rm -rf "$dest" mv "$tmp" "$dest" chown -R buttontask:buttontask "$dest" 2>/dev/null || true chmod -R u=rwX,go=rX "$dest" 2>/dev/null || true rm -f "$pkg" write_status helpers "установка хелперов" sync_helpers "$dest" write_status switching "$ver" switch_to "$dest" restart_services write_status health_check "$ver" if health_check; then write_status success "$ver" exit 0 fi # Rollback. if [ -s "$PREV_MARK" ]; then switch_to "$(cat "$PREV_MARK")" restart_services write_status rolledback "запуск $ver не удался" else write_status error "health-check failed, no previous version" fi exit 1 } cmd_rollback() { [ -s "$PREV_MARK" ] || { write_status error "no previous version"; exit 1; } write_status switching "rollback" switch_to "$(cat "$PREV_MARK")" restart_services write_status rollback_done "откат выполнен" } cmd_sync_helpers() { local dest dest="$(readlink -f "$CURRENT" 2>/dev/null || true)" [ -n "$dest" ] && [ -d "$dest" ] || { echo "no current version" >&2; exit 1; } sync_helpers "$dest" echo "helpers synced from $dest" } cmd_remove() { local name="$1" [[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid version name" >&2; exit 1; } local dest="$VERSIONS/$name" [ -d "$dest" ] || { echo "version not found" >&2; exit 1; } local cur prev target cur="$(readlink -f "$CURRENT" 2>/dev/null || true)" prev="" [ -s "$PREV_MARK" ] && prev="$(readlink -f "$(cat "$PREV_MARK")" 2>/dev/null || true)" target="$(readlink -f "$dest")" [ -n "$cur" ] && [ "$target" = "$cur" ] && { echo "cannot remove active version" >&2; exit 1; } [ -n "$prev" ] && [ "$target" = "$prev" ] && { echo "cannot remove rollback target" >&2; exit 1; } rm -rf "$dest" echo "removed $name" } case "${1:-}" in stage) [ "$#" -ge 2 ] || { echo "usage: bt-update stage "; exit 2; }; cmd_stage "$2" ;; rollback) cmd_rollback ;; sync-helpers) cmd_sync_helpers ;; remove) [ "$#" -ge 2 ] || { echo "usage: bt-update remove "; exit 2; }; cmd_remove "$2" ;; *) echo "usage: bt-update {stage |rollback|sync-helpers|remove }"; exit 2 ;; esac