backups: в нотификацию добавлены размеры приложений и свободное место
- список приложений теперь со значком статуса (забекаплено, упал дамп, бекапить нечего) и занятым местом; размеры считает dust одним вызовом - в конце уведомления — свободное место на дисках сервера, по одной строке на файловую систему
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user