393 lines
14 KiB
Python
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()
|