Переглянути джерело

Add host-based update watcher for Docker deployments

Implements a signal-based update mechanism that allows triggering
`dw update` from within Docker containers. The container creates a
trigger file that a host systemd service watches for, then executes
the full update process on the host machine.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 тижнів тому
батько
коміт
105e44dabe

+ 3 - 1
.gitignore

@@ -28,4 +28,6 @@ node_modules/
 static/custom/*
 !static/custom/.gitkeep
 .claude/
-static/dist/
+static/dist/
+# Update trigger file (created by container, consumed by host watcher)
+.update-trigger

+ 12 - 0
modules/core/version_manager.py

@@ -1,6 +1,10 @@
 """
 Version management for Dune Weaver
 Handles current version reading and GitHub API integration for latest version checking
+
+Testing overrides (environment variables):
+  FORCE_UPDATE_AVAILABLE=1  - Force update to appear available
+  FAKE_LATEST_VERSION=5.0.0 - Override the "latest" version for testing
 """
 
 import asyncio
@@ -14,6 +18,14 @@ import logging
 
 logger = logging.getLogger(__name__)
 
+# Testing overrides via environment variables
+FORCE_UPDATE_AVAILABLE = os.environ.get("FORCE_UPDATE_AVAILABLE", "").lower() in ("1", "true", "yes")
+FAKE_LATEST_VERSION = os.environ.get("FAKE_LATEST_VERSION", "")
+
+if FORCE_UPDATE_AVAILABLE or FAKE_LATEST_VERSION:
+    logger.warning(f"Version override active: FORCE_UPDATE_AVAILABLE={FORCE_UPDATE_AVAILABLE}, FAKE_LATEST_VERSION={FAKE_LATEST_VERSION}")
+
+
 class VersionManager:
     def __init__(self):
         self.repo_owner = "tuanchris"

+ 80 - 71
modules/update/update_manager.py

@@ -1,9 +1,16 @@
 import subprocess
 import logging
+import os
+from pathlib import Path
+from datetime import datetime
 
 # Configure logging
 logger = logging.getLogger(__name__)
 
+# Trigger file location - visible to both container (/app) and host
+TRIGGER_FILE = Path("/app/.update-trigger")
+
+
 def check_git_updates():
     """Check for available Git updates."""
     try:
@@ -49,77 +56,79 @@ def check_git_updates():
             "latest_local_tag": None,
         }
 
-def update_software():
-    """Update the software to the latest version.
 
-    This runs inside the Docker container, so it:
-    1. Pulls latest code via git (mounted volume at /app)
-    2. Pulls new Docker image for the backend
-    3. Restarts the container to apply updates
+def is_update_watcher_available() -> bool:
+    """Check if the update watcher service is running on the host.
+
+    The watcher service monitors the trigger file and runs 'dw update'
+    when it detects a trigger.
+    """
+    # The watcher is available if we can write to the trigger file location
+    # and the parent directory exists (indicating proper volume mount)
+    try:
+        return TRIGGER_FILE.parent.exists() and os.access(TRIGGER_FILE.parent, os.W_OK)
+    except Exception:
+        return False
+
+
+def trigger_host_update(message: str = None) -> tuple[bool, str | None]:
+    """Signal the host to run 'dw update' by creating a trigger file.
 
-    Note: For a complete update including container recreation,
-    run 'dw update' from the host machine instead.
+    The update watcher service on the host monitors this file and
+    executes the full update process when triggered.
+
+    Args:
+        message: Optional message to include in the trigger file
+
+    Returns:
+        Tuple of (success, error_message)
+    """
+    try:
+        # Write trigger file with timestamp and optional message
+        trigger_content = f"triggered_at={datetime.now().isoformat()}\n"
+        if message:
+            trigger_content += f"message={message}\n"
+
+        TRIGGER_FILE.write_text(trigger_content)
+        logger.info(f"Update trigger created at {TRIGGER_FILE}")
+        return True, None
+    except Exception as e:
+        error_msg = f"Failed to create update trigger: {e}"
+        logger.error(error_msg)
+        return False, error_msg
+
+
+def update_software():
+    """Trigger a software update on the host machine.
+
+    When running in Docker, this creates a trigger file that the host's
+    update-watcher service monitors. The watcher then runs 'dw update'
+    on the host, which properly handles:
+    - Git pull for latest code
+    - Docker image pulls
+    - Container recreation with new images
+    - Cleanup of old images
+
+    Returns:
+        Tuple of (success, error_message, error_log)
     """
-    error_log = []
-    logger.info("Starting software update process")
-
-    def run_command(command, error_message, capture_output=False, cwd=None):
-        try:
-            logger.debug(f"Running command: {' '.join(command)}")
-            result = subprocess.run(command, check=True, capture_output=capture_output, text=True, cwd=cwd)
-            return result.stdout if capture_output else True
-        except subprocess.CalledProcessError as e:
-            logger.error(f"{error_message}: {e}")
-            error_log.append(error_message)
-            return None
-
-    # Step 1: Pull latest code via git (works because /app is mounted from host)
-    logger.info("Pulling latest code from git...")
-    git_result = run_command(
-        ["git", "pull", "--ff-only"],
-        "Failed to pull latest code from git",
-        cwd="/app"
-    )
-    if git_result:
-        logger.info("Git pull completed successfully")
-
-    # Step 2: Pull new Docker image for the backend only
-    # Note: There is no separate frontend image - it's either bundled or built locally
-    logger.info("Pulling latest Docker image...")
-    run_command(
-        ["docker", "pull", "ghcr.io/tuanchris/dune-weaver:main"],
-        "Failed to pull backend Docker image"
-    )
-
-    # Step 3: Restart the backend container to apply updates
-    # We can't recreate ourselves from inside the container, so we just restart
-    # For full container recreation with new images, use 'dw update' from host
-    logger.info("Restarting backend container...")
-
-    # Use docker restart which works from inside the container
-    restart_result = run_command(
-        ["docker", "restart", "dune-weaver-backend"],
-        "Failed to restart backend container"
-    )
-
-    if not restart_result:
-        # If docker restart fails, try a graceful approach
-        logger.info("Attempting graceful restart via compose...")
-        try:
-            # Just restart, don't try to recreate (which would fail)
-            subprocess.run(
-                ["docker", "compose", "restart", "backend"],
-                check=True,
-                cwd="/app"
-            )
-            logger.info("Container restarted successfully via compose")
-        except (subprocess.CalledProcessError, FileNotFoundError) as e:
-            logger.warning(f"Compose restart also failed: {e}")
-            error_log.append("Container restart failed - please run 'dw update' from host")
-
-    if error_log:
-        logger.error(f"Software update completed with errors: {error_log}")
-        return False, "Update completed with errors. For best results, run 'dw update' from the host machine.", error_log
-
-    logger.info("Software update completed successfully")
-    return True, None, None
+    logger.info("Initiating software update...")
+
+    # Check if we can trigger host update
+    if not is_update_watcher_available():
+        error_msg = (
+            "Update watcher not available. The update-watcher service may not be "
+            "installed or the volume mount is not configured correctly. "
+            "Please run 'dw update' manually from the host machine."
+        )
+        logger.error(error_msg)
+        return False, error_msg, [error_msg]
+
+    # Trigger the host update
+    success, error = trigger_host_update("Triggered from web UI")
+
+    if success:
+        logger.info("Update triggered successfully - host will process shortly")
+        return True, None, None
+    else:
+        return False, error, [error]

+ 17 - 0
scripts/dune-weaver-update.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=Dune Weaver Update Watcher
+After=multi-user.target docker.service
+Wants=docker.service
+
+[Service]
+Type=simple
+User=root
+WorkingDirectory=/home/pi/dune-weaver
+ExecStart=/home/pi/dune-weaver/scripts/update-watcher.sh
+Restart=always
+RestartSec=10
+StartLimitInterval=200
+StartLimitBurst=5
+
+[Install]
+WantedBy=multi-user.target

+ 73 - 0
scripts/update-watcher.sh

@@ -0,0 +1,73 @@
+#!/bin/bash
+#
+# Update Watcher for Dune Weaver
+#
+# This script runs on the host machine and watches for update triggers
+# from the Docker container. When a trigger is detected, it runs 'dw update'.
+#
+# The container signals an update by creating .update-trigger file in the
+# mounted volume, which the host can see and act upon.
+#
+
+set -e
+
+# Configuration
+TRIGGER_FILE=""
+INSTALL_DIR=""
+LOG_PREFIX="[update-watcher]"
+
+# Find dune-weaver directory (same logic as dw script)
+find_install_dir() {
+    if [[ -f "$HOME/dune-weaver/main.py" ]]; then
+        echo "$HOME/dune-weaver"
+    elif [[ -f "/home/pi/dune-weaver/main.py" ]]; then
+        echo "/home/pi/dune-weaver"
+    else
+        echo ""
+    fi
+}
+
+log() {
+    echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') $1"
+}
+
+# Initialize
+INSTALL_DIR=$(find_install_dir)
+if [[ -z "$INSTALL_DIR" ]]; then
+    log "ERROR: Dune Weaver installation not found"
+    exit 1
+fi
+
+TRIGGER_FILE="$INSTALL_DIR/.update-trigger"
+log "Watching for update triggers at: $TRIGGER_FILE"
+log "Install directory: $INSTALL_DIR"
+
+# Main watch loop
+while true; do
+    if [[ -f "$TRIGGER_FILE" ]]; then
+        log "Update trigger detected!"
+
+        # Read any message from trigger file (optional metadata)
+        if [[ -s "$TRIGGER_FILE" ]]; then
+            log "Trigger message: $(cat "$TRIGGER_FILE")"
+        fi
+
+        # Remove trigger file before update to prevent re-triggering
+        rm -f "$TRIGGER_FILE"
+
+        # Run the update
+        log "Starting update process..."
+        cd "$INSTALL_DIR"
+
+        if /usr/local/bin/dw update 2>&1 | while read -r line; do log "$line"; done; then
+            log "Update completed successfully"
+        else
+            log "Update completed with errors (exit code: $?)"
+        fi
+
+        log "Resuming watch..."
+    fi
+
+    # Poll every 2 seconds
+    sleep 2
+done

+ 41 - 0
setup-pi.sh

@@ -240,6 +240,46 @@ install_cli() {
     print_success "'dw' command installed"
 }
 
+# Install update watcher service (for Docker deployments)
+install_update_watcher() {
+    if [[ "$USE_DOCKER" != "true" ]]; then
+        # Update watcher only needed for Docker deployments
+        return
+    fi
+
+    print_step "Installing update watcher service..."
+
+    # Make watcher script executable
+    chmod +x "$INSTALL_DIR/scripts/update-watcher.sh"
+
+    # Create systemd service with correct paths
+    sudo tee /etc/systemd/system/dune-weaver-update.service > /dev/null << EOF
+[Unit]
+Description=Dune Weaver Update Watcher
+After=multi-user.target docker.service
+Wants=docker.service
+
+[Service]
+Type=simple
+User=root
+WorkingDirectory=$INSTALL_DIR
+ExecStart=$INSTALL_DIR/scripts/update-watcher.sh
+Restart=always
+RestartSec=10
+StartLimitInterval=200
+StartLimitBurst=5
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    sudo systemctl daemon-reload
+    sudo systemctl enable dune-weaver-update
+    sudo systemctl start dune-weaver-update
+
+    print_success "Update watcher service installed"
+}
+
 # Deploy with Docker
 deploy_docker() {
     print_step "Deploying Dune Weaver with Docker Compose..."
@@ -384,6 +424,7 @@ main() {
     fi
 
     install_cli
+    install_update_watcher
     print_final_instructions
 }