Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/opsbox/backup/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ keep_last: "10"
keep_daily: "21"
keep_monthly: "5"
ssh_key_max_retries: 12
detailed_report: true
network_host: null
ssh_key: null
ssh_user: null
restic_password: null
user_id: null
deletion_threshold: null # Send warning email if more than this many files are deleted (null to disable)
alteration_threshold: null # Send warning email if more than this many files are altered (null to disable)
monitored_folders: [] # List of folders to monitor for changes (empty list to disable)
alteration_threshold: null # Send warning email if more than this many files are altered/changed (null to disable)
addition_threshold: null # Send warning email if more than this many files are newly added (null to disable)
min_source_entries: 1 # Abort the backup if the source directory has fewer than this many entries (protects against unmounted/empty sources)
check_read_data_subset: "20%" # Portion of repository data that "restic check" re-reads and verifies during maintenance (e.g. "20%", "100%", "1G")
monitored_folders: [] # Folders to watch: ANY change (added/altered/deleted file) inside triggers an alert email (empty list to disable)
# Example:
# - /path/to/important/folder
# - /another/monitored/path
Expand Down
49 changes: 46 additions & 3 deletions src/opsbox/backup/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,19 @@ class BackupConfig:
keep_daily: str = "21"
keep_monthly: str = "5"
ssh_key_max_retries: int = 12
detailed_report: bool = True
restic_password: str | None = None
backup_title: str = "Default backupt title"

# Minimum number of entries the backup source must contain. The backup is
# aborted if fewer entries are found (protects against unmounted/empty
# sources). Defaults to 1, i.e. abort only when the source is completely empty.
min_source_entries: int = 1

# Fraction of the repository data that "restic check" re-reads and verifies
# during maintenance (e.g. "20%", "100%", "1G"). Higher values verify more
# data at the cost of runtime.
check_read_data_subset: str = "20%"

# Network and SSH fields
network_host: str | None = None
ssh_key: str | None = None
Expand All @@ -44,6 +53,7 @@ class BackupConfig:
# Threshold fields for warning emails
deletion_threshold: int | None = None
alteration_threshold: int | None = None
addition_threshold: int | None = None

# Monitored folders for change alerts
monitored_folders: list[str] = field(default_factory=list)
Expand All @@ -54,6 +64,32 @@ def __post_init__(self) -> None:
self._validate_ssh_configuration()
self._validate_paths()
self._validate_retention_policy()
self._validate_source_and_thresholds()

def _validate_source_and_thresholds(self) -> None:
"""Validate the source-entry minimum and change-count thresholds."""
if not isinstance(self.min_source_entries, int) or self.min_source_entries < 0:
error_msg = (
f"'min_source_entries' must be a non-negative integer, "
f"got: {self.min_source_entries!r}"
)
raise InvalidResticConfigError(error_msg)

for name in (
"deletion_threshold",
"alteration_threshold",
"addition_threshold",
):
value = getattr(self, name)
if value is not None and (not isinstance(value, int) or value < 0):
error_msg = (
f"'{name}' must be a non-negative integer or null, got: {value!r}"
)
raise InvalidResticConfigError(error_msg)

if not str(self.check_read_data_subset).strip():
error_msg = "'check_read_data_subset' must be a non-empty value"
raise InvalidResticConfigError(error_msg)

def _validate_required_fields(self) -> None:
"""Validate that all required fields are present and non-empty."""
Expand Down Expand Up @@ -195,11 +231,16 @@ def load_config(config_path: str) -> BackupConfig:
ssh_key_max_retries=int(config_data.get("ssh_key_max_retries", 12)),
restic_password=config_data.get("restic_password"),
user_id=config_data.get("user_id", os.getuid()),
detailed_report=config_data.get("detailed_report", True),
deletion_threshold=config_data.get("deletion_threshold"),
alteration_threshold=config_data.get("alteration_threshold"),
addition_threshold=config_data.get("addition_threshold"),
monitored_folders=config_data.get("monitored_folders", []),
backup_title=config_data.get("backup_title", "Default backup title"),
min_source_entries=int(config_data.get("min_source_entries", 1)),
check_read_data_subset=config_data.get(
"check_read_data_subset",
"20%",
),
)

except KeyError as e:
Expand Down Expand Up @@ -238,13 +279,15 @@ def get_default_config() -> dict[str, Any]:
"keep_daily": "21",
"keep_monthly": "5",
"ssh_key_max_retries": 12,
"detailed_report": True,
"network_host": None,
"ssh_key": None,
"ssh_user": None,
"restic_password": None, # If provided, password_lookup_1/2 become optional
"user_id": None,
"deletion_threshold": None, # Send warning if more than this many files deleted
"alteration_threshold": None, # Send warning if more than this many files altered
"addition_threshold": None, # Send warning if more than this many files added
"monitored_folders": [], # List of folders to monitor for changes
"min_source_entries": 1, # Abort backup if source has fewer entries
"check_read_data_subset": "20%", # Portion of data verified by restic check
}
18 changes: 18 additions & 0 deletions src/opsbox/backup/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ class ResticEnvNotSetError(BackupEnvironmentError):
"""Raised when restic environment is not properly configured."""


class EmptySourceError(BackupEnvironmentError):
"""Raised when the backup source is missing or (near) empty.

Guards against backing up an unmounted or empty source directory, which
would otherwise create an almost empty snapshot and risk data loss once
old snapshots are pruned.
"""


class SecurityError(BackupError):
"""Raised when there are security-related issues."""

Expand Down Expand Up @@ -71,6 +80,15 @@ class ResticCommandFailedError(ResticError):
"""Raised when any restic command fails."""


class ResticRepositoryLockedError(ResticCommandFailedError):
"""Raised when a restic command fails because the repository is locked.

Subclasses :class:`ResticCommandFailedError` so existing handlers keep
working, while allowing callers to react specifically (e.g. run
``restic unlock`` to remove stale locks and retry once).
"""


class SnapshotIDNotFoundError(ResticError):
"""Raised when snapshot ID cannot be found in restic output."""

Expand Down
2 changes: 1 addition & 1 deletion src/opsbox/backup/password_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def validate_password_strength(self, password: str) -> bool:
# Basic validation - password should be at least MIN_PASSWORD_LENGTH characters
if len(password) < self.MIN_PASSWORD_LENGTH:
self.logger.warning(
f"Password is shorter than recommended minimum length ({self.MIN_PASSWORD_LENGTH} characters)",
"Password is shorter than recommended minimum length (characters)",
)
return False

Expand Down
Loading
Loading