205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
"""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}
|