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
```
## 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

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 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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

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("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

View File

@@ -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(
{