Imposta backup robocopy su share Windows

This commit is contained in:
2026-07-01 12:37:48 +02:00
parent d5bff3d8e4
commit 0970e0af3d
12 changed files with 304 additions and 44 deletions

View File

@@ -76,6 +76,34 @@ Argomenti: -m bakrest.tray_app --nogui
Avvia in: C:\devel\bak&rest 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: Per una prova senza spegnimento:
```text ```text

View File

@@ -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 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. 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; - `PyYAML` o `tomllib`/`tomli-w` per la configurazione;
- `sqlite3` standard library per il registro cambiamenti; - `sqlite3` standard library per il registro cambiamenti;
- `subprocess` per eseguire `rsync`; - `subprocess` per eseguire `rsync`;
- `robocopy` come motore predefinito per share SMB Windows;
- `logging` standard library per i log giornalieri. - `logging` standard library per i log giornalieri.
Packaging candidato: Packaging candidato:

View File

@@ -42,15 +42,21 @@ exclude_patterns = [
] ]
[backup] [backup]
engine = "robocopy"
server_host = "backup-server" 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" rsync_path = "rsync"
robocopy_path = "robocopy"
shutdown_on_success = true shutdown_on_success = true
shutdown_command = "shutdown /s /t 0" shutdown_command = "shutdown /s /t 0"
[backup.server_check] [backup.server_check]
type = "tcp" type = "tcp"
port = 22 port = 445
interval_seconds = 300 interval_seconds = 300
timeout_seconds = 3 timeout_seconds = 3

View File

@@ -52,38 +52,13 @@ def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult:
skipped_count=len(skipped) + len(unstable), skipped_count=len(skipped) + len(unstable),
) )
files_from_path: str | None = None copy_result = _run_copy_engine(config, stable, logger)
try: if not copy_result.success:
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( return BackupResult(
False, False,
message, copy_result.message,
skipped_count=len(skipped) + len(unstable), skipped_count=len(skipped) + len(unstable),
error_code="rsync_start_failed", error_code=copy_result.error_code,
)
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") 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]: def _common_roots(config: AppConfig, events: list[ChangeEvent]) -> list[Path]:
roots: list[Path] = [] roots: list[Path] = []
for event in events: for event in events:

View File

@@ -45,6 +45,10 @@ def _exit_code_for_error(error_code: str | None) -> int:
return 30 return 30
if error_code == "rsync_failed": if error_code == "rsync_failed":
return 31 return 31
if error_code == "robocopy_start_failed":
return 30
if error_code == "robocopy_failed":
return 31
return 1 return 1

View File

@@ -22,9 +22,12 @@ class ServerCheckConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class BackupConfig: class BackupConfig:
engine: str
server_host: str server_host: str
remote_destination: str remote_destination: str
remote_destinations: tuple[str, ...]
rsync_path: str rsync_path: str
robocopy_path: str
shutdown_on_success: bool shutdown_on_success: bool
shutdown_command: str shutdown_command: str
server_check: ServerCheckConfig server_check: ServerCheckConfig
@@ -130,9 +133,12 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
exclude_patterns=tuple(watch.get("exclude_patterns", [])), exclude_patterns=tuple(watch.get("exclude_patterns", [])),
), ),
backup=BackupConfig( backup=BackupConfig(
engine=str(backup.get("engine", "robocopy")).lower(),
server_host=str(backup.get("server_host", "backup-server")), server_host=str(backup.get("server_host", "backup-server")),
remote_destination=str(backup.get("remote_destination", "")), remote_destination=str(backup.get("remote_destination", "")),
remote_destinations=_remote_destinations(backup),
rsync_path=str(backup.get("rsync_path", "rsync")), 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_on_success=bool(backup.get("shutdown_on_success", True)),
shutdown_command=str(backup.get("shutdown_command", "shutdown /s /t 0")), shutdown_command=str(backup.get("shutdown_command", "shutdown /s /t 0")),
server_check=ServerCheckConfig( server_check=ServerCheckConfig(
@@ -165,3 +171,23 @@ def _normalize_exclude_dir(value: str) -> Path | str:
if "%" in value or not expanded.is_absolute(): if "%" in value or not expanded.is_absolute():
return value.lower() return value.lower()
return expanded 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

View File

@@ -86,14 +86,14 @@ class BakRestConfigApp(ctk.CTk):
self.server_host_var = tk.StringVar() self.server_host_var = tk.StringVar()
self.server_port_var = tk.StringVar() self.server_port_var = tk.StringVar()
self.remote_destination_var = tk.StringVar() self.engine_var = tk.StringVar()
self.rsync_path_var = tk.StringVar() self.robocopy_path_var = tk.StringVar()
self.shutdown_on_success_var = tk.BooleanVar() self.shutdown_on_success_var = tk.BooleanVar()
_label_entry(backup_frame, "Server", self.server_host_var, 0) _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, "Porta TCP", self.server_port_var, 1)
_label_entry(backup_frame, "Destinazione remota", self.remote_destination_var, 2) _label_entry(backup_frame, "Motore backup", self.engine_var, 2)
_label_entry(backup_frame, "Percorso rsync", self.rsync_path_var, 3) _label_entry(backup_frame, "Percorso robocopy", self.robocopy_path_var, 3)
shutdown_check = ctk.CTkCheckBox( shutdown_check = ctk.CTkCheckBox(
backup_frame, 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) 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 = 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) actions.grid_columnconfigure(0, weight=1)
save_button = ctk.CTkButton(actions, text="Salva configurazione", command=self._save_config) 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_host_var.set(str(backup.get("server_host", "")))
self.server_port_var.set(str(server_check.get("port", 22))) self.server_port_var.set(str(server_check.get("port", 22)))
self.remote_destination_var.set(str(backup.get("remote_destination", ""))) self.engine_var.set(str(backup.get("engine", "robocopy")))
self.rsync_path_var.set(str(backup.get("rsync_path", "rsync"))) 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))) self.shutdown_on_success_var.set(bool(backup.get("shutdown_on_success", True)))
def _save_config(self) -> None: def _save_config(self) -> None:
@@ -168,8 +176,10 @@ class BakRestConfigApp(ctk.CTk):
watch["exclude_patterns"] = self.exclude_patterns.items() watch["exclude_patterns"] = self.exclude_patterns.items()
backup["server_host"] = self.server_host_var.get().strip() backup["server_host"] = self.server_host_var.get().strip()
backup["remote_destination"] = self.remote_destination_var.get().strip() backup["engine"] = self.engine_var.get().strip() or "robocopy"
backup["rsync_path"] = self.rsync_path_var.get().strip() or "rsync" 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()) backup["shutdown_on_success"] = bool(self.shutdown_on_success_var.get())
server_check["port"] = _safe_int(self.server_port_var.get(), 22) 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 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: def main() -> int:
app = BakRestConfigApp() app = BakRestConfigApp()
app.mainloop() app.mainloop()

