Files

235 lines
7.9 KiB
Bash

#!/usr/bin/env bash
#
# bt-update - OTA update with automatic rollback for ButtonTask.
#
# Usage:
# bt-update stage <package.tar.gz> # deploy + health-check + commit/rollback
# bt-update rollback # revert to the previous version
#
# Layout (see paths.py):
# $ROOT/versions/<ver>/ deployed versions (ButtonTask + webconfig/)
# $ROOT/current -> versions/<ver>
# $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 <pkg>"; exit 2; }; cmd_stage "$2" ;;
rollback) cmd_rollback ;;
sync-helpers) cmd_sync_helpers ;;
remove) [ "$#" -ge 2 ] || { echo "usage: bt-update remove <name>"; exit 2; }; cmd_remove "$2" ;;
*) echo "usage: bt-update {stage <pkg>|rollback|sync-helpers|remove <name>}"; exit 2 ;;
esac