Backup: support for multiple storages
Some checks failed
Linting / YAML Lint (push) Failing after 9s
Linting / Ansible Lint (push) Successful in 19s

This commit is contained in:
2025-12-20 21:19:06 +03:00
parent 0e96b5030d
commit 2655869814

View File

@@ -10,12 +10,14 @@ 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
# Default config path
CONFIG_PATH = Path("/etc/backup/config.toml") CONFIG_PATH = Path("/etc/backup/config.toml")
# File name to store directories and files to back up # File name to store directories and files to back up
@@ -37,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
@@ -58,7 +50,6 @@ 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]
@@ -68,11 +59,35 @@ class Application:
owner: str owner: str
class ResticStorage: class Storage(ABC):
def __init__(self, cfg: StorageConfig): def backup(self, backup_dirs: List[str]) -> bool:
if cfg.type != "restic": """Backup directories"""
raise ValueError(f"Unsupported storage type for ResticStorage: {cfg.type}") raise NotImplementedError()
self.cfg = cfg
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: def backup(self, backup_dirs: List[str]) -> bool:
if not backup_dirs: if not backup_dirs:
@@ -81,16 +96,16 @@ class ResticStorage:
try: try:
logger.info("Starting restic backup") logger.info("Starting restic backup")
logger.info("Destination: %s", self.cfg.restic_repository) logger.info("Destination: %s", self.restic_repository)
env = os.environ.copy() env = os.environ.copy()
env.update( env.update(
{ {
"RESTIC_REPOSITORY": self.cfg.restic_repository, "RESTIC_REPOSITORY": self.restic_repository,
"RESTIC_PASSWORD": self.cfg.restic_password, "RESTIC_PASSWORD": self.restic_password,
"AWS_ACCESS_KEY_ID": self.cfg.aws_access_key_id, "AWS_ACCESS_KEY_ID": self.aws_access_key_id,
"AWS_SECRET_ACCESS_KEY": self.cfg.aws_secret_access_key, "AWS_SECRET_ACCESS_KEY": self.aws_secret_access_key,
"AWS_DEFAULT_REGION": self.cfg.aws_default_region, "AWS_DEFAULT_REGION": self.aws_default_region,
} }
) )
@@ -145,12 +160,12 @@ class ResticStorage:
class BackupManager: class BackupManager:
def __init__(self, config: Config, storage: ResticStorage): 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 = config self.config = config
self.storage = storage self.storages = storages
def _select_telegram(self) -> Optional[TelegramConfig]: def _select_telegram(self) -> Optional[TelegramConfig]:
if "telegram" in self.config.notifications: if "telegram" in self.config.notifications:
@@ -371,14 +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.storage.backup(backup_dirs)
if not restic_success: for storage in self.storages:
backup_result = storage.backup(backup_dirs)
if not backup_result:
self.errors.append("Restic backup failed") self.errors.append("Restic backup failed")
# Determine overall success # Determine overall success
overall_success = restic_success and len(self.errors) == 0 overall_success = overall_success and backup_result
# Send notification # Send notification
self.send_telegram_notification(overall_success) self.send_telegram_notification(overall_success)
@@ -410,20 +426,14 @@ def initialize(config_path: Path) -> BackupManager:
roots = [Path(root) for root in roots_raw] roots = [Path(root) for root in roots_raw]
storage_raw = raw_config.get("storage") or {} storage_raw = raw_config.get("storage") or {}
storage: Dict[str, StorageConfig] = {} storages: List[Storage] = []
for name, cfg in storage_raw.items(): for name, params in storage_raw.items():
if not isinstance(cfg, dict): if not isinstance(params, dict):
raise ValueError(f"Storage config for {name} must be a table") raise ValueError(f"Storage config for {name} must be a table")
storage[name] = StorageConfig( storage_type = params.get("type", "")
type=cfg.get("type", ""), if storage_type == ResticStorage.TYPE_NAME:
restic_repository=cfg.get("restic_repository", ""), storages.append(ResticStorage(name, params))
restic_password=cfg.get("restic_password", ""), if not storages:
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") raise ValueError("At least one storage backend must be configured")
notifications_raw = raw_config.get("notifications") or {} notifications_raw = raw_config.get("notifications") or {}
@@ -441,19 +451,6 @@ def initialize(config_path: Path) -> BackupManager:
if not notifications: if not notifications:
raise ValueError("At least one notification backend must be configured") 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(): for name, cfg in notifications.items():
if not all( if not all(
[ [
@@ -467,26 +464,9 @@ def initialize(config_path: Path) -> BackupManager:
f"Missing notification configuration values for backend {name}" f"Missing notification configuration values for backend {name}"
) )
config = Config(roots=roots, storage=storage, notifications=notifications) config = Config(roots=roots, notifications=notifications)
storage_backend = _create_storage(config)
return BackupManager(config=config, storage=storage_backend) return BackupManager(config=config, storages=storages)
def _create_storage(config: Config) -> ResticStorage:
# Prefer explicit yandex key if present
if "yandex" in config.storage:
candidate = config.storage["yandex"]
if candidate.type != "restic":
raise ValueError("Storage 'yandex' must be of type 'restic'")
return ResticStorage(candidate)
# Otherwise take the first restic storage
for name, cfg in config.storage.items():
if cfg.type == "restic":
return ResticStorage(cfg)
raise ValueError("No restic storage backend configured")
def main(): def main():