Skip to content

Commit ec07bc0

Browse files
MarcSchuhTNGMarcSchuh
authored andcommitted
Adding an exclusion option for restic
1 parent ae51488 commit ec07bc0

7 files changed

Lines changed: 216 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "opsbox"
7-
version = "0.1.5"
7+
version = "0.1.6"
88
description = "A comprehensive Python library for server operations including backup scripts, encrypted mail functionality, and utility tools"
99
readme = "README.md"
1010
license = {text = "MIT"}

src/opsbox/backup/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
BackupEnvironmentError,
66
BackupError,
77
ConfigurationError,
8+
FolderNotFoundError,
89
InvalidResticConfigError,
910
MaintenanceError,
1011
NetworkUnreachableError,
@@ -31,6 +32,7 @@
3132
"BackupScript",
3233
"ConfigManager",
3334
"ConfigurationError",
35+
"FolderNotFoundError",
3436
"InvalidResticConfigError",
3537
"MaintenanceError",
3638
"NetworkChecker",

src/opsbox/backup/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,7 @@ class DiffParsingError(BackupError):
8989

9090
class InvalidSnapshotIDError(BackupError):
9191
"""Raised when snapshot ID format is invalid."""
92+
93+
94+
class FolderNotFoundError(BackupError):
95+
"""Raised when a required folder or file is not found."""

src/opsbox/backup/restic_backup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class BackupScript:
5555
"""Refactored backup script with improved architecture and error handling."""
5656

5757
MIN_SNAPSHOTS_FOR_DIFF = 2
58-
MAX_FILES_IN_EMAIL = 100
58+
MAX_FILES_IN_EMAIL = 200
5959

