diff --git a/backend/bot/app/web/admin_api_impl/backups.py b/backend/bot/app/web/admin_api_impl/backups.py index 49e9286..63d4004 100644 --- a/backend/bot/app/web/admin_api_impl/backups.py +++ b/backend/bot/app/web/admin_api_impl/backups.py @@ -12,6 +12,7 @@ from bot.services.backup_restore_service import ( BackupRestoreError, BackupRestoreService, ) +from bot.services.backup_worker import BackupWorker def _backup_archive_payload(archive) -> Dict[str, Any]: @@ -89,6 +90,46 @@ async def admin_backups_upload_route(request: web.Request) -> web.Response: return _ok({"archive": _backup_archive_payload(archive)}) +async def admin_backups_create_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + bot = request.app["bot"] + session_factory = request.app.get("async_session_factory") + worker = BackupWorker(settings, bot, session_factory=session_factory) + + ttl_seconds = max( + 60, + int( + max( + getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200, + getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800, + ) + ), + ) + try: + async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired: + if not acquired: + return _error(409, "backup_create_busy", "Backup or restore is already running") + await worker.refresh_settings() + result = await worker.create_and_send_backup(backup_type="manual") + archive = BackupRestoreService(settings).inspect_archive(result.archive_path) + except BackupArchiveError as exc: + return _error(400, "invalid_backup_archive", str(exc)) + except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc: + logger.exception("Manual backup creation failed") + return _error(500, "backup_create_failed", str(exc)) + except Exception as exc: + logger.exception("Manual backup creation failed") + return _error(500, "backup_create_failed", str(exc)) + + return _ok( + { + "result": result.to_payload(), + "archive": _backup_archive_payload(archive), + } + ) + + async def admin_backups_restore_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index b3f8a31..5b20c35 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -76,6 +76,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route) router.add_get("/api/admin/backups", admin_backups_list_route) + router.add_post("/api/admin/backups/create", admin_backups_create_route) router.add_post("/api/admin/backups/upload", admin_backups_upload_route) router.add_post("/api/admin/backups/restore", admin_backups_restore_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/backend/bot/services/backup_worker.py b/backend/bot/services/backup_worker.py index 4d70c64..90dee5d 100644 --- a/backend/bot/services/backup_worker.py +++ b/backend/bot/services/backup_worker.py @@ -67,6 +67,18 @@ class BackupResult: size_bytes: int warnings: list[str] = field(default_factory=list) + def to_payload(self) -> dict[str, object]: + return { + "archive_name": self.archive_path.name, + "archive_path": str(self.archive_path), + "started_at": self.started_at.isoformat(), + "completed_at": self.completed_at.isoformat(), + "db_dump_included": self.db_dump_included, + "compose_files_count": self.compose_files_count, + "size_bytes": self.size_bytes, + "warnings": self.warnings, + } + class BackupWorker: SETTINGS_REFRESH_SECONDS = 60 @@ -120,20 +132,21 @@ class BackupWorker: logging.exception("Backup worker tick failed") await self._notify_failure(exc) - async def create_and_send_backup(self) -> BackupResult: - result = await self.create_backup() + async def create_and_send_backup(self, *, backup_type: str = "scheduled") -> BackupResult: + result = await self.create_backup(backup_type=backup_type) try: await self.send_backup(result) finally: self.prune_old_backups() return result - async def create_backup(self) -> BackupResult: + async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult: started_at = datetime.now(timezone.utc) stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip" backup_dir = Path(self.settings.BACKUP_DIR).expanduser() backup_dir.mkdir(parents=True, exist_ok=True) + archive_path = self._unique_archive_path(backup_dir / archive_name) with tempfile.TemporaryDirectory( prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-", @@ -158,7 +171,7 @@ class BackupWorker: manifest = { "app": BACKUP_APP_ID, "format_version": BACKUP_FORMAT_VERSION, - "type": "scheduled", + "type": str(backup_type or "scheduled"), "created_at": completed_at.isoformat(), "created_at_local": completed_at.astimezone().isoformat(), "postgres": { @@ -182,8 +195,7 @@ class BackupWorker: ) write_manifest(staging_dir, manifest) - tmp_archive = backup_dir / f"{archive_name}.tmp" - archive_path = backup_dir / archive_name + tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp") write_zip_from_directory(staging_dir, tmp_archive) tmp_archive.replace(archive_path) @@ -197,6 +209,17 @@ class BackupWorker: warnings=warnings, ) + def _unique_archive_path(self, archive_path: Path) -> Path: + if not archive_path.exists(): + return archive_path + for index in range(2, 1000): + candidate = archive_path.with_name( + f"{archive_path.stem}-{index}{archive_path.suffix}" + ) + if not candidate.exists(): + return candidate + raise RuntimeError("Could not allocate a unique backup archive filename") + async def _dump_database(self, dump_path: Path) -> None: await asyncio.to_thread(self._run_pg_dump, dump_path) @@ -353,6 +376,9 @@ class BackupWorker: return f"{int(size)} {unit}" return f"{size:.1f} {unit}" + async def refresh_settings(self) -> None: + await self._refresh_settings() + async def _refresh_settings(self) -> None: if self.session_factory is None: return diff --git a/docs/features/backups.md b/docs/features/backups.md index cccc342..8cc2bd8 100644 --- a/docs/features/backups.md +++ b/docs/features/backups.md @@ -66,11 +66,14 @@ COMPOSE_RESTORE_MODE=ro Откройте **Система -> Бэкапы**. В разделе можно: +- создать новый backup вручную, не дожидаясь следующего запуска по расписанию; - выбрать архив, уже лежащий в `data/backups`; - загрузить ZIP-архив вручную; - отметить, что восстанавливать: `БД`, `compose-папка` или оба варианта; - запустить восстановление после подтверждения. +Ручное создание использует тот же механизм, что и расписание: делает `pg_dump`, добавляет compose snapshot, сохраняет ZIP в `BACKUP_DIR`, отправляет архив в Telegram и применяет локальный retention. На время ручного запуска используется общий Redis lock, поэтому он не пересечется с плановым backup или restore. + БД восстанавливается через `pg_restore --clean --if-exists --no-owner --no-privileges`. На время восстановления лучше не запускать платежи, рассылки, массовую синхронизацию и ручные изменения подписок. Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами: diff --git a/frontend/src/admin/sections/BackupsSection.svelte b/frontend/src/admin/sections/BackupsSection.svelte index f440f43..cc1fff7 100644 --- a/frontend/src/admin/sections/BackupsSection.svelte +++ b/frontend/src/admin/sections/BackupsSection.svelte @@ -10,6 +10,7 @@ import { CheckCircle2, Database, + Plus, RefreshCw, Server, TriangleAlert, @@ -29,6 +30,7 @@ $: ({ archives, backupDir, + backupsCreating, backupsLoading, backupsUploading, backupsRestoring, @@ -39,13 +41,16 @@ selectedName = archives[0].name; } $: selectedArchive = (archives || []).find((item) => item.name === selectedName) || null; - $: if (selectedArchive && restoreDatabase && !selectedArchive.has_database) restoreDatabase = false; + $: if (selectedArchive && restoreDatabase && !selectedArchive.has_database) + restoreDatabase = false; $: if (selectedArchive && restoreCompose && !selectedArchive.has_compose) restoreCompose = false; $: if (selectedArchive && !restoreDatabase && !restoreCompose) { if (selectedArchive.has_database) restoreDatabase = true; else if (selectedArchive.has_compose) restoreCompose = true; } - $: canRestore = Boolean(selectedArchive && (restoreDatabase || restoreCompose) && !backupsRestoring); + $: canRestore = Boolean( + selectedArchive && (restoreDatabase || restoreCompose) && !backupsRestoring && !backupsCreating + ); $: backupHeaders = [ "", at("backups_col_archive", {}, "Архив"), @@ -85,6 +90,11 @@ event.currentTarget.value = ""; } + async function createManualBackup() { + const archive = await backupsStore.createBackup(); + if (archive?.name) selectedName = archive.name; + } + async function restoreSelected() { if (!canRestore) return; const confirmText = at( @@ -114,6 +124,12 @@ {at("btn_refresh", {}, "Обновить")} + + + {backupsCreating + ? at("backups_creating", {}, "Создание...") + : at("backups_create", {}, "Создать бэкап")} + fileInput?.click()} disabled={backupsUploading}> {backupsUploading @@ -222,11 +238,18 @@ aria-label={archive.name} /> - + {archive.name} - {fmtDate(archiveDate(archive))} - {formatSize(archive.size_bytes)} + {fmtDate(archiveDate(archive))} + {formatSize(archive.size_bytes)} {#if archive.has_database} diff --git a/frontend/src/lib/admin/stores/backupsStore.js b/frontend/src/lib/admin/stores/backupsStore.js index f1e9035..43a3cf4 100644 --- a/frontend/src/lib/admin/stores/backupsStore.js +++ b/frontend/src/lib/admin/stores/backupsStore.js @@ -5,8 +5,10 @@ export function createBackupsStore({ api, onToast, at }) { archives: [], backupDir: "", backupsLoading: false, + backupsCreating: false, backupsUploading: false, backupsRestoring: false, + lastCreated: null, lastRestore: null, }); @@ -21,13 +23,38 @@ export function createBackupsStore({ api, onToast, at }) { backupDir: data.backup_dir || "", })); } else { - onToast(data?.message || data?.error || at("backups_load_failed", {}, "Не удалось загрузить бэкапы")); + onToast( + data?.message || + data?.error || + at("backups_load_failed", {}, "Не удалось загрузить бэкапы") + ); } } finally { state.update((s) => ({ ...s, backupsLoading: false })); } } + async function createBackup() { + state.update((s) => ({ ...s, backupsCreating: true, lastCreated: null })); + try { + const data = await api("/admin/backups/create", { + method: "POST", + }); + if (data?.ok) { + state.update((s) => ({ ...s, lastCreated: data.result || null })); + onToast(at("backups_create_done", {}, "Бэкап создан")); + await loadArchives(); + return data.archive || null; + } + onToast( + data?.message || data?.error || at("backups_create_failed", {}, "Не удалось создать бэкап") + ); + return null; + } finally { + state.update((s) => ({ ...s, backupsCreating: false })); + } + } + async function uploadArchive(file) { if (!file) return null; state.update((s) => ({ ...s, backupsUploading: true })); @@ -43,7 +70,11 @@ export function createBackupsStore({ api, onToast, at }) { await loadArchives(); return data.archive || null; } - onToast(data?.message || data?.error || at("backups_upload_failed", {}, "Не удалось загрузить архив")); + onToast( + data?.message || + data?.error || + at("backups_upload_failed", {}, "Не удалось загрузить архив") + ); return null; } finally { state.update((s) => ({ ...s, backupsUploading: false })); @@ -77,7 +108,9 @@ export function createBackupsStore({ api, onToast, at }) { onToast(at("backups_restore_done", {}, "Восстановление завершено")); return true; } - onToast(data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить")); + onToast( + data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить") + ); return false; } finally { state.update((s) => ({ ...s, backupsRestoring: false })); @@ -87,6 +120,7 @@ export function createBackupsStore({ api, onToast, at }) { return { subscribe: state.subscribe, loadArchives, + createBackup, uploadArchive, restoreArchive, }; diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index aba1bdc..05e0737 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -475,6 +475,35 @@ export async function mockApi(path, options = {}, context = {}) { archives: clone(mockBackups), }; } + if (path === "/admin/backups/create") { + const createdAt = new Date(); + const stamp = createdAt + .toISOString() + .replace(/[-:]/g, "") + .replace("T", "-") + .replace(/\.\d{3}Z$/, "+0000"); + const archive = { + ...mockBackups[0], + name: `remnawave-minishop-backup-${stamp}.zip`, + modified_at: createdAt.toISOString(), + created_at: createdAt.toISOString(), + created_at_local: createdAt.toISOString(), + }; + return { + ok: true, + archive, + result: { + archive_name: archive.name, + archive_path: `data/backups/${archive.name}`, + started_at: createdAt.toISOString(), + completed_at: createdAt.toISOString(), + db_dump_included: true, + compose_files_count: archive.compose_files_count, + size_bytes: archive.size_bytes, + warnings: [], + }, + }; + } if (path === "/admin/backups/upload") { return { ok: true, diff --git a/tests/test_backup_worker.py b/tests/test_backup_worker.py index 5f5d56b..4fbd3d0 100644 --- a/tests/test_backup_worker.py +++ b/tests/test_backup_worker.py @@ -119,6 +119,39 @@ def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path): assert send_kwargs["message_thread_id"] == 77 +def test_backup_worker_can_create_manual_backup(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings( + tmp_path, + compose_dir, + BACKUP_POSTGRES_DUMP_ENABLED=True, + BACKUP_COMPOSE_ENABLED=False, + ) + bot = _FakeBot() + worker = _FakePgDumpBackupWorker(settings, bot) + + result = asyncio.run(worker.create_backup(backup_type="manual")) + + assert result.archive_path.is_file() + assert result.to_payload()["archive_name"] == result.archive_path.name + with zipfile.ZipFile(result.archive_path) as archive: + manifest = json.loads(archive.read("manifest.json").decode("utf-8")) + assert manifest["type"] == "manual" + assert "archive" in manifest + + +def test_backup_worker_allocates_unique_archive_path(tmp_path): + settings = _settings(tmp_path, tmp_path / "compose") + worker = _FakePgDumpBackupWorker(settings, _FakeBot()) + archive_path = tmp_path / "remnawave-minishop-backup-20260527-120000+0300.zip" + archive_path.write_text("existing", encoding="utf-8") + + unique_path = worker._unique_archive_path(archive_path) + + assert unique_path.name == "remnawave-minishop-backup-20260527-120000+0300-2.zip" + + def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path): missing_compose_dir = tmp_path / "missing-compose" settings = _settings( diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 48ca360..c8f41ce 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -187,6 +187,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route", ("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route", ("GET", "/api/admin/backups"): "admin_backups_list_route", + ("POST", "/api/admin/backups/create"): "admin_backups_create_route", ("POST", "/api/admin/backups/upload"): "admin_backups_upload_route", ("POST", "/api/admin/backups/restore"): "admin_backups_restore_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",