Compare commits

...

6 Commits

Author SHA1 Message Date
6a16ebf084 Backup: parse config to dataclasses
Some checks failed
Linting / YAML Lint (push) Failing after 9s
Linting / Ansible Lint (push) Successful in 16s
2025-12-20 17:44:02 +03:00
2617aa2bd2 Backup: support multiple roots 2025-12-20 17:27:29 +03:00
b686e4da4d Backup: change config format to toml
With support of multiple config values
2025-12-20 17:13:35 +03:00
439c239ac8 Lefthook: fix python format hook 2025-12-20 11:55:13 +03:00
acf599f905 Lefthook: check py files with mypy
Some checks failed
Linting / YAML Lint (push) Failing after 8s
Linting / Ansible Lint (push) Successful in 18s
2025-12-20 11:38:14 +03:00
eae4f5e27b Lefthook: format py files on commit 2025-12-20 11:35:54 +03:00
5 changed files with 170 additions and 27 deletions

View File

@@ -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.

View File

@@ -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)

View 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 }}"

View File

@@ -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}"

View File

@@ -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