backups: в нотификацию добавлены размеры приложений и свободное место
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s

- список приложений теперь со значком статуса (забекаплено, упал дамп,
  бекапить нечего) и занятым местом; размеры считает dust одним вызовом
- в конце уведомления — свободное место на дисках сервера, по одной строке
  на файловую систему
This commit is contained in:
av
2026-08-22 11:50:07 +03:00
parent b5b43a484e
commit daa4379dbe
2 changed files with 212 additions and 32 deletions
+4
View File
@@ -152,3 +152,7 @@ ansible-playbook -i production.yml --diff playbook-gitea.yml
- Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.). - Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.).
- `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic. - `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic.
- Cron-расписание настраивается в `playbook-backups.yml`. - Cron-расписание настраивается в `playbook-backups.yml`.
- Уведомление включает список всех найденных приложений со значком статуса (✅ забекаплено,
❌ упал скрипт дампа, ⏭ бекапить нечего) и занятым местом, а в конце — свободное место
на дисках. Размеры считает `dust` (ставится ролью eget); если его нет, прогон продолжается
без размеров.
+208 -32
View File
@@ -11,13 +11,18 @@ restic-операции разнесены на фазы с разной час
- verify -- check --read-data-subset, помесячно (полное покрытие за год). - verify -- check --read-data-subset, помесячно (полное покрытие за год).
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче. не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
Размеры приложений считает dust (ставится ролью eget в bin_prefix); если его нет
или он упал, прогон продолжается, а размеры в уведомлении просто не показываются.
""" """
import argparse import argparse
import itertools import itertools
import json
import logging import logging
import os import os
import pwd import pwd
import shutil
import subprocess import subprocess
import sys import sys
import time import time
@@ -25,6 +30,7 @@ import tomllib
from abc import ABC from abc import ABC
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -41,6 +47,10 @@ BACKUP_TARGETS_FILE = "backup-targets"
# Used when backup-targets file not exists # Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups" 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. # Retention policy applied by the `forget` phase on every run.
KEEP_DAILY = "90" KEEP_DAILY = "90"
KEEP_MONTHLY = "36" KEEP_MONTHLY = "36"
@@ -72,6 +82,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class Config: class Config:
host_name: str host_name: str
roots: list[Path]
@dataclass @dataclass
@@ -118,6 +129,47 @@ class Application:
backup_targets: list[Path] 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 @dataclass
class BackupResult: class BackupResult:
success: bool success: bool
@@ -132,6 +184,79 @@ class StorageRunResult:
phases: list[str] 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: def format_duration(seconds: float) -> str:
if seconds < 60: if seconds < 60:
return f"{seconds:.1f}s" return f"{seconds:.1f}s"
@@ -437,7 +562,8 @@ class BackupManager:
) -> None: ) -> None:
self.errors: list[str] = [] self.errors: list[str] = []
self.warnings: 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.config = config
self.storages = storages self.storages = storages
self.notifiers = notifiers self.notifiers = notifiers
@@ -457,6 +583,7 @@ class BackupManager:
self._run_archive_phase(applications) self._run_archive_phase(applications)
backup_dirs = self._collect_backup_dirs(applications) backup_dirs = self._collect_backup_dirs(applications)
overall_success = self._run_storages(backup_dirs) overall_success = self._run_storages(backup_dirs)
self._collect_usage(applications)
self._send_notification(overall_success) self._send_notification(overall_success)
@@ -490,20 +617,24 @@ class BackupManager:
if PHASE_BACKUP in self.active_phases: if PHASE_BACKUP in self.active_phases:
for app in applications: 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: else:
logger.info("Backup phase not active, skipping per-app archive scripts") 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 self.archive_duration = time.monotonic() - archive_start
logger.info( logger.info(
"Archive phase finished in %s", format_duration(self.archive_duration) "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) app_dir = str(app.path)
username = app.owner username = app.owner
app_name = app.path.name
if app.backup_script is None: if app.backup_script is None:
if app.backup_targets: if app.backup_targets:
@@ -515,23 +646,44 @@ class BackupManager:
app_dir, app_dir,
username, username,
) )
self.backed_up_apps.append(app_name) return AppStatus.DONE
else:
warning_msg = ( warning_msg = (
f"Nothing to back up for app: {app_dir} (user {username}): " f"Nothing to back up for app: {app_dir} (user {username}): "
f"no backup script and no backup targets" f"no backup script and no backup targets"
) )
logger.warning(warning_msg) logger.warning(warning_msg)
self.warnings.append(warning_msg) self.warnings.append(warning_msg)
return return AppStatus.SKIPPED
logger.info("Processing backup for app: %s (user %s)", app_dir, username) 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): if not self._run_app_backup(str(app.backup_script), app_dir, username):
return return AppStatus.FAILED
# Дамп сделан, но в restic он попадёт только если есть цели бекапа; # Дамп сделан, но в restic он попадёт только если есть цели бекапа;
# об их отсутствии уже предупредил ApplicationFinder. # об их отсутствии уже предупредил ApplicationFinder.
if app.backup_targets: return AppStatus.DONE if app.backup_targets else AppStatus.SKIPPED
self.backed_up_apps.append(app_name)
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 @staticmethod
def _collect_backup_dirs(applications: list[Application]) -> list[str]: def _collect_backup_dirs(applications: list[Application]) -> list[str]:
@@ -625,26 +777,56 @@ class BackupManager:
self.errors.append(f"App {username}: {error_msg}") self.errors.append(f"App {username}: {error_msg}")
return False 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"<li>{APP_STATUS_ICONS[result.status]} {result.name}{size}</li>"
return f"<p>Приложения:</p><ul>{items}</ul>"
def _render_run_stats(self) -> str:
"""Фазы restic и затраченное время."""
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
block = f"<p>🔧 Фазы restic: {phases_text}</p>"
block += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
if self.storage_results:
items = "".join(
f"<li>{'' if r.success else ''} {r.name}: {format_duration(r.duration)}</li>"
for r in self.storage_results
)
block += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
return block
def _render_disks(self) -> str:
"""Свободное место на дисках сервера."""
if not self.disk_usages:
return ""
items = "".join(
f"<li>{u.path}: свободно {format_size(u.free)} из {format_size(u.total)}"
f" (занято {u.used_percent:.0f}%)</li>"
for u in self.disk_usages
)
return f"<p>💾 Свободное место:</p><ul>{items}</ul>"
def _send_notification(self, success: bool) -> None: def _send_notification(self, success: bool) -> None:
"""Send notification to Notifiers""" """Send notification to Notifiers"""
host = self.config.host_name host = self.config.host_name
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
if success and not self.errors: if success and not self.errors:
title = f"{host}: бекап успешно завершен" title = f"{host}: бекап успешно завершен"
message = f"<p><b>{host}</b>: бекап успешно завершен!</p>" message = f"<p><b>{host}</b>: бекап успешно завершен!</p>"
if self.backed_up_apps:
items = "".join(f"<li>{b}</li>" for b in self.backed_up_apps)
message += f"<p>Успешные бекапы:</p><ul>{items}</ul>"
else: else:
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})" title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>" message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>"
if self.backed_up_apps: message += self._render_apps()
items = "".join(f"<li>{b}</li>" for b in self.backed_up_apps)
message += f"<p>✅ Успешные бекапы:</p><ul>{items}</ul>"
if not (success and not self.errors):
if self.warnings: if self.warnings:
items = "".join(f"<li>{w}</li>" for w in self.warnings) items = "".join(f"<li>{w}</li>" for w in self.warnings)
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>" message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
@@ -653,14 +835,8 @@ class BackupManager:
items = "".join(f"<li>{e}</li>" for e in self.errors) items = "".join(f"<li>{e}</li>" for e in self.errors)
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>" message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
message += f"<p>🔧 Фазы restic: {phases_text}</p>" message += self._render_run_stats()
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>" message += self._render_disks()
if self.storage_results:
items = "".join(
f"<li>{'' if r.success else ''} {r.name}: {format_duration(r.duration)}</li>"
for r in self.storage_results
)
message += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
for notificator in self.notifiers: for notificator in self.notifiers:
try: try:
@@ -763,7 +939,7 @@ def initialize(
schedule = build_schedule(raw_config) schedule = build_schedule(raw_config)
maintenance = build_maintenance(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) app_finder = ApplicationFinder(roots)
backup_manager = BackupManager( backup_manager = BackupManager(
config=config, config=config,