diff --git a/README.md b/README.md index 7c0658e..ed89189 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,15 @@ python -m pip install -e . python -m bakrest.watchdog_runner ``` +## Avvio tray app per test + +```powershell +python -m pip install -e . +python -m bakrest.tray_app +``` + +La tray app legge lo stato prodotto dal servizio e offre il comando `Backup e spegni`. Se il backup fallisce, lo spegnimento non viene eseguito. + ## Installazione servizio Windows Da PowerShell avviato come amministratore: diff --git a/SPECIFICHE.md b/SPECIFICHE.md index 1f7c8dd..e8c1199 100644 --- a/SPECIFICHE.md +++ b/SPECIFICHE.md @@ -80,6 +80,8 @@ Il backup deve essere eseguito dalla tray app quando l'utente seleziona `Backup Il motore consigliato e' il vero `rsync`, eseguito da Python tramite `subprocess`. Python deve fare da orchestratore, non da reimplementazione di `rsync`. +Per la prima versione il programma non deve bloccare, intercettare o ritardare lo spegnimento normale del PC tramite menu Start o altri comandi Windows standard. Il comando `Backup e spegni` e' un percorso esplicito e consigliato, ma l'utente deve poter spegnere il computer con le vie usuali. La gestione del caso in cui l'utente dimentichi il backup prima dello spegnimento verra' progettata in una fase successiva. + Flusso previsto: 1. L'utente clicca la tray icon. @@ -93,6 +95,12 @@ Flusso previsto: 9. Se il backup e' riuscito, la tray app spegne il computer. 10. Se il backup fallisce, il computer non viene spento. +Se il server di backup non e' raggiungibile, la tray app deve mostrare una finestra di messaggio con testo simile a: + +```text +Server non raggiungibile. E' spento? +``` + ## Registro dei cambiamenti Il servizio watchdog deve registrare i cambiamenti in modo persistente, cosi' da non perdere eventi in caso di riavvio o crash. diff --git a/pyproject.toml b/pyproject.toml index 6ac6f10..c92cede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "Windows watchdog service and tray backup utility." readme = "README.md" requires-python = ">=3.11" dependencies = [ + "pillow>=10.0.0", + "pystray>=0.19.5", "watchdog>=4.0.0", "pywin32>=306; platform_system == 'Windows'", ] diff --git a/src/bakrest/backup.py b/src/bakrest/backup.py new file mode 100644 index 0000000..33f68c2 --- /dev/null +++ b/src/bakrest/backup.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import logging +import os +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path + +from .config import AppConfig +from .registry import ChangeEvent, ChangeRegistry +from .server_check import check_server + + +@dataclass(frozen=True) +class BackupResult: + success: bool + message: str + copied_count: int = 0 + skipped_count: int = 0 + error_code: str | None = None + + +def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult: + status = check_server(config.backup) + if not status.reachable: + message = f"Server backup non raggiungibile: {status.message}" + logger.error(message) + return BackupResult(False, message, error_code="server_unreachable") + + registry = ChangeRegistry(config.storage.change_registry) + pending = registry.list_pending() + if not pending: + logger.info("No pending files to back up") + return BackupResult(True, "Nessun file in attesa", copied_count=0) + + existing, skipped = _split_existing_files(pending) + if skipped: + logger.info("Skipped %s non-existing files", len(skipped)) + registry.mark_status([event.path for event in skipped], "ignored") + + stable, unstable = _split_stable_files(existing, config) + if unstable: + logger.warning("Skipped %s unstable files", len(unstable)) + + if not stable: + return BackupResult( + False, + "Nessun file stabile da copiare", + copied_count=0, + skipped_count=len(skipped) + len(unstable), + ) + + files_from_path: str | None = None + try: + command = _build_rsync_command(config, stable) + files_from_path = _extract_files_from_path(command) + logger.info("Running rsync command: %s", _redact_command(command)) + completed = subprocess.run(command, capture_output=True, text=True, check=False) + except OSError as exc: + message = f"Errore avvio rsync: {exc}" + logger.exception(message) + return BackupResult( + False, + message, + skipped_count=len(skipped) + len(unstable), + error_code="rsync_start_failed", + ) + finally: + if files_from_path: + _delete_temp_file(files_from_path, logger) + + if completed.stdout: + logger.info("rsync stdout: %s", completed.stdout.strip()) + if completed.stderr: + logger.warning("rsync stderr: %s", completed.stderr.strip()) + + if completed.returncode != 0: + message = f"rsync fallito con codice {completed.returncode}" + logger.error(message) + return BackupResult( + False, + message, + skipped_count=len(skipped) + len(unstable), + error_code="rsync_failed", + ) + + registry.mark_status([event.path for event in stable], "copied") + message = f"Backup completato: {len(stable)} file copiati" + logger.info(message) + return BackupResult(True, message, copied_count=len(stable), skipped_count=len(skipped) + len(unstable)) + + +def shutdown_after_backup(config: AppConfig, logger: logging.Logger) -> bool: + if not config.backup.shutdown_on_success: + logger.info("Shutdown disabled by configuration") + return False + + logger.info("Executing shutdown command: %s", config.backup.shutdown_command) + subprocess.run(config.backup.shutdown_command, shell=True, check=False) + return True + + +def _split_existing_files(events: list[ChangeEvent]) -> tuple[list[ChangeEvent], list[ChangeEvent]]: + existing: list[ChangeEvent] = [] + skipped: list[ChangeEvent] = [] + for event in events: + if event.event_type == "deleted": + skipped.append(event) + elif Path(event.path).is_file(): + existing.append(event) + else: + skipped.append(event) + return existing, skipped + + +def _split_stable_files(events: list[ChangeEvent], config: AppConfig) -> tuple[list[ChangeEvent], list[ChangeEvent]]: + stable: list[ChangeEvent] = [] + unstable: list[ChangeEvent] = [] + for event in events: + if _is_file_stable(Path(event.path), config): + stable.append(event) + else: + unstable.append(event) + return stable, unstable + + +def _is_file_stable(path: Path, config: AppConfig) -> bool: + try: + stat = path.stat() + except OSError: + return False + + if time.time() - stat.st_mtime < config.stability.min_age_seconds: + return False + + previous = (stat.st_size, stat.st_mtime_ns) + for _ in range(config.stability.unchanged_checks_required): + time.sleep(config.stability.unchanged_check_interval_seconds) + try: + current_stat = path.stat() + except OSError: + return False + current = (current_stat.st_size, current_stat.st_mtime_ns) + if current != previous: + return False + previous = current + return True + + +def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[str]: + roots = _common_roots(config, events) + if len(roots) == 1: + source_root = roots[0] + files_from_entries = [_relative_posix(Path(event.path), source_root) for event in events] + else: + source_root = Path(Path(event.path).anchor or os.sep) + files_from_entries = [_relative_posix(Path(event.path), source_root) for event in events] + + files_from_path = _write_files_from(files_from_entries) + return [ + config.backup.rsync_path, + "-av", + "--relative", + f"--files-from={files_from_path}", + _source_arg(source_root), + config.backup.remote_destination, + ] + + +def _common_roots(config: AppConfig, events: list[ChangeEvent]) -> list[Path]: + roots: list[Path] = [] + for event in events: + path = Path(event.path) + for include_dir in config.watch.include_dirs: + try: + path.relative_to(include_dir) + if include_dir not in roots: + roots.append(include_dir) + break + except ValueError: + continue + return roots + + +def _write_files_from(entries: list[str]) -> str: + handle = tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="\n", + prefix="bakrest-files-", + suffix=".txt", + delete=False, + ) + with handle: + for entry in entries: + handle.write(f"{entry}\n") + return handle.name + + +def _extract_files_from_path(command: list[str]) -> str | None: + prefix = "--files-from=" + for item in command: + if item.startswith(prefix): + return item[len(prefix) :] + return None + + +def _delete_temp_file(path: str, logger: logging.Logger) -> None: + try: + Path(path).unlink(missing_ok=True) + except OSError: + logger.warning("Could not delete temporary rsync file list: %s", path) + + +def _relative_posix(path: Path, root: Path) -> str: + return path.relative_to(root).as_posix() + + +def _source_arg(path: Path) -> str: + value = str(path) + if not value.endswith(("\\", "/")): + value += "\\" + return value + + +def _redact_command(command: list[str]) -> str: + return " ".join(command) diff --git a/src/bakrest/registry.py b/src/bakrest/registry.py index b0b6043..1438494 100644 --- a/src/bakrest/registry.py +++ b/src/bakrest/registry.py @@ -46,6 +46,37 @@ class ChangeRegistry: ).fetchone() return int(row[0]) + def list_pending(self) -> list[ChangeEvent]: + with closing(self._connect()) as connection: + rows = connection.execute( + """ + SELECT path, event_type, old_path, size + FROM changes + WHERE status = 'pending' + ORDER BY updated_at ASC + """ + ).fetchall() + return [ + ChangeEvent(path=row[0], event_type=row[1], old_path=row[2], size=row[3]) + for row in rows + ] + + def mark_status(self, paths: list[str], status: str) -> None: + if not paths: + return + timestamp = datetime.now(timezone.utc).isoformat() + placeholders = ", ".join("?" for _ in paths) + with closing(self._connect()) as connection: + connection.execute( + f""" + UPDATE changes + SET status = ?, updated_at = ? + WHERE path IN ({placeholders}) + """, + [status, timestamp, *paths], + ) + connection.commit() + def _initialize(self) -> None: with closing(self._connect()) as connection: connection.execute( diff --git a/src/bakrest/tray_app.py b/src/bakrest/tray_app.py new file mode 100644 index 0000000..956d8d8 --- /dev/null +++ b/src/bakrest/tray_app.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import logging +import threading +import tkinter as tk +from tkinter import messagebox +from pathlib import Path + +from PIL import Image, ImageDraw +import pystray + +from .backup import run_backup, shutdown_after_backup +from .config import AppConfig, load_config +from .logging_setup import configure_logging +from .registry import ChangeRegistry + + +class BakRestTrayApp: + def __init__(self, config: AppConfig, logger: logging.Logger) -> None: + self.config = config + self.logger = logger + self.registry = ChangeRegistry(config.storage.change_registry) + self.icon = pystray.Icon( + "BakRestTray", + _create_icon_image(), + "Bak&Rest", + self._build_menu(), + ) + + def run(self) -> None: + self.logger.info("BakRestTray started") + self.icon.run() + + def _build_menu(self) -> pystray.Menu: + return pystray.Menu( + pystray.MenuItem(lambda item: self._status_text(), None, enabled=False), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Backup e spegni", self._backup_and_shutdown), + pystray.MenuItem("Aggiorna stato", self._refresh), + pystray.MenuItem("Esci", self._quit), + ) + + def _status_text(self) -> str: + pending = self.registry.pending_count() + server = self._read_server_status() + return f"Server: {server} | File in attesa: {pending}" + + def _read_server_status(self) -> str: + path = self.config.storage.status_file + if not path.exists(): + return "sconosciuto" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "errore stato" + return "raggiungibile" if data.get("reachable") else "non raggiungibile" + + def _backup_and_shutdown(self, icon: pystray.Icon, item: pystray.MenuItem) -> None: + threading.Thread(target=self._backup_and_shutdown_worker, daemon=True).start() + + def _backup_and_shutdown_worker(self) -> None: + self.logger.info("User requested Backup e spegni") + self.icon.title = "Bak&Rest - backup in corso" + result = run_backup(self.config, self.logger) + if result.success: + self.icon.title = "Bak&Rest - backup completato" + shutdown_after_backup(self.config, self.logger) + else: + self.icon.title = "Bak&Rest - backup fallito" + if result.error_code == "server_unreachable": + _show_error_message("Bak&Rest", "Server non raggiungibile. E' spento?") + else: + _show_error_message("Bak&Rest", result.message) + self.icon.update_menu() + + def _refresh(self, icon: pystray.Icon, item: pystray.MenuItem) -> None: + self.icon.update_menu() + + def _quit(self, icon: pystray.Icon, item: pystray.MenuItem) -> None: + self.logger.info("BakRestTray stopped") + self.icon.stop() + + +def _create_icon_image() -> Image.Image: + image = Image.new("RGBA", (64, 64), (26, 38, 52, 255)) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((10, 14, 54, 50), radius=8, fill=(77, 163, 255, 255)) + draw.rectangle((18, 24, 46, 44), fill=(245, 248, 252, 255)) + draw.line((22, 34, 32, 42, 46, 26), fill=(26, 38, 52, 255), width=5) + return image + + +def _show_error_message(title: str, message: str) -> None: + root = tk.Tk() + root.withdraw() + root.attributes("-topmost", True) + try: + messagebox.showerror(title, message, parent=root) + finally: + root.destroy() + + +def main() -> int: + config = load_config() + logger = configure_logging(config.storage.logs_dir, "tray") + app = BakRestTrayApp(config, logger) + app.run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..a685f6a --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + +from bakrest.backup import _build_rsync_command, _split_existing_files +from bakrest.config import parse_config +from bakrest.registry import ChangeEvent + + +def test_split_existing_files_ignores_deleted(tmp_path: Path) -> None: + existing = tmp_path / "image.jpg" + existing.write_text("ok", encoding="utf-8") + events = [ + ChangeEvent(path=str(existing), event_type="modified"), + ChangeEvent(path=str(tmp_path / "missing.jpg"), event_type="deleted"), + ] + + present, skipped = _split_existing_files(events) + + assert [event.path for event in present] == [str(existing)] + assert [event.path for event in skipped] == [str(tmp_path / "missing.jpg")] + + +def test_build_rsync_command_uses_files_from(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + image = source / "image.jpg" + image.write_text("ok", encoding="utf-8") + config = parse_config( + { + "watch": { + "include_dirs": [str(source)], + "include_extensions": [".jpg"], + "exclude_dirs": [], + "exclude_patterns": [], + }, + "backup": { + "rsync_path": "rsync", + "remote_destination": "user@host:/backup/", + "server_check": {"type": "tcp", "port": 22, "interval_seconds": 300, "timeout_seconds": 1}, + }, + }, + tmp_path / "config.toml", + ) + + command = _build_rsync_command(config, [ChangeEvent(path=str(image), event_type="modified")]) + + assert command[0] == "rsync" + assert "-av" in command + assert "--relative" in command + assert any(part.startswith("--files-from=") for part in command) + assert command[-1] == "user@host:/backup/"