feat: manual backup button
This commit is contained in:
@@ -12,6 +12,7 @@ from bot.services.backup_restore_service import (
|
|||||||
BackupRestoreError,
|
BackupRestoreError,
|
||||||
BackupRestoreService,
|
BackupRestoreService,
|
||||||
)
|
)
|
||||||
|
from bot.services.backup_worker import BackupWorker
|
||||||
|
|
||||||
|
|
||||||
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
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)})
|
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:
|
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
|||||||
@@ -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/logo", admin_appearance_logo_upload_route)
|
||||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_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_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/upload", admin_backups_upload_route)
|
||||||
router.add_post("/api/admin/backups/restore", admin_backups_restore_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)
|
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ class BackupResult:
|
|||||||
size_bytes: int
|
size_bytes: int
|
||||||
warnings: list[str] = field(default_factory=list)
|
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:
|
class BackupWorker:
|
||||||
SETTINGS_REFRESH_SECONDS = 60
|
SETTINGS_REFRESH_SECONDS = 60
|
||||||
@@ -120,20 +132,21 @@ class BackupWorker:
|
|||||||
logging.exception("Backup worker tick failed")
|
logging.exception("Backup worker tick failed")
|
||||||
await self._notify_failure(exc)
|
await self._notify_failure(exc)
|
||||||
|
|
||||||
async def create_and_send_backup(self) -> BackupResult:
|
async def create_and_send_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||||
result = await self.create_backup()
|
result = await self.create_backup(backup_type=backup_type)
|
||||||
try:
|
try:
|
||||||
await self.send_backup(result)
|
await self.send_backup(result)
|
||||||
finally:
|
finally:
|
||||||
self.prune_old_backups()
|
self.prune_old_backups()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def create_backup(self) -> BackupResult:
|
async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||||
started_at = datetime.now(timezone.utc)
|
started_at = datetime.now(timezone.utc)
|
||||||
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z")
|
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z")
|
||||||
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
|
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
|
||||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive_path = self._unique_archive_path(backup_dir / archive_name)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(
|
with tempfile.TemporaryDirectory(
|
||||||
prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-",
|
prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-",
|
||||||
@@ -158,7 +171,7 @@ class BackupWorker:
|
|||||||
manifest = {
|
manifest = {
|
||||||
"app": BACKUP_APP_ID,
|
"app": BACKUP_APP_ID,
|
||||||
"format_version": BACKUP_FORMAT_VERSION,
|
"format_version": BACKUP_FORMAT_VERSION,
|
||||||
"type": "scheduled",
|
"type": str(backup_type or "scheduled"),
|
||||||
"created_at": completed_at.isoformat(),
|
"created_at": completed_at.isoformat(),
|
||||||
"created_at_local": completed_at.astimezone().isoformat(),
|
"created_at_local": completed_at.astimezone().isoformat(),
|
||||||
"postgres": {
|
"postgres": {
|
||||||
@@ -182,8 +195,7 @@ class BackupWorker:
|
|||||||
)
|
)
|
||||||
write_manifest(staging_dir, manifest)
|
write_manifest(staging_dir, manifest)
|
||||||
|
|
||||||
tmp_archive = backup_dir / f"{archive_name}.tmp"
|
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||||
archive_path = backup_dir / archive_name
|
|
||||||
write_zip_from_directory(staging_dir, tmp_archive)
|
write_zip_from_directory(staging_dir, tmp_archive)
|
||||||
tmp_archive.replace(archive_path)
|
tmp_archive.replace(archive_path)
|
||||||
|
|
||||||
@@ -197,6 +209,17 @@ class BackupWorker:
|
|||||||
warnings=warnings,
|
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:
|
async def _dump_database(self, dump_path: Path) -> None:
|
||||||
await asyncio.to_thread(self._run_pg_dump, dump_path)
|
await asyncio.to_thread(self._run_pg_dump, dump_path)
|
||||||
|
|
||||||
@@ -353,6 +376,9 @@ class BackupWorker:
|
|||||||
return f"{int(size)} {unit}"
|
return f"{int(size)} {unit}"
|
||||||
return f"{size:.1f} {unit}"
|
return f"{size:.1f} {unit}"
|
||||||
|
|
||||||
|
async def refresh_settings(self) -> None:
|
||||||
|
await self._refresh_settings()
|
||||||
|
|
||||||
async def _refresh_settings(self) -> None:
|
async def _refresh_settings(self) -> None:
|
||||||
if self.session_factory is None:
|
if self.session_factory is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -66,11 +66,14 @@ COMPOSE_RESTORE_MODE=ro
|
|||||||
|
|
||||||
Откройте **Система -> Бэкапы**. В разделе можно:
|
Откройте **Система -> Бэкапы**. В разделе можно:
|
||||||
|
|
||||||
|
- создать новый backup вручную, не дожидаясь следующего запуска по расписанию;
|
||||||
- выбрать архив, уже лежащий в `data/backups`;
|
- выбрать архив, уже лежащий в `data/backups`;
|
||||||
- загрузить ZIP-архив вручную;
|
- загрузить ZIP-архив вручную;
|
||||||
- отметить, что восстанавливать: `БД`, `compose-папка` или оба варианта;
|
- отметить, что восстанавливать: `БД`, `compose-папка` или оба варианта;
|
||||||
- запустить восстановление после подтверждения.
|
- запустить восстановление после подтверждения.
|
||||||
|
|
||||||
|
Ручное создание использует тот же механизм, что и расписание: делает `pg_dump`, добавляет compose snapshot, сохраняет ZIP в `BACKUP_DIR`, отправляет архив в Telegram и применяет локальный retention. На время ручного запуска используется общий Redis lock, поэтому он не пересечется с плановым backup или restore.
|
||||||
|
|
||||||
БД восстанавливается через `pg_restore --clean --if-exists --no-owner --no-privileges`. На время восстановления лучше не запускать платежи, рассылки, массовую синхронизацию и ручные изменения подписок.
|
БД восстанавливается через `pg_restore --clean --if-exists --no-owner --no-privileges`. На время восстановления лучше не запускать платежи, рассылки, массовую синхронизацию и ручные изменения подписок.
|
||||||
|
|
||||||
Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами:
|
Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
Database,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
$: ({
|
$: ({
|
||||||
archives,
|
archives,
|
||||||
backupDir,
|
backupDir,
|
||||||
|
backupsCreating,
|
||||||
backupsLoading,
|
backupsLoading,
|
||||||
backupsUploading,
|
backupsUploading,
|
||||||
backupsRestoring,
|
backupsRestoring,
|
||||||
@@ -39,13 +41,16 @@
|
|||||||
selectedName = archives[0].name;
|
selectedName = archives[0].name;
|
||||||
}
|
}
|
||||||
$: selectedArchive = (archives || []).find((item) => item.name === selectedName) || null;
|
$: 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 && restoreCompose && !selectedArchive.has_compose) restoreCompose = false;
|
||||||
$: if (selectedArchive && !restoreDatabase && !restoreCompose) {
|
$: if (selectedArchive && !restoreDatabase && !restoreCompose) {
|
||||||
if (selectedArchive.has_database) restoreDatabase = true;
|
if (selectedArchive.has_database) restoreDatabase = true;
|
||||||
else if (selectedArchive.has_compose) restoreCompose = true;
|
else if (selectedArchive.has_compose) restoreCompose = true;
|
||||||
}
|
}
|
||||||
$: canRestore = Boolean(selectedArchive && (restoreDatabase || restoreCompose) && !backupsRestoring);
|
$: canRestore = Boolean(
|
||||||
|
selectedArchive && (restoreDatabase || restoreCompose) && !backupsRestoring && !backupsCreating
|
||||||
|
);
|
||||||
$: backupHeaders = [
|
$: backupHeaders = [
|
||||||
"",
|
"",
|
||||||
at("backups_col_archive", {}, "Архив"),
|
at("backups_col_archive", {}, "Архив"),
|
||||||
@@ -85,6 +90,11 @@
|
|||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createManualBackup() {
|
||||||
|
const archive = await backupsStore.createBackup();
|
||||||
|
if (archive?.name) selectedName = archive.name;
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreSelected() {
|
async function restoreSelected() {
|
||||||
if (!canRestore) return;
|
if (!canRestore) return;
|
||||||
const confirmText = at(
|
const confirmText = at(
|
||||||
@@ -114,6 +124,12 @@
|
|||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
{at("btn_refresh", {}, "Обновить")}
|
{at("btn_refresh", {}, "Обновить")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
|
<AdminButton onclick={createManualBackup} disabled={backupsCreating || backupsRestoring}>
|
||||||
|
<Plus size={14} />
|
||||||
|
{backupsCreating
|
||||||
|
? at("backups_creating", {}, "Создание...")
|
||||||
|
: at("backups_create", {}, "Создать бэкап")}
|
||||||
|
</AdminButton>
|
||||||
<AdminButton onclick={() => fileInput?.click()} disabled={backupsUploading}>
|
<AdminButton onclick={() => fileInput?.click()} disabled={backupsUploading}>
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{backupsUploading
|
{backupsUploading
|
||||||
@@ -222,11 +238,18 @@
|
|||||||
aria-label={archive.name}
|
aria-label={archive.name}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-cell-wrap backups-name" data-label={at("backups_col_archive", {}, "Архив")}>
|
<td
|
||||||
|
class="admin-cell-wrap backups-name"
|
||||||
|
data-label={at("backups_col_archive", {}, "Архив")}
|
||||||
|
>
|
||||||
{archive.name}
|
{archive.name}
|
||||||
</td>
|
</td>
|
||||||
<td data-label={at("backups_col_created", {}, "Создан")}>{fmtDate(archiveDate(archive))}</td>
|
<td data-label={at("backups_col_created", {}, "Создан")}
|
||||||
<td data-label={at("backups_col_size", {}, "Размер")}>{formatSize(archive.size_bytes)}</td>
|
>{fmtDate(archiveDate(archive))}</td
|
||||||
|
>
|
||||||
|
<td data-label={at("backups_col_size", {}, "Размер")}
|
||||||
|
>{formatSize(archive.size_bytes)}</td
|
||||||
|
>
|
||||||
<td data-label={at("backups_col_contents", {}, "Состав")}>
|
<td data-label={at("backups_col_contents", {}, "Состав")}>
|
||||||
<span class="backups-badges">
|
<span class="backups-badges">
|
||||||
{#if archive.has_database}
|
{#if archive.has_database}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ export function createBackupsStore({ api, onToast, at }) {
|
|||||||
archives: [],
|
archives: [],
|
||||||
backupDir: "",
|
backupDir: "",
|
||||||
backupsLoading: false,
|
backupsLoading: false,
|
||||||
|
backupsCreating: false,
|
||||||
backupsUploading: false,
|
backupsUploading: false,
|
||||||
backupsRestoring: false,
|
backupsRestoring: false,
|
||||||
|
lastCreated: null,
|
||||||
lastRestore: null,
|
lastRestore: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,13 +23,38 @@ export function createBackupsStore({ api, onToast, at }) {
|
|||||||
backupDir: data.backup_dir || "",
|
backupDir: data.backup_dir || "",
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
onToast(data?.message || data?.error || at("backups_load_failed", {}, "Не удалось загрузить бэкапы"));
|
onToast(
|
||||||
|
data?.message ||
|
||||||
|
data?.error ||
|
||||||
|
at("backups_load_failed", {}, "Не удалось загрузить бэкапы")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
state.update((s) => ({ ...s, backupsLoading: false }));
|
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) {
|
async function uploadArchive(file) {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
state.update((s) => ({ ...s, backupsUploading: true }));
|
state.update((s) => ({ ...s, backupsUploading: true }));
|
||||||
@@ -43,7 +70,11 @@ export function createBackupsStore({ api, onToast, at }) {
|
|||||||
await loadArchives();
|
await loadArchives();
|
||||||
return data.archive || null;
|
return data.archive || null;
|
||||||
}
|
}
|
||||||
onToast(data?.message || data?.error || at("backups_upload_failed", {}, "Не удалось загрузить архив"));
|
onToast(
|
||||||
|
data?.message ||
|
||||||
|
data?.error ||
|
||||||
|
at("backups_upload_failed", {}, "Не удалось загрузить архив")
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
state.update((s) => ({ ...s, backupsUploading: false }));
|
state.update((s) => ({ ...s, backupsUploading: false }));
|
||||||
@@ -77,7 +108,9 @@ export function createBackupsStore({ api, onToast, at }) {
|
|||||||
onToast(at("backups_restore_done", {}, "Восстановление завершено"));
|
onToast(at("backups_restore_done", {}, "Восстановление завершено"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
onToast(data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить"));
|
onToast(
|
||||||
|
data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить")
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.update((s) => ({ ...s, backupsRestoring: false }));
|
state.update((s) => ({ ...s, backupsRestoring: false }));
|
||||||
@@ -87,6 +120,7 @@ export function createBackupsStore({ api, onToast, at }) {
|
|||||||
return {
|
return {
|
||||||
subscribe: state.subscribe,
|
subscribe: state.subscribe,
|
||||||
loadArchives,
|
loadArchives,
|
||||||
|
createBackup,
|
||||||
uploadArchive,
|
uploadArchive,
|
||||||
restoreArchive,
|
restoreArchive,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -475,6 +475,35 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
archives: clone(mockBackups),
|
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") {
|
if (path === "/admin/backups/upload") {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -119,6 +119,39 @@ def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path):
|
|||||||
assert send_kwargs["message_thread_id"] == 77
|
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):
|
def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):
|
||||||
missing_compose_dir = tmp_path / "missing-compose"
|
missing_compose_dir = tmp_path / "missing-compose"
|
||||||
settings = _settings(
|
settings = _settings(
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
|
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
|
||||||
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route",
|
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route",
|
||||||
("GET", "/api/admin/backups"): "admin_backups_list_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/upload"): "admin_backups_upload_route",
|
||||||
("POST", "/api/admin/backups/restore"): "admin_backups_restore_route",
|
("POST", "/api/admin/backups/restore"): "admin_backups_restore_route",
|
||||||
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
|
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user