chore: backup warning details
This commit is contained in:
@@ -699,12 +699,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
"title": f"+{count}",
|
"title": f"+{count}",
|
||||||
"subtitle": tariff.name(lang),
|
"subtitle": tariff.name(lang),
|
||||||
"valid_from": _billing_iso_datetime(
|
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
|
||||||
(rub_quote or stars_quote).get("valid_from")
|
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
|
||||||
),
|
|
||||||
"valid_until": _billing_iso_datetime(
|
|
||||||
(rub_quote or stars_quote).get("valid_until")
|
|
||||||
),
|
|
||||||
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
|
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
|
||||||
}
|
}
|
||||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||||
|
|||||||
@@ -182,13 +182,17 @@ def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
|
|||||||
"""Resolve purchased units from a payment record for webhook activation."""
|
"""Resolve purchased units from a payment record for webhook activation."""
|
||||||
base = sale_mode_base(sale_mode)
|
base = sale_mode_base(sale_mode)
|
||||||
if sale_mode_is_traffic(base):
|
if sale_mode_is_traffic(base):
|
||||||
return getattr(payment, "purchased_gb", None) or getattr(
|
return (
|
||||||
payment, "subscription_duration_months", None
|
getattr(payment, "purchased_gb", None)
|
||||||
) or 1
|
or getattr(payment, "subscription_duration_months", None)
|
||||||
|
or 1
|
||||||
|
)
|
||||||
if sale_mode_is_hwid_devices(base):
|
if sale_mode_is_hwid_devices(base):
|
||||||
return getattr(payment, "purchased_hwid_devices", None) or getattr(
|
return (
|
||||||
payment, "subscription_duration_months", None
|
getattr(payment, "purchased_hwid_devices", None)
|
||||||
) or 1
|
or getattr(payment, "subscription_duration_months", None)
|
||||||
|
or 1
|
||||||
|
)
|
||||||
return getattr(payment, "subscription_duration_months", None) or 1
|
return getattr(payment, "subscription_duration_months", None) or 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ BACKUP_RUNTIME_SETTING_KEYS = {
|
|||||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||||
"BACKUP_COMPOSE_EXCLUDE_DIRS",
|
"BACKUP_COMPOSE_EXCLUDE_DIRS",
|
||||||
}
|
}
|
||||||
|
TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024
|
||||||
|
TELEGRAM_WARNING_DETAIL_LIMIT = 6
|
||||||
|
TELEGRAM_WARNING_LINE_LIMIT = 220
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -263,16 +266,26 @@ class BackupWorker:
|
|||||||
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
|
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
|
||||||
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
|
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
|
||||||
if not source_raw:
|
if not source_raw:
|
||||||
warnings.append("Compose source directory is not configured.")
|
warnings.append(
|
||||||
|
"Compose source directory is not configured. Set "
|
||||||
|
"BACKUP_COMPOSE_SOURCE_DIR or mount the compose folder into the backup container."
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
source_dir = Path(source_raw).expanduser()
|
source_dir = Path(source_raw).expanduser()
|
||||||
if not source_dir.exists() or not source_dir.is_dir():
|
if not source_dir.exists() or not source_dir.is_dir():
|
||||||
warnings.append(f"Compose source directory is unavailable: {source_dir}")
|
warnings.append(
|
||||||
|
"If manual backup includes compose but scheduled backup does not, recreate "
|
||||||
|
"the worker service with the compose-source mount. Compose source directory "
|
||||||
|
f"is unavailable in this container: {source_dir}"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
|
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
|
||||||
warnings.append(f"Compose source directory has no compose file marker: {source_dir}")
|
warnings.append(
|
||||||
|
"Check that COMPOSE_BACKUP_SOURCE points to the folder with docker-compose.yml. "
|
||||||
|
f"Compose source directory has no compose file marker: {source_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
excluded_dirs = self._compose_excluded_dirs()
|
excluded_dirs = self._compose_excluded_dirs()
|
||||||
files_count = 0
|
files_count = 0
|
||||||
@@ -358,8 +371,31 @@ class BackupWorker:
|
|||||||
f"Archive size: {self._human_size(result.size_bytes)}",
|
f"Archive size: {self._human_size(result.size_bytes)}",
|
||||||
]
|
]
|
||||||
if result.warnings:
|
if result.warnings:
|
||||||
lines.append(f"Warnings: {len(result.warnings)}")
|
lines.append(f"Warnings ({len(result.warnings)}):")
|
||||||
return "\n".join(lines)
|
for index, warning in enumerate(
|
||||||
|
result.warnings[:TELEGRAM_WARNING_DETAIL_LIMIT],
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
lines.append(f"{index}. {self._caption_warning(warning)}")
|
||||||
|
hidden_count = len(result.warnings) - TELEGRAM_WARNING_DETAIL_LIMIT
|
||||||
|
if hidden_count > 0:
|
||||||
|
lines.append(f"... and {hidden_count} more warning(s)")
|
||||||
|
return self._fit_caption(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _caption_warning(warning: str) -> str:
|
||||||
|
text = " ".join(str(warning or "").split())
|
||||||
|
if len(text) <= TELEGRAM_WARNING_LINE_LIMIT:
|
||||||
|
return text
|
||||||
|
return f"{text[: TELEGRAM_WARNING_LINE_LIMIT - 1].rstrip()}..."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fit_caption(lines: list[str]) -> str:
|
||||||
|
caption = "\n".join(lines)
|
||||||
|
if len(caption) <= TELEGRAM_DOCUMENT_CAPTION_LIMIT:
|
||||||
|
return caption
|
||||||
|
suffix = "\n... caption truncated"
|
||||||
|
return f"{caption[: TELEGRAM_DOCUMENT_CAPTION_LIMIT - len(suffix)].rstrip()}{suffix}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _human_size(size_bytes: int) -> str:
|
def _human_size(size_bytes: int) -> str:
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ BACKUP_THREAD_ID=123
|
|||||||
|
|
||||||
`COMPOSE_BACKUP_SOURCE=.` означает папку рядом с текущим `docker-compose.yml`. Если compose лежит в другом месте, укажите абсолютный host-путь.
|
`COMPOSE_BACKUP_SOURCE=.` означает папку рядом с текущим `docker-compose.yml`. Если compose лежит в другом месте, укажите абсолютный host-путь.
|
||||||
|
|
||||||
|
Ручное создание бэкапа из админки выполняется в `backend`-контейнере, а автоматический backup по расписанию - в `worker`-контейнере. Оба контейнера должны видеть `/app/compose-source`. Если ручной backup содержит compose-папку, а автоматический нет, пересоздайте worker после обновления compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate worker
|
||||||
|
docker compose exec worker ls -la /app/compose-source
|
||||||
|
```
|
||||||
|
|
||||||
Если нужно запретить восстановление compose-файлов из контейнера, задайте:
|
Если нужно запретить восстановление compose-файлов из контейнера, задайте:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -80,6 +81,7 @@ def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_pat
|
|||||||
send_kwargs = bot.send_document.await_args.kwargs
|
send_kwargs = bot.send_document.await_args.kwargs
|
||||||
assert send_kwargs["chat_id"] == 123
|
assert send_kwargs["chat_id"] == 123
|
||||||
assert "Database dump: yes" in send_kwargs["caption"]
|
assert "Database dump: yes" in send_kwargs["caption"]
|
||||||
|
assert "Warnings" not in send_kwargs["caption"]
|
||||||
|
|
||||||
with zipfile.ZipFile(result.archive_path) as archive:
|
with zipfile.ZipFile(result.archive_path) as archive:
|
||||||
names = set(archive.namelist())
|
names = set(archive.namelist())
|
||||||
@@ -168,12 +170,50 @@ def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path
|
|||||||
assert result.archive_path.is_file()
|
assert result.archive_path.is_file()
|
||||||
assert result.db_dump_included is True
|
assert result.db_dump_included is True
|
||||||
assert result.compose_files_count == 0
|
assert result.compose_files_count == 0
|
||||||
assert any("Compose source directory is unavailable" in item for item in result.warnings)
|
assert any(
|
||||||
|
"Compose source directory is unavailable in this container" in item
|
||||||
|
for item in result.warnings
|
||||||
|
)
|
||||||
|
bot.send_document.assert_awaited_once()
|
||||||
|
caption = bot.send_document.await_args.kwargs["caption"]
|
||||||
|
assert "Warnings (1):" in caption
|
||||||
|
assert "1. If manual backup includes compose but scheduled backup does not" in caption
|
||||||
|
assert "Compose source directory is unavailable in this container" in caption
|
||||||
|
assert "recreate the worker service" in caption
|
||||||
with zipfile.ZipFile(result.archive_path) as archive:
|
with zipfile.ZipFile(result.archive_path) as archive:
|
||||||
names = set(archive.namelist())
|
names = set(archive.namelist())
|
||||||
assert "database/shop.dump" in names
|
assert "database/shop.dump" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_worker_caption_lists_and_truncates_warning_details(tmp_path):
|
||||||
|
settings = _settings(tmp_path, tmp_path / "compose")
|
||||||
|
worker = _FakePgDumpBackupWorker(settings, _FakeBot())
|
||||||
|
result = SimpleNamespace(
|
||||||
|
completed_at=datetime.now(timezone.utc),
|
||||||
|
db_dump_included=True,
|
||||||
|
compose_files_count=0,
|
||||||
|
size_bytes=1024,
|
||||||
|
warnings=[
|
||||||
|
"first warning",
|
||||||
|
"second warning",
|
||||||
|
"third warning",
|
||||||
|
"fourth warning",
|
||||||
|
"fifth warning",
|
||||||
|
"sixth warning",
|
||||||
|
"seventh warning",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
caption = worker._caption(result)
|
||||||
|
|
||||||
|
assert "Warnings (7):" in caption
|
||||||
|
assert "1. first warning" in caption
|
||||||
|
assert "6. sixth warning" in caption
|
||||||
|
assert "seventh warning" not in caption
|
||||||
|
assert "... and 1 more warning(s)" in caption
|
||||||
|
assert len(caption) <= 1024
|
||||||
|
|
||||||
|
|
||||||
def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch):
|
def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch):
|
||||||
from bot.services import settings_override_service
|
from bot.services import settings_override_service
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user