diff --git a/SPECIFICHE.md b/SPECIFICHE.md index 2d91ee6..84abbdb 100644 --- a/SPECIFICHE.md +++ b/SPECIFICHE.md @@ -94,6 +94,7 @@ Il backup deve essere incrementale non distruttivo: - 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. +- ogni file deve essere rimosso dalla coda appena la copia di quel singolo file e' riuscita su tutte le share configurate, senza attendere la fine dell'intero backup. 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`. diff --git a/src/bakrest/backup.py b/src/bakrest/backup.py index 25b0b26..c781598 100644 --- a/src/bakrest/backup.py +++ b/src/bakrest/backup.py @@ -52,19 +52,24 @@ def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult: skipped_count=len(skipped) + len(unstable), ) - copy_result = _run_copy_engine(config, stable, logger) + copy_result = _run_copy_engine(config, stable, logger, registry) if not copy_result.success: return BackupResult( False, copy_result.message, + copied_count=copy_result.copied_count, skipped_count=len(skipped) + len(unstable), error_code=copy_result.error_code, ) - registry.mark_status([event.path for event in stable], "copied") - message = f"Backup completato: {len(stable)} file copiati" + message = f"Backup completato: {copy_result.copied_count} file copiati" logger.info(message) - return BackupResult(True, message, copied_count=len(stable), skipped_count=len(skipped) + len(unstable)) + return BackupResult( + True, + message, + copied_count=copy_result.copied_count, + skipped_count=len(skipped) + len(unstable), + ) def shutdown_after_backup(config: AppConfig, logger: logging.Logger) -> bool: @@ -144,25 +149,40 @@ def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[s ] -def _run_copy_engine(config: AppConfig, events: list[ChangeEvent], logger: logging.Logger) -> BackupResult: +def _run_copy_engine( + config: AppConfig, + events: list[ChangeEvent], + logger: logging.Logger, + registry: ChangeRegistry, +) -> BackupResult: if config.backup.engine == "robocopy": - return _run_robocopy(config, events, logger) + return _run_robocopy(config, events, logger, registry) if config.backup.engine == "rsync": - return _run_rsync(config, events, logger) + result = _run_rsync(config, events, logger) + if result.success: + registry.mark_status([event.path for event in events], "copied") + return BackupResult(True, result.message, copied_count=len(events)) + return result 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: +def _run_robocopy( + config: AppConfig, + events: list[ChangeEvent], + logger: logging.Logger, + registry: ChangeRegistry, +) -> 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_files = 0 copied_commands = 0 - for destination_root in config.backup.remote_destinations: - for event in events: + for event in events: + for destination_root in config.backup.remote_destinations: command = _build_robocopy_command(config, event, destination_root) logger.info("Running robocopy command: %s", _redact_command(command)) try: @@ -182,8 +202,19 @@ def _run_robocopy(config: AppConfig, events: list[ChangeEvent], logger: logging. logger.error(message) return BackupResult(False, message, error_code="robocopy_failed") copied_commands += 1 + registry.mark_status([event.path], "copied") + copied_files += 1 + logger.info( + "Marked copied after successful robocopy to %s destination(s): %s", + len(config.backup.remote_destinations), + event.path, + ) - return BackupResult(True, f"Robocopy completato: {copied_commands} copie") + return BackupResult( + True, + f"Robocopy completato: {copied_files} file, {copied_commands} copie", + copied_count=copied_files, + ) def _run_rsync(config: AppConfig, events: list[ChangeEvent], logger: logging.Logger) -> BackupResult: diff --git a/tests/test_backup.py b/tests/test_backup.py index f72e383..c61e762 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -1,15 +1,17 @@ from __future__ import annotations from pathlib import Path +import subprocess from bakrest.backup import ( _build_robocopy_command, _build_rsync_command, _robocopy_destination_dir, + _run_robocopy, _split_existing_files, ) from bakrest.config import parse_config -from bakrest.registry import ChangeEvent +from bakrest.registry import ChangeEvent, ChangeRegistry def test_split_existing_files_ignores_deleted(tmp_path: Path) -> None: @@ -95,3 +97,78 @@ def test_build_robocopy_command_preserves_path_under_include_root(tmp_path: Path assert command[3] == "image.psd" assert "/MIR" not in command assert "/PURGE" not in command + + +def test_robocopy_marks_each_file_copied_after_all_destinations( + tmp_path: Path, + monkeypatch, +) -> None: + source = tmp_path / "Lavori" + source.mkdir() + first = source / "a.docx" + second = source / "b.docx" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + config = parse_config( + { + "watch": { + "include_dirs": [str(source)], + "include_extensions": [".docx"], + "exclude_dirs": [], + "exclude_patterns": [], + }, + "backup": { + "engine": "robocopy", + "robocopy_path": "robocopy", + "remote_destinations": ["\\\\server\\Backup1", "\\\\server\\Backup2"], + "server_check": {"type": "tcp", "port": 445, "interval_seconds": 1800, "timeout_seconds": 1}, + }, + "storage": { + "change_registry": str(tmp_path / "changes.sqlite"), + "logs_dir": str(tmp_path / "logs"), + "status_file": str(tmp_path / "status.json"), + }, + "stability": { + "min_age_seconds": 0, + "unchanged_check_interval_seconds": 0, + "unchanged_checks_required": 0, + }, + }, + tmp_path / "config.toml", + ) + registry = ChangeRegistry(config.storage.change_registry) + events = [ + ChangeEvent(path=str(first), event_type="modified"), + ChangeEvent(path=str(second), event_type="modified"), + ] + for event in events: + registry.record(event) + + pending_counts_after_commands: list[int] = [] + + def fake_run(command, capture_output, text, check): + pending_counts_after_commands.append(registry.pending_count()) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr("bakrest.backup.subprocess.run", fake_run) + + result = _run_robocopy(config, events, _NullLogger(), registry) + + assert result.success + assert result.copied_count == 2 + assert registry.pending_count() == 0 + assert pending_counts_after_commands == [2, 2, 1, 1] + + +class _NullLogger: + def info(self, *args, **kwargs) -> None: + pass + + def warning(self, *args, **kwargs) -> None: + pass + + def error(self, *args, **kwargs) -> None: + pass + + def exception(self, *args, **kwargs) -> None: + pass