208 lines
7.0 KiB
Python
208 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import List
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
from sqlmodel import select
|
|
|
|
from .config import settings
|
|
from .database import get_session, init_db
|
|
from .job_runner import JobPayload, runner
|
|
from .models import Job, JobStatus, Profile, Token, RunHistory
|
|
from .schemas import (
|
|
AuthToken,
|
|
BackupLog,
|
|
BackupStartRequest,
|
|
HealthResponse,
|
|
JobStatusResponse,
|
|
ProfileCreate,
|
|
ProfileRead,
|
|
TokenType,
|
|
)
|
|
|
|
app = FastAPI(title="Backup Orchestrator", version="0.1.0")
|
|
oauth_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
|
|
|
|
|
def _collect_folders(text: str) -> List[str]:
|
|
return [entry.strip() for entry in text.split("||") if entry.strip()]
|
|
|
|
|
|
def _profile_to_response(profile: Profile) -> ProfileRead:
|
|
return ProfileRead(
|
|
id=profile.id, # type: ignore[arg-type]
|
|
name=profile.name,
|
|
folders=_collect_folders(profile.folders),
|
|
description=profile.description,
|
|
schedule_enabled=profile.schedule_enabled,
|
|
created_at=profile.created_at,
|
|
updated_at=profile.updated_at,
|
|
)
|
|
|
|
|
|
def _get_current_user(token: str = Depends(oauth_scheme)): # noqa: C901
|
|
session = get_session()
|
|
try:
|
|
record = session.exec(select(Token).where(Token.token == token)).first()
|
|
if not record or record.expires_at < datetime.utcnow():
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
|
return record.user
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.on_event("startup")
|
|
def on_startup() -> None:
|
|
init_db()
|
|
|
|
|
|
@app.post("/auth/login", response_model=AuthToken)
|
|
def login(form_data: OAuth2PasswordRequestForm = Depends()) -> AuthToken:
|
|
if form_data.username != settings.api_username or form_data.password != settings.api_password:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
session = get_session()
|
|
expires_at = datetime.utcnow() + timedelta(minutes=settings.token_lifetime_minutes)
|
|
token_value = Token(token=_generate_token(), user=form_data.username, expires_at=expires_at)
|
|
session.add(token_value)
|
|
session.commit()
|
|
session.close()
|
|
return AuthToken(access_token=token_value.token, token_type=TokenType.bearer, expires_at=expires_at)
|
|
|
|
|
|
def _generate_token(length: int = 36) -> str:
|
|
from secrets import token_urlsafe
|
|
|
|
return token_urlsafe(length)
|
|
|
|
|
|
@app.get("/profiles", response_model=List[ProfileRead])
|
|
def list_profiles(_: str = Depends(_get_current_user)) -> List[ProfileRead]:
|
|
session = get_session()
|
|
try:
|
|
results = session.exec(select(Profile).order_by(Profile.created_at)).all()
|
|
return [_profile_to_response(profile) for profile in results]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/profiles", response_model=ProfileRead, status_code=status.HTTP_201_CREATED)
|
|
def create_profile(data: ProfileCreate, _: str = Depends(_get_current_user)) -> ProfileRead:
|
|
session = get_session()
|
|
try:
|
|
profile = Profile(
|
|
name=data.name,
|
|
folders="||".join(data.folders),
|
|
description=data.description,
|
|
schedule_enabled=data.schedule_enabled,
|
|
)
|
|
session.add(profile)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return _profile_to_response(profile)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.put("/profiles/{profile_id}", response_model=ProfileRead)
|
|
def update_profile(profile_id: int, data: ProfileCreate, _: str = Depends(_get_current_user)) -> ProfileRead:
|
|
session = get_session()
|
|
try:
|
|
profile = session.get(Profile, profile_id)
|
|
if not profile:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
|
profile.name = data.name
|
|
profile.folders = "||".join(data.folders)
|
|
profile.description = data.description
|
|
profile.schedule_enabled = data.schedule_enabled
|
|
profile.updated_at = datetime.utcnow()
|
|
session.add(profile)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return _profile_to_response(profile)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.delete("/profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_profile(profile_id: int, _: str = Depends(_get_current_user)) -> None:
|
|
session = get_session()
|
|
try:
|
|
profile = session.get(Profile, profile_id)
|
|
if not profile:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
|
session.delete(profile)
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/backup/start")
|
|
def start_backup(request: BackupStartRequest, _: str = Depends(_get_current_user)) -> dict:
|
|
session = get_session()
|
|
try:
|
|
profile = session.get(Profile, request.profile_id)
|
|
if not profile:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
|
folders = request.folders or _collect_folders(profile.folders)
|
|
if not folders:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No folders provided")
|
|
job = Job(
|
|
profile_name=profile.name,
|
|
client_host=request.client_host,
|
|
folders="||".join(folders),
|
|
ssh_username=request.ssh_username,
|
|
run_id=_generate_run_id(),
|
|
)
|
|
session.add(job)
|
|
session.commit()
|
|
session.refresh(job)
|
|
runner.enqueue(JobPayload(job_id=job.id, ssh_password=request.ssh_password))
|
|
return {"job_id": job.id}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _generate_run_id() -> str:
|
|
from uuid import uuid4
|
|
|
|
return uuid4().hex
|
|
|
|
|
|
@app.get("/backup/status/{job_id}", response_model=JobStatusResponse)
|
|
def job_status(job_id: int, _: str = Depends(_get_current_user)) -> JobStatusResponse:
|
|
session = get_session()
|
|
try:
|
|
job = session.get(Job, job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
|
return JobStatusResponse(
|
|
job_id=job.id, # type: ignore[arg-type]
|
|
status=job.status.value,
|
|
progress=job.progress,
|
|
summary=job.summary,
|
|
last_log_lines=runner.tail_log(job),
|
|
created_at=job.created_at,
|
|
updated_at=job.updated_at,
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/backup/log/{job_id}", response_model=BackupLog)
|
|
def job_log(job_id: int, _: str = Depends(_get_current_user)) -> BackupLog:
|
|
session = get_session()
|
|
try:
|
|
job = session.get(Job, job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
|
return BackupLog(job_id=job.id, lines=runner.tail_log(job, lines=200))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
def health() -> HealthResponse:
|
|
return HealthResponse(status="ok", version=app.version)
|