Initial commit
This commit is contained in:
207
server/app/main.py
Normal file
207
server/app/main.py
Normal file
@@ -0,0 +1,207 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user