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
49 changes: 49 additions & 0 deletions .cursor/rules/version-bump.mdc
Original file line number Diff line number Diff line change
@@ -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"
```
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
9 changes: 8 additions & 1 deletion src/opsbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 3 additions & 2 deletions src/opsbox/backup/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -49,6 +51,5 @@
"UserDoesNotExistError",
"VerificationError",
"WrongOSForResticBackupError",
"__version__",
]

__version__ = "2.0.0"
101 changes: 88 additions & 13 deletions src/opsbox/backup/config.example.yaml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions src/opsbox/backup/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions src/opsbox/backup/restic_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading