Files
Solo_bot/utils/backup.py
T
2026-04-16 13:14:11 +00:00

393 lines
14 KiB
Python

import os
import shutil
import subprocess
import tarfile
from datetime import datetime, timedelta
from pathlib import Path
import aiofiles
from aiogram import Bot
from aiogram.types import BufferedInputFile
from config import (
ADMIN_ID,
BACKUP_CAPTION,
BACKUP_CREATE_ARCHIVE,
BACKUP_DESTINATION,
BACKUP_INCLUDE_CONFIG,
BACKUP_INCLUDE_DB,
BACKUP_INCLUDE_IMG,
BACKUP_INCLUDE_TEXTS,
BACKUP_OTHER_BOT_TOKEN,
BACKUP_S3_ACCESS_KEY,
BACKUP_S3_BUCKET,
BACKUP_S3_ENDPOINT,
BACKUP_S3_KEEP,
BACKUP_S3_PATH,
BACKUP_S3_REGION,
BACKUP_S3_SECRET_KEY,
BACK_DIR,
DB_NAME,
DB_PASSWORD,
DB_USER,
PG_HOST,
PG_IN_DOCKER,
PG_PORT,
)
from logger import logger
DOCKER_POSTGRES_CONTAINER = "solobot-postgres"
def _s3_configured() -> bool:
return bool(BACKUP_S3_ENDPOINT and BACKUP_S3_ACCESS_KEY and BACKUP_S3_SECRET_KEY and BACKUP_S3_BUCKET)
def _parse_destination() -> tuple[str | None, int | None]:
"""Парсит BACKUP_DESTINATION в (chat_id, thread_id)."""
raw = BACKUP_DESTINATION.strip()
if not raw:
return None, None
parts = raw.split(":", 1)
chat_id = parts[0]
thread_id = int(parts[1]) if len(parts) > 1 and parts[1].strip() else None
return chat_id, thread_id
def _find_docker_postgres_container() -> str | None:
if shutil.which("docker") is None:
return None
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", DOCKER_POSTGRES_CONTAINER],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip().lower() == "true":
return DOCKER_POSTGRES_CONTAINER
return None
def _get_postgres_execution_target() -> tuple[str, str | None]:
if PG_IN_DOCKER:
container = _find_docker_postgres_container()
if container:
return "docker", container
raise FileNotFoundError(
f"PostgreSQL настроен на Docker, но контейнер '{DOCKER_POSTGRES_CONTAINER}' не найден или не запущен"
)
return "host", None
def _create_database_backup_via_docker(filename: Path, container: str) -> None:
with open(filename, "wb") as dump_file:
result = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={DB_PASSWORD}",
container,
"pg_dump",
"-U",
DB_USER,
"-h",
"127.0.0.1",
"-p",
"5432",
"-F",
"c",
DB_NAME,
],
stdout=dump_file,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr)
async def backup_database(bot_instance: Bot | None = None) -> Exception | None:
"""
Создает резервную копию и отправляет в S3 или Telegram.
Блокирующие операции выполняются в пуле потоков/процессов.
"""
from core.executor import run_io
if BACKUP_CREATE_ARCHIVE:
if not any([BACKUP_INCLUDE_DB, BACKUP_INCLUDE_CONFIG, BACKUP_INCLUDE_TEXTS, BACKUP_INCLUDE_IMG]):
backup_file_path, exception = await run_io(_create_database_backup)
else:
backup_file_path, exception = await run_io(_create_backup_archive)
else:
backup_file_path, exception = await run_io(_create_database_backup)
if exception:
logger.error("[Backup] Ошибка при создании: {}", exception)
return exception
logger.info("[Backup] Файл создан: {}", backup_file_path)
try:
if _s3_configured():
s3_err = await run_io(_upload_to_s3, backup_file_path)
if s3_err:
logger.error("[Backup] Ошибка S3: {}", s3_err)
return s3_err
else:
await _send_backup_telegram(backup_file_path, bot_instance=bot_instance)
exception = await run_io(_cleanup_old_backups)
if exception:
logger.error("[Backup] Ошибка при очистке старых: {}", exception)
return exception
return None
except Exception as e:
logger.error("[Backup] Ошибка при отправке: {}", e)
return e
def _create_database_backup() -> tuple[str | None, Exception | None]:
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
pid_suffix = os.getpid()
backup_dir = Path(BACK_DIR)
backup_dir.mkdir(parents=True, exist_ok=True)
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}-{pid_suffix}.sql"
try:
target, container = _get_postgres_execution_target()
if target == "docker" and container:
_create_database_backup_via_docker(filename, container)
logger.info("[Backup] БД создана через Docker-контейнер {}: {}", container, filename)
elif shutil.which("pg_dump") is not None:
env = os.environ.copy()
env["PGPASSWORD"] = DB_PASSWORD
subprocess.run(
[
"pg_dump",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-F",
"c",
"-f",
str(filename),
DB_NAME,
],
check=True,
capture_output=True,
text=True,
env=env,
)
logger.info("[Backup] БД создана через host pg_dump: {}", filename)
else:
raise FileNotFoundError("PostgreSQL недоступен: не найден контейнер и отсутствует host pg_dump")
return str(filename), None
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else e.stderr
logger.error("[Backup] pg_dump: {}", stderr)
return None, e
except Exception as e:
logger.error("[Backup] Непредвиденная ошибка: {}", e)
return None, e
def _create_backup_archive() -> tuple[str | None, Exception | None]:
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
pid_suffix = os.getpid()
backup_dir = Path(BACK_DIR)
backup_dir.mkdir(parents=True, exist_ok=True)
archive_path = backup_dir / f"{DB_NAME}-full-backup-{date_formatted}-{pid_suffix}.tar.gz"
project_root = Path(__file__).parent.parent
archive_folder = f"backup-{date_formatted}"
db_backup_path = None
try:
with tarfile.open(archive_path, "w:gz") as tar:
if BACKUP_INCLUDE_DB:
db_backup_path, db_exception = _create_database_backup()
if db_exception:
logger.warning("[Backup] БД для архива не создана: {}", db_exception)
elif db_backup_path and os.path.exists(db_backup_path):
tar.add(db_backup_path, arcname=f"{archive_folder}/database.sql")
logger.info("[Backup] БД добавлена в архив")
if BACKUP_INCLUDE_CONFIG:
config_path = project_root / "config.py"
if config_path.exists():
tar.add(config_path, arcname=f"{archive_folder}/config.py")
logger.info("[Backup] config.py в архив")
else:
logger.warning("[Backup] config.py не найден")
if BACKUP_INCLUDE_TEXTS:
texts_path = project_root / "handlers" / "texts.py"
if texts_path.exists():
tar.add(texts_path, arcname=f"{archive_folder}/texts.py")
logger.info("[Backup] texts.py в архив")
else:
logger.warning("[Backup] handlers/texts.py не найден")
if BACKUP_INCLUDE_IMG:
img_dir = project_root / "img"
if img_dir.exists() and img_dir.is_dir():
img_files = [f for f in img_dir.iterdir() if f.is_file()]
for img_file in img_files:
tar.add(img_file, arcname=f"{archive_folder}/img/{img_file.name}")
logger.info("[Backup] img/ в архив ({} файлов)", len(img_files))
else:
logger.warning("[Backup] img/ не найдена")
logger.info("[Backup] Архив создан: {}", archive_path)
if db_backup_path and os.path.exists(db_backup_path) and db_backup_path != str(archive_path):
try:
os.unlink(db_backup_path)
logger.info("[Backup] Временный файл БД удалён: {}", db_backup_path)
except Exception as e:
logger.warning("[Backup] Не удалось удалить временный файл БД: {}", e)
return str(archive_path), None
except Exception as e:
logger.error("[Backup] Ошибка создания архива: {}", e)
return None, e
def _cleanup_old_backups() -> Exception | None:
try:
backup_dir = Path(BACK_DIR)
if not backup_dir.exists():
return None
cutoff_date = datetime.now() - timedelta(days=3)
for backup_file in backup_dir.glob("*.sql"):
if backup_file.is_file():
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if file_mtime < cutoff_date:
backup_file.unlink()
logger.info("[Backup] Удалён старый: {}", backup_file)
for archive_file in backup_dir.glob("*.tar.gz"):
if archive_file.is_file():
file_mtime = datetime.fromtimestamp(archive_file.stat().st_mtime)
if file_mtime < cutoff_date:
archive_file.unlink()
logger.info("[Backup] Удалён старый архив: {}", archive_file)
logger.info("[Backup] Очистка старых завершена")
return None
except Exception as e:
logger.error("[Backup] Ошибка при очистке: {}", e)
return e
def _create_s3_client():
import boto3
return boto3.client(
"s3",
endpoint_url=BACKUP_S3_ENDPOINT,
aws_access_key_id=BACKUP_S3_ACCESS_KEY,
aws_secret_access_key=BACKUP_S3_SECRET_KEY,
region_name=BACKUP_S3_REGION or "us-east-1",
)
def _upload_to_s3(backup_file_path: str) -> Exception | None:
"""Загружает бекап в S3 и чистит старые (синхронно, вызывается через run_io)."""
try:
s3 = _create_s3_client()
prefix = BACKUP_S3_PATH.strip("/")
object_key = f"{prefix}/{os.path.basename(backup_file_path)}"
s3.upload_file(backup_file_path, BACKUP_S3_BUCKET, object_key)
logger.info("[Backup S3] Загружен: {}", object_key)
_cleanup_s3_backups(s3, prefix)
return None
except Exception as e:
logger.error("[Backup S3] Ошибка: {}", e)
return e
def _cleanup_s3_backups(s3, prefix: str) -> None:
"""Удаляет старые бекапы в S3, оставляя BACKUP_S3_KEEP последних."""
if BACKUP_S3_KEEP <= 0:
return
all_objects = []
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=BACKUP_S3_BUCKET, Prefix=f"{prefix}/"):
all_objects.extend(page.get("Contents", []))
all_objects.sort(key=lambda x: x["LastModified"])
if len(all_objects) <= BACKUP_S3_KEEP:
return
to_delete = all_objects[: -BACKUP_S3_KEEP]
for i in range(0, len(to_delete), 1000):
batch = [{"Key": obj["Key"]} for obj in to_delete[i : i + 1000]]
s3.delete_objects(Bucket=BACKUP_S3_BUCKET, Delete={"Objects": batch, "Quiet": True})
logger.info("[Backup S3] Удалено старых бекапов: {}", len(to_delete))
async def create_backup_and_send_to_admins(client) -> None:
await client.login()
await client.database.export()
async def _send_backup_telegram(backup_file_path: str, bot_instance: Bot | None = None) -> None:
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
active_bot = bot_instance
own_session = False
if BACKUP_OTHER_BOT_TOKEN:
active_bot = Bot(token=BACKUP_OTHER_BOT_TOKEN)
own_session = True
elif active_bot is None:
from bot import bot as active_bot
try:
async with aiofiles.open(backup_file_path, "rb") as f:
backup_data = await f.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
chat_id, thread_id = _parse_destination()
if chat_id:
send_kwargs: dict = {"chat_id": chat_id, "document": backup_input_file}
if thread_id:
send_kwargs["message_thread_id"] = thread_id
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
await active_bot.send_document(**send_kwargs)
logger.info("[Backup] Отправлено в {}{}", chat_id, f" (тред {thread_id})" if thread_id else "")
else:
for admin_id in ADMIN_ID:
try:
send_kwargs = {"chat_id": admin_id, "document": backup_input_file}
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
await active_bot.send_document(**send_kwargs)
logger.info("[Backup] Отправлено админу: {}", admin_id)
except Exception as e:
logger.error("[Backup] Не отправлено админу {}: {}", admin_id, e)
finally:
if own_session:
await active_bot.session.close()