diff --git a/AGENTS.md b/AGENTS.md index 12b77fb..ea691f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,3 +152,7 @@ ansible-playbook -i production.yml --diff playbook-gitea.yml - Шаблоны скриптов бэкапов в `files//` (backup.template.sh, gobackup.template.yml и др.). - `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic. - Cron-расписание настраивается в `playbook-backups.yml`. +- Уведомление включает список всех найденных приложений со значком статуса (✅ забекаплено, + ❌ упал скрипт дампа, ⏭ бекапить нечего) и занятым местом, а в конце — свободное место + на дисках. Размеры считает `dust` (ставится ролью eget); если его нет, прогон продолжается + без размеров. diff --git a/files/backups/backup-all.py b/files/backups/backup-all.py index 958186c..beca7e3 100644 --- a/files/backups/backup-all.py +++ b/files/backups/backup-all.py @@ -11,13 +11,18 @@ restic-операции разнесены на фазы с разной час - verify -- check --read-data-subset, помесячно (полное покрытие за год). Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче. + +Размеры приложений считает dust (ставится ролью eget в bin_prefix); если его нет +или он упал, прогон продолжается, а размеры в уведомлении просто не показываются. """ import argparse import itertools +import json import logging import os import pwd +import shutil import subprocess import sys import time @@ -25,6 +30,7 @@ import tomllib from abc import ABC from dataclasses import dataclass, field from datetime import datetime, timedelta +from enum import Enum from pathlib import Path from typing import Any @@ -41,6 +47,10 @@ BACKUP_TARGETS_FILE = "backup-targets" # Used when backup-targets file not exists BACKUP_DEFAULT_DIR = "backups" +# Утилита подсчёта размеров директорий (github.com/bootandy/dust). +# Ставится ролью eget в bin_prefix, который есть в PATH cron-задачи. +DUST_BIN = "dust" + # Retention policy applied by the `forget` phase on every run. KEEP_DAILY = "90" KEEP_MONTHLY = "36" @@ -72,6 +82,7 @@ logger = logging.getLogger(__name__) @dataclass class Config: host_name: str + roots: list[Path] @dataclass @@ -118,6 +129,47 @@ class Application: backup_targets: list[Path] +class AppStatus(Enum): + """Что случилось с приложением в этот прогон.""" + + DONE = "done" + FAILED = "failed" + SKIPPED = "skipped" + + +APP_STATUS_ICONS = { + AppStatus.DONE: "✅", + AppStatus.FAILED: "❌", + AppStatus.SKIPPED: "⏭", +} + + +@dataclass +class AppRunResult: + """Строка приложения в уведомлении: статус бекапа и занятое место.""" + + name: str + status: AppStatus + size: int | None = None + + +@dataclass +class DiskUsage: + """Занятое и свободное место на файловой системе.""" + + path: Path + total: int + free: int + + @property + def used(self) -> int: + return self.total - self.free + + @property + def used_percent(self) -> float: + return 100.0 * self.used / self.total if self.total else 0.0 + + @dataclass class BackupResult: success: bool @@ -132,6 +184,79 @@ class StorageRunResult: phases: list[str] +def format_size(size: int) -> str: + """Байты в человекочитаемый вид: 4.1 GiB, 512 MiB, 12 KiB.""" + value = float(size) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if value < 1024 or unit == "TiB": + precision = 0 if unit == "B" or value >= 100 else 1 + return f"{value:.{precision}f} {unit}" + value /= 1024 + return f"{value:.1f} TiB" + + +def measure_app_sizes(paths: list[Path]) -> dict[str, int]: + """Размеры директорий приложений одним вызовом dust: путь -> байты. + + dust с `-o b` печатает размеры строками вида "1052672B", а при нескольких + аргументах заворачивает их в корень "(total)" — разбираем оба случая. + """ + if not paths: + return {} + + cmd = [DUST_BIN, "--output-json", "--output-format", "b", "--depth", "0"] + cmd += ["--no-progress", *(str(path) for path in paths)] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.warning("Failed to run %s: %s", DUST_BIN, exc) + return {} + + if result.returncode != 0: + logger.warning( + "%s exited with code %s: %s", DUST_BIN, result.returncode, result.stderr + ) + return {} + + try: + tree = json.loads(result.stdout) + except json.JSONDecodeError as exc: + logger.warning("Could not parse %s output: %s", DUST_BIN, exc) + return {} + + sizes: dict[str, int] = {} + for node in [tree, *tree.get("children", [])]: + raw_size = str(node.get("size", "")).rstrip("B") + if not raw_size.isdigit(): + continue + sizes[str(node.get("name", ""))] = int(raw_size) + return sizes + + +def collect_disk_usage(paths: list[Path]) -> list[DiskUsage]: + """Занятое/свободное место по файловым системам, на которых лежат paths. + + Пути с одной и той же файловой системы схлопываются: смысла показывать + /mnt/applications дважды нет. + """ + usages: list[DiskUsage] = [] + seen_devices: set[int] = set() + + for path in paths: + try: + device = path.stat().st_dev + if device in seen_devices: + continue + total, _used, free = shutil.disk_usage(path) + except OSError as exc: + logger.warning("Could not read disk usage for %s: %s", path, exc) + continue + seen_devices.add(device) + usages.append(DiskUsage(path=path, total=total, free=free)) + + return usages + + def format_duration(seconds: float) -> str: if seconds < 60: return f"{seconds:.1f}s" @@ -437,7 +562,8 @@ class BackupManager: ) -> None: self.errors: list[str] = [] self.warnings: list[str] = [] - self.backed_up_apps: list[str] = [] + self.app_results: list[AppRunResult] = [] + self.disk_usages: list[DiskUsage] = [] self.config = config self.storages = storages self.notifiers = notifiers @@ -457,6 +583,7 @@ class BackupManager: self._run_archive_phase(applications) backup_dirs = self._collect_backup_dirs(applications) overall_success = self._run_storages(backup_dirs) + self._collect_usage(applications) self._send_notification(overall_success) @@ -490,20 +617,24 @@ class BackupManager: if PHASE_BACKUP in self.active_phases: for app in applications: - self._archive_app(app) + status = self._archive_app(app) + self.app_results.append(AppRunResult(name=app.path.name, status=status)) else: logger.info("Backup phase not active, skipping per-app archive scripts") + self.app_results = [ + AppRunResult(name=app.path.name, status=AppStatus.SKIPPED) + for app in applications + ] self.archive_duration = time.monotonic() - archive_start logger.info( "Archive phase finished in %s", format_duration(self.archive_duration) ) - def _archive_app(self, app: Application) -> None: + def _archive_app(self, app: Application) -> AppStatus: """Обработать одно приложение: сделать дамп, если он предусмотрен.""" app_dir = str(app.path) username = app.owner - app_name = app.path.name if app.backup_script is None: if app.backup_targets: @@ -515,23 +646,44 @@ class BackupManager: app_dir, username, ) - self.backed_up_apps.append(app_name) - else: - warning_msg = ( - f"Nothing to back up for app: {app_dir} (user {username}): " - f"no backup script and no backup targets" - ) - logger.warning(warning_msg) - self.warnings.append(warning_msg) - return + return AppStatus.DONE + + warning_msg = ( + f"Nothing to back up for app: {app_dir} (user {username}): " + f"no backup script and no backup targets" + ) + logger.warning(warning_msg) + self.warnings.append(warning_msg) + return AppStatus.SKIPPED logger.info("Processing backup for app: %s (user %s)", app_dir, username) if not self._run_app_backup(str(app.backup_script), app_dir, username): - return + return AppStatus.FAILED # Дамп сделан, но в restic он попадёт только если есть цели бекапа; # об их отсутствии уже предупредил ApplicationFinder. - if app.backup_targets: - self.backed_up_apps.append(app_name) + return AppStatus.DONE if app.backup_targets else AppStatus.SKIPPED + + def _collect_usage(self, applications: list[Application]) -> None: + """Померить размеры приложений и свободное место на их файловых системах. + + Считаем после архивации, чтобы свежие дампы попали в размер, и после + restic: цифры информационные, задерживать из-за них бекап незачем. + """ + usage_start = time.monotonic() + + sizes = measure_app_sizes([app.path for app in applications]) + by_name = {app.path.name: str(app.path) for app in applications} + for result in self.app_results: + result.size = sizes.get(by_name.get(result.name, "")) + + # Корень системы плюс диски, на которых лежат приложения: на сервере это + # разные диски, и место кончается на них независимо. + self.disk_usages = collect_disk_usage([Path("/"), *self.config.roots]) + + logger.info( + "Usage stats collected in %s", + format_duration(time.monotonic() - usage_start), + ) @staticmethod def _collect_backup_dirs(applications: list[Application]) -> list[str]: @@ -625,26 +777,56 @@ class BackupManager: self.errors.append(f"App {username}: {error_msg}") return False + def _render_apps(self) -> str: + """Список приложений: значок статуса, имя и занятое место.""" + if not self.app_results: + return "" + items = "" + for result in self.app_results: + # Размер отсутствует, только если dust не отработал: тогда просто имя. + size = f" — {format_size(result.size)}" if result.size is not None else "" + items += f"
  • {APP_STATUS_ICONS[result.status]} {result.name}{size}
  • " + return f"

    Приложения:

    " + + def _render_run_stats(self) -> str: + """Фазы restic и затраченное время.""" + phases_text = ", ".join(self.active_phases) if self.active_phases else "—" + block = f"

    🔧 Фазы restic: {phases_text}

    " + block += f"

    ⏱ Время архивации: {format_duration(self.archive_duration)}

    " + if self.storage_results: + items = "".join( + f"
  • {'✅' if r.success else '❌'} {r.name}: {format_duration(r.duration)}
  • " + for r in self.storage_results + ) + block += f"

    ⏱ Время записи в хранилища:

    " + return block + + def _render_disks(self) -> str: + """Свободное место на дисках сервера.""" + if not self.disk_usages: + return "" + items = "".join( + f"
  • {u.path}: свободно {format_size(u.free)} из {format_size(u.total)}" + f" (занято {u.used_percent:.0f}%)
  • " + for u in self.disk_usages + ) + return f"

    💾 Свободное место:

    " + def _send_notification(self, success: bool) -> None: """Send notification to Notifiers""" host = self.config.host_name - phases_text = ", ".join(self.active_phases) if self.active_phases else "—" if success and not self.errors: title = f"{host}: бекап успешно завершен" message = f"

    {host}: бекап успешно завершен!

    " - if self.backed_up_apps: - items = "".join(f"
  • {b}
  • " for b in self.backed_up_apps) - message += f"

    Успешные бекапы:

    " else: title = f"{host}: бекап завершен с ошибками ({len(self.errors)})" message = f"

    {host}: бекап завершен с ошибками!

    " - if self.backed_up_apps: - items = "".join(f"
  • {b}
  • " for b in self.backed_up_apps) - message += f"

    ✅ Успешные бекапы:

    " + message += self._render_apps() + if not (success and not self.errors): if self.warnings: items = "".join(f"
  • {w}
  • " for w in self.warnings) message += f"

    ⚠️ Предупреждения:

    " @@ -653,14 +835,8 @@ class BackupManager: items = "".join(f"
  • {e}
  • " for e in self.errors) message += f"

    ❌ Ошибки:

    " - message += f"

    🔧 Фазы restic: {phases_text}

    " - message += f"

    ⏱ Время архивации: {format_duration(self.archive_duration)}

    " - if self.storage_results: - items = "".join( - f"
  • {'✅' if r.success else '❌'} {r.name}: {format_duration(r.duration)}
  • " - for r in self.storage_results - ) - message += f"

    ⏱ Время записи в хранилища:

    " + message += self._render_run_stats() + message += self._render_disks() for notificator in self.notifiers: try: @@ -763,7 +939,7 @@ def initialize( schedule = build_schedule(raw_config) maintenance = build_maintenance(raw_config) - config = Config(host_name=host_name) + config = Config(host_name=host_name, roots=roots) app_finder = ApplicationFinder(roots) backup_manager = BackupManager( config=config,