diff --git a/.cursor/rules/version-bump.mdc b/.cursor/rules/version-bump.mdc new file mode 100644 index 0000000..b865221 --- /dev/null +++ b/.cursor/rules/version-bump.mdc @@ -0,0 +1,49 @@ +--- +description: Always bump the version in pyproject.toml when code changes +alwaysApply: true +--- + +# Version Bump Required + +Whenever you change, add, or update code, configuration, or behavior in this +repository, you **must** also bump `version` in `pyproject.toml` +(`[project]` table). Never ship a functional change without a version bump. + +## How to bump (Semantic Versioning: MAJOR.MINOR.PATCH) + +- **PATCH** (`0.4.1` -> `0.4.2`): bug fixes, refactors, docs, internal tweaks + with no API change. +- **MINOR** (`0.4.1` -> `0.5.0`): new backwards-compatible feature, new config + option, new CLI flag. +- **MAJOR** (`0.4.1` -> `1.0.0`): breaking change (removed/renamed public API, + incompatible config or behavior). + +Increase exactly one segment and reset the lower ones to `0`. + +## Rules + +- Edit only the single `version = "X.Y.Z"` line under `[project]`. +- Do NOT hardcode the version in code. `pyproject.toml` is the single source of + truth; `__version__` is derived at runtime via + `importlib.metadata.version("opsbox")` (see `src/opsbox/__init__.py`), so no + in-code sync is needed. +- Do the bump in the **same change** as the code edit, not as a follow-up. + +## Do not bump for + +- Pure whitespace/formatting-only runs with no behavioral effect. +- Changes limited to files under `.cursor/` or local editor tooling. + +## Example + +```toml +# before +[project] +name = "opsbox" +version = "0.4.1" + +# after a bug fix +[project] +name = "opsbox" +version = "0.4.2" +``` diff --git a/pyproject.toml b/pyproject.toml index 44f7d62..e3edc64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsbox" -version = "0.4.1" +version = "0.5.1" description = "A comprehensive Python library for server operations including backup scripts, encrypted mail functionality, and utility tools" readme = "README.md" license = {text = "MIT"} diff --git a/src/opsbox/__init__.py b/src/opsbox/__init__.py index 1b2c0ec..db0bd08 100644 --- a/src/opsbox/__init__.py +++ b/src/opsbox/__init__.py @@ -4,7 +4,14 @@ encrypted mail functionality, and utility tools. """ -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + # Single source of truth: the version declared in pyproject.toml. + __version__ = version("opsbox") +except PackageNotFoundError: # pragma: no cover - e.g. running from a raw checkout + __version__ = "0.0.0.dev0" + __author__ = "OpsBox Team" __email__ = "opsbox@example.com" diff --git a/src/opsbox/backup/__init__.py b/src/opsbox/backup/__init__.py index 0bd29f3..26cfe5d 100644 --- a/src/opsbox/backup/__init__.py +++ b/src/opsbox/backup/__init__.py @@ -1,5 +1,7 @@ """Backup module for OpsBox with improved architecture and error handling.""" +from opsbox import __version__ + from .config_manager import BackupConfig, ConfigManager from .exceptions import ( BackupEnvironmentError, @@ -49,6 +51,5 @@ "UserDoesNotExistError", "VerificationError", "WrongOSForResticBackupError", + "__version__", ] - -__version__ = "2.0.0" diff --git a/src/opsbox/backup/config.example.yaml b/src/opsbox/backup/config.example.yaml index 15686e3..80e908d 100644 --- a/src/opsbox/backup/config.example.yaml +++ b/src/opsbox/backup/config.example.yaml @@ -1,30 +1,105 @@ +# ============================================================================= +# OpsBox restic backup - example configuration +# ============================================================================= +# Copy this file, adjust the values and pass it via: +# restic_backup --config /path/to/your-config.yaml +# +# Fields marked "required" must be present. All other fields are optional and +# fall back to the defaults shown below (see config_manager.BackupConfig). + +# --- What to back up --------------------------------------------------------- + +# Directory that gets backed up (required). backup_source: /path/to/backup/source + +# Glob patterns that are excluded from the backup (optional, defaults to []). excluded_files: - "*.tmp" - "*.log" - "*.cache" + +# Restic repository / backup destination (required). backup_target: sftp:user@host:/path/to/repo + +# --- Repository password ----------------------------------------------------- +# Provide EITHER both password_lookup_1 + password_lookup_2 (looked up via the +# password manager) OR restic_password directly. Do not set both mechanisms. + +# Two-part lookup key resolved by the password manager. password_lookup_1: restic password_lookup_2: password + +# Direct repository password. If set, password_lookup_1/2 must be omitted/null. +restic_password: null + +# --- Notifications & verification -------------------------------------------- + +# Path to the encrypted-mail settings JSON used for notifications (required). email_settings_path: /path/to/email_settings.json + +# A file that must exist in the created snapshot; used to verify the backup +# actually contains data (required). file_to_check: important_file.txt + +# Subject prefix used in notification emails (optional). +backup_title: "Default backup title" + +# --- Users ------------------------------------------------------------------- + +# User the backup runs as (optional, defaults to the current user). default_user: backup_user -keep_last: "10" -keep_daily: "21" -keep_monthly: "5" -ssh_key_max_retries: 12 + +# Numeric user id (optional, defaults to the current process uid). +user_id: null + +# --- SSH / network (only needed for remote sftp targets) --------------------- +# network_host, ssh_key and ssh_user must be configured together. Leave all +# null for local repositories. + +# Host to ping before starting; the backup is skipped if it is unreachable. network_host: null + +# SSH private key added to the agent for the connection. ssh_key: null + +# SSH user that owns the key above. 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/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) + +# How often loading the SSH key into the agent is retried (optional). +ssh_key_max_retries: 12 + +# --- Retention policy (passed to "restic forget") ---------------------------- +keep_last: "10" +keep_daily: "21" +keep_monthly: "5" + +# --- Safety & maintenance ---------------------------------------------------- + +# Abort the backup if the source directory has fewer than this many entries +# (protects against backing up an unmounted/empty source). +min_source_entries: 1 + +# Portion of repository data that "restic check" re-reads and verifies during +# maintenance (e.g. "20%", "100%", "1G"). +check_read_data_subset: "20%" + +# Per-command timeout in seconds applied to every restic invocation +# (backup, check, prune, ...). Leave as null for NO timeout, so large backups +# are never aborted prematurely. Set a positive integer only if you explicitly +# want to bound how long a single restic command may run. +command_timeout: null + +# --- Change alerts ----------------------------------------------------------- + +# Send a warning email if a single run adds/alters/deletes more than this many +# files. Use null to disable the respective check. +deletion_threshold: null # too many files deleted +alteration_threshold: null # too many files altered/changed +addition_threshold: null # too many newly added files + +# Folders to watch: ANY change (added/altered/deleted file) inside triggers an +# alert email. Empty list disables monitoring. +monitored_folders: [] # Example: # - /path/to/important/folder # - /another/monitored/path -backup_title: "Default backup title" diff --git a/src/opsbox/backup/config_manager.py b/src/opsbox/backup/config_manager.py index a799e29..f4b2439 100644 --- a/src/opsbox/backup/config_manager.py +++ b/src/opsbox/backup/config_manager.py @@ -44,6 +44,11 @@ class BackupConfig: # data at the cost of runtime. check_read_data_subset: str = "20%" + # Timeout (in seconds) applied to every individual restic command. ``None`` + # (the default) means no timeout, so long-running backups are not aborted + # prematurely. Set a positive integer to enforce a per-command limit. + command_timeout: int | None = None + # Network and SSH fields network_host: str | None = None ssh_key: str | None = None @@ -91,6 +96,17 @@ def _validate_source_and_thresholds(self) -> None: error_msg = "'check_read_data_subset' must be a non-empty value" raise InvalidResticConfigError(error_msg) + if self.command_timeout is not None and ( + not isinstance(self.command_timeout, int) + or isinstance(self.command_timeout, bool) + or self.command_timeout <= 0 + ): + error_msg = ( + f"'command_timeout' must be a positive integer (seconds) or " + f"null for no timeout, got: {self.command_timeout!r}" + ) + raise InvalidResticConfigError(error_msg) + def _validate_required_fields(self) -> None: """Validate that all required fields are present and non-empty.""" # If restic_password is provided, password_lookup fields are optional @@ -241,6 +257,7 @@ def load_config(config_path: str) -> BackupConfig: "check_read_data_subset", "20%", ), + command_timeout=config_data.get("command_timeout"), ) except KeyError as e: @@ -290,4 +307,5 @@ def get_default_config() -> dict[str, Any]: "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 + "command_timeout": None, # Per-command timeout in seconds (null = no timeout) } diff --git a/src/opsbox/backup/restic_backup.py b/src/opsbox/backup/restic_backup.py index 1f714bc..88b9261 100644 --- a/src/opsbox/backup/restic_backup.py +++ b/src/opsbox/backup/restic_backup.py @@ -155,6 +155,7 @@ def _initialize_components(self, restic_path: str) -> None: restic_path, self.config.backup_target, self.logger, + command_timeout=self.config.command_timeout, ) def _failure_mail_subject(self, error: BaseException) -> str: diff --git a/src/opsbox/backup/restic_client.py b/src/opsbox/backup/restic_client.py index ed9463a..cf526b6 100644 --- a/src/opsbox/backup/restic_client.py +++ b/src/opsbox/backup/restic_client.py @@ -20,6 +20,12 @@ "unable to create lock", ) +# Sentinel for the per-call ``timeout`` argument, meaning "fall back to the +# client's configured ``command_timeout``". A distinct object is used so an +# explicit ``timeout=None`` (run without any timeout) can be told apart from +# "no timeout argument was passed". +_USE_CONFIG_TIMEOUT = object() + def is_lock_error(text: str | None) -> bool: """Return True if ``text`` looks like a restic repository-lock error.""" @@ -44,11 +50,24 @@ def __init__( restic_path: str, backup_target: str, logger: logging.Logger, + command_timeout: int | None = None, ) -> None: - """Initialize the restic client with path, target, and logger.""" + """Initialize the restic client with path, target, and logger. + + Args: + restic_path: Path to the restic executable. + backup_target: The restic repository / backup target. + logger: Logger used for command and error reporting. + command_timeout: Timeout in seconds applied to every restic command. + ``None`` (the default) means no timeout, i.e. commands may run + indefinitely. Large backups can easily exceed any fixed limit, + so a timeout is only enforced when explicitly configured. + + """ self.restic_path = restic_path self.backup_target = backup_target self.logger = logger + self.command_timeout = command_timeout self.temp_dir = Path(tempfile.gettempdir()) self.temp_dir.mkdir(parents=True, exist_ok=True) self.cache_dir = self.temp_dir / "restic_cache" @@ -125,11 +144,22 @@ def _append_to_session_log( with self.session_log.open("a", encoding="utf-8") as session_file: session_file.write("".join(parts)) + def _resolve_timeout(self, timeout: int | None | object) -> int | None: + """Resolve a per-call timeout against the configured default. + + When ``timeout`` is the ``_USE_CONFIG_TIMEOUT`` sentinel, the client's + ``command_timeout`` is used (``None`` = no timeout). An explicit value + (including ``None``) always overrides the configured default. + """ + if timeout is _USE_CONFIG_TIMEOUT: + return self.command_timeout + return timeout # type: ignore[return-value] + def _run_command( self, command: list[str], text: bool = True, - timeout: int = 3600, + timeout: int | None | object = _USE_CONFIG_TIMEOUT, raise_on_error: bool = True, ) -> subprocess.CompletedProcess[str]: """Execute a command with proper error handling and logging. @@ -137,7 +167,8 @@ def _run_command( Args: command: The command and arguments to execute text: Whether to decode the output as text - timeout: Timeout in seconds for the command + timeout: Timeout in seconds for the command. Defaults to the + client's configured ``command_timeout`` (``None`` = no timeout). raise_on_error: If True, a non-zero exit code raises ResticCommandFailedError. If False, the non-zero exit code is logged as a warning and the completed process (including its @@ -146,6 +177,7 @@ def _run_command( """ self.logger.debug(f"Running command: {' '.join(command)}") + effective_timeout = self._resolve_timeout(timeout) try: # Capture this command only; session_log keeps the full run history @@ -154,7 +186,7 @@ def _run_command( command, capture_output=False, text=text, - timeout=timeout, + timeout=effective_timeout, env=self._get_environment(), check=False, stdout=f, @@ -186,7 +218,7 @@ def _run_command( except subprocess.TimeoutExpired as e: log_contents = self.log_file.read_text() if self.log_file.exists() else None self._append_to_session_log(command, log_contents or "") - error_msg = f"Command timed out after {timeout} seconds" + error_msg = f"Command timed out after {effective_timeout} seconds" self.logger.exception(f"{error_msg}: {' '.join(command)}") raise ResticCommandFailedError(error_msg) from e except subprocess.SubprocessError as e: @@ -199,7 +231,7 @@ def _run_command( def _run_json_command( self, command: list[str], - timeout: int = 3600, + timeout: int | None | object = _USE_CONFIG_TIMEOUT, ) -> str: """Execute a restic ``--json`` command and return its raw stdout. @@ -209,7 +241,8 @@ def _run_json_command( Args: command: The command and arguments to execute (should include --json) - timeout: Timeout in seconds for the command + timeout: Timeout in seconds for the command. Defaults to the + client's configured ``command_timeout`` (``None`` = no timeout). Returns: The command's stdout as a string @@ -219,6 +252,7 @@ def _run_json_command( """ self.logger.debug(f"Running JSON command: {' '.join(command)}") + effective_timeout = self._resolve_timeout(timeout) try: with self.log_file.open("w") as f: @@ -227,7 +261,7 @@ def _run_json_command( stdout=subprocess.PIPE, stderr=f, text=True, - timeout=timeout, + timeout=effective_timeout, env=self._get_environment(), check=False, ) @@ -249,7 +283,7 @@ def _run_json_command( except subprocess.TimeoutExpired as e: log_contents = self.log_file.read_text() if self.log_file.exists() else None self._append_to_session_log(command, log_contents or "") - error_msg = f"Command timed out after {timeout} seconds" + error_msg = f"Command timed out after {effective_timeout} seconds" self.logger.exception(f"{error_msg}: {' '.join(command)}") raise ResticCommandFailedError(error_msg) from e except subprocess.SubprocessError as e: diff --git a/uv.lock b/uv.lock index dab945f..b4bbbc1 100644 --- a/uv.lock +++ b/uv.lock @@ -533,7 +533,7 @@ wheels = [ [[package]] name = "opsbox" -version = "0.4.1" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "dbus-python" },