6060
def __init__(
6161
self,

src/opsbox/rsync/config.example.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,9 @@ rsync_options:
4343
# Show progress during transfer
4444
progress: true
4545

46+
# Optional: Path to file containing patterns to exclude from sync (one pattern per line)
47+
# If specified, the file must exist or a FolderNotFoundError will be raised
48+
exclude_file: null # e.g., "/home/ben/exclude_me.txt"
49+
4650
# Optional: Title for the rsync operation (defaults to "Default rsync title")
4751
rsync_title: "Default rsync title"

src/opsbox/rsync/rsync_manager.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from opsbox.backup.exceptions import (
2020
ConfigurationError,
21+
FolderNotFoundError,
2122
NetworkUnreachableError,
2223
SSHKeyNotFoundError,
2324
UserDoesNotExistError,
@@ -74,6 +75,7 @@ class RsyncConfig:
7475
rsync_title: str = "Default rsync title"
7576
rsync_options: dict[str, Any] = field(default_factory=dict)
7677
default_user: str = field(default_factory=lambda: getpass.getuser())
78+
exclude_file: Path | None = None
7779

7880
def __post_init__(self) -> None:
7981
"""Validate configuration after initialization."""
@@ -113,6 +115,9 @@ def _validate_paths(self) -> None:
113115
if not self.email_settings_path.exists():
114116
error_msg = f"Email settings file not found: {self.email_settings_path}"
115117
raise ConfigurationError(error_msg)
118+
if self.exclude_file is not None and not self.exclude_file.exists():
119+
error_msg = f"Exclude file not found: {self.exclude_file}"
120+
raise FolderNotFoundError(error_msg)
116121

117122

118123
class RsyncManager:
@@ -207,6 +212,7 @@ def _load_config(config_path: Path) -> RsyncConfig:
207212

208213
try:
209214
# Create RsyncConfig instance with validation
215+
exclude_file = config_data.get("exclude_file")
210216
return RsyncConfig(
211217
rsync_source=config_data["rsync_source"],
212218
rsync_target=config_data["rsync_target"],
@@ -220,6 +226,7 @@ def _load_config(config_path: Path) -> RsyncConfig:
220226
rsync_options=config_data.get("rsync_options", {}),
221227
default_user=config_data.get("default_user", getpass.getuser()),
222228
rsync_title=config_data.get("rsync_title", "Default rsync title"),
229+
exclude_file=Path(exclude_file) if exclude_file else None,
223230
)
224231

225232
except KeyError as e:
@@ -291,6 +298,10 @@ def _build_rsync_command(self) -> list[str]:
291298
if self.config.rsync_options.get("progress", False):
292299
cmd.append("--progress")
293300

301+
# Add exclude file if specified
302+
if self.config.exclude_file is not None:
303+
cmd.extend(["--exclude-from", str(self.config.exclude_file)])
304+
294305
# Add log file
295306
cmd.extend(["--log-file", str(self.log_file_path)])
296307

tests/rsync/test_rsync_manager.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from opsbox.backup.exceptions import (
1111
ConfigurationError,
12+
FolderNotFoundError,
1213
NetworkUnreachableError,
1314
SSHKeyNotFoundError,
1415
)
@@ -177,6 +178,52 @@ def test_config_email_settings_path_not_exists(self) -> None:
177178

178179
assert "Email settings file not found" in str(exc_info.value)
179180

181+
def test_config_exclude_file_not_exists(self) -> None:
182+
"""Test raises FolderNotFoundError when exclude_file doesn't exist."""
183+
with tempfile.TemporaryDirectory() as temp_dir:
184+
temp_path = Path(temp_dir)
185+
email_settings = self._create_test_email_settings(temp_path)
186+
187+
with pytest.raises(FolderNotFoundError):
188+
RsyncConfig(
189+
rsync_source="user@host:/source",
190+
rsync_target="/target",
191+
email_settings_path=email_settings,
192+
exclude_file=Path("/nonexistent/exclude.txt"),
193+
)
194+
195+
def test_config_exclude_file_optional(self) -> None:
196+
"""Test that exclude_file is optional and None is valid."""
197+
with tempfile.TemporaryDirectory() as temp_dir:
198+
temp_path = Path(temp_dir)
199+
email_settings = self._create_test_email_settings(temp_path)
200+
201+
config = RsyncConfig(
202+
rsync_source="user@host:/source",
203+
rsync_target="/target",
204+
email_settings_path=email_settings,
205+
exclude_file=None,
206+
)
207+
208+
assert config.exclude_file is None
209+
210+
def test_config_exclude_file_valid(self) -> None:
211+
"""Test that exclude_file is accepted when file exists."""
212+
with tempfile.TemporaryDirectory() as temp_dir:
213+
temp_path = Path(temp_dir)
214+
email_settings = self._create_test_email_settings(temp_path)
215+
exclude_file = temp_path / "exclude.txt"
216+
exclude_file.write_text("*.tmp\n*.log\n")
217+
218+
config = RsyncConfig(
219+
rsync_source="user@host:/source",
220+
rsync_target="/target",
221+
email_settings_path=email_settings,
222+
exclude_file=exclude_file,
223+
)
224+
225+
assert config.exclude_file == exclude_file
226+
180227

181228
class TestRsyncManagerInitialization:
182229
"""Test cases for RsyncManager initialization."""
@@ -349,6 +396,89 @@ def test_initialization_missing_required_field(self) -> None:
349396

350397
assert "Missing required configuration field" in str(exc_info.value)
351398

399+
@patch("opsbox.rsync.rsync_manager.configure_logging")
400+
@patch("opsbox.rsync.rsync_manager.EncryptedMail")
401+
@patch("opsbox.rsync.rsync_manager.LockManager")
402+
@patch("opsbox.rsync.rsync_manager.NetworkChecker")
403+
@patch("opsbox.rsync.rsync_manager.SSHManager")
404+
def test_initialization_with_exclude_file(
405+
self,
406+
mock_ssh_manager: MagicMock,
407+
mock_network_checker: MagicMock,
408+
mock_lock_manager: MagicMock,
409+
mock_encrypted_mail: MagicMock,
410+
mock_configure_logging: MagicMock,
411+
) -> None:
412+
"""Test initializes successfully with exclude_file in config."""
413+
with tempfile.TemporaryDirectory() as temp_dir:
414+
temp_path = Path(temp_dir)
415+
email_settings = self._create_test_email_settings(temp_path)
416+
exclude_file = temp_path / "exclude.txt"
417+
exclude_file.write_text("*.tmp\n*.log\n")
418+
419+
config_data = {
420+
"rsync_source": "user@host:/source",
421+
"rsync_target": str(temp_path / "target"),
422+
"email_settings_path": str(email_settings),
423+
"exclude_file": str(exclude_file),
424+
}
425+
config_file = self._create_test_config_file(
426+
temp_path,
427+
email_settings,
428+
config_data,
429+
)
430+
431+
target_dir = temp_path / "target"
432+
target_dir.mkdir()
433+
434+
mock_logger = Mock()
435+
mock_configure_logging.return_value = mock_logger
436+
437+
manager = RsyncManager(config_path=config_file, log_level="INFO")
438+
439+
assert manager.config.exclude_file == exclude_file
440+
441+
@patch("opsbox.rsync.rsync_manager.configure_logging")
442+
@patch("opsbox.rsync.rsync_manager.EncryptedMail")
443+
@patch("opsbox.rsync.rsync_manager.LockManager")
444+
@patch("opsbox.rsync.rsync_manager.NetworkChecker")
445+
@patch("opsbox.rsync.rsync_manager.SSHManager")
446+
def test_initialization_with_exclude_file_not_exists(
447+
self,
448+
mock_ssh_manager: MagicMock,
449+
mock_network_checker: MagicMock,
450+
mock_lock_manager: MagicMock,
451+
mock_encrypted_mail: MagicMock,
452+
mock_configure_logging: MagicMock,
453+
) -> None:
454+
"""Test raises FolderNotFoundError when exclude_file in config doesn't exist."""
455+
with tempfile.TemporaryDirectory() as temp_dir:
456+
temp_path = Path(temp_dir)
457+
email_settings = self._create_test_email_settings(temp_path)
458+
459+
config_data = {
460+
"rsync_source": "user@host:/source",
461+
"rsync_target": str(temp_path / "target"),
462+
"email_settings_path": str(email_settings),
463+
"exclude_file": "/nonexistent/exclude.txt",
464+
}
465+
config_file = self._create_test_config_file(
466+
temp_path,
467+
email_settings,
468+
config_data,
469+
)
470+
471+
target_dir = temp_path / "target"
472+
target_dir.mkdir()
473+
474+
mock_logger = Mock()
475+
mock_configure_logging.return_value = mock_logger
476+
477+
with pytest.raises(ConfigurationError) as exc_info:
478+
RsyncManager(config_path=config_file, log_level="INFO")
479+
480+
assert "Exclude file not found" in str(exc_info.value)
481+
352482

353483
class TestRsyncCommandBuilding:
354484
"""Test cases for rsync command building."""
@@ -463,6 +593,69 @@ def test_build_rsync_command_with_log_file(self) -> None:
463593
log_file_index = cmd.index("--log-file")
464594
assert cmd[log_file_index + 1] == str(manager.log_file_path)
465595

596+
def test_build_rsync_command_with_exclude_file(self) -> None:
597+
"""Test includes exclude-from option when exclude_file is configured."""
598+
with tempfile.TemporaryDirectory() as temp_dir:
599+
temp_path = Path(temp_dir)
600+
exclude_file = temp_path / "exclude.txt"
601+
exclude_file.write_text("*.tmp\n*.log\n")
602+
603+
config_data = {
604+
"rsync_source": "user@host:/source",
605+
"rsync_target": str(temp_path / "target"),
606+
"email_settings_path": str(temp_path / "email_settings.yaml"),
607+
"exclude_file": str(exclude_file),
608+
}
609+
manager = self._create_test_manager(temp_path, config_data)
610+
611+
cmd = manager._build_rsync_command()
612+
613+
assert "--exclude-from" in cmd
614+
exclude_index = cmd.index("--exclude-from")
615+
assert cmd[exclude_index + 1] == str(exclude_file)
616+
617+
def test_build_rsync_command_without_exclude_file(self) -> None:
618+
"""Test does not include exclude-from option when exclude_file is not configured."""
619+
with tempfile.TemporaryDirectory() as temp_dir:
620+
temp_path = Path(temp_dir)
621+
manager = self._create_test_manager(temp_path)
622+
623+
cmd = manager._build_rsync_command()
624+
625+
assert "--exclude-from" not in cmd
626+
627+
def test_build_rsync_command_with_exclude_file_and_other_options(self) -> None:
628+
"""Test exclude-from works correctly with other rsync options."""
629+
with tempfile.TemporaryDirectory() as temp_dir:
630+
temp_path = Path(temp_dir)
631+
exclude_file = temp_path / "exclude.txt"
632+
exclude_file.write_text("*.tmp\n*.log\n")
633+
634+
config_data = {
635+
"rsync_source": "user@host:/source",
636+
"rsync_target": str(temp_path / "target"),
637+
"email_settings_path": str(temp_path / "email_settings.yaml"),
638+
"exclude_file": str(exclude_file),
639+
"rsync_options": {
640+
"chown": "user:group",
641+
"delete": True,
642+
"progress": True,
643+
},
644+
}
645+
manager = self._create_test_manager(temp_path, config_data)
646+
647+
cmd = manager._build_rsync_command()
648+
649+
# Verify all options are present
650+
assert "--exclude-from" in cmd
651+
assert "--chown" in cmd
652+
assert "--delete" in cmd
653+
assert "--progress" in cmd
654+
# Verify exclude-from comes before log-file (as per implementation)
655+
exclude_index = cmd.index("--exclude-from")
656+
log_file_index = cmd.index("--log-file")
657+
assert exclude_index < log_file_index
658+
466659

467660
class TestRsyncUtilityFunctions:
468661
"""Test cases for utility functions."""

0 commit comments

Comments
 (0)