feat: manual backup button
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user