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)