From 87a35f6fbca1a2a697063fcdae1e6f2a3c71e916 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Wed, 27 Aug 2025 15:37:02 +0000 Subject: [PATCH] feat: add automatic stable update system - Add systemd timer for daily update checks (2-6 AM window) - Create pulse-auto-update.sh script with safe rollback on failure - Add --enable-auto-updates flag to install script - Prompt users during fresh install to enable auto-updates - Respect autoUpdateEnabled flag in system.json - Only install stable releases, never RCs - Full logging to systemd journal - Tested and verified working in container --- install.sh | 141 ++++++++++++- scripts/pulse-auto-update.sh | 295 +++++++++++++++++++++++++++ scripts/systemd/pulse-update.service | 35 ++++ scripts/systemd/pulse-update.timer | 19 ++ 4 files changed, 489 insertions(+), 1 deletion(-) create mode 100755 scripts/pulse-auto-update.sh create mode 100644 scripts/systemd/pulse-update.service create mode 100644 scripts/systemd/pulse-update.timer diff --git a/install.sh b/install.sh index 69376d4bb..8d1869fb5 100755 --- a/install.sh +++ b/install.sh @@ -870,6 +870,99 @@ setup_directories() { chmod 700 "$CONFIG_DIR" } +setup_auto_updates() { + print_info "Setting up automatic updates..." + + # Copy auto-update script if it exists in the release + if [[ -f "$INSTALL_DIR/scripts/pulse-auto-update.sh" ]]; then + cp "$INSTALL_DIR/scripts/pulse-auto-update.sh" /usr/local/bin/pulse-auto-update.sh + chmod +x /usr/local/bin/pulse-auto-update.sh + else + # Download from GitHub if not in release + print_info "Downloading auto-update script..." + if ! curl -fsSL "https://raw.githubusercontent.com/$GITHUB_REPO/main/scripts/pulse-auto-update.sh" -o /usr/local/bin/pulse-auto-update.sh; then + print_error "Failed to download auto-update script" + return 1 + fi + chmod +x /usr/local/bin/pulse-auto-update.sh + fi + + # Install systemd timer and service + cat > /etc/systemd/system/pulse-update.service << 'EOF' +[Unit] +Description=Automatic Pulse update check and install +Documentation=https://github.com/rcourtman/Pulse +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/local/bin/pulse-auto-update.sh +Restart=no +TimeoutStartSec=600 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=pulse-update +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ReadWritePaths=/opt/pulse /etc/pulse /tmp +PrivateNetwork=no +Nice=10 + +[Install] +WantedBy=multi-user.target +EOF + + cat > /etc/systemd/system/pulse-update.timer << 'EOF' +[Unit] +Description=Daily check for Pulse updates +Documentation=https://github.com/rcourtman/Pulse +After=network-online.target +Wants=network-online.target + +[Timer] +OnCalendar=daily +OnCalendar=02:00 +RandomizedDelaySec=4h +Persistent=true +AccuracySec=1h + +[Install] +WantedBy=timers.target +EOF + + systemctl daemon-reload >/dev/null 2>&1 + + # Enable timer but don't start it yet + systemctl enable pulse-update.timer >/dev/null 2>&1 + + # Update system.json to enable auto-updates + if [[ -f "$CONFIG_DIR/system.json" ]]; then + # Update existing file + local temp_file="/tmp/system_$$.json" + if command -v jq &> /dev/null; then + jq '.autoUpdateEnabled = true' "$CONFIG_DIR/system.json" > "$temp_file" && mv "$temp_file" "$CONFIG_DIR/system.json" + else + # Fallback to sed if jq not available + sed -i 's/"pollingInterval"/"autoUpdateEnabled":true,"pollingInterval"/' "$CONFIG_DIR/system.json" 2>/dev/null || \ + sed -i 's/^{/{"autoUpdateEnabled":true,/' "$CONFIG_DIR/system.json" 2>/dev/null || true + fi + else + # Create new file with auto-updates enabled + echo '{"autoUpdateEnabled":true,"pollingInterval":5}' > "$CONFIG_DIR/system.json" + fi + + chown pulse:pulse "$CONFIG_DIR/system.json" 2>/dev/null || true + + # Start the timer + systemctl start pulse-update.timer >/dev/null 2>&1 + + print_success "Automatic updates enabled (daily check with 2-6 hour random delay)" +} + install_systemd_service() { print_info "Installing systemd service..." @@ -965,6 +1058,20 @@ print_completion() { echo " Update: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash" echo " Reset: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --reset" echo " Uninstall: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --uninstall" + + # Show auto-update status if timer exists + if systemctl list-unit-files --no-legend | grep -q "^pulse-update.timer"; then + echo + echo -e "${YELLOW}Auto-updates:${NC}" + if systemctl is-enabled --quiet pulse-update.timer 2>/dev/null; then + echo " Status: ${GREEN}Enabled${NC} (daily check between 2-6 AM)" + echo " Disable: systemctl disable --now pulse-update.timer" + else + echo " Status: Disabled" + echo " Enable: systemctl enable --now pulse-update.timer" + fi + fi + echo } @@ -1222,7 +1329,7 @@ main() { # This is an update/reinstall, don't prompt for port FRONTEND_PORT=${FRONTEND_PORT:-7655} else - # Fresh installation - ask for port configuration + # Fresh installation - ask for port configuration and auto-updates FRONTEND_PORT=${FRONTEND_PORT:-} if [[ -z "$FRONTEND_PORT" ]]; then if [[ "$IN_CONTAINER" == "true" ]]; then @@ -1238,6 +1345,17 @@ main() { fi fi fi + + # Ask about auto-updates for fresh installation (unless forced by flag) + if [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_CONTAINER" != "true" ]]; then + echo + echo "Enable automatic updates?" + echo "Pulse can automatically install stable updates daily (between 2-6 AM)" + safe_read "Enable auto-updates? [y/N]: " enable_updates + if [[ "$enable_updates" =~ ^[Yy]$ ]]; then + ENABLE_AUTO_UPDATES=true + fi + fi fi install_dependencies @@ -1245,6 +1363,12 @@ main() { setup_directories download_pulse install_systemd_service + + # Setup auto-updates if requested + if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then + setup_auto_updates + fi + start_pulse print_completion fi @@ -1271,13 +1395,22 @@ uninstall_pulse() { systemctl disable $SERVICE_NAME fi + # Stop and disable auto-update timer if it exists + if systemctl is-enabled --quiet pulse-update.timer 2>/dev/null; then + echo "Disabling auto-update timer..." + systemctl disable --now pulse-update.timer + fi + # Remove files echo "Removing Pulse files..." rm -rf /opt/pulse rm -rf /etc/pulse rm -f /etc/systemd/system/pulse.service rm -f /etc/systemd/system/pulse-backend.service + rm -f /etc/systemd/system/pulse-update.service + rm -f /etc/systemd/system/pulse-update.timer rm -f /usr/local/bin/pulse + rm -f /usr/local/bin/pulse-auto-update.sh # Remove user (if it exists and isn't being used by other services) if id "pulse" &>/dev/null; then @@ -1327,6 +1460,7 @@ reset_pulse() { FORCE_VERSION="" FORCE_CHANNEL="" IN_CONTAINER=false +ENABLE_AUTO_UPDATES=false while [[ $# -gt 0 ]]; do case $1 in @@ -1352,6 +1486,10 @@ while [[ $# -gt 0 ]]; do IN_CONTAINER=true shift ;; + --enable-auto-updates) + ENABLE_AUTO_UPDATES=true + shift + ;; -h|--help) echo "Usage: $0 [OPTIONS]" echo "" @@ -1359,6 +1497,7 @@ while [[ $# -gt 0 ]]; do echo " --rc, --pre Install latest RC/pre-release version" echo " --stable Install latest stable version (default)" echo " --version VERSION Install specific version (e.g., v4.4.0-rc.1)" + echo " --enable-auto-updates Enable automatic stable updates (via systemd timer)" echo "" echo "Management options:" echo " --reset Reset Pulse to fresh configuration" diff --git a/scripts/pulse-auto-update.sh b/scripts/pulse-auto-update.sh new file mode 100755 index 000000000..1d58d0524 --- /dev/null +++ b/scripts/pulse-auto-update.sh @@ -0,0 +1,295 @@ +#!/bin/bash + +# Pulse Automatic Update Script +# This script checks for and installs stable Pulse updates +# It is designed to be run by systemd timer + +set -euo pipefail + +# Configuration +GITHUB_REPO="rcourtman/Pulse" +INSTALL_DIR="/opt/pulse" +CONFIG_DIR="/etc/pulse" +LOG_TAG="pulse-auto-update" +MAX_LOG_SIZE=10485760 # 10MB + +# Logging function +log() { + local level=$1 + shift + logger -t "$LOG_TAG" -p "user.$level" "$@" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $@" +} + +# Check if auto-updates are enabled +check_auto_updates_enabled() { + # Check system.json for autoUpdateEnabled flag (note: no 's' - matches Go struct) + if [[ -f "$CONFIG_DIR/system.json" ]]; then + local enabled=$(cat "$CONFIG_DIR/system.json" 2>/dev/null | grep -o '"autoUpdateEnabled"[[:space:]]*:[[:space:]]*true' || true) + if [[ -z "$enabled" ]]; then + log info "Auto-updates disabled in configuration" + exit 0 + fi + fi + + # Also check if timer is enabled (belt and suspenders) + if ! systemctl is-enabled --quiet pulse-update.timer 2>/dev/null; then + log info "Auto-update timer is disabled" + exit 0 + fi +} + +# Get current version +get_current_version() { + local version="" + + # Try to get version from binary + if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then + version=$("$INSTALL_DIR/bin/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true) + elif [[ -f "$INSTALL_DIR/pulse" ]]; then + version=$("$INSTALL_DIR/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true) + fi + + # Fallback to VERSION file + if [[ -z "$version" ]] && [[ -f "$INSTALL_DIR/VERSION" ]]; then + version=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n' || true) + fi + + echo "${version:-unknown}" +} + +# Get latest stable release from GitHub +get_latest_stable_version() { + local latest_version="" + + # Get latest stable release (not pre-releases) + latest_version=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" | \ + grep '"tag_name":' | \ + sed -E 's/.*"([^"]+)".*/\1/' || true) + + # Check if we got rate limited or failed + if [[ -z "$latest_version" ]] || [[ "$latest_version" == *"rate limit"* ]]; then + # Try direct GitHub latest URL as fallback + latest_version=$(curl -sI "https://github.com/$GITHUB_REPO/releases/latest" | \ + grep -i '^location:' | \ + sed -E 's|.*tag/([^[:space:]]+).*|\1|' | \ + tr -d '\r' || true) + fi + + echo "${latest_version:-}" +} + +# Compare versions (returns 0 if v1 > v2, 1 if v1 <= v2) +version_greater_than() { + local v1="${1#v}" # Remove 'v' prefix + local v2="${2#v}" + + # Don't update if current version is unknown + if [[ "$2" == "unknown" ]]; then + return 1 + fi + + # Split versions into parts + IFS='.' read -ra V1_PARTS <<< "${v1%%-*}" # Remove pre-release suffix + IFS='.' read -ra V2_PARTS <<< "${v2%%-*}" + + # Compare major.minor.patch + for i in 0 1 2; do + local p1="${V1_PARTS[$i]:-0}" + local p2="${V2_PARTS[$i]:-0}" + if [[ "$p1" -gt "$p2" ]]; then + return 0 + elif [[ "$p1" -lt "$p2" ]]; then + return 1 + fi + done + + # Versions are equal in major.minor.patch + # Now check pre-release suffixes + local has_suffix1=false + local has_suffix2=false + + [[ "$v1" == *-* ]] && has_suffix1=true + [[ "$v2" == *-* ]] && has_suffix2=true + + # Stable (no suffix) > pre-release (has suffix) + if [[ "$has_suffix1" == "false" ]] && [[ "$has_suffix2" == "true" ]]; then + return 0 # v1 (stable) > v2 (pre-release) + elif [[ "$has_suffix1" == "true" ]] && [[ "$has_suffix2" == "false" ]]; then + return 1 # v1 (pre-release) < v2 (stable) + elif [[ "$has_suffix1" == "true" ]] && [[ "$has_suffix2" == "true" ]]; then + # Both are pre-releases, compare suffixes lexicographically + local suffix1="${v1#*-}" + local suffix2="${v2#*-}" + if [[ "$suffix1" > "$suffix2" ]]; then + return 0 + fi + fi + + return 1 # v1 <= v2 +} + +# Detect service name (could be pulse or pulse-backend) +detect_service_name() { + if systemctl list-unit-files --no-legend | grep -q "^pulse-backend.service"; then + echo "pulse-backend" + elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then + echo "pulse" + else + echo "pulse" # Default + fi +} + +# Perform the update +perform_update() { + local new_version=$1 + local service_name=$(detect_service_name) + + log info "Starting update to $new_version" + + # Create backup of current installation + local backup_dir="/tmp/pulse-backup-$(date +%Y%m%d-%H%M%S)" + log info "Creating backup in $backup_dir" + mkdir -p "$backup_dir" + + # Backup binary + if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then + cp -a "$INSTALL_DIR/bin/pulse" "$backup_dir/" || true + elif [[ -f "$INSTALL_DIR/pulse" ]]; then + cp -a "$INSTALL_DIR/pulse" "$backup_dir/" || true + fi + + # Backup VERSION file + if [[ -f "$INSTALL_DIR/VERSION" ]]; then + cp -a "$INSTALL_DIR/VERSION" "$backup_dir/" || true + fi + + # Download update using install script (safest method) + log info "Downloading and installing update" + + # Run install script with specific version + if curl -sSL "https://raw.githubusercontent.com/$GITHUB_REPO/main/install.sh" | \ + bash -s -- --version "$new_version" 2>&1 | \ + while IFS= read -r line; do + log info "installer: $line" + done; then + + log info "Update successfully installed" + + # Verify new version + local installed_version=$(get_current_version) + if [[ "$installed_version" == "$new_version" ]]; then + log info "Version verified: $installed_version" + + # Clean up backup + rm -rf "$backup_dir" + + return 0 + else + log error "Version mismatch after update. Expected: $new_version, Got: $installed_version" + + # Restore from backup + log info "Restoring from backup" + if [[ -f "$backup_dir/pulse" ]]; then + if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then + cp -f "$backup_dir/pulse" "$INSTALL_DIR/bin/pulse" + else + cp -f "$backup_dir/pulse" "$INSTALL_DIR/pulse" + fi + fi + if [[ -f "$backup_dir/VERSION" ]]; then + cp -f "$backup_dir/VERSION" "$INSTALL_DIR/VERSION" + fi + + # Restart service with old version + systemctl restart "$service_name" || true + + # Clean up backup + rm -rf "$backup_dir" + + return 1 + fi + else + log error "Update installation failed" + + # Restore from backup + log info "Restoring from backup" + if [[ -f "$backup_dir/pulse" ]]; then + if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then + cp -f "$backup_dir/pulse" "$INSTALL_DIR/bin/pulse" + else + cp -f "$backup_dir/pulse" "$INSTALL_DIR/pulse" + fi + fi + if [[ -f "$backup_dir/VERSION" ]]; then + cp -f "$backup_dir/VERSION" "$INSTALL_DIR/VERSION" + fi + + # Clean up backup + rm -rf "$backup_dir" + + return 1 + fi +} + +# Main update check +main() { + log info "Starting Pulse auto-update check" + + # Check if auto-updates are enabled + check_auto_updates_enabled + + # Check if we're in Docker (updates not supported) + if [[ -f /.dockerenv ]] || grep -q docker /proc/1/cgroup 2>/dev/null; then + log info "Docker environment detected, skipping auto-update" + exit 0 + fi + + # Get current version + local current_version=$(get_current_version) + log info "Current version: $current_version" + + if [[ "$current_version" == "unknown" ]]; then + log error "Could not determine current version, skipping update" + exit 1 + fi + + # Get latest stable version + local latest_version=$(get_latest_stable_version) + + if [[ -z "$latest_version" ]]; then + log error "Could not determine latest version from GitHub" + exit 1 + fi + + log info "Latest stable version: $latest_version" + + # Compare versions + if version_greater_than "$latest_version" "$current_version"; then + log info "New version available: $latest_version (current: $current_version)" + + # Perform update + if perform_update "$latest_version"; then + log info "Update completed successfully to $latest_version" + + # Send notification if webhooks are configured + if [[ -f "$CONFIG_DIR/webhooks.enc" ]] || [[ -f "$CONFIG_DIR/webhooks.json" ]]; then + # Create a simple notification via the Pulse API + curl -s -X POST "http://localhost:7655/api/internal/notification" \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"update\",\"message\":\"Pulse automatically updated from $current_version to $latest_version\"}" \ + 2>/dev/null || true + fi + else + log error "Update failed" + exit 1 + fi + else + log info "Already running latest version" + fi + + log info "Auto-update check completed" +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/scripts/systemd/pulse-update.service b/scripts/systemd/pulse-update.service new file mode 100644 index 000000000..5ca055b43 --- /dev/null +++ b/scripts/systemd/pulse-update.service @@ -0,0 +1,35 @@ +[Unit] +Description=Automatic Pulse update check and install +Documentation=https://github.com/rcourtman/Pulse +After=network-online.target +Wants=network-online.target +# Don't run if pulse service is not running +Requisite=pulse.service + +[Service] +Type=oneshot +# Run as root to allow service restart +User=root +Group=root +# Use the update script +ExecStart=/opt/pulse/scripts/pulse-auto-update.sh +# Restart policy for the update service itself +Restart=no +# Timeout for the update process (10 minutes should be plenty) +TimeoutStartSec=600 +# Log to journal +StandardOutput=journal +StandardError=journal +SyslogIdentifier=pulse-update +# Security hardening +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ReadWritePaths=/opt/pulse /etc/pulse /tmp +# Network access needed for GitHub +PrivateNetwork=no +# Nice level to run updates at lower priority +Nice=10 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/scripts/systemd/pulse-update.timer b/scripts/systemd/pulse-update.timer new file mode 100644 index 000000000..49af3c9a0 --- /dev/null +++ b/scripts/systemd/pulse-update.timer @@ -0,0 +1,19 @@ +[Unit] +Description=Daily check for Pulse updates +Documentation=https://github.com/rcourtman/Pulse +After=network-online.target +Wants=network-online.target + +[Timer] +# Run daily at 2 AM with a random delay up to 4 hours +# This spreads the load on GitHub and prevents all instances updating at once +OnCalendar=daily +OnCalendar=02:00 +RandomizedDelaySec=4h +# Persist the last trigger time during downtime +Persistent=true +# Ensure we run if the system was off when scheduled +AccuracySec=1h + +[Install] +WantedBy=timers.target \ No newline at end of file