Compare commits
4 Commits
acf599f905
...
6a16ebf084
| Author | SHA1 | Date | |
|---|---|---|---|
|
6a16ebf084
|
|||
|
2617aa2bd2
|
|||
|
b686e4da4d
|
|||
|
439c239ac8
|
@@ -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,7 +4,7 @@ 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
|
||||||
@@ -12,9 +12,10 @@ import logging
|
|||||||
import pwd
|
import pwd
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
import requests
|
import requests
|
||||||
import configparser
|
import tomllib
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -28,17 +29,107 @@ 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 StorageConfig:
|
||||||
AWS_ACCESS_KEY_ID = config.get("restic", "AWS_ACCESS_KEY_ID")
|
type: str
|
||||||
AWS_SECRET_ACCESS_KEY = config.get("restic", "AWS_SECRET_ACCESS_KEY")
|
restic_repository: str
|
||||||
AWS_DEFAULT_REGION = config.get("restic", "AWS_DEFAULT_REGION")
|
restic_password: str
|
||||||
TELEGRAM_BOT_TOKEN = config.get("telegram", "TELEGRAM_BOT_TOKEN")
|
aws_access_key_id: str
|
||||||
TELEGRAM_CHAT_ID = config.get("telegram", "TELEGRAM_CHAT_ID")
|
aws_secret_access_key: str
|
||||||
NOTIFICATIONS_NAME = config.get("telegram", "NOTIFICATIONS_NAME")
|
aws_default_region: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelegramConfig:
|
||||||
|
type: str
|
||||||
|
telegram_bot_token: str
|
||||||
|
telegram_chat_id: str
|
||||||
|
notifications_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
roots: List[Path]
|
||||||
|
storage: Dict[str, StorageConfig]
|
||||||
|
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
|
# File name to store directories and files to back up
|
||||||
BACKUP_TARGETS_FILE = "backup-targets"
|
BACKUP_TARGETS_FILE = "backup-targets"
|
||||||
@@ -59,12 +150,22 @@ class BackupManager:
|
|||||||
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)
|
||||||
|
|
||||||
|
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]:
|
||||||
|
if "telegram" in self.config.notifications:
|
||||||
|
return self.config.notifications["telegram"]
|
||||||
|
return next(iter(self.config.notifications.values()), None)
|
||||||
|
|
||||||
def find_applications(self) -> List[Application]:
|
def find_applications(self) -> List[Application]:
|
||||||
"""Get all application directories and their owners."""
|
"""Get all application directories and their owners."""
|
||||||
applications: List[Application] = []
|
applications: List[Application] = []
|
||||||
applications_path = Path("/mnt/applications")
|
source_dirs = itertools.chain(*(root.iterdir() for root in self.config.roots))
|
||||||
source_dirs = applications_path.iterdir()
|
|
||||||
|
|
||||||
for app_dir in source_dirs:
|
for app_dir in source_dirs:
|
||||||
if "lost+found" in str(app_dir):
|
if "lost+found" in str(app_dir):
|
||||||
@@ -201,19 +302,21 @@ class BackupManager:
|
|||||||
logger.warning("No backup directories found")
|
logger.warning("No backup directories found")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
storage_cfg = self._select_storage()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting restic backup")
|
logger.info("Starting restic backup")
|
||||||
logger.info("Destination: %s", RESTIC_REPOSITORY)
|
logger.info("Destination: %s", storage_cfg.restic_repository)
|
||||||
|
|
||||||
# Set environment variables for restic
|
# Set environment variables for restic
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update(
|
env.update(
|
||||||
{
|
{
|
||||||
"RESTIC_REPOSITORY": RESTIC_REPOSITORY,
|
"RESTIC_REPOSITORY": storage_cfg.restic_repository,
|
||||||
"RESTIC_PASSWORD": RESTIC_PASSWORD,
|
"RESTIC_PASSWORD": storage_cfg.restic_password,
|
||||||
"AWS_ACCESS_KEY_ID": AWS_ACCESS_KEY_ID,
|
"AWS_ACCESS_KEY_ID": storage_cfg.aws_access_key_id,
|
||||||
"AWS_SECRET_ACCESS_KEY": AWS_SECRET_ACCESS_KEY,
|
"AWS_SECRET_ACCESS_KEY": storage_cfg.aws_secret_access_key,
|
||||||
"AWS_DEFAULT_REGION": AWS_DEFAULT_REGION,
|
"AWS_DEFAULT_REGION": storage_cfg.aws_default_region,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,15 +385,22 @@ class BackupManager:
|
|||||||
|
|
||||||
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()
|
||||||
|
if telegram_cfg is None:
|
||||||
|
logger.warning("No telegram notification backend configured")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if success and not self.errors:
|
if success and not self.errors:
|
||||||
message = f"<b>{NOTIFICATIONS_NAME}</b>: бекап успешно завершен!"
|
message = (
|
||||||
|
f"<b>{telegram_cfg.notifications_name}</b>: бекап успешно завершен!"
|
||||||
|
)
|
||||||
if self.successful_backups:
|
if self.successful_backups:
|
||||||
message += (
|
message += (
|
||||||
f"\n\nУспешные бекапы: {', '.join(self.successful_backups)}"
|
f"\n\nУспешные бекапы: {', '.join(self.successful_backups)}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message = f"<b>{NOTIFICATIONS_NAME}</b>: бекап завершен с ошибками!"
|
message = f"<b>{telegram_cfg.notifications_name}</b>: бекап завершен с ошибками!"
|
||||||
|
|
||||||
if self.successful_backups:
|
if self.successful_backups:
|
||||||
message += (
|
message += (
|
||||||
@@ -303,8 +413,12 @@ class BackupManager:
|
|||||||
if self.errors:
|
if self.errors:
|
||||||
message += f"\n\n❌ Ошибки:\n" + "\n".join(self.errors)
|
message += f"\n\n❌ Ошибки:\n" + "\n".join(self.errors)
|
||||||
|
|
||||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
url = f"https://api.telegram.org/bot{telegram_cfg.telegram_bot_token}/sendMessage"
|
||||||
data = {"chat_id": TELEGRAM_CHAT_ID, "parse_mode": "HTML", "text": message}
|
data = {
|
||||||
|
"chat_id": telegram_cfg.telegram_chat_id,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"text": message,
|
||||||
|
}
|
||||||
|
|
||||||
response = requests.post(url, data=data, timeout=30)
|
response = requests.post(url, data=data, timeout=30)
|
||||||
|
|
||||||
|
|||||||
17
files/backups/config.template.toml
Normal file
17
files/backups/config.template.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
roots = [
|
||||||
|
"{{ application_dir }}"
|
||||||
|
]
|
||||||
|
|
||||||
|
[storage.yandex]
|
||||||
|
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 }}"
|
||||||
|
|
||||||
|
[notifications.telegram]
|
||||||
|
type = "telegram"
|
||||||
|
telegram_bot_token = "{{ notifications_tg_bot_token }}"
|
||||||
|
telegram_chat_id = "{{ notifications_tg_chat_id }}"
|
||||||
|
notifications_name = "{{ notifications_name }}"
|
||||||
@@ -18,6 +18,7 @@ pre-commit:
|
|||||||
- name: "format python"
|
- name: "format python"
|
||||||
glob: "**/*.py"
|
glob: "**/*.py"
|
||||||
run: "black --quiet {staged_files}"
|
run: "black --quiet {staged_files}"
|
||||||
|
stage_fixed: true
|
||||||
|
|
||||||
- name: "mypy"
|
- name: "mypy"
|
||||||
glob: "**/*.py"
|
glob: "**/*.py"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user