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/<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); если его нет, прогон продолжается
|
||||||
|
без размеров.
|
||||||
|
|||||||
+202
-26
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user