diff --git a/README.md b/README.md index 4c0c8d4..7612365 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,34 @@ Argomenti: -m bakrest.tray_app --nogui Avvia in: C:\devel\bak&rest ``` +## Motore backup Windows + +Il default e' `robocopy`, pensato per copiare verso share SMB su un server Windows: + +```toml +[backup] +engine = "robocopy" +server_host = "backup-server" +remote_destinations = [ + "\\\\backup-server\\BakRest1", + "\\\\backup-server\\BakRest2", +] +robocopy_path = "robocopy" + +[backup.server_check] +type = "tcp" +port = 445 +``` + +La copia ricrea il percorso relativo sotto ogni share. Se una cartella monitorata e' `D:\Lavori`, il file `D:\Lavori\Cliente\a.psd` viene copiato in: + +```text +\\backup-server\BakRest1\Lavori\Cliente\a.psd +\\backup-server\BakRest2\Lavori\Cliente\a.psd +``` + +Il programma non usa opzioni distruttive di `robocopy`: non usa `/MIR` e non usa `/PURGE`. Le cancellazioni sul master non vengono propagate allo slave. + Per una prova senza spegnimento: ```text diff --git a/SPECIFICHE.md b/SPECIFICHE.md index 1a250cb..091d79d 100644 --- a/SPECIFICHE.md +++ b/SPECIFICHE.md @@ -85,7 +85,16 @@ Se un computer viene spento senza usare `Backup e spegni`, i file gia' registrat Il backup deve essere eseguito dalla tray app quando l'utente seleziona `Backup e spegni`. -Il motore consigliato e' il vero `rsync`, eseguito da Python tramite `subprocess`. Python deve fare da orchestratore, non da reimplementazione di `rsync`. +Il motore consigliato per server Windows 10 e' `robocopy`, eseguito da Python tramite `subprocess`, verso una o piu' share SMB. `rsync` resta un'opzione secondaria solo per scenari in cui sia installato e configurato anche sul server remoto. + +Il backup deve essere incrementale non distruttivo: + +- creazioni e modifiche sul master generano copie verso lo slave; +- cancellazioni sul master non devono cancellare file sullo slave; +- `robocopy` non deve essere usato con `/MIR` o `/PURGE`; +- i file cancellati o non piu' presenti devono essere marcati come ignorati nella coda. + +La copia deve ricreare sotto ogni share di destinazione il percorso relativo rispetto alla cartella monitorata. Esempio: con cartella monitorata `D:\Lavori`, il file `D:\Lavori\Cliente\a.psd` deve essere copiato in `\\server\share\Lavori\Cliente\a.psd`. 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. @@ -384,6 +393,7 @@ Librerie candidate: - `PyYAML` o `tomllib`/`tomli-w` per la configurazione; - `sqlite3` standard library per il registro cambiamenti; - `subprocess` per eseguire `rsync`; +- `robocopy` come motore predefinito per share SMB Windows; - `logging` standard library per i log giornalieri. Packaging candidato: diff --git a/config/config.example.toml b/config/config.example.toml index 229ed28..8e32408 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -42,15 +42,21 @@ exclude_patterns = [ ] [backup] +engine = "robocopy" server_host = "backup-server" -remote_destination = "utente@backup-server:/backup/bak-rest/" +remote_destinations = [ + "\\\\backup-server\\BakRest1", + "\\\\backup-server\\BakRest2", +] +remote_destination = "\\\\backup-server\\BakRest1" rsync_path = "rsync" +robocopy_path = "robocopy" shutdown_on_success = true shutdown_command = "shutdown /s /t 0" [backup.server_check] type = "tcp" -port = 22 +port = 445 interval_seconds = 300 timeout_seconds = 3 diff --git a/src/bakrest/backup.py b/src/bakrest/backup.py index 33f68c2..25b0b26 100644 --- a/src/bakrest/backup.py +++ b/src/bakrest/backup.py @@ -52,38 +52,13 @@ def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult: 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) + copy_result = _run_copy_engine(config, stable, logger) + if not copy_result.success: return BackupResult( False, - message, + copy_result.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", + error_code=copy_result.error_code, ) registry.mark_status([event.path for event in stable], "copied") @@ -169,6 +144,113 @@ def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[s ] +def _run_copy_engine(config: AppConfig, events: list[ChangeEvent], logger: logging.Logger) -> BackupResult: + if config.backup.engine == "robocopy": + return _run_robocopy(config, events, logger) + if config.backup.engine == "rsync": + return _run_rsync(config, events, logger) + message = f"Motore backup non supportato: {config.backup.engine}" + logger.error(message) + return BackupResult(False, message, error_code="backup_engine_unsupported") + + +def _run_robocopy(config: AppConfig, events: list[ChangeEvent], logger: logging.Logger) -> BackupResult: + if not config.backup.remote_destinations: + message = "Nessuna destinazione robocopy configurata" + logger.error(message) + return BackupResult(False, message, error_code="backup_destination_missing") + + copied_commands = 0 + for destination_root in config.backup.remote_destinations: + for event in events: + command = _build_robocopy_command(config, event, destination_root) + logger.info("Running robocopy command: %s", _redact_command(command)) + try: + completed = subprocess.run(command, capture_output=True, text=True, check=False) + except OSError as exc: + message = f"Errore avvio robocopy: {exc}" + logger.exception(message) + return BackupResult(False, message, error_code="robocopy_start_failed") + + if completed.stdout: + logger.info("robocopy stdout: %s", completed.stdout.strip()) + if completed.stderr: + logger.warning("robocopy stderr: %s", completed.stderr.strip()) + + if completed.returncode >= 8: + message = f"robocopy fallito con codice {completed.returncode}" + logger.error(message) + return BackupResult(False, message, error_code="robocopy_failed") + copied_commands += 1 + + return BackupResult(True, f"Robocopy completato: {copied_commands} copie") + + +def _run_rsync(config: AppConfig, events: list[ChangeEvent], logger: logging.Logger) -> BackupResult: + files_from_path: str | None = None + try: + command = _build_rsync_command(config, events) + 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, 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, error_code="rsync_failed") + + return BackupResult(True, "rsync completato") + + +def _build_robocopy_command(config: AppConfig, event: ChangeEvent, destination_root: str) -> list[str]: + source_path = Path(event.path) + destination_dir = _robocopy_destination_dir(config, source_path, Path(destination_root)) + return [ + config.backup.robocopy_path, + str(source_path.parent), + str(destination_dir), + source_path.name, + "/COPY:DAT", + "/DCOPY:DAT", + "/R:2", + "/W:2", + "/NP", + ] + + +def _robocopy_destination_dir(config: AppConfig, source_path: Path, destination_root: Path) -> Path: + base_root, relative_path = _source_relative_path(config, source_path) + return destination_root / base_root / relative_path.parent + + +def _source_relative_path(config: AppConfig, source_path: Path) -> tuple[str, Path]: + for include_dir in config.watch.include_dirs: + try: + return include_dir.name, source_path.relative_to(include_dir) + except ValueError: + continue + + anchor = source_path.anchor.replace("\\", "").replace("/", "").replace(":", "") + base_root = anchor or "root" + try: + relative = source_path.relative_to(source_path.anchor) + except ValueError: + relative = Path(source_path.name) + return base_root, relative + + def _common_roots(config: AppConfig, events: list[ChangeEvent]) -> list[Path]: roots: list[Path] = [] for event in events: diff --git a/src/bakrest/backup_nogui.py b/src/bakrest/backup_nogui.py index d8ce757..0b14d28 100644 --- a/src/bakrest/backup_nogui.py +++ b/src/bakrest/backup_nogui.py @@ -45,6 +45,10 @@ def _exit_code_for_error(error_code: str | None) -> int: return 30 if error_code == "rsync_failed": return 31 + if error_code == "robocopy_start_failed": + return 30 + if error_code == "robocopy_failed": + return 31 return 1 diff --git a/src/bakrest/config.py b/src/bakrest/config.py index ca9e85f..26beade 100644 --- a/src/bakrest/config.py +++ b/src/bakrest/config.py @@ -22,9 +22,12 @@ class ServerCheckConfig: @dataclass(frozen=True) class BackupConfig: + engine: str server_host: str remote_destination: str + remote_destinations: tuple[str, ...] rsync_path: str + robocopy_path: str shutdown_on_success: bool shutdown_command: str server_check: ServerCheckConfig @@ -130,9 +133,12 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig: exclude_patterns=tuple(watch.get("exclude_patterns", [])), ), backup=BackupConfig( + engine=str(backup.get("engine", "robocopy")).lower(), server_host=str(backup.get("server_host", "backup-server")), remote_destination=str(backup.get("remote_destination", "")), + remote_destinations=_remote_destinations(backup), rsync_path=str(backup.get("rsync_path", "rsync")), + robocopy_path=str(backup.get("robocopy_path", "robocopy")), shutdown_on_success=bool(backup.get("shutdown_on_success", True)), shutdown_command=str(backup.get("shutdown_command", "shutdown /s /t 0")), server_check=ServerCheckConfig( @@ -165,3 +171,23 @@ def _normalize_exclude_dir(value: str) -> Path | str: if "%" in value or not expanded.is_absolute(): return value.lower() return expanded + + +def _remote_destinations(backup: dict[str, Any]) -> tuple[str, ...]: + destinations = backup.get("remote_destinations") + if isinstance(destinations, list): + return tuple(str(item).strip() for item in destinations if str(item).strip()) + + destination = str(backup.get("remote_destination", "")).strip() + engine = str(backup.get("engine", "robocopy")).lower() + if engine == "robocopy" and not _is_windows_destination(destination): + return () + return (destination,) if destination else () + + +def _is_windows_destination(destination: str) -> bool: + if destination.startswith("\\\\"): + return True + if len(destination) >= 3 and destination[1:3] == ":\\": + return True + return False diff --git a/src/bakrest/config_app.py b/src/bakrest/config_app.py index 51a60a0..0a7393c 100644 --- a/src/bakrest/config_app.py +++ b/src/bakrest/config_app.py @@ -86,14 +86,14 @@ class BakRestConfigApp(ctk.CTk): self.server_host_var = tk.StringVar() self.server_port_var = tk.StringVar() - self.remote_destination_var = tk.StringVar() - self.rsync_path_var = tk.StringVar() + self.engine_var = tk.StringVar() + self.robocopy_path_var = tk.StringVar() self.shutdown_on_success_var = tk.BooleanVar() _label_entry(backup_frame, "Server", self.server_host_var, 0) _label_entry(backup_frame, "Porta TCP", self.server_port_var, 1) - _label_entry(backup_frame, "Destinazione remota", self.remote_destination_var, 2) - _label_entry(backup_frame, "Percorso rsync", self.rsync_path_var, 3) + _label_entry(backup_frame, "Motore backup", self.engine_var, 2) + _label_entry(backup_frame, "Percorso robocopy", self.robocopy_path_var, 3) shutdown_check = ctk.CTkCheckBox( backup_frame, @@ -102,8 +102,15 @@ class BakRestConfigApp(ctk.CTk): ) shutdown_check.grid(row=4, column=0, columnspan=2, sticky="w", padx=12, pady=8) + destinations_frame = ctk.CTkFrame(self.general_tab) + destinations_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", padx=0, pady=(0, 8)) + destinations_frame.grid_columnconfigure(0, weight=1) + destinations_frame.grid_rowconfigure(0, weight=1) + self.remote_destinations = _ListEditor(destinations_frame, "Share destinazione", browse_dirs=True) + self.remote_destinations.grid(row=0, column=0, sticky="nsew", padx=12, pady=12) + actions = ctk.CTkFrame(self.general_tab, fg_color="transparent") - actions.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + actions.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 8)) actions.grid_columnconfigure(0, weight=1) save_button = ctk.CTkButton(actions, text="Salva configurazione", command=self._save_config) @@ -153,8 +160,9 @@ class BakRestConfigApp(ctk.CTk): self.server_host_var.set(str(backup.get("server_host", ""))) self.server_port_var.set(str(server_check.get("port", 22))) - self.remote_destination_var.set(str(backup.get("remote_destination", ""))) - self.rsync_path_var.set(str(backup.get("rsync_path", "rsync"))) + self.engine_var.set(str(backup.get("engine", "robocopy"))) + self.robocopy_path_var.set(str(backup.get("robocopy_path", "robocopy"))) + self.remote_destinations.set_items(_configured_destinations(backup)) self.shutdown_on_success_var.set(bool(backup.get("shutdown_on_success", True))) def _save_config(self) -> None: @@ -168,8 +176,10 @@ class BakRestConfigApp(ctk.CTk): watch["exclude_patterns"] = self.exclude_patterns.items() backup["server_host"] = self.server_host_var.get().strip() - backup["remote_destination"] = self.remote_destination_var.get().strip() - backup["rsync_path"] = self.rsync_path_var.get().strip() or "rsync" + backup["engine"] = self.engine_var.get().strip() or "robocopy" + backup["robocopy_path"] = self.robocopy_path_var.get().strip() or "robocopy" + backup["remote_destinations"] = self.remote_destinations.items() + backup["remote_destination"] = backup["remote_destinations"][0] if backup["remote_destinations"] else "" backup["shutdown_on_success"] = bool(self.shutdown_on_success_var.get()) server_check["port"] = _safe_int(self.server_port_var.get(), 22) @@ -333,6 +343,14 @@ def _safe_int(value: str, default: int) -> int: return default +def _configured_destinations(backup: dict[str, object]) -> list[str]: + destinations = backup.get("remote_destinations") + if isinstance(destinations, list): + return [str(item) for item in destinations] + destination = str(backup.get("remote_destination", "")) + return [destination] if destination else [] + + def main() -> int: app = BakRestConfigApp() app.mainloop() diff --git a/src/bakrest/config_cli.py b/src/bakrest/config_cli.py index 9fc8505..08fb978 100644 --- a/src/bakrest/config_cli.py +++ b/src/bakrest/config_cli.py @@ -27,10 +27,14 @@ def main() -> int: print(f"config: {config.path}") print(f"service_autostart: {config.service_autostart}") print(f"tray_autostart: {config.tray_autostart}") + print(f"backup_engine: {config.backup.engine}") print("include_dirs:") for include_dir in config.watch.include_dirs: print(f" - {include_dir}") print(f"include_extensions: {', '.join(sorted(config.watch.include_extensions))}") + print("remote_destinations:") + for destination in config.backup.remote_destinations: + print(f" - {destination}") print(f"registry: {config.storage.change_registry}") print(f"logs_dir: {config.storage.logs_dir}") return 0 diff --git a/src/bakrest/default_config.py b/src/bakrest/default_config.py index b93d232..d184904 100644 --- a/src/bakrest/default_config.py +++ b/src/bakrest/default_config.py @@ -42,15 +42,21 @@ exclude_patterns = [ ] [backup] +engine = "robocopy" server_host = "backup-server" -remote_destination = "utente@backup-server:/backup/bak-rest/" +remote_destinations = [ + "\\\\\\\\backup-server\\\\BakRest1", + "\\\\\\\\backup-server\\\\BakRest2", +] +remote_destination = "\\\\\\\\backup-server\\\\BakRest1" rsync_path = "rsync" +robocopy_path = "robocopy" shutdown_on_success = true shutdown_command = "shutdown /s /t 0" [backup.server_check] type = "tcp" -port = 22 +port = 445 interval_seconds = 300 timeout_seconds = 3 diff --git a/tests/test_backup.py b/tests/test_backup.py index a685f6a..f72e383 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -2,7 +2,12 @@ from __future__ import annotations from pathlib import Path -from bakrest.backup import _build_rsync_command, _split_existing_files +from bakrest.backup import ( + _build_robocopy_command, + _build_rsync_command, + _robocopy_destination_dir, + _split_existing_files, +) from bakrest.config import parse_config from bakrest.registry import ChangeEvent @@ -50,3 +55,43 @@ def test_build_rsync_command_uses_files_from(tmp_path: Path) -> None: assert "--relative" in command assert any(part.startswith("--files-from=") for part in command) assert command[-1] == "user@host:/backup/" + + +def test_build_robocopy_command_preserves_path_under_include_root(tmp_path: Path) -> None: + source = tmp_path / "Lavori" + nested = source / "Cliente" / "Grafica" + nested.mkdir(parents=True) + image = nested / "image.psd" + image.write_text("ok", encoding="utf-8") + config = parse_config( + { + "watch": { + "include_dirs": [str(source)], + "include_extensions": [".psd"], + "exclude_dirs": [], + "exclude_patterns": [], + }, + "backup": { + "engine": "robocopy", + "robocopy_path": "robocopy", + "remote_destinations": ["\\\\server\\Backup1", "\\\\server\\Backup2"], + "server_check": {"type": "tcp", "port": 445, "interval_seconds": 300, "timeout_seconds": 1}, + }, + }, + tmp_path / "config.toml", + ) + + destination = _robocopy_destination_dir(config, image, Path("\\\\server\\Backup1")) + command = _build_robocopy_command( + config, + ChangeEvent(path=str(image), event_type="modified"), + "\\\\server\\Backup1", + ) + + assert destination == Path("\\\\server\\Backup1") / "Lavori" / "Cliente" / "Grafica" + assert command[0] == "robocopy" + assert command[1] == str(nested) + assert command[2] == str(destination) + assert command[3] == "image.psd" + assert "/MIR" not in command + assert "/PURGE" not in command diff --git a/tests/test_backup_nogui.py b/tests/test_backup_nogui.py index c92efd0..d6ba5c9 100644 --- a/tests/test_backup_nogui.py +++ b/tests/test_backup_nogui.py @@ -7,4 +7,6 @@ def test_nogui_exit_codes_are_specific() -> None: assert _exit_code_for_error("server_unreachable") == 20 assert _exit_code_for_error("rsync_start_failed") == 30 assert _exit_code_for_error("rsync_failed") == 31 + assert _exit_code_for_error("robocopy_start_failed") == 30 + assert _exit_code_for_error("robocopy_failed") == 31 assert _exit_code_for_error(None) == 1 diff --git a/tests/test_config_and_filters.py b/tests/test_config_and_filters.py index 9650b3e..bd215e8 100644 --- a/tests/test_config_and_filters.py +++ b/tests/test_config_and_filters.py @@ -22,6 +22,35 @@ def test_parse_config_normalizes_extensions(tmp_path: Path) -> None: assert config.watch.include_extensions == frozenset({".jpg", ".png"}) +def test_parse_config_reads_multiple_remote_destinations(tmp_path: Path) -> None: + config = parse_config( + { + "backup": { + "engine": "robocopy", + "remote_destinations": ["\\\\server\\Backup1", "\\\\server\\Backup2"], + } + }, + tmp_path / "config.toml", + ) + + assert config.backup.engine == "robocopy" + assert config.backup.remote_destinations == ("\\\\server\\Backup1", "\\\\server\\Backup2") + + +def test_parse_config_ignores_legacy_rsync_destination_for_robocopy(tmp_path: Path) -> None: + config = parse_config( + { + "backup": { + "engine": "robocopy", + "remote_destination": "utente@backup-server:/backup/bak-rest/", + } + }, + tmp_path / "config.toml", + ) + + assert config.backup.remote_destinations == () + + def test_filter_accepts_included_extension(tmp_path: Path) -> None: config = parse_config( {