Compare commits
21 Commits
392938d0fb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
a31a07bd16
|
|||
|
54a951b96a
|
|||
|
e1379bc480
|
|||
|
037e0cab9b
|
|||
|
2655869814
|
|||
|
0e96b5030d
|
|||
|
a217c79e7d
|
|||
|
6a16ebf084
|
|||
|
2617aa2bd2
|
|||
|
b686e4da4d
|
|||
|
439c239ac8
|
|||
|
acf599f905
|
|||
|
eae4f5e27b
|
|||
|
4fbe9bd5de
|
|||
|
dcc4970b20
|
|||
|
2eac1362b5
|
|||
|
e3d8479397
|
|||
|
91c5eab236
|
|||
|
ca7f089fe6
|
|||
|
479e256b1e
|
|||
|
11e5b5752e
|
@@ -59,6 +59,7 @@ Ansible-based server automation for personal services. Playbooks provision Docke
|
|||||||
- Ansible lint: `ansible-lint .` (CI default).
|
- Ansible lint: `ansible-lint .` (CI default).
|
||||||
- Authelia config validation: `task authelia-validate-config` (renders with secrets then validates via docker).
|
- Authelia config validation: `task authelia-validate-config` (renders with secrets then validates via docker).
|
||||||
- Black formatting for Python helpers: `task format-py-files`.
|
- Black formatting for Python helpers: `task format-py-files`.
|
||||||
|
- Python types validation with mypy: `mypy <file.py>`.
|
||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
- Deployments rely on `production.yml` inventory and per-app playbooks; run with `--diff` for visibility.
|
- Deployments rely on `production.yml` inventory and per-app playbooks; run with `--diff` for visibility.
|
||||||
|
|||||||
@@ -4,18 +4,28 @@ Backup script for all applications
|
|||||||
Automatically discovers and runs backup scripts for all users,
|
Automatically discovers and runs backup scripts for all users,
|
||||||
then creates restic backups and sends notifications.
|
then creates restic backups and sends notifications.
|
||||||
"""
|
"""
|
||||||
|
import itertools
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import logging
|
import logging
|
||||||
import pwd
|
import pwd
|
||||||
|
from abc import ABC
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple, Optional
|
from typing import Dict, List, Optional, Any
|
||||||
import requests
|
import requests
|
||||||
import configparser
|
import tomllib
|
||||||
import itertools
|
|
||||||
|
|
||||||
|
# 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(
|
||||||
@@ -28,44 +38,193 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
config = configparser.ConfigParser()
|
|
||||||
config.read("/etc/backup/config.ini")
|
|
||||||
|
|
||||||
RESTIC_REPOSITORY = config.get("restic", "RESTIC_REPOSITORY")
|
@dataclass
|
||||||
RESTIC_PASSWORD = config.get("restic", "RESTIC_PASSWORD")
|
class Config:
|
||||||
AWS_ACCESS_KEY_ID = config.get("restic", "AWS_ACCESS_KEY_ID")
|
host_name: str
|
||||||
AWS_SECRET_ACCESS_KEY = config.get("restic", "AWS_SECRET_ACCESS_KEY")
|
roots: List[Path]
|
||||||
AWS_DEFAULT_REGION = config.get("restic", "AWS_DEFAULT_REGION")
|
|
||||||
TELEGRAM_BOT_TOKEN = config.get("telegram", "TELEGRAM_BOT_TOKEN")
|
|
||||||
TELEGRAM_CHAT_ID = config.get("telegram", "TELEGRAM_CHAT_ID")
|
@dataclass
|
||||||
NOTIFICATIONS_NAME = config.get("telegram", "NOTIFICATIONS_NAME")
|
class Application:
|
||||||
|
path: Path
|
||||||
|
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:
|
||||||
|
return self.__backup_internal(backup_dirs)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("Restic backup process failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __backup_internal(self, backup_dirs: List[str]) -> bool:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Notifier(ABC):
|
||||||
|
def send(self, html_message: str):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotifier(Notifier):
|
||||||
|
TYPE_NAME = "telegram"
|
||||||
|
|
||||||
|
def __init__(self, name: str, params: Dict[str, Any]):
|
||||||
|
self.name = name
|
||||||
|
self.telegram_bot_token = str(params.get("telegram_bot_token", ""))
|
||||||
|
self.telegram_chat_id = str(params.get("telegram_chat_id", ""))
|
||||||
|
if not all(
|
||||||
|
[
|
||||||
|
self.telegram_bot_token,
|
||||||
|
self.telegram_chat_id,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing notification configuration values for backend {name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def send(self, html_message: str):
|
||||||
|
url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
|
||||||
|
data = {
|
||||||
|
"chat_id": self.telegram_chat_id,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"text": html_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, data=data, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info("Telegram notification sent successfully")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to send Telegram notification: {response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BackupManager:
|
class BackupManager:
|
||||||
def __init__(self):
|
def __init__(
|
||||||
self.errors = []
|
self,
|
||||||
self.warnings = []
|
config: Config,
|
||||||
self.successful_backups = []
|
roots: List[Path],
|
||||||
|
storages: List[Storage],
|
||||||
|
notifiers: List[Notifier],
|
||||||
|
):
|
||||||
|
self.errors: List[str] = []
|
||||||
|
self.warnings: List[str] = []
|
||||||
|
self.successful_backups: List[str] = []
|
||||||
|
self.config = config
|
||||||
|
self.roots: List[Path] = roots
|
||||||
|
self.storages = storages
|
||||||
|
self.notifiers = notifiers
|
||||||
|
|
||||||
def get_application_directories(self) -> List[Tuple[str, str]]:
|
def find_applications(self) -> List[Application]:
|
||||||
"""Get all home directories and their owners"""
|
"""Get all application directories and their owners."""
|
||||||
app_dirs = []
|
applications: List[Application] = []
|
||||||
applications_path = Path("/mnt/applications")
|
source_dirs = itertools.chain(*(root.iterdir() for root in self.roots))
|
||||||
source_dirs = applications_path.iterdir()
|
|
||||||
|
|
||||||
for app_dir in source_dirs:
|
for app_dir in source_dirs:
|
||||||
if app_dir == "lost+found":
|
if "lost+found" in str(app_dir):
|
||||||
continue
|
continue
|
||||||
if app_dir.is_dir():
|
if app_dir.is_dir():
|
||||||
try:
|
try:
|
||||||
# Get the owner of the directory
|
|
||||||
stat_info = app_dir.stat()
|
stat_info = app_dir.stat()
|
||||||
owner = pwd.getpwuid(stat_info.st_uid).pw_name
|
owner = pwd.getpwuid(stat_info.st_uid).pw_name
|
||||||
app_dirs.append((str(app_dir), owner))
|
applications.append(Application(path=app_dir, owner=owner))
|
||||||
except (KeyError, OSError) as e:
|
except (KeyError, OSError) as e:
|
||||||
logger.warning(f"Could not get owner for {app_dir}: {e}")
|
logger.warning(f"Could not get owner for {app_dir}: {e}")
|
||||||
|
|
||||||
return app_dirs
|
return applications
|
||||||
|
|
||||||
def find_backup_script(self, app_dir: str) -> Optional[str]:
|
def find_backup_script(self, app_dir: str) -> Optional[str]:
|
||||||
"""Find backup script in user's home directory"""
|
"""Find backup script in user's home directory"""
|
||||||
@@ -126,150 +285,102 @@ class BackupManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_backup_directories(self) -> List[str]:
|
def get_backup_directories(self) -> List[str]:
|
||||||
"""Get all backup directories that exist"""
|
"""Collect backup targets according to backup-targets rules"""
|
||||||
backup_dirs = []
|
backup_dirs: List[str] = []
|
||||||
app_dirs = self.get_application_directories()
|
applications = self.find_applications()
|
||||||
|
|
||||||
for app_dir, _ in app_dirs:
|
def parse_targets_file(targets_file: Path) -> List[str]:
|
||||||
backup_path = os.path.join(app_dir, "backups")
|
"""Parse backup-targets file, skipping comments and empty lines."""
|
||||||
if os.path.exists(backup_path) and os.path.isdir(backup_path):
|
targets: List[str] = []
|
||||||
backup_dirs.append(backup_path)
|
try:
|
||||||
|
for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
targets.append(line)
|
||||||
|
except OSError as e:
|
||||||
|
warning_msg = f"Could not read backup targets file {targets_file}: {e}"
|
||||||
|
logger.warning(warning_msg)
|
||||||
|
self.warnings.append(warning_msg)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
for app in applications:
|
||||||
|
app_dir = app.path
|
||||||
|
targets_file = app_dir / BACKUP_TARGETS_FILE
|
||||||
|
resolved_targets: List[Path] = []
|
||||||
|
|
||||||
|
if targets_file.exists():
|
||||||
|
# Read custom targets defined by the application.
|
||||||
|
for target_line in parse_targets_file(targets_file):
|
||||||
|
target_path = Path(target_line)
|
||||||
|
if not target_path.is_absolute():
|
||||||
|
target_path = (app_dir / target_path).resolve()
|
||||||
|
else:
|
||||||
|
target_path = target_path.resolve()
|
||||||
|
if target_path.exists():
|
||||||
|
resolved_targets.append(target_path)
|
||||||
|
else:
|
||||||
|
warning_msg = (
|
||||||
|
f"Backup target does not exist for {app_dir}: {target_path}"
|
||||||
|
)
|
||||||
|
logger.warning(warning_msg)
|
||||||
|
self.warnings.append(warning_msg)
|
||||||
|
else:
|
||||||
|
# Fallback to default backups directory when no list is provided.
|
||||||
|
default_target = (app_dir / BACKUP_DEFAULT_DIR).resolve()
|
||||||
|
if default_target.exists():
|
||||||
|
resolved_targets.append(default_target)
|
||||||
|
else:
|
||||||
|
warning_msg = f"Default backup path does not exist for {app_dir}: {default_target}"
|
||||||
|
logger.warning(warning_msg)
|
||||||
|
self.warnings.append(warning_msg)
|
||||||
|
|
||||||
|
for target in resolved_targets:
|
||||||
|
target_str = str(target)
|
||||||
|
if target_str not in backup_dirs:
|
||||||
|
backup_dirs.append(target_str)
|
||||||
|
|
||||||
return backup_dirs
|
return backup_dirs
|
||||||
|
|
||||||
def run_restic_backup(self, backup_dirs: List[str]) -> bool:
|
def send_notification(self, success: bool) -> None:
|
||||||
"""Run restic backup for all backup directories"""
|
"""Send notification to Notifiers"""
|
||||||
if not backup_dirs:
|
|
||||||
logger.warning("No backup directories found")
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
if success and not self.errors:
|
||||||
logger.info("Starting restic backup")
|
message = f"<b>{self.config.host_name}</b>: бекап успешно завершен!"
|
||||||
logger.info("Destination: %s", RESTIC_REPOSITORY)
|
if self.successful_backups:
|
||||||
|
message += f"\n\nУспешные бекапы: {', '.join(self.successful_backups)}"
|
||||||
|
else:
|
||||||
|
message = f"<b>{self.config.host_name}</b>: бекап завершен с ошибками!"
|
||||||
|
|
||||||
# Set environment variables for restic
|
if self.successful_backups:
|
||||||
env = os.environ.copy()
|
message += (
|
||||||
env.update(
|
f"\n\n✅ Успешные бекапы: {', '.join(self.successful_backups)}"
|
||||||
{
|
|
||||||
"RESTIC_REPOSITORY": RESTIC_REPOSITORY,
|
|
||||||
"RESTIC_PASSWORD": RESTIC_PASSWORD,
|
|
||||||
"AWS_ACCESS_KEY_ID": AWS_ACCESS_KEY_ID,
|
|
||||||
"AWS_SECRET_ACCESS_KEY": AWS_SECRET_ACCESS_KEY,
|
|
||||||
"AWS_DEFAULT_REGION": 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:
|
|
||||||
"""Send notification to Telegram"""
|
|
||||||
try:
|
|
||||||
if success and not self.errors:
|
|
||||||
message = f"<b>{NOTIFICATIONS_NAME}</b>: бекап успешно завершен!"
|
|
||||||
if self.successful_backups:
|
|
||||||
message += (
|
|
||||||
f"\n\nУспешные бекапы: {', '.join(self.successful_backups)}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
message = f"<b>{NOTIFICATIONS_NAME}</b>: бекап завершен с ошибками!"
|
|
||||||
|
|
||||||
if self.successful_backups:
|
|
||||||
message += (
|
|
||||||
f"\n\n✅ Успешные бекапы: {', '.join(self.successful_backups)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.warnings:
|
|
||||||
message += f"\n\n⚠️ Предупреждения:\n" + "\n".join(self.warnings)
|
|
||||||
|
|
||||||
if self.errors:
|
|
||||||
message += f"\n\n❌ Ошибки:\n" + "\n".join(self.errors)
|
|
||||||
|
|
||||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
|
||||||
data = {"chat_id": TELEGRAM_CHAT_ID, "parse_mode": "HTML", "text": message}
|
|
||||||
|
|
||||||
response = requests.post(url, data=data, timeout=30)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
logger.info("Telegram notification sent successfully")
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to send Telegram notification: {response.status_code} - {response.text}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
if self.warnings:
|
||||||
logger.error(f"Failed to send Telegram notification: {str(e)}")
|
message += f"\n\n⚠️ Предупреждения:\n" + "\n".join(self.warnings)
|
||||||
|
|
||||||
|
if self.errors:
|
||||||
|
message += f"\n\n❌ Ошибки:\n" + "\n".join(self.errors)
|
||||||
|
|
||||||
|
for notificator in self.notifiers:
|
||||||
|
try:
|
||||||
|
notificator.send(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification: {str(e)}")
|
||||||
|
|
||||||
def run_backup_process(self) -> bool:
|
def run_backup_process(self) -> bool:
|
||||||
"""Main backup process"""
|
"""Main backup process"""
|
||||||
logger.info("Starting backup process")
|
logger.info("Starting backup process")
|
||||||
|
|
||||||
# Get all home directories
|
# Get all home directories
|
||||||
app_dirs = self.get_application_directories()
|
applications = self.find_applications()
|
||||||
logger.info(f"Found {len(app_dirs)} application directories")
|
logger.info(f"Found {len(applications)} application directories")
|
||||||
|
|
||||||
# Process each user's backup
|
# Process each user's backup
|
||||||
for app_dir, username in app_dirs:
|
for app in applications:
|
||||||
|
app_dir = str(app.path)
|
||||||
|
username = app.owner
|
||||||
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
||||||
|
|
||||||
# Find backup script
|
# Find backup script
|
||||||
@@ -289,14 +400,18 @@ 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_notification(overall_success)
|
||||||
|
|
||||||
logger.info("Backup process completed")
|
logger.info("Backup process completed")
|
||||||
|
|
||||||
@@ -311,9 +426,53 @@ 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
|
||||||
|
|
||||||
|
host_name = str(raw_config.get("host_name", "unknown"))
|
||||||
|
|
||||||
|
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("notifier") or {}
|
||||||
|
notifiers: List[Notifier] = []
|
||||||
|
for name, params in notifications_raw.items():
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
raise ValueError(f"Notificator config for {name} must be a table")
|
||||||
|
notifier_type = params.get("type", "")
|
||||||
|
if notifier_type == TelegramNotifier.TYPE_NAME:
|
||||||
|
notifiers.append(TelegramNotifier(name, params))
|
||||||
|
if not notifiers:
|
||||||
|
raise ValueError("At least one notification backend must be configured")
|
||||||
|
|
||||||
|
config = Config(host_name=host_name, roots=roots)
|
||||||
|
|
||||||
|
return BackupManager(
|
||||||
|
config=config, roots=roots, storages=storages, notifiers=notifiers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
[restic]
|
|
||||||
RESTIC_REPOSITORY={{ restic_repository }}
|
|
||||||
RESTIC_PASSWORD={{ restic_password }}
|
|
||||||
AWS_ACCESS_KEY_ID={{ restic_s3_access_key }}
|
|
||||||
AWS_SECRET_ACCESS_KEY={{ restic_s3_access_secret }}
|
|
||||||
AWS_DEFAULT_REGION={{ restic_s3_region }}
|
|
||||||
|
|
||||||
[telegram]
|
|
||||||
TELEGRAM_BOT_TOKEN={{ notifications_tg_bot_token }}
|
|
||||||
TELEGRAM_CHAT_ID={{ notifications_tg_chat_id }}
|
|
||||||
NOTIFICATIONS_NAME={{ notifications_name }}
|
|
||||||
18
files/backups/config.template.toml
Normal file
18
files/backups/config.template.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
host_name = "{{ notifications_name }}"
|
||||||
|
|
||||||
|
roots = [
|
||||||
|
"{{ application_dir }}"
|
||||||
|
]
|
||||||
|
|
||||||
|
[storage.yandex_cloud_s3]
|
||||||
|
type = "restic"
|
||||||
|
restic_repository = "{{ restic_repository }}"
|
||||||
|
restic_password = "{{ restic_password }}"
|
||||||
|
aws_access_key_id = "{{ restic_s3_access_key }}"
|
||||||
|
aws_secret_access_key = "{{ restic_s3_access_secret }}"
|
||||||
|
aws_default_region = "{{ restic_s3_region }}"
|
||||||
|
|
||||||
|
[notifier.server_notifications_channel]
|
||||||
|
type = "telegram"
|
||||||
|
telegram_bot_token = "{{ notifications_tg_bot_token }}"
|
||||||
|
telegram_chat_id = "{{ notifications_tg_chat_id }}"
|
||||||
@@ -23,7 +23,3 @@ models:
|
|||||||
undo:
|
undo:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
path: "{{ (data_dir, 'gramps_db/59a0f3d6-1c3d-4410-8c1d-1c9c6689659f/undo.db') | path_join }}"
|
path: "{{ (data_dir, 'gramps_db/59a0f3d6-1c3d-4410-8c1d-1c9c6689659f/undo.db') | path_join }}"
|
||||||
archive:
|
|
||||||
includes:
|
|
||||||
- "{{ data_dir }}"
|
|
||||||
- "{{ media_dir }}"
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
models:
|
models:
|
||||||
|
|
||||||
gramps:
|
memos:
|
||||||
compress_with:
|
compress_with:
|
||||||
type: 'tgz'
|
type: 'tgz'
|
||||||
storages:
|
storages:
|
||||||
@@ -14,8 +14,3 @@ models:
|
|||||||
users:
|
users:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
path: "{{ (data_dir, 'memos_prod.db') | path_join }}"
|
path: "{{ (data_dir, 'memos_prod.db') | path_join }}"
|
||||||
archive:
|
|
||||||
includes:
|
|
||||||
- "{{ data_dir }}"
|
|
||||||
excludes:
|
|
||||||
- "{{ (data_dir, '.thumbnail_cache') | path_join }}"
|
|
||||||
|
|||||||
11
lefthook.yml
11
lefthook.yml
@@ -1,6 +1,8 @@
|
|||||||
# Refer for explanation to following link:
|
# Refer for explanation to following link:
|
||||||
# https://lefthook.dev/configuration/
|
# https://lefthook.dev/configuration/
|
||||||
|
|
||||||
|
glob_matcher: doublestar
|
||||||
|
|
||||||
templates:
|
templates:
|
||||||
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
||||||
|
|
||||||
@@ -12,3 +14,12 @@ pre-commit:
|
|||||||
|
|
||||||
- name: "check secret files"
|
- name: "check secret files"
|
||||||
run: "python3 {av-hooks-dir}/pre-commit/check-secrets-encrypted-with-ansible-vault.py"
|
run: "python3 {av-hooks-dir}/pre-commit/check-secrets-encrypted-with-ansible-vault.py"
|
||||||
|
|
||||||
|
- name: "format python"
|
||||||
|
glob: "**/*.py"
|
||||||
|
run: "black --quiet {staged_files}"
|
||||||
|
stage_fixed: true
|
||||||
|
|
||||||
|
- name: "mypy"
|
||||||
|
glob: "**/*.py"
|
||||||
|
run: "mypy {staged_files}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
backup_config_dir: "/etc/backup"
|
backup_config_dir: "/etc/backup"
|
||||||
backup_config_file: "{{ (backup_config_dir, 'config.ini') | path_join }}"
|
backup_config_file: "{{ (backup_config_dir, 'config.toml') | path_join }}"
|
||||||
|
|
||||||
restic_shell_script: "{{ (bin_prefix, 'restic-shell.sh') | path_join }}"
|
restic_shell_script: "{{ (bin_prefix, 'restic-shell.sh') | path_join }}"
|
||||||
backup_all_script: "{{ (bin_prefix, 'backup-all.py') | path_join }}"
|
backup_all_script: "{{ (bin_prefix, 'backup-all.py') | path_join }}"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
- name: "Create backup config file"
|
- name: "Create backup config file"
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: "files/backups/config.template.ini"
|
src: "files/backups/config.template.toml"
|
||||||
dest: "{{ backup_config_file }}"
|
dest: "{{ backup_config_file }}"
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
|
|||||||
@@ -57,6 +57,19 @@
|
|||||||
group: "{{ app_user }}"
|
group: "{{ app_user }}"
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: "Create backup targets file"
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ base_dir }}/backup-targets"
|
||||||
|
line: "{{ item }}"
|
||||||
|
create: true
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_user }}"
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ data_dir }}"
|
||||||
|
- "{{ media_dir }}"
|
||||||
|
- "{{ backups_dir }}"
|
||||||
|
|
||||||
- name: "Copy rename script"
|
- name: "Copy rename script"
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
src: "files/{{ app_name }}/gramps_rename.py"
|
src: "files/{{ app_name }}/gramps_rename.py"
|
||||||
|
|||||||
@@ -53,6 +53,18 @@
|
|||||||
group: "{{ app_user }}"
|
group: "{{ app_user }}"
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: "Create backup targets file"
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ base_dir }}/backup-targets"
|
||||||
|
line: "{{ item }}"
|
||||||
|
create: true
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_user }}"
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ data_dir }}"
|
||||||
|
- "{{ backups_dir }}"
|
||||||
|
|
||||||
- name: "Copy docker compose file"
|
- name: "Copy docker compose file"
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||||
|
|||||||
@@ -51,13 +51,29 @@
|
|||||||
group: "{{ app_user }}"
|
group: "{{ app_user }}"
|
||||||
mode: "0640"
|
mode: "0640"
|
||||||
|
|
||||||
- name: "Copy backup script"
|
# - name: "Copy backup script"
|
||||||
ansible.builtin.template:
|
# ansible.builtin.template:
|
||||||
src: "files/{{ app_name }}/backup.template.sh"
|
# src: "files/{{ app_name }}/backup.template.sh"
|
||||||
|
# dest: "{{ base_dir }}/backup.sh"
|
||||||
|
# owner: "{{ app_user }}"
|
||||||
|
# group: "{{ app_user }}"
|
||||||
|
# mode: "0750"
|
||||||
|
|
||||||
|
- name: "Disable backup script"
|
||||||
|
ansible.builtin.file:
|
||||||
dest: "{{ base_dir }}/backup.sh"
|
dest: "{{ base_dir }}/backup.sh"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: "Create backup targets file"
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ base_dir }}/backup-targets"
|
||||||
|
line: "{{ item }}"
|
||||||
|
create: true
|
||||||
owner: "{{ app_user }}"
|
owner: "{{ app_user }}"
|
||||||
group: "{{ app_user }}"
|
group: "{{ app_user }}"
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ data_dir }}"
|
||||||
|
|
||||||
- name: "Copy docker compose file"
|
- name: "Copy docker compose file"
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|||||||
Reference in New Issue
Block a user