View File

@@ -27,10 +27,14 @@ def main() -> int:
print(f"config: {config.path}") print(f"config: {config.path}")
print(f"service_autostart: {config.service_autostart}") print(f"service_autostart: {config.service_autostart}")
print(f"tray_autostart: {config.tray_autostart}") print(f"tray_autostart: {config.tray_autostart}")
print(f"backup_engine: {config.backup.engine}")
print("include_dirs:") print("include_dirs:")
for include_dir in config.watch.include_dirs: for include_dir in config.watch.include_dirs:
print(f" - {include_dir}") print(f" - {include_dir}")
print(f"include_extensions: {', '.join(sorted(config.watch.include_extensions))}") 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"registry: {config.storage.change_registry}")
print(f"logs_dir: {config.storage.logs_dir}") print(f"logs_dir: {config.storage.logs_dir}")
return 0 return 0

View File

@@ -42,15 +42,21 @@ exclude_patterns = [
] ]
[backup] [backup]
engine = "robocopy"
server_host = "backup-server" 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" rsync_path = "rsync"
robocopy_path = "robocopy"
shutdown_on_success = true shutdown_on_success = true
shutdown_command = "shutdown /s /t 0" shutdown_command = "shutdown /s /t 0"
[backup.server_check] [backup.server_check]
type = "tcp" type = "tcp"
port = 22 port = 445
interval_seconds = 300 interval_seconds = 300
timeout_seconds = 3 timeout_seconds = 3

View File

@@ -2,7 +2,12 @@ from __future__ import annotations
from pathlib import Path 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.config import parse_config
from bakrest.registry import ChangeEvent 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 "--relative" in command
assert any(part.startswith("--files-from=") for part in command) assert any(part.startswith("--files-from=") for part in command)
assert command[-1] == "user@host:/backup/" 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

View File

@@ -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("server_unreachable") == 20
assert _exit_code_for_error("rsync_start_failed") == 30 assert _exit_code_for_error("rsync_start_failed") == 30
assert _exit_code_for_error("rsync_failed") == 31 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 assert _exit_code_for_error(None) == 1

View File

@@ -22,6 +22,35 @@ def test_parse_config_normalizes_extensions(tmp_path: Path) -> None:
assert config.watch.include_extensions == frozenset({".jpg", ".png"}) 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: def test_filter_accepts_included_extension(tmp_path: Path) -> None:
config = parse_config( config = parse_config(
{ {