Compare commits

...

14 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
4fbe9bd5de Backups: skip system dir lost+found
Some checks failed
Linting / YAML Lint (push) Failing after 8s
Linting / Ansible Lint (push) Successful in 15s
2025-12-20 11:22:24 +03:00
dcc4970b20 Add owner and group to backup-targets files 2025-12-20 11:18:37 +03:00
2eac1362b5 Wanderer: backup all data with restic 2025-12-20 11:18:11 +03:00
e3d8479397 Memos: exclude media files from gobackup
Backup media files with backup-targets
2025-12-20 11:06:56 +03:00
91c5eab236 Gramps: exclude media files from gobackup
Backup media files with backup-targets
2025-12-20 11:04:50 +03:00
ca7f089fe6 Backups: use dataclass Application for app info 2025-12-20 10:48:40 +03:00
479e256b1e Backups: use constants for file names 2025-12-20 10:36:19 +03:00
11e5b5752e Backups: add backup-targets file support 2025-12-20 10:32:00 +03:00
10 changed files with 296 additions and 61 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,17 +4,18 @@ 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 dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import List, Tuple, Optional from typing import Dict, List, Optional
import requests import requests
import configparser import tomllib
import itertools from collections.abc import Iterable
# Configure logging # Configure logging
@@ -28,44 +29,156 @@ 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
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
class Application:
path: Path
owner: str
class BackupManager: class BackupManager:
def __init__(self): def __init__(self):
self.errors = [] self.errors: List[str] = []
self.warnings = [] self.warnings: List[str] = []
self.successful_backups = [] self.successful_backups: List[str] = []
self.config = read_config(CONFIG_PATH)
def get_application_directories(self) -> List[Tuple[str, str]]: def _select_storage(self) -> StorageConfig:
"""Get all home directories and their owners""" if "yandex" in self.config.storage:
app_dirs = [] return self.config.storage["yandex"]
applications_path = Path("/mnt/applications") return next(iter(self.config.storage.values()))
source_dirs = applications_path.iterdir()
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]:
"""Get all application directories and their owners."""
applications: List[Application] = []
source_dirs = itertools.chain(*(root.iterdir() for root in self.config.roots))
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,14 +239,60 @@ 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
@@ -143,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,
} }
) )
@@ -224,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 += (
@@ -245,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)
@@ -265,11 +437,13 @@ class BackupManager:
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

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

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

View File

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

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

View File

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

View File

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

View File

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