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
This commit is contained in:
Pulse Monitor
2025-08-27 15:37:02 +00:00
parent 477649ff6e
commit 87a35f6fbc
4 changed files with 489 additions and 1 deletions
+140 -1
View File
@@ -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"
+295
View File
@@ -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 "$@"
+35
View File
@@ -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
+19
View File
@@ -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