Compare commits

3 Commits

Author SHA1 Message Date
2655869814 Backup: support for multiple storages
Some checks failed
Linting / YAML Lint (push) Failing after 9s
Linting / Ansible Lint (push) Successful in 19s
2025-12-20 21:19:06 +03:00
0e96b5030d Backup: refactoring 2025-12-20 21:04:54 +03:00
a217c79e7d Backup: extract restic storage into separate class 2025-12-20 21:03:32 +03:00

View File

@@ -10,13 +10,22 @@ import sys
import subprocess import subprocess
import logging import logging
import pwd import pwd
from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional, Any
import requests import requests
import tomllib import tomllib
from collections.abc import Iterable
# Default config path
CONFIG_PATH = Path("/etc/backup/config.toml")
# File name to store directories and files to back up
BACKUP_TARGETS_FILE = "backup-targets"
# Default directory fo backups (relative to app dir)
# Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups"
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -30,16 +39,6 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass
class StorageConfig:
type: str
restic_repository: str
restic_password: str
aws_access_key_id: str
aws_secret_access_key: str
aws_default_region: str
@dataclass @dataclass
class TelegramConfig: class TelegramConfig:
type: str type: str
@@ -51,111 +50,122 @@ class TelegramConfig:
@dataclass @dataclass
class Config: class Config:
roots: List[Path] roots: List[Path]
storage: Dict[str, StorageConfig]
notifications: Dict[str, TelegramConfig] notifications: Dict[str, TelegramConfig]
def read_config(config_path: Path) -> Config:
try:
with config_path.open("rb") as config_file:
raw_config = tomllib.load(config_file)
except OSError as e:
logger.error(f"Failed to read config file {config_path}: {e}")
raise
roots_raw = raw_config.get("roots") or []
if not isinstance(roots_raw, list) or not roots_raw:
raise ValueError("roots must be a non-empty list of paths in config.toml")
roots = [Path(root) for root in roots_raw]
storage_raw = raw_config.get("storage") or {}
storage: Dict[str, StorageConfig] = {}
for name, cfg in storage_raw.items():
if not isinstance(cfg, dict):
raise ValueError(f"Storage config for {name} must be a table")
storage[name] = StorageConfig(
type=cfg.get("type", ""),
restic_repository=cfg.get("restic_repository", ""),
restic_password=cfg.get("restic_password", ""),
aws_access_key_id=cfg.get("aws_access_key_id", ""),
aws_secret_access_key=cfg.get("aws_secret_access_key", ""),
aws_default_region=cfg.get("aws_default_region", ""),
)
if not storage:
raise ValueError("At least one storage backend must be configured")
notifications_raw = raw_config.get("notifications") or {}
notifications: Dict[str, TelegramConfig] = {}
for name, cfg in notifications_raw.items():
if not isinstance(cfg, dict):
raise ValueError(f"Notification config for {name} must be a table")
notifications[name] = TelegramConfig(
type=cfg.get("type", ""),
telegram_bot_token=cfg.get("telegram_bot_token", ""),
telegram_chat_id=cfg.get("telegram_chat_id", ""),
notifications_name=cfg.get("notifications_name", ""),
)
if not notifications:
raise ValueError("At least one notification backend must be configured")
for name, cfg in storage.items():
if not all(
[
cfg.type,
cfg.restic_repository,
cfg.restic_password,
cfg.aws_access_key_id,
cfg.aws_secret_access_key,
cfg.aws_default_region,
]
):
raise ValueError(f"Missing storage configuration values for backend {name}")
for name, cfg in notifications.items():
if not all(
[
cfg.type,
cfg.telegram_bot_token,
cfg.telegram_chat_id,
cfg.notifications_name,
]
):
raise ValueError(
f"Missing notification configuration values for backend {name}"
)
return Config(roots=roots, storage=storage, notifications=notifications)
CONFIG_PATH = Path("/etc/backup/config.toml")
# File name to store directories and files to back up
BACKUP_TARGETS_FILE = "backup-targets"
# Default directory fo backups (relative to app dir)
# Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups"
@dataclass @dataclass
class Application: class Application:
path: Path path: Path
owner: str owner: str
class Storage(ABC):
def backup(self, backup_dirs: List[str]) -> bool:
"""Backup directories"""
raise NotImplementedError()
class ResticStorage(Storage):
TYPE_NAME = "restic"
def __init__(self, name: str, params: Dict[str, Any]):
self.name = name
self.restic_repository = str(params.get("restic_repository", ""))
self.restic_password = str(params.get("restic_password", ""))
self.aws_access_key_id = str(params.get("aws_access_key_id", ""))
self.aws_secret_access_key = str(params.get("aws_secret_access_key", ""))
self.aws_default_region = str(params.get("aws_default_region", ""))
if not all(
[
self.restic_repository,
self.restic_password,
self.aws_access_key_id,
self.aws_secret_access_key,
self.aws_default_region,
]
):
raise ValueError(
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
)
def backup(self, backup_dirs: List[str]) -> bool:
if not backup_dirs:
logger.warning("No backup directories found")
return True
try:
logger.info("Starting restic backup")
logger.info("Destination: %s", self.restic_repository)
env = os.environ.copy()
env.update(
{
"RESTIC_REPOSITORY": self.restic_repository,
"RESTIC_PASSWORD": self.restic_password,
"AWS_ACCESS_KEY_ID": self.aws_access_key_id,
"AWS_SECRET_ACCESS_KEY": self.aws_secret_access_key,
"AWS_DEFAULT_REGION": self.aws_default_region,
}
)
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic backup failed: %s", result.stderr)
return False
logger.info("Restic backup completed successfully")
check_cmd = ["restic", "check"]
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic check failed: %s", result.stderr)
return False
logger.info("Restic check completed successfully")
forget_cmd = [
"restic",
"forget",
"--compact",
"--prune",
"--keep-daily",
"90",
"--keep-monthly",
"36",
]
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic forget/prune failed: %s", result.stderr)
return False
logger.info("Restic forget/prune completed successfully")
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Final restic check failed: %s", result.stderr)
return False
logger.info("Final restic check completed successfully")
return True
except Exception as exc: # noqa: BLE001
logger.error("Restic backup process failed: %s", exc)
return False
class BackupManager: class BackupManager:
def __init__(self): def __init__(self, config: Config, storages: List[Storage]):
self.errors: List[str] = [] self.errors: List[str] = []
self.warnings: List[str] = [] self.warnings: List[str] = []
self.successful_backups: List[str] = [] self.successful_backups: List[str] = []
self.config = read_config(CONFIG_PATH) self.config = config
self.storages = storages
def _select_storage(self) -> StorageConfig:
if "yandex" in self.config.storage:
return self.config.storage["yandex"]
return next(iter(self.config.storage.values()))
def _select_telegram(self) -> Optional[TelegramConfig]: def _select_telegram(self) -> Optional[TelegramConfig]:
if "telegram" in self.config.notifications: if "telegram" in self.config.notifications:
@@ -296,93 +306,6 @@ class BackupManager:
return backup_dirs return backup_dirs
def run_restic_backup(self, backup_dirs: List[str]) -> bool:
"""Run restic backup for all backup directories"""
if not backup_dirs:
logger.warning("No backup directories found")
return True
storage_cfg = self._select_storage()
try:
logger.info("Starting restic backup")
logger.info("Destination: %s", storage_cfg.restic_repository)
# Set environment variables for restic
env = os.environ.copy()
env.update(
{
"RESTIC_REPOSITORY": storage_cfg.restic_repository,
"RESTIC_PASSWORD": storage_cfg.restic_password,
"AWS_ACCESS_KEY_ID": storage_cfg.aws_access_key_id,
"AWS_SECRET_ACCESS_KEY": storage_cfg.aws_secret_access_key,
"AWS_DEFAULT_REGION": storage_cfg.aws_default_region,
}
)
# Run backup
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
error_msg = f"Restic backup failed: {result.stderr}"
logger.error(error_msg)
self.errors.append(f"Restic backup: {error_msg}")
return False
logger.info("Restic backup completed successfully")
# Run check
check_cmd = ["restic", "check"]
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
error_msg = f"Restic check failed: {result.stderr}"
logger.error(error_msg)
self.errors.append(f"Restic check: {error_msg}")
return False
logger.info("Restic check completed successfully")
# Run forget and prune
forget_cmd = [
"restic",
"forget",
"--compact",
"--prune",
"--keep-daily",
"90",
"--keep-monthly",
"36",
]
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
error_msg = f"Restic forget/prune failed: {result.stderr}"
logger.error(error_msg)
self.errors.append(f"Restic forget/prune: {error_msg}")
return False
logger.info("Restic forget/prune completed successfully")
# Final check
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
error_msg = f"Final restic check failed: {result.stderr}"
logger.error(error_msg)
self.errors.append(f"Final restic check: {error_msg}")
return False
logger.info("Final restic check completed successfully")
return True
except Exception as e:
error_msg = f"Restic backup process failed: {str(e)}"
logger.error(error_msg)
self.errors.append(f"Restic: {error_msg}")
return False
def send_telegram_notification(self, success: bool) -> None: def send_telegram_notification(self, success: bool) -> None:
"""Send notification to Telegram""" """Send notification to Telegram"""
telegram_cfg = self._select_telegram() telegram_cfg = self._select_telegram()
@@ -463,11 +386,15 @@ class BackupManager:
backup_dirs = self.get_backup_directories() backup_dirs = self.get_backup_directories()
logger.info(f"Found backup directories: {backup_dirs}") logger.info(f"Found backup directories: {backup_dirs}")
# Run restic backup overall_success = True
restic_success = self.run_restic_backup(backup_dirs)
# Determine overall success for storage in self.storages:
overall_success = restic_success and len(self.errors) == 0 backup_result = storage.backup(backup_dirs)
if not backup_result:
self.errors.append("Restic backup failed")
# Determine overall success
overall_success = overall_success and backup_result
# Send notification # Send notification
self.send_telegram_notification(overall_success) self.send_telegram_notification(overall_success)
@@ -485,9 +412,66 @@ class BackupManager:
return True return True
def initialize(config_path: Path) -> BackupManager:
try:
with config_path.open("rb") as config_file:
raw_config = tomllib.load(config_file)
except OSError as e:
logger.error(f"Failed to read config file {config_path}: {e}")
raise
roots_raw = raw_config.get("roots") or []
if not isinstance(roots_raw, list) or not roots_raw:
raise ValueError("roots must be a non-empty list of paths in config.toml")
roots = [Path(root) for root in roots_raw]
storage_raw = raw_config.get("storage") or {}
storages: List[Storage] = []
for name, params in storage_raw.items():
if not isinstance(params, dict):
raise ValueError(f"Storage config for {name} must be a table")
storage_type = params.get("type", "")
if storage_type == ResticStorage.TYPE_NAME:
storages.append(ResticStorage(name, params))
if not storages:
raise ValueError("At least one storage backend must be configured")
notifications_raw = raw_config.get("notifications") or {}
notifications: Dict[str, TelegramConfig] = {}
for name, cfg in notifications_raw.items():
if not isinstance(cfg, dict):
raise ValueError(f"Notification config for {name} must be a table")
notifications[name] = TelegramConfig(
type=cfg.get("type", ""),
telegram_bot_token=cfg.get("telegram_bot_token", ""),
telegram_chat_id=cfg.get("telegram_chat_id", ""),
notifications_name=cfg.get("notifications_name", ""),
)
if not notifications:
raise ValueError("At least one notification backend must be configured")
for name, cfg in notifications.items():
if not all(
[
cfg.type,
cfg.telegram_bot_token,
cfg.telegram_chat_id,
cfg.notifications_name,
]
):
raise ValueError(
f"Missing notification configuration values for backend {name}"
)
config = Config(roots=roots, notifications=notifications)
return BackupManager(config=config, storages=storages)
def main(): def main():
try: try:
backup_manager = BackupManager() backup_manager = initialize(CONFIG_PATH)
success = backup_manager.run_backup_process() success = backup_manager.run_backup_process()
if not success: if not success:
sys.exit(1) sys.exit(1)