chore(v2): establish protocol and test foundation

This commit is contained in:
2026-07-27 18:44:47 +02:00
parent 33b0c21292
commit 791f4526f9
86 changed files with 4060 additions and 2582 deletions
-5
View File
@@ -1,5 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-47
View File
@@ -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()
-28
View File
@@ -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.
-17
View File
@@ -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()
-60
View File
@@ -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"}
-137
View File
@@ -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.
-34
View File
@@ -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"}
-54
View File
@@ -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),
)
-23
View File
@@ -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
-101
View File
@@ -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)
-43
View File
@@ -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
-61
View File
@@ -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"}
-143
View File
@@ -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.
-17
View File
@@ -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)
-39
View File
@@ -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
-74
View File
@@ -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)
-47
View File
@@ -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")
-92
View File
@@ -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")
-138
View File
@@ -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()
-90
View File
@@ -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
-81
View File
@@ -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
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
"""Backup Tool v2 package."""
__version__ = "2.0.0.dev0"
+23
View File
@@ -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())
View File
-33
View File
@@ -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()
-82
View File
@@ -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
-205
View File
@@ -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
-88
View File
@@ -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