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

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