Imposta backup robocopy su share Windows
This commit is contained in:
28
README.md
28
README.md
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user