Преглед изворни кода

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
6 измењених фајлова са 226 додато и 72 уклоњено
  1. 3 1
      .gitignore
  2. 12 0
      modules/core/version_manager.py
  3. 80 71
      modules/update/update_manager.py
  4. 17 0
      scripts/dune-weaver-update.service
  5. 73 0
      scripts/update-watcher.sh
  6. 41 0
      setup-pi.sh

+ 3 - 1
.gitignore

@@ -28,4 +28,6 @@ node_modules/
 static/custom/*
 static/custom/*
 !static/custom/.gitkeep
 !static/custom/.gitkeep
 .claude/
 .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
 Version management for Dune Weaver
 Handles current version reading and GitHub API integration for latest version checking
 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
 import asyncio
@@ -14,6 +18,14 @@ import logging
 
 
 logger = logging.getLogger(__name__)
 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:
 class VersionManager:
     def __init__(self):
     def __init__(self):
         self.repo_owner = "tuanchris"
         self.repo_owner = "tuanchris"

+ 80 - 71
modules/update/update_manager.py

@@ -1,9 +1,16 @@
 import subprocess
 import subprocess
 import logging
 import logging
+import os
+from pathlib import Path
+from datetime import datetime
 
 
 # Configure logging
 # Configure logging
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Trigger file location - visible to both container (/app) and host
+TRIGGER_FILE = Path("/app/.update-trigger")
+
+
 def check_git_updates():
 def check_git_updates():
     """Check for available Git updates."""
     """Check for available Git updates."""
     try:
     try:
@@ -49,77 +56,79 @@ def check_git_updates():
             "latest_local_tag": None,
             "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"
     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 with Docker
 deploy_docker() {
 deploy_docker() {
     print_step "Deploying Dune Weaver with Docker Compose..."
     print_step "Deploying Dune Weaver with Docker Compose..."
@@ -384,6 +424,7 @@ main() {
     fi
     fi
 
 
     install_cli
     install_cli
+    install_update_watcher
     print_final_instructions
     print_final_instructions
 }
 }