chore(v2): establish protocol and test foundation
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -1,47 +0,0 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,17 +0,0 @@
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./backup_tool.db")
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=os.environ.get("SQL_ECHO", "false").lower() == "true")
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -1,60 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import engine, Base
|
||||
from app import models # noqa: F401 - registers models with Base.metadata
|
||||
from app.routers import sources, jobs, executions, backups, settings, dashboard
|
||||
from backup.scheduler import backup_scheduler
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
backup_scheduler.start()
|
||||
await backup_scheduler.sync_schedules()
|
||||
yield
|
||||
backup_scheduler.shutdown()
|
||||
|
||||
app = FastAPI(
|
||||
title="Backup Tool API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(sources.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(executions.router)
|
||||
app.include_router(backups.router)
|
||||
app.include_router(settings.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
# Static files for production
|
||||
frontend_dist = os.path.join(os.path.dirname(__file__), "../../frontend/dist")
|
||||
if os.path.exists(frontend_dist):
|
||||
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
|
||||
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
index_file = os.path.join(frontend_dist, "index.html")
|
||||
if os.path.exists(index_file):
|
||||
return FileResponse(index_file)
|
||||
return {"detail": "Frontend not built"}
|
||||
@@ -1,137 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Source(Base):
|
||||
__tablename__ = "sources"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
type = Column(String, nullable=False) # local, ssh, database
|
||||
config = Column(JSON, default=lambda: {})
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
jobs = relationship("Job", back_populates="source", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Source(id={self.id}, name='{self.name}', type='{self.type}')>"
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True)
|
||||
strategy = Column(String, nullable=False, default="full") # full, incremental
|
||||
destination_path = Column(String, nullable=False)
|
||||
exclude_patterns = Column(JSON, default=lambda: [])
|
||||
enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
source = relationship("Source", back_populates="jobs")
|
||||
schedule = relationship(
|
||||
"Schedule", back_populates="job", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
executions = relationship(
|
||||
"JobExecution", back_populates="job", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Job(id={self.id}, name='{self.name}', source_id={self.source_id})>"
|
||||
|
||||
|
||||
class Schedule(Base):
|
||||
__tablename__ = "schedules"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_id = Column(
|
||||
Integer, ForeignKey("jobs.id"), unique=True, nullable=False, index=True
|
||||
)
|
||||
cron_expression = Column(String, nullable=False)
|
||||
enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
job = relationship("Job", back_populates="schedule")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Schedule(id={self.id}, job_id={self.job_id}, cron='{self.cron_expression}')>"
|
||||
|
||||
|
||||
class JobExecution(Base):
|
||||
__tablename__ = "job_executions"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False, index=True)
|
||||
status = Column(
|
||||
String, nullable=False, default="pending"
|
||||
) # pending, running, success, failed, cancelled
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
bytes_processed = Column(Integer, default=0)
|
||||
bytes_backed_up = Column(Integer, default=0)
|
||||
error_message = Column(Text, nullable=True)
|
||||
triggered_by = Column(String, nullable=False) # manual, schedule
|
||||
|
||||
job = relationship("Job", back_populates="executions")
|
||||
backups = relationship(
|
||||
"Backup", back_populates="execution", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<JobExecution(id={self.id}, job_id={self.job_id}, status='{self.status}')>"
|
||||
|
||||
|
||||
class Backup(Base):
|
||||
__tablename__ = "backups"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
execution_id = Column(
|
||||
Integer, ForeignKey("job_executions.id"), nullable=False, index=True
|
||||
)
|
||||
storage_path = Column(String, nullable=False)
|
||||
size_bytes = Column(Integer, default=0)
|
||||
checksum = Column(String, nullable=True)
|
||||
type = Column(String, nullable=False) # full, incremental
|
||||
parent_backup_id = Column(
|
||||
Integer, ForeignKey("backups.id"), nullable=True, index=True
|
||||
)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
execution = relationship("JobExecution", back_populates="backups")
|
||||
parent_backup = relationship("Backup", remote_side=[id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Backup(id={self.id}, execution_id={self.execution_id}, type='{self.type}')>"
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(Text, nullable=True)
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Setting(key='{self.key}', value='{self.value}')>"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,34 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models import Backup
|
||||
from app.schemas import Backup as BackupSchema
|
||||
|
||||
router = APIRouter(prefix="/api/backups", tags=["backups"])
|
||||
|
||||
@router.get("/", response_model=List[BackupSchema])
|
||||
async def list_backups(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Backup).order_by(Backup.created_at.desc()))
|
||||
backups = result.scalars().all()
|
||||
return backups
|
||||
|
||||
@router.get("/{backup_id}", response_model=BackupSchema)
|
||||
async def get_backup(backup_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Backup).where(Backup.id == backup_id))
|
||||
backup = result.scalar_one_or_none()
|
||||
if not backup:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
return backup
|
||||
|
||||
@router.delete("/{backup_id}")
|
||||
async def delete_backup(backup_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Backup).where(Backup.id == backup_id))
|
||||
backup = result.scalar_one_or_none()
|
||||
if not backup:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
|
||||
await db.delete(backup)
|
||||
await db.commit()
|
||||
return {"message": "Backup deleted"}
|
||||
@@ -1,54 +0,0 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Job, JobExecution, Backup
|
||||
from app.schemas import DashboardStats, JobExecution as JobExecutionSchema
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/", response_model=DashboardStats)
|
||||
async def get_dashboard_stats(db: AsyncSession = Depends(get_db)):
|
||||
# Active jobs count (enabled jobs)
|
||||
active_jobs_result = await db.execute(
|
||||
select(func.count(Job.id)).where(Job.enabled == True)
|
||||
)
|
||||
active_jobs = active_jobs_result.scalar() or 0
|
||||
|
||||
# Total backups count
|
||||
total_backups_result = await db.execute(select(func.count(Backup.id)))
|
||||
total_backups = total_backups_result.scalar() or 0
|
||||
|
||||
# Storage used bytes (sum of all backup sizes)
|
||||
storage_used_result = await db.execute(select(func.sum(Backup.size_bytes)))
|
||||
storage_used_bytes = storage_used_result.scalar() or 0
|
||||
|
||||
# Recent failures count (last 24 hours)
|
||||
twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
recent_failures_result = await db.execute(
|
||||
select(func.count(JobExecution.id)).where(
|
||||
and_(
|
||||
JobExecution.status == "failed",
|
||||
JobExecution.completed_at >= twenty_four_hours_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
recent_failures = recent_failures_result.scalar() or 0
|
||||
|
||||
# Recent executions (last 10, ordered by started_at desc)
|
||||
recent_executions_result = await db.execute(
|
||||
select(JobExecution).order_by(JobExecution.started_at.desc()).limit(10)
|
||||
)
|
||||
recent_executions = recent_executions_result.scalars().all()
|
||||
|
||||
return DashboardStats(
|
||||
active_jobs=active_jobs,
|
||||
total_backups=total_backups,
|
||||
storage_used_bytes=storage_used_bytes,
|
||||
recent_failures=recent_failures,
|
||||
recent_executions=list(recent_executions),
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models import JobExecution
|
||||
from app.schemas import JobExecution as JobExecutionSchema
|
||||
|
||||
router = APIRouter(prefix="/api/executions", tags=["executions"])
|
||||
|
||||
@router.get("/", response_model=List[JobExecutionSchema])
|
||||
async def list_executions(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JobExecution).order_by(JobExecution.started_at.desc()))
|
||||
executions = result.scalars().all()
|
||||
return executions
|
||||
|
||||
@router.get("/{execution_id}", response_model=JobExecutionSchema)
|
||||
async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JobExecution).where(JobExecution.id == execution_id))
|
||||
execution = result.scalar_one_or_none()
|
||||
if not execution:
|
||||
raise HTTPException(status_code=404, detail="Execution not found")
|
||||
return execution
|
||||
@@ -1,101 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from app.database import get_db, AsyncSessionLocal
|
||||
from app.models import Job, Schedule
|
||||
from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate, Schedule as ScheduleSchema
|
||||
from backup.engine import BackupEngine
|
||||
|
||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
|
||||
@router.get("/", response_model=List[JobSchema])
|
||||
async def list_jobs(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Job))
|
||||
jobs = result.scalars().all()
|
||||
return jobs
|
||||
|
||||
@router.post("/", response_model=JobSchema)
|
||||
async def create_job(job: JobCreate, db: AsyncSession = Depends(get_db)):
|
||||
db_job = Job(**job.model_dump())
|
||||
db.add(db_job)
|
||||
await db.commit()
|
||||
await db.refresh(db_job)
|
||||
return db_job
|
||||
|
||||
@router.get("/{job_id}", response_model=JobSchema)
|
||||
async def get_job(job_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Job).where(Job.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
@router.put("/{job_id}", response_model=JobSchema)
|
||||
async def update_job(
|
||||
job_id: int,
|
||||
job_update: JobUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Job).where(Job.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
update_data = job_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(job, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return job
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_job(job_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Job).where(Job.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
await db.delete(job)
|
||||
await db.commit()
|
||||
return {"message": "Job deleted"}
|
||||
|
||||
@router.post("/{job_id}/run")
|
||||
async def run_job(
|
||||
job_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Job).where(Job.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Run in background
|
||||
async def execute():
|
||||
async with AsyncSessionLocal() as session:
|
||||
engine = BackupEngine(session)
|
||||
await engine.execute_job(job_id, triggered_by="manual")
|
||||
|
||||
background_tasks.add_task(execute)
|
||||
return {"message": "Job execution started"}
|
||||
|
||||
@router.post("/{job_id}/schedule", response_model=ScheduleSchema)
|
||||
async def create_schedule(
|
||||
job_id: int,
|
||||
schedule: ScheduleCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Job).where(Job.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
schedule_data = schedule.model_dump()
|
||||
schedule_data["job_id"] = job_id
|
||||
db_schedule = Schedule(**schedule_data)
|
||||
db.add(db_schedule)
|
||||
await db.commit()
|
||||
await db.refresh(db_schedule)
|
||||
return ScheduleSchema.model_validate(db_schedule)
|
||||
@@ -1,43 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models import Setting
|
||||
from app.schemas import Setting as SettingSchema, SettingUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
@router.get("/", response_model=List[SettingSchema])
|
||||
async def list_settings(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Setting))
|
||||
settings = result.scalars().all()
|
||||
return settings
|
||||
|
||||
@router.get("/{key}", response_model=SettingSchema)
|
||||
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return setting
|
||||
|
||||
@router.put("/{key}", response_model=SettingSchema)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
setting_update: SettingUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if not setting:
|
||||
# Create if not exists
|
||||
setting = Setting(key=key, value=setting_update.value)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = setting_update.value
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(setting)
|
||||
return setting
|
||||
@@ -1,61 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models import Source
|
||||
from app.schemas import SourceCreate, SourceUpdate, Source as SourceSchema
|
||||
|
||||
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
||||
|
||||
@router.get("/", response_model=List[SourceSchema])
|
||||
async def list_sources(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Source))
|
||||
sources = result.scalars().all()
|
||||
return sources
|
||||
|
||||
@router.post("/", response_model=SourceSchema)
|
||||
async def create_source(source: SourceCreate, db: AsyncSession = Depends(get_db)):
|
||||
db_source = Source(**source.model_dump())
|
||||
db.add(db_source)
|
||||
await db.commit()
|
||||
await db.refresh(db_source)
|
||||
return db_source
|
||||
|
||||
@router.get("/{source_id}", response_model=SourceSchema)
|
||||
async def get_source(source_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Source).where(Source.id == source_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
return source
|
||||
|
||||
@router.put("/{source_id}", response_model=SourceSchema)
|
||||
async def update_source(
|
||||
source_id: int,
|
||||
source_update: SourceUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Source).where(Source.id == source_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
|
||||
update_data = source_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(source, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return source
|
||||
|
||||
@router.delete("/{source_id}")
|
||||
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Source).where(Source.id == source_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
return {"message": "Source deleted"}
|
||||
@@ -1,143 +0,0 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
# Source schemas
|
||||
class SourceBase(BaseModel):
|
||||
name: str
|
||||
type: str = Field(..., pattern="^(local|ssh|database)$")
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class SourceCreate(SourceBase):
|
||||
pass
|
||||
|
||||
class SourceUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Source(SourceBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Job schemas
|
||||
class JobBase(BaseModel):
|
||||
name: str
|
||||
source_id: int
|
||||
strategy: str = Field(default="full", pattern="^(full|incremental)$")
|
||||
destination_path: str
|
||||
exclude_patterns: List[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
|
||||
class JobCreate(JobBase):
|
||||
pass
|
||||
|
||||
class JobUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
strategy: Optional[str] = None
|
||||
destination_path: Optional[str] = None
|
||||
exclude_patterns: Optional[List[str]] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
class Job(JobBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Schedule schemas
|
||||
class ScheduleBase(BaseModel):
|
||||
job_id: int
|
||||
cron_expression: str
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@classmethod
|
||||
def validate_cron(cls, v: str) -> str:
|
||||
pattern = r'^([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)$'
|
||||
if not re.match(pattern, v):
|
||||
raise ValueError('Invalid cron expression format')
|
||||
return v
|
||||
|
||||
class ScheduleCreate(ScheduleBase):
|
||||
pass
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
cron_expression: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
class Schedule(ScheduleBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Execution schemas
|
||||
class JobExecutionBase(BaseModel):
|
||||
job_id: int
|
||||
status: str = Field(default="pending", pattern="^(pending|running|success|failed|cancelled)$")
|
||||
triggered_by: str = Field(..., pattern="^(manual|schedule)$")
|
||||
|
||||
class JobExecutionCreate(JobExecutionBase):
|
||||
pass
|
||||
|
||||
class JobExecution(JobExecutionBase):
|
||||
id: int
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
bytes_processed: int = 0
|
||||
bytes_backed_up: int = 0
|
||||
error_message: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class JobExecutionUpdate(BaseModel):
|
||||
status: Optional[str] = Field(None, pattern="^(pending|running|success|failed|cancelled)$")
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# Backup schemas
|
||||
class BackupBase(BaseModel):
|
||||
execution_id: int
|
||||
storage_path: str
|
||||
size_bytes: int = 0
|
||||
checksum: Optional[str] = None
|
||||
type: str = Field(..., pattern="^(full|incremental)$")
|
||||
parent_backup_id: Optional[int] = None
|
||||
|
||||
class BackupCreate(BackupBase):
|
||||
pass
|
||||
|
||||
class Backup(BackupBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Settings schemas
|
||||
class SettingBase(BaseModel):
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
|
||||
class SettingCreate(SettingBase):
|
||||
pass
|
||||
|
||||
class SettingUpdate(BaseModel):
|
||||
value: Optional[str] = None
|
||||
|
||||
class Setting(SettingBase):
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Dashboard schemas
|
||||
class DashboardStats(BaseModel):
|
||||
active_jobs: int
|
||||
total_backups: int
|
||||
storage_used_bytes: int
|
||||
recent_failures: int
|
||||
recent_executions: List[JobExecution]
|
||||
Binary file not shown.
@@ -1,17 +0,0 @@
|
||||
from typing import Dict, Any
|
||||
from .base import SourceAdapter
|
||||
from .local import LocalAdapter
|
||||
from .ssh import SSHAdapter
|
||||
from .database import DatabaseAdapter
|
||||
|
||||
ADAPTER_MAP = {
|
||||
"local": LocalAdapter,
|
||||
"ssh": SSHAdapter,
|
||||
"database": DatabaseAdapter,
|
||||
}
|
||||
|
||||
def get_adapter(source_type: str, config: Dict[str, Any]) -> SourceAdapter:
|
||||
adapter_class = ADAPTER_MAP.get(source_type)
|
||||
if not adapter_class:
|
||||
raise ValueError(f"Unknown source type: {source_type}")
|
||||
return adapter_class(config)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,39 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class FileInfo:
|
||||
path: str
|
||||
size: int
|
||||
modified_time: float
|
||||
is_directory: bool
|
||||
|
||||
class SourceAdapter(ABC):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
"""Establish connection to source."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
"""Close connection to source."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_files(self, path: str = "") -> List[FileInfo]:
|
||||
"""List files at given path."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def read_file(self, path: str) -> AsyncIterator[bytes]:
|
||||
"""Read file in chunks."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
|
||||
"""Get database dump. Only implemented for database adapters."""
|
||||
pass
|
||||
@@ -1,74 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import List, AsyncIterator, Dict, Any
|
||||
from .base import SourceAdapter, FileInfo
|
||||
|
||||
|
||||
class DatabaseAdapter(SourceAdapter):
|
||||
async def connect(self) -> None:
|
||||
pass
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
pass
|
||||
|
||||
async def list_files(self, path: str = "") -> List[FileInfo]:
|
||||
return []
|
||||
|
||||
async def read_file(self, path: str) -> AsyncIterator[bytes]:
|
||||
raise NotImplementedError("Database adapter does not support file reading")
|
||||
|
||||
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
|
||||
db_type = config.get("db_type", "postgresql")
|
||||
host = config.get("host", "localhost")
|
||||
port = config.get("port", 5432 if db_type == "postgresql" else 3306)
|
||||
database = config.get("database")
|
||||
username = config.get("username")
|
||||
password = config.get("password")
|
||||
|
||||
if not database:
|
||||
raise ValueError("Database name is required")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
if db_type == "postgresql":
|
||||
env = os.environ.copy()
|
||||
if password:
|
||||
env["PGPASSWORD"] = password
|
||||
|
||||
cmd = [
|
||||
"pg_dump",
|
||||
"-h", host,
|
||||
"-p", str(port),
|
||||
"-U", username or "postgres",
|
||||
"-f", tmp_path,
|
||||
database
|
||||
]
|
||||
elif db_type == "mysql":
|
||||
env = os.environ.copy()
|
||||
if password:
|
||||
env["MYSQL_PWD"] = password
|
||||
|
||||
cmd = [
|
||||
"mysqldump",
|
||||
"-h", host,
|
||||
"-P", str(port),
|
||||
"-u", username or "root",
|
||||
"--result-file", tmp_path,
|
||||
database
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Database dump failed: {result.stderr}")
|
||||
|
||||
with open(tmp_path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
yield chunk
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
@@ -1,47 +0,0 @@
|
||||
import os
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from typing import List, AsyncIterator, Dict, Any
|
||||
from .base import SourceAdapter, FileInfo
|
||||
|
||||
class LocalAdapter(SourceAdapter):
|
||||
async def connect(self) -> None:
|
||||
base_path = self.config.get("path", ".")
|
||||
if not os.path.exists(base_path):
|
||||
raise FileNotFoundError(f"Path not found: {base_path}")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
pass
|
||||
|
||||
async def list_files(self, path: str = "") -> List[FileInfo]:
|
||||
base_path = Path(self.config.get("path", "."))
|
||||
target_path = base_path / path if path else base_path
|
||||
|
||||
files = []
|
||||
exclude_patterns = self.config.get("exclude", [])
|
||||
|
||||
for item in target_path.iterdir():
|
||||
# Check exclude patterns
|
||||
if any(item.match(pattern) for pattern in exclude_patterns):
|
||||
continue
|
||||
|
||||
stat = item.stat()
|
||||
files.append(FileInfo(
|
||||
path=str(item.relative_to(base_path)),
|
||||
size=stat.st_size,
|
||||
modified_time=stat.st_mtime,
|
||||
is_directory=item.is_dir()
|
||||
))
|
||||
|
||||
return files
|
||||
|
||||
async def read_file(self, path: str) -> AsyncIterator[bytes]:
|
||||
base_path = Path(self.config.get("path", "."))
|
||||
file_path = base_path / path
|
||||
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while chunk := await f.read(8192):
|
||||
yield chunk
|
||||
|
||||
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
|
||||
raise NotImplementedError("Local adapter does not support database dumps")
|
||||
@@ -1,92 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, AsyncIterator, Dict, Any
|
||||
import paramiko
|
||||
from .base import SourceAdapter, FileInfo
|
||||
|
||||
|
||||
class SSHAdapter(SourceAdapter):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
self.client = None
|
||||
self.sftp = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
host = self.config.get("host", "localhost")
|
||||
port = self.config.get("port", 22)
|
||||
username = self.config.get("username")
|
||||
password = self.config.get("password")
|
||||
key_path = self.config.get("key_path")
|
||||
|
||||
connect_kwargs = {
|
||||
"hostname": host,
|
||||
"port": port,
|
||||
"username": username,
|
||||
}
|
||||
|
||||
if password:
|
||||
connect_kwargs["password"] = password
|
||||
elif key_path and os.path.exists(key_path):
|
||||
connect_kwargs["key_filename"] = key_path
|
||||
|
||||
self.client.connect(**connect_kwargs)
|
||||
self.sftp = self.client.open_sftp()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self.sftp:
|
||||
self.sftp.close()
|
||||
self.sftp = None
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def list_files(self, path: str = "") -> List[FileInfo]:
|
||||
remote_path = self.config.get("path", ".")
|
||||
target_path = f"{remote_path}/{path}" if path else remote_path
|
||||
|
||||
files = []
|
||||
exclude_patterns = self.config.get("exclude", [])
|
||||
|
||||
try:
|
||||
for entry in self.sftp.listdir_attr(target_path):
|
||||
entry_path = f"{target_path}/{entry.filename}"
|
||||
rel_path = entry_path.replace(remote_path + "/", "", 1) if remote_path != "." else entry_path
|
||||
|
||||
if any(pattern in rel_path for pattern in exclude_patterns):
|
||||
continue
|
||||
|
||||
is_dir = entry.st_mode & 0o40000 == 0o40000 if hasattr(entry, 'st_mode') else False
|
||||
|
||||
files.append(FileInfo(
|
||||
path=rel_path,
|
||||
size=entry.st_size,
|
||||
modified_time=entry.st_mtime,
|
||||
is_directory=is_dir
|
||||
))
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
return files
|
||||
|
||||
async def read_file(self, path: str) -> AsyncIterator[bytes]:
|
||||
remote_path = self.config.get("path", ".")
|
||||
file_path = f"{remote_path}/{path}" if not path.startswith("/") else path
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
self.sftp.get(file_path, tmp_path)
|
||||
with open(tmp_path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
yield chunk
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
|
||||
raise NotImplementedError("SSH adapter does not support database dumps directly")
|
||||
@@ -1,138 +0,0 @@
|
||||
import os
|
||||
import hashlib
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models import Job, JobExecution, Backup
|
||||
from backup.adapters import get_adapter
|
||||
from backup.retention import RetentionPolicy
|
||||
|
||||
class BackupEngine:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def execute_job(self, job_id: int, triggered_by: str = "manual") -> JobExecution:
|
||||
# Create execution record
|
||||
execution = JobExecution(
|
||||
job_id=job_id,
|
||||
status="pending",
|
||||
triggered_by=triggered_by
|
||||
)
|
||||
self.db.add(execution)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(execution)
|
||||
|
||||
try:
|
||||
# Load job with source
|
||||
result = await self.db.execute(
|
||||
select(Job).where(Job.id == job_id)
|
||||
)
|
||||
job = result.scalar_one()
|
||||
|
||||
# Update status to running
|
||||
execution.status = "running"
|
||||
execution.started_at = datetime.now(timezone.utc)
|
||||
await self.db.commit()
|
||||
|
||||
# Determine strategy
|
||||
strategy = job.strategy
|
||||
parent_backup_id = None
|
||||
|
||||
if strategy == "incremental":
|
||||
# Find last successful full backup
|
||||
result = await self.db.execute(
|
||||
select(Backup)
|
||||
.join(JobExecution)
|
||||
.where(
|
||||
JobExecution.job_id == job_id,
|
||||
JobExecution.status == "success",
|
||||
Backup.type == "full"
|
||||
)
|
||||
.order_by(Backup.created_at.desc())
|
||||
)
|
||||
last_full = result.scalar_one_or_none()
|
||||
|
||||
if last_full:
|
||||
parent_backup_id = last_full.id
|
||||
else:
|
||||
# No full backup exists, do full instead
|
||||
strategy = "full"
|
||||
|
||||
# Create backup directory
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
|
||||
backup_dir = Path(job.destination_path) / str(job_id) / f"{timestamp}_{strategy}"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get adapter and connect
|
||||
adapter = get_adapter(job.source.type, job.source.config)
|
||||
await adapter.connect()
|
||||
|
||||
try:
|
||||
# Copy files
|
||||
total_processed = 0
|
||||
total_backed_up = 0
|
||||
|
||||
source_path = Path(job.source.config.get("path", "."))
|
||||
|
||||
for item in source_path.rglob("*"):
|
||||
if item.is_file():
|
||||
rel_path = item.relative_to(source_path)
|
||||
dest_path = backup_dir / "data" / rel_path
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy file
|
||||
shutil.copy2(item, dest_path)
|
||||
|
||||
size = item.stat().st_size
|
||||
total_processed += size
|
||||
total_backed_up += size
|
||||
|
||||
# Calculate checksum
|
||||
checksum = await self._calculate_checksum(backup_dir)
|
||||
|
||||
# Create backup record
|
||||
backup = Backup(
|
||||
execution_id=execution.id,
|
||||
storage_path=str(backup_dir),
|
||||
size_bytes=total_backed_up,
|
||||
checksum=checksum,
|
||||
type=strategy,
|
||||
parent_backup_id=parent_backup_id
|
||||
)
|
||||
self.db.add(backup)
|
||||
|
||||
# Update execution
|
||||
execution.status = "success"
|
||||
execution.completed_at = datetime.now(timezone.utc)
|
||||
execution.bytes_processed = total_processed
|
||||
execution.bytes_backed_up = total_backed_up
|
||||
|
||||
# Apply retention policy
|
||||
retention = RetentionPolicy(self.db)
|
||||
keep_count = getattr(job, 'retention_count', None)
|
||||
keep_days = getattr(job, 'retention_days', None)
|
||||
if keep_count or keep_days:
|
||||
await retention.apply_retention_for_job(job_id, keep_count, keep_days)
|
||||
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
execution.status = "failed"
|
||||
execution.completed_at = datetime.now(timezone.utc)
|
||||
execution.error_message = str(e)
|
||||
|
||||
await self.db.commit()
|
||||
return execution
|
||||
|
||||
async def _calculate_checksum(self, path: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
for item in sorted(path.rglob("*")):
|
||||
if item.is_file():
|
||||
with open(item, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
@@ -1,90 +0,0 @@
|
||||
import shutil
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Backup
|
||||
|
||||
|
||||
class RetentionPolicy:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def apply_retention_for_job(
|
||||
self,
|
||||
job_id: int,
|
||||
keep_count: Optional[int] = None,
|
||||
keep_days: Optional[int] = None
|
||||
) -> List[Backup]:
|
||||
"""
|
||||
Apply retention policy for a job's backups.
|
||||
|
||||
Args:
|
||||
job_id: The job ID to apply retention for
|
||||
keep_count: Maximum number of backups to keep (oldest deleted first)
|
||||
keep_days: Delete backups older than this many days
|
||||
|
||||
Returns:
|
||||
List of deleted backups
|
||||
"""
|
||||
deleted_backups = []
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Backup)
|
||||
.where(Backup.execution.has(job_id=job_id))
|
||||
.order_by(Backup.created_at.asc())
|
||||
)
|
||||
backups = result.scalars().all()
|
||||
|
||||
if not backups:
|
||||
return deleted_backups
|
||||
|
||||
backups_to_delete = set()
|
||||
|
||||
if keep_count is not None and len(backups) > keep_count:
|
||||
backups_to_delete.update(backups[:-keep_count])
|
||||
|
||||
if keep_days is not None:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days)
|
||||
for backup in backups:
|
||||
if backup.created_at < cutoff_date:
|
||||
backups_to_delete.add(backup)
|
||||
|
||||
for backup in list(backups_to_delete):
|
||||
await self._delete_backup(backup)
|
||||
deleted_backups.append(backup)
|
||||
|
||||
await self.db.commit()
|
||||
return deleted_backups
|
||||
|
||||
async def _delete_backup(self, backup: Backup):
|
||||
"""Delete a backup and its storage."""
|
||||
try:
|
||||
storage_path = Path(backup.storage_path)
|
||||
if storage_path.exists():
|
||||
shutil.rmtree(storage_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self.db.delete(backup)
|
||||
|
||||
async def cleanup_orphaned_backups(self) -> int:
|
||||
"""
|
||||
Remove backup records whose storage no longer exists.
|
||||
|
||||
Returns:
|
||||
Number of orphaned backups removed
|
||||
"""
|
||||
result = await self.db.execute(select(Backup))
|
||||
backups = result.scalars().all()
|
||||
|
||||
removed_count = 0
|
||||
for backup in backups:
|
||||
storage_path = Path(backup.storage_path)
|
||||
if not storage_path.exists():
|
||||
await self.db.delete(backup)
|
||||
removed_count += 1
|
||||
|
||||
await self.db.commit()
|
||||
return removed_count
|
||||
@@ -1,81 +0,0 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Schedule
|
||||
from backup.engine import BackupEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupScheduler:
|
||||
def __init__(self):
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self._job_map = {}
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler."""
|
||||
self.scheduler.start()
|
||||
logger.info("Backup scheduler started")
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the scheduler."""
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Backup scheduler shutdown")
|
||||
|
||||
async def sync_schedules(self):
|
||||
"""Sync all enabled schedules from database."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(Schedule).where(Schedule.enabled == True)
|
||||
)
|
||||
schedules = result.scalars().all()
|
||||
|
||||
# Clear existing jobs
|
||||
for schedule_id, job_id in list(self._job_map.items()):
|
||||
self.scheduler.remove_job(job_id)
|
||||
del self._job_map[schedule_id]
|
||||
|
||||
# Add new jobs
|
||||
for schedule in schedules:
|
||||
await self._add_schedule_job(schedule)
|
||||
|
||||
async def _add_schedule_job(self, schedule: Schedule):
|
||||
"""Add a single schedule job to the scheduler."""
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(schedule.cron_expression)
|
||||
job = self.scheduler.add_job(
|
||||
self._run_backup_job,
|
||||
trigger=trigger,
|
||||
args=[schedule.job_id],
|
||||
id=f"backup_job_{schedule.job_id}",
|
||||
replace_existing=True
|
||||
)
|
||||
self._job_map[schedule.id] = job.id
|
||||
logger.info(f"Scheduled backup job {schedule.job_id} with cron: {schedule.cron_expression}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule job {schedule.job_id}: {e}")
|
||||
|
||||
async def _run_backup_job(self, job_id: int):
|
||||
"""Execute a backup job."""
|
||||
logger.info(f"Running scheduled backup job {job_id}")
|
||||
async with AsyncSessionLocal() as db:
|
||||
engine = BackupEngine(db)
|
||||
await engine.execute_job(job_id, triggered_by="schedule")
|
||||
|
||||
async def add_schedule(self, schedule: Schedule):
|
||||
"""Add a new schedule to the scheduler."""
|
||||
await self._add_schedule_job(schedule)
|
||||
|
||||
def remove_schedule(self, schedule_id: int):
|
||||
"""Remove a schedule from the scheduler."""
|
||||
if schedule_id in self._job_map:
|
||||
self.scheduler.remove_job(self._job_map[schedule_id])
|
||||
del self._job_map[schedule_id]
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
backup_scheduler = BackupScheduler()
|
||||
Binary file not shown.
+43
-48
@@ -1,68 +1,63 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
requires = ["setuptools==80.9.0", "wheel==0.46.3"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "backup-tool"
|
||||
version = "0.1.0"
|
||||
description = "Web-based backup management tool for small teams and SMBs"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{name = "Backup Tool Team"}
|
||||
]
|
||||
keywords = ["backup", "restore", "scheduler", "web"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: System :: Archiving :: Backup",
|
||||
]
|
||||
version = "2.0.0.dev0"
|
||||
description = "Self-hosted, integrity-first backup appliance"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.12,<3.15"
|
||||
authors = [{ name = "Backup Tool Team" }]
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"aiosqlite>=0.21.0",
|
||||
"alembic>=1.15.0",
|
||||
"pydantic>=2.13.0",
|
||||
"pydantic-settings>=2.9.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"paramiko>=3.5.0",
|
||||
"aiofiles>=23.2.0",
|
||||
"httpx>=0.28.0",
|
||||
"aiofiles==25.1.0",
|
||||
"aiosqlite==0.22.1",
|
||||
"alembic==1.18.5",
|
||||
"apscheduler==3.11.3",
|
||||
"fastapi==0.136.1",
|
||||
"httpx==0.28.1",
|
||||
"paramiko==5.0.0",
|
||||
"pydantic==2.13.4",
|
||||
"pydantic-settings==2.14.2",
|
||||
"sqlalchemy[asyncio]==2.0.49",
|
||||
"uvicorn[standard]==0.51.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.26.0",
|
||||
]
|
||||
prod = [
|
||||
"gunicorn>=23.0.0",
|
||||
"jsonschema==4.26.0",
|
||||
"mypy==2.3.0",
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"ruff==0.16.0",
|
||||
]
|
||||
prod = ["gunicorn==23.0.0"]
|
||||
|
||||
[project.scripts]
|
||||
backup-tool = "app.main:main"
|
||||
backup-tool = "backup_tool.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/backup-tool/backup-tool"
|
||||
Documentation = "https://github.com/backup-tool/backup-tool#readme"
|
||||
Repository = "https://github.com/backup-tool/backup-tool.git"
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*", "backup*", "alembic*"]
|
||||
where = ["src"]
|
||||
include = ["backup_tool*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
backup_tool = ["py.typed"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = [".", "app", "backup"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
alembic = ["*.ini", "*.py", "*.mako"]
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
packages = ["backup_tool"]
|
||||
mypy_path = "src"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Backup Tool v2 package."""
|
||||
|
||||
__version__ = "2.0.0.dev0"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Minimal v2 command entry point; runtime roles arrive in M1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from backup_tool import __version__
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="backup-tool")
|
||||
parser.add_argument("--version", action="version", version=__version__)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
build_parser().parse_args(argv)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,33 +0,0 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db):
|
||||
async def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,82 +0,0 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models import Source, Job, JobExecution
|
||||
from backup.engine import BackupEngine
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_source(db: AsyncSession):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create test files
|
||||
(Path(tmpdir) / "test.txt").write_text("Hello, World!")
|
||||
(Path(tmpdir) / "subdir").mkdir()
|
||||
(Path(tmpdir) / "subdir" / "nested.txt").write_text("Nested content")
|
||||
|
||||
source = Source(
|
||||
name="Test Source",
|
||||
type="local",
|
||||
config={"path": tmpdir}
|
||||
)
|
||||
db.add(source)
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
yield source
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_job(db: AsyncSession, test_source):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
job = Job(
|
||||
name="Test Job",
|
||||
source_id=test_source.id,
|
||||
strategy="full",
|
||||
destination_path=tmpdir
|
||||
)
|
||||
db.add(job)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
yield job
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_full_backup(db: AsyncSession, test_job):
|
||||
engine = BackupEngine(db)
|
||||
execution = await engine.execute_job(test_job.id, triggered_by="manual")
|
||||
|
||||
assert execution.status == "success"
|
||||
assert execution.bytes_processed > 0
|
||||
assert execution.bytes_backed_up > 0
|
||||
assert execution.triggered_by == "manual"
|
||||
|
||||
# Refresh test_job to load executions relationship
|
||||
await db.refresh(test_job, ["executions"])
|
||||
|
||||
# Verify backup was created
|
||||
assert len(test_job.executions) == 1
|
||||
|
||||
# Refresh execution to load backups relationship
|
||||
await db.refresh(test_job.executions[0], ["backups"])
|
||||
backup = test_job.executions[0].backups[0]
|
||||
assert backup.type == "full"
|
||||
assert backup.checksum is not None
|
||||
assert os.path.exists(backup.storage_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_incremental_without_full(db: AsyncSession, test_job):
|
||||
# Set job to incremental but no full backup exists
|
||||
test_job.strategy = "incremental"
|
||||
await db.commit()
|
||||
|
||||
engine = BackupEngine(db)
|
||||
execution = await engine.execute_job(test_job.id)
|
||||
|
||||
# Should fall back to full backup
|
||||
assert execution.status == "success"
|
||||
|
||||
# Refresh execution to load backups relationship
|
||||
await db.refresh(execution, ["backups"])
|
||||
backup = execution.backups[0]
|
||||
assert backup.type == "full"
|
||||
assert backup.parent_backup_id is None
|
||||
@@ -1,205 +0,0 @@
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job(client):
|
||||
# Create a source first (job requires source_id)
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
assert source_resp.status_code == 200
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
response = await client.post("/api/jobs/", json={
|
||||
"name": "Test Job",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Test Job"
|
||||
assert data["source_id"] == source_id
|
||||
assert data["strategy"] == "full"
|
||||
assert "id" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_jobs(client):
|
||||
# Create source and job
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
await client.post("/api/jobs/", json={
|
||||
"name": "Test Job",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
|
||||
response = await client.get("/api/jobs/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_job(client):
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
job_resp = await client.post("/api/jobs/", json={
|
||||
"name": "Test Job",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
job_id = job_resp.json()["id"]
|
||||
|
||||
response = await client.get(f"/api/jobs/{job_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == job_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_job(client):
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
job_resp = await client.post("/api/jobs/", json={
|
||||
"name": "Delete Me",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
job_id = job_resp.json()["id"]
|
||||
|
||||
response = await client.delete(f"/api/jobs/{job_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify deletion
|
||||
get_resp = await client.get(f"/api/jobs/{job_id}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_job(client):
|
||||
# Create source
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
# Create job
|
||||
job_resp = await client.post("/api/jobs/", json={
|
||||
"name": "Test Job",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
job_id = job_resp.json()["id"]
|
||||
|
||||
response = await client.post(f"/api/jobs/{job_id}/run")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Job execution started"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_job_not_found(client):
|
||||
response = await client.post("/api/jobs/999/run")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_job(client):
|
||||
# Create source
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
# Create job
|
||||
job_resp = await client.post("/api/jobs/", json={
|
||||
"name": "Original Name",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
job_id = job_resp.json()["id"]
|
||||
|
||||
response = await client.put(f"/api/jobs/{job_id}", json={
|
||||
"name": "Updated Name"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Name"
|
||||
assert response.json()["strategy"] == "full" # Unchanged
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_schedule(client):
|
||||
# Create source
|
||||
source_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = source_resp.json()["id"]
|
||||
|
||||
# Create job
|
||||
job_resp = await client.post("/api/jobs/", json={
|
||||
"name": "Test Job",
|
||||
"source_id": source_id,
|
||||
"strategy": "full",
|
||||
"destination_path": "/tmp/backups",
|
||||
"exclude_patterns": [],
|
||||
"enabled": True
|
||||
})
|
||||
job_id = job_resp.json()["id"]
|
||||
|
||||
response = await client.post(f"/api/jobs/{job_id}/schedule", json={
|
||||
"job_id": job_id,
|
||||
"cron_expression": "0 0 * * *",
|
||||
"enabled": True
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["job_id"] == job_id
|
||||
assert data["cron_expression"] == "0 0 * * *"
|
||||
assert "id" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_job_not_found(client):
|
||||
response = await client.get("/api/jobs/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_job_not_found(client):
|
||||
response = await client.put("/api/jobs/99999", json={"name": "Test"})
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_job_not_found(client):
|
||||
response = await client.delete("/api/jobs/99999")
|
||||
assert response.status_code == 404
|
||||
@@ -1,88 +0,0 @@
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_source(client):
|
||||
response = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Test Source"
|
||||
assert data["type"] == "local"
|
||||
assert "id" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sources(client):
|
||||
# Create source first
|
||||
await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
|
||||
response = await client.get("/api/sources/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_source(client):
|
||||
create_resp = await client.post("/api/sources/", json={
|
||||
"name": "Test Source",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
response = await client.get(f"/api/sources/{source_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == source_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_source(client):
|
||||
create_resp = await client.post("/api/sources/", json={
|
||||
"name": "Delete Me",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
response = await client.delete(f"/api/sources/{source_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify deletion
|
||||
get_resp = await client.get(f"/api/sources/{source_id}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_source(client):
|
||||
create_resp = await client.post("/api/sources/", json={
|
||||
"name": "Original Name",
|
||||
"type": "local",
|
||||
"config": {"path": "/tmp/test"}
|
||||
})
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
response = await client.put(f"/api/sources/{source_id}", json={
|
||||
"name": "Updated Name"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Name"
|
||||
assert response.json()["type"] == "local" # Unchanged
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_source_not_found(client):
|
||||
response = await client.get("/api/sources/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_source_not_found(client):
|
||||
response = await client.put("/api/sources/99999", json={"name": "Test"})
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_source_not_found(client):
|
||||
response = await client.delete("/api/sources/99999")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user