""" AutoUpdater Plugin - Automatic Update System for EU-Utility Checks for updates, downloads, and installs new versions automatically. Uses semantic versioning and supports rollback on failure. """ import os import json import hashlib import urllib.request import urllib.error import zipfile import shutil import threading import time from pathlib import Path from typing import Optional, Dict, Any, Callable, List from dataclasses import dataclass, asdict from enum import Enum from core.base_plugin import BasePlugin class UpdateStatus(Enum): """Status of an update operation.""" IDLE = "idle" CHECKING = "checking" AVAILABLE = "available" DOWNLOADING = "downloading" VERIFYING = "verifying" INSTALLING = "installing" COMPLETED = "completed" FAILED = "failed" ROLLING_BACK = "rolling_back" @dataclass class VersionInfo: """Version information structure.""" major: int minor: int patch: int prerelease: Optional[str] = None build: Optional[str] = None @classmethod def from_string(cls, version_str: str) -> "VersionInfo": """Parse version from string (e.g., '1.2.3-beta+build123').""" # Remove 'v' prefix if present version_str = version_str.lstrip('v') # Split prerelease and build metadata build = None if '+' in version_str: version_str, build = version_str.split('+', 1) prerelease = None if '-' in version_str: version_str, prerelease = version_str.split('-', 1) parts = version_str.split('.') major = int(parts[0]) if len(parts) > 0 else 0 minor = int(parts[1]) if len(parts) > 1 else 0 patch = int(parts[2]) if len(parts) > 2 else 0 return cls(major, minor, patch, prerelease, build) def __str__(self) -> str: version = f"{self.major}.{self.minor}.{self.patch}" if self.prerelease: version += f"-{self.prerelease}" if self.build: version += f"+{self.build}" return version def __lt__(self, other: "VersionInfo") -> bool: if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch): return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch) # Prerelease versions are lower than release versions if self.prerelease and not other.prerelease: return True if not self.prerelease and other.prerelease: return False if self.prerelease and other.prerelease: return self.prerelease < other.prerelease return False def __le__(self, other: "VersionInfo") -> bool: return self == other or self < other def __gt__(self, other: "VersionInfo") -> bool: return not self <= other def __ge__(self, other: "VersionInfo") -> bool: return not self < other @dataclass class UpdateInfo: """Information about an available update.""" version: str download_url: str checksum: str size_bytes: int release_notes: str mandatory: bool = False release_date: Optional[str] = None class AutoUpdaterPlugin(BasePlugin): """ Automatic update system for EU-Utility. Features: - Check for updates from remote repository - Download and verify updates - Automatic or manual installation - Rollback on failure - Update history tracking """ name = "auto_updater" description = "Automatic update system with rollback support" version = "1.0.0" author = "EU-Utility" # Default configuration DEFAULT_CONFIG = { "check_interval_hours": 24, "auto_check": True, "auto_install": False, # Manual by default for safety "update_server": "https://api.eu-utility.app/updates", "backup_dir": "data/backups", "temp_dir": "data/temp", "current_version": "1.0.0", "channel": "stable", # stable, beta, alpha } def __init__(self): super().__init__() self._config = self.DEFAULT_CONFIG.copy() self._status = UpdateStatus.IDLE self._current_update: Optional[UpdateInfo] = None self._update_history: List[Dict[str, Any]] = [] self._check_thread: Optional[threading.Thread] = None self._running = False self._listeners: List[Callable] = [] self._data_dir = Path("data") self._data_dir.mkdir(exist_ok=True) self._load_history() def on_start(self) -> None: """Start the auto-updater service.""" print(f"[{self.name}] Starting auto-updater...") self._running = True # Ensure directories exist Path(self._config["backup_dir"]).mkdir(parents=True, exist_ok=True) Path(self._config["temp_dir"]).mkdir(parents=True, exist_ok=True) # Start automatic check thread if enabled if self._config["auto_check"]: self._check_thread = threading.Thread(target=self._auto_check_loop, daemon=True) self._check_thread.start() print(f"[{self.name}] Auto-check enabled (interval: {self._config['check_interval_hours']}h)") def on_stop(self) -> None: """Stop the auto-updater service.""" print(f"[{self.name}] Stopping auto-updater...") self._running = False self._save_history() # Configuration def set_config(self, config: Dict[str, Any]) -> None: """Update configuration.""" self._config.update(config) def get_config(self) -> Dict[str, Any]: """Get current configuration.""" return self._config.copy() # Status & Events def get_status(self) -> str: """Get current update status.""" return self._status.value def add_listener(self, callback: Callable[[UpdateStatus, Optional[UpdateInfo]], None]) -> None: """Add a status change listener.""" self._listeners.append(callback) def remove_listener(self, callback: Callable) -> None: """Remove a status change listener.""" if callback in self._listeners: self._listeners.remove(callback) def _set_status(self, status: UpdateStatus, info: Optional[UpdateInfo] = None) -> None: """Set status and notify listeners.""" self._status = status self._current_update = info for listener in self._listeners: try: listener(status, info) except Exception as e: print(f"[{self.name}] Listener error: {e}") # Update Operations def check_for_updates(self) -> Optional[UpdateInfo]: """ Check for available updates. Returns: UpdateInfo if update available, None otherwise """ self._set_status(UpdateStatus.CHECKING) try: current = VersionInfo.from_string(self._config["current_version"]) # Build check URL url = f"{self._config['update_server']}/check" params = { "version": str(current), "channel": self._config["channel"], "platform": os.name, } # Make request query = "&".join(f"{k}={v}" for k, v in params.items()) full_url = f"{url}?{query}" req = urllib.request.Request( full_url, headers={"User-Agent": f"EU-Utility/{current}"} ) with urllib.request.urlopen(req, timeout=30) as response: data = json.loads(response.read().decode('utf-8')) if data.get("update_available"): update_info = UpdateInfo( version=data["version"], download_url=data["download_url"], checksum=data["checksum"], size_bytes=data["size_bytes"], release_notes=data.get("release_notes", ""), mandatory=data.get("mandatory", False), release_date=data.get("release_date"), ) new_version = VersionInfo.from_string(update_info.version) if new_version > current: self._set_status(UpdateStatus.AVAILABLE, update_info) print(f"[{self.name}] Update available: {current} → {new_version}") return update_info self._set_status(UpdateStatus.IDLE) print(f"[{self.name}] No updates available") return None except Exception as e: self._set_status(UpdateStatus.FAILED) print(f"[{self.name}] Update check failed: {e}") return None def download_update(self, update_info: Optional[UpdateInfo] = None) -> Optional[Path]: """ Download an update. Args: update_info: Update to download (uses current if None) Returns: Path to downloaded file, or None on failure """ info = update_info or self._current_update if not info: print(f"[{self.name}] No update to download") return None self._set_status(UpdateStatus.DOWNLOADING, info) try: temp_dir = Path(self._config["temp_dir"]) temp_dir.mkdir(parents=True, exist_ok=True) download_path = temp_dir / f"update_{info.version}.zip" print(f"[{self.name}] Downloading update {info.version}...") # Download with progress req = urllib.request.Request( info.download_url, headers={"User-Agent": f"EU-Utility/{self._config['current_version']}"} ) with urllib.request.urlopen(req, timeout=300) as response: total_size = int(response.headers.get('Content-Length', 0)) downloaded = 0 chunk_size = 8192 with open(download_path, 'wb') as f: while True: chunk = response.read(chunk_size) if not chunk: break f.write(chunk) downloaded += len(chunk) if total_size > 0: percent = (downloaded / total_size) * 100 print(f"[{self.name}] Download: {percent:.1f}%") # Verify checksum self._set_status(UpdateStatus.VERIFYING, info) if not self._verify_checksum(download_path, info.checksum): raise ValueError("Checksum verification failed") print(f"[{self.name}] Download complete: {download_path}") return download_path except Exception as e: self._set_status(UpdateStatus.FAILED, info) print(f"[{self.name}] Download failed: {e}") return None def install_update(self, download_path: Path, update_info: Optional[UpdateInfo] = None) -> bool: """ Install a downloaded update. Args: download_path: Path to downloaded update file update_info: Update information Returns: True if installation successful """ info = update_info or self._current_update if not info: return False self._set_status(UpdateStatus.INSTALLING, info) backup_path = None try: # Create backup backup_path = self._create_backup() print(f"[{self.name}] Backup created: {backup_path}") # Extract update temp_extract = Path(self._config["temp_dir"]) / "extract" temp_extract.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(download_path, 'r') as zip_ref: zip_ref.extractall(temp_extract) # Apply update self._apply_update(temp_extract) # Update version old_version = self._config["current_version"] self._config["current_version"] = info.version self._save_config() # Record success self._record_update(old_version, info.version, True) # Cleanup shutil.rmtree(temp_extract, ignore_errors=True) download_path.unlink(missing_ok=True) self._set_status(UpdateStatus.COMPLETED, info) print(f"[{self.name}] Update installed: {old_version} → {info.version}") return True except Exception as e: self._set_status(UpdateStatus.FAILED, info) print(f"[{self.name}] Installation failed: {e}") # Attempt rollback if backup_path: self._rollback(backup_path) self._record_update(self._config["current_version"], info.version, False, str(e)) return False def update(self) -> bool: """ Full update process: check, download, and install. Returns: True if update successful """ # Check for updates update_info = self.check_for_updates() if not update_info: return False # Download download_path = self.download_update(update_info) if not download_path: return False # Install return self.install_update(download_path, update_info) # Private Methods def _auto_check_loop(self) -> None: """Background thread for automatic update checks.""" interval_seconds = self._config["check_interval_hours"] * 3600 while self._running: try: self.check_for_updates() except Exception as e: print(f"[{self.name}] Auto-check error: {e}") # Sleep with early exit check for _ in range(interval_seconds): if not self._running: break time.sleep(1) def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool: """Verify file checksum (SHA256).""" sha256 = hashlib.sha256() with open(file_path, 'rb') as f: for chunk in iter(lambda: f.read(8192), b''): sha256.update(chunk) return sha256.hexdigest().lower() == expected_checksum.lower() def _create_backup(self) -> Path: """Create a backup of current installation.""" backup_dir = Path(self._config["backup_dir"]) backup_dir.mkdir(parents=True, exist_ok=True) timestamp = time.strftime("%Y%m%d_%H%M%S") backup_path = backup_dir / f"backup_{self._config['current_version']}_{timestamp}" backup_path.mkdir(exist_ok=True) # Backup core files for item in ["core", "plugins", "main.py", "requirements.txt"]: src = Path(item) if src.exists(): dst = backup_path / item if src.is_dir(): shutil.copytree(src, dst, dirs_exist_ok=True) else: shutil.copy2(src, dst) return backup_path def _apply_update(self, extract_path: Path) -> None: """Apply extracted update files.""" # Copy new files for item in extract_path.iterdir(): dst = Path(item.name) # Remove old version if dst.exists(): if dst.is_dir(): shutil.rmtree(dst) else: dst.unlink() # Copy new version if item.is_dir(): shutil.copytree(item, dst) else: shutil.copy2(item, dst) def _rollback(self, backup_path: Path) -> bool: """Rollback to backup version.""" self._set_status(UpdateStatus.ROLLING_BACK) print(f"[{self.name}] Rolling back...") try: for item in backup_path.iterdir(): dst = Path(item.name) if dst.exists(): if dst.is_dir(): shutil.rmtree(dst) else: dst.unlink() if item.is_dir(): shutil.copytree(item, dst) else: shutil.copy2(item, dst) print(f"[{self.name}] Rollback complete") return True except Exception as e: print(f"[{self.name}] Rollback failed: {e}") return False def _record_update(self, old_version: str, new_version: str, success: bool, error: Optional[str] = None) -> None: """Record update attempt in history.""" record = { "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "old_version": old_version, "new_version": new_version, "success": success, "error": error, } self._update_history.append(record) self._save_history() def _load_history(self) -> None: """Load update history from file.""" history_file = self._data_dir / "update_history.json" if history_file.exists(): try: with open(history_file) as f: self._update_history = json.load(f) except Exception as e: print(f"[{self.name}] Failed to load history: {e}") def _save_history(self) -> None: """Save update history to file.""" history_file = self._data_dir / "update_history.json" try: with open(history_file, 'w') as f: json.dump(self._update_history, f, indent=2) except Exception as e: print(f"[{self.name}] Failed to save history: {e}") def _save_config(self) -> None: """Save configuration to file.""" config_file = self._data_dir / "updater_config.json" try: with open(config_file, 'w') as f: json.dump(self._config, f, indent=2) except Exception as e: print(f"[{self.name}] Failed to save config: {e}") # Public API def get_update_history(self) -> List[Dict[str, Any]]: """Get update history.""" return self._update_history.copy() def get_current_version(self) -> str: """Get current version string.""" return self._config["current_version"] def set_channel(self, channel: str) -> None: """Set update channel (stable, beta, alpha).""" if channel in ["stable", "beta", "alpha"]: self._config["channel"] = channel def force_check(self) -> Optional[UpdateInfo]: """Force an immediate update check.""" return self.check_for_updates()