Aggiorna coda dopo ogni file copiato
This commit is contained in:
@@ -94,6 +94,7 @@ Il backup deve essere incrementale non distruttivo:
|
|||||||
- cancellazioni sul master non devono cancellare file sullo slave;
|
- cancellazioni sul master non devono cancellare file sullo slave;
|
||||||
- `robocopy` non deve essere usato con `/MIR` o `/PURGE`;
|
- `robocopy` non deve essere usato con `/MIR` o `/PURGE`;
|
||||||
- i file cancellati o non piu' presenti devono essere marcati come ignorati nella coda.
|
- 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`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -52,19 +52,24 @@ def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult:
|
|||||||
skipped_count=len(skipped) + len(unstable),
|
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:
|
if not copy_result.success:
|
||||||
return BackupResult(
|
return BackupResult(
|
||||||
False,
|
False,
|
||||||
copy_result.message,
|
copy_result.message,
|
||||||
|
copied_count=copy_result.copied_count,
|
||||||
skipped_count=len(skipped) + len(unstable),
|
skipped_count=len(skipped) + len(unstable),
|
||||||
error_code=copy_result.error_code,
|
error_code=copy_result.error_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
registry.mark_status([event.path for event in stable], "copied")
|
message = f"Backup completato: {copy_result.copied_count} file copiati"
|
||||||
message = f"Backup completato: {len(stable)} file copiati"
|
|
||||||
logger.info(message)
|
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:
|
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":
|
if config.backup.engine == "robocopy":
|
||||||
return _run_robocopy(config, events, logger)
|
return _run_robocopy(config, events, logger, registry)
|
||||||
if config.backup.engine == "rsync":
|
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}"
|
message = f"Motore backup non supportato: {config.backup.engine}"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return BackupResult(False, message, error_code="backup_engine_unsupported")
|
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:
|
if not config.backup.remote_destinations:
|
||||||
message = "Nessuna destinazione robocopy configurata"
|
message = "Nessuna destinazione robocopy configurata"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return BackupResult(False, message, error_code="backup_destination_missing")
|
return BackupResult(False, message, error_code="backup_destination_missing")
|
||||||
|
|
||||||
|
copied_files = 0
|
||||||
copied_commands = 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)
|
command = _build_robocopy_command(config, event, destination_root)
|
||||||
logger.info("Running robocopy command: %s", _redact_command(command))
|
logger.info("Running robocopy command: %s", _redact_command(command))
|
||||||
try:
|
try:
|
||||||
@@ -182,8 +202,19 @@ def _run_robocopy(config: AppConfig, events: list[ChangeEvent], logger: logging.
|
|||||||
logger.error(message)
|
logger.error(message)
|
||||||
return BackupResult(False, message, error_code="robocopy_failed")
|
return BackupResult(False, message, error_code="robocopy_failed")
|
||||||
copied_commands += 1
|
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:
|
def _run_rsync(config: AppConfig, events: list[ChangeEvent], logger: logging.Logger) -> BackupResult:
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from bakrest.backup import (
|
from bakrest.backup import (
|
||||||
_build_robocopy_command,
|
_build_robocopy_command,
|
||||||
_build_rsync_command,
|
_build_rsync_command,
|
||||||
_robocopy_destination_dir,
|
_robocopy_destination_dir,
|
||||||
|
_run_robocopy,
|
||||||
_split_existing_files,
|
_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, ChangeRegistry
|
||||||
|
|
||||||
|
|
||||||
def test_split_existing_files_ignores_deleted(tmp_path: Path) -> None:
|
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 command[3] == "image.psd"
|
||||||
assert "/MIR" not in command
|
assert "/MIR" not in command
|
||||||
assert "/PURGE" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user