235 lines
7.9 KiB
Bash
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
|