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/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic.
- Cron-расписание настраивается в `playbook-backups.yml`.
- Уведомление включает список всех найденных приложений со значком статуса (✅ забекаплено,
❌ упал скрипт дампа, ⏭ бекапить нечего) и занятым местом, а в конце — свободное место
на дисках. Размеры считает `dust` (ставится ролью eget); если его нет, прогон продолжается
без размеров.
+208 -32
View File
@@ -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"<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:
"""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"<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:
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
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>"
message += self._render_apps()
if not (success and not self.errors):
if self.warnings:
items = "".join(f"<li>{w}</li>" for w in self.warnings)
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
@@ -653,14 +835,8 @@ class BackupManager:
items = "".join(f"<li>{e}</li>" for e in self.errors)
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
message += 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
)
message += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
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,