Files
yakpanel-core/YakPanel-server/backend/app/api/backup.py

205 lines
7.5 KiB
Python
Raw Normal View History

2026-04-07 02:04:22 +05:30
"""YakPanel - Backup plans API"""
import os
import tarfile
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from croniter import croniter
from app.core.database import get_db
from app.core.config import get_runtime_config
from app.core.notification import send_email
from app.api.auth import get_current_user
from app.models.user import User
from app.models.site import Site
from app.models.database import Database
from app.models.backup_plan import BackupPlan
from app.services.database_service import backup_mysql_database, backup_postgresql_database, backup_mongodb_database
router = APIRouter(prefix="/backup", tags=["backup"])
class CreateBackupPlanRequest(BaseModel):
name: str
plan_type: str # site | database
target_id: int
schedule: str # cron, e.g. "0 2 * * *"
enabled: bool = True
@router.get("/plans")
async def backup_plans_list(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all backup plans"""
result = await db.execute(select(BackupPlan).order_by(BackupPlan.id))
rows = result.scalars().all()
return [
{
"id": r.id,
"name": r.name,
"plan_type": r.plan_type,
"target_id": r.target_id,
"schedule": r.schedule,
"enabled": r.enabled,
}
for r in rows
]
@router.post("/plans")
async def backup_plan_create(
body: CreateBackupPlanRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a backup plan"""
if body.plan_type not in ("site", "database"):
raise HTTPException(status_code=400, detail="plan_type must be site or database")
if not body.schedule or len(body.schedule) < 9:
raise HTTPException(status_code=400, detail="Invalid cron schedule")
try:
croniter(body.schedule)
except Exception:
raise HTTPException(status_code=400, detail="Invalid cron expression")
if body.plan_type == "site":
r = await db.execute(select(Site).where(Site.id == body.target_id))
if not r.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Site not found")
else:
r = await db.execute(select(Database).where(Database.id == body.target_id))
if not r.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Database not found")
plan = BackupPlan(
name=body.name,
plan_type=body.plan_type,
target_id=body.target_id,
schedule=body.schedule,
enabled=body.enabled,
)
db.add(plan)
await db.commit()
return {"status": True, "msg": "Backup plan created", "id": plan.id}
@router.put("/plans/{plan_id}")
async def backup_plan_update(
plan_id: int,
body: CreateBackupPlanRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update a backup plan"""
result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="Backup plan not found")
if body.schedule:
try:
croniter(body.schedule)
except Exception:
raise HTTPException(status_code=400, detail="Invalid cron expression")
plan.name = body.name
plan.plan_type = body.plan_type
plan.target_id = body.target_id
plan.schedule = body.schedule
plan.enabled = body.enabled
await db.commit()
return {"status": True, "msg": "Updated"}
@router.delete("/plans/{plan_id}")
async def backup_plan_delete(
plan_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a backup plan"""
result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="Backup plan not found")
await db.delete(plan)
await db.commit()
return {"status": True, "msg": "Deleted"}
def _run_site_backup(site: Site) -> tuple[bool, str, str | None]:
"""Run site backup (sync, for use in run_scheduled). Returns (ok, msg, filename)."""
cfg = get_runtime_config()
backup_dir = cfg["backup_path"]
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{site.name}_{ts}.tar.gz"
dest = os.path.join(backup_dir, filename)
try:
with tarfile.open(dest, "w:gz") as tf:
tf.add(site.path, arcname=os.path.basename(site.path))
return True, "Backup created", filename
except Exception as e:
return False, str(e), None
def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]:
"""Run database backup (sync). Returns (ok, msg, filename)."""
cfg = get_runtime_config()
backup_dir = os.path.join(cfg["backup_path"], "database")
if dbo.db_type == "MySQL":
return backup_mysql_database(dbo.name, backup_dir)
if dbo.db_type == "PostgreSQL":
return backup_postgresql_database(dbo.name, backup_dir)
if dbo.db_type == "MongoDB":
return backup_mongodb_database(dbo.name, backup_dir)
return False, "Unsupported database type", None
@router.post("/run-scheduled")
async def backup_run_scheduled(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Run all due backup plans. Call this from cron (e.g. every hour) or manually."""
from datetime import datetime as dt
now = dt.utcnow()
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
plans = result.scalars().all()
results = []
for plan in plans:
try:
prev_run = croniter(plan.schedule, now).get_prev(dt)
# Run if we're within 15 minutes after the scheduled time
secs_since = (now - prev_run).total_seconds()
if secs_since > 900 or secs_since < 0: # Not within 15 min window
continue
except Exception:
continue
if plan.plan_type == "site":
r = await db.execute(select(Site).where(Site.id == plan.target_id))
site = r.scalar_one_or_none()
if not site or not os.path.isdir(site.path):
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
continue
ok, msg, filename = _run_site_backup(site)
if ok:
send_email(
subject=f"YakPanel - Scheduled backup: {plan.name}",
body=f"Site backup completed: {filename}\nSite: {site.name}",
)
else:
r = await db.execute(select(Database).where(Database.id == plan.target_id))
dbo = r.scalar_one_or_none()
if not dbo:
results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"})
continue
ok, msg, filename = _run_database_backup(dbo)
if ok:
send_email(
subject=f"YakPanel - Scheduled backup: {plan.name}",
body=f"Database backup completed: {filename}\nDatabase: {dbo.name}",
)
results.append({"plan": plan.name, "status": "ok" if ok else "failed", "msg": msg})
return {"status": True, "results": results}