Initial YakPanel commit
This commit is contained in:
204
YakPanel-server/backend/app/api/backup.py
Normal file
204
YakPanel-server/backend/app/api/backup.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user