feat(v2): add local sources and repository jobs
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from backup_tool.config import Settings
|
||||
|
||||
|
||||
class SourceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entry:
|
||||
path: str
|
||||
kind: str
|
||||
size: int | None = None
|
||||
|
||||
|
||||
class LocalAdapter:
|
||||
def __init__(self, root: Path, settings: Settings):
|
||||
self.root = root.expanduser().resolve()
|
||||
self.settings = settings
|
||||
|
||||
def validate_config(self) -> None:
|
||||
if not self.root.is_dir():
|
||||
raise SourceError("local source root must be an existing directory")
|
||||
if not any(self.root.is_relative_to(root) for root in self.settings.local_source_roots):
|
||||
raise SourceError("local source root is outside configured allowlists")
|
||||
|
||||
async def probe(self) -> dict[str, int]:
|
||||
self.validate_config()
|
||||
count = sum(1 for item in self.root.rglob("*") if item.is_file() and not item.is_symlink())
|
||||
return {"entry_count": count}
|
||||
|
||||
async def enumerate_entries(self) -> AsyncIterator[Entry]:
|
||||
self.validate_config()
|
||||
for item in self.root.rglob("*"):
|
||||
relative = item.relative_to(self.root).as_posix()
|
||||
if item.is_symlink():
|
||||
yield Entry(relative, "symlink")
|
||||
elif item.is_file():
|
||||
yield Entry(relative, "file", item.stat().st_size)
|
||||
elif item.is_dir():
|
||||
yield Entry(relative, "directory")
|
||||
|
||||
async def open_content(self, path: str) -> AsyncIterator[bytes]:
|
||||
candidate = (self.root / path).resolve()
|
||||
if (
|
||||
not candidate.is_relative_to(self.root)
|
||||
or not candidate.is_file()
|
||||
or candidate.is_symlink()
|
||||
):
|
||||
raise SourceError("invalid local source entry")
|
||||
with candidate.open("rb") as handle:
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
yield chunk
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import desc, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from backup_tool.adapters import LocalAdapter, SourceError
|
||||
from backup_tool.cli import build_alembic_config
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine
|
||||
@@ -21,9 +22,11 @@ from backup_tool.db.models import (
|
||||
ApiToken,
|
||||
AuditEvent,
|
||||
IdempotencyRecord,
|
||||
Job,
|
||||
Repository,
|
||||
Secret,
|
||||
Session,
|
||||
Source,
|
||||
User,
|
||||
)
|
||||
from backup_tool.repository import (
|
||||
@@ -78,6 +81,23 @@ class LoginInput(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class SourceInput(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
kind: str
|
||||
public_config: dict[str, Any]
|
||||
|
||||
|
||||
class JobInput(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
source_id: str
|
||||
repository_id: str
|
||||
requested_mode: str = "incremental"
|
||||
exclusions: list[str] = Field(default_factory=list)
|
||||
retention: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
allow_empty: bool = False
|
||||
|
||||
|
||||
class TokenInput(BaseModel):
|
||||
scopes: list[str] = Field(min_length=1)
|
||||
expires_at: datetime | None = None
|
||||
@@ -621,6 +641,115 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
raise Problem(409, "repository_policy_immutable", "Repository policy is immutable.")
|
||||
raise Problem(422, "validation_failed", "No mutable fields supplied.")
|
||||
|
||||
@app.post("/api/v2/sources", status_code=201)
|
||||
async def create_source(
|
||||
input_: SourceInput,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> dict[str, Any]:
|
||||
if input_.kind != "local":
|
||||
raise Problem(422, "validation_failed", "Only local sources are available.")
|
||||
root = input_.public_config.get("root")
|
||||
if not isinstance(root, str):
|
||||
raise Problem(422, "validation_failed", "Local source root is required.")
|
||||
try:
|
||||
LocalAdapter(Path(root), settings).validate_config()
|
||||
except SourceError as error:
|
||||
raise Problem(422, "validation_failed", str(error)) from error
|
||||
source = Source(
|
||||
name=input_.name, kind="local", public_config={"root": root}, secret_refs=[]
|
||||
)
|
||||
db.add(source)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as error:
|
||||
await db.rollback()
|
||||
raise Problem(409, "resource_conflict", "Source name already exists.") from error
|
||||
await db.refresh(source)
|
||||
return {
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
"kind": source.kind,
|
||||
"state": source.state,
|
||||
"public_config": source.public_config,
|
||||
}
|
||||
|
||||
@app.post("/api/v2/sources/{source_id}/probe")
|
||||
async def probe_source(
|
||||
source_id: str,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> dict[str, Any]:
|
||||
source = await db.get(Source, source_id)
|
||||
if source is None:
|
||||
raise Problem(404, "resource_not_found", "Source was not found.")
|
||||
if source.state != "active":
|
||||
raise Problem(409, "source_archived", "Source is archived.")
|
||||
try:
|
||||
result = await LocalAdapter(Path(source.public_config["root"]), settings).probe()
|
||||
except SourceError as error:
|
||||
raise Problem(409, "source_probe_failed", str(error)) from error
|
||||
source.last_probe = result
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
@app.delete("/api/v2/sources/{source_id}", status_code=204)
|
||||
async def archive_source(
|
||||
source_id: str,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> Response:
|
||||
source = await db.get(Source, source_id)
|
||||
if source is None:
|
||||
raise Problem(404, "resource_not_found", "Source was not found.")
|
||||
source.state = "archived"
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/api/v2/jobs", status_code=201)
|
||||
async def create_job(
|
||||
input_: JobInput,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> dict[str, Any]:
|
||||
if input_.requested_mode not in {"full", "incremental"}:
|
||||
raise Problem(422, "validation_failed", "Invalid requested mode.")
|
||||
source = await db.get(Source, input_.source_id)
|
||||
repository = await db.get(Repository, input_.repository_id)
|
||||
if source is None or repository is None:
|
||||
raise Problem(422, "validation_failed", "Source and repository must exist.")
|
||||
if source.state != "active" or repository.state != "active":
|
||||
raise Problem(409, "resource_archived", "Source or repository is unavailable.")
|
||||
job = Job(
|
||||
name=input_.name,
|
||||
source_id=source.id,
|
||||
repository_id=repository.id,
|
||||
requested_mode=input_.requested_mode,
|
||||
exclusions=input_.exclusions,
|
||||
retention=input_.retention,
|
||||
enabled=input_.enabled,
|
||||
allow_empty=input_.allow_empty,
|
||||
)
|
||||
db.add(job)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as error:
|
||||
await db.rollback()
|
||||
raise Problem(409, "resource_conflict", "Job name already exists.") from error
|
||||
await db.refresh(job)
|
||||
return {
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"source_id": job.source_id,
|
||||
"repository_id": job.repository_id,
|
||||
"requested_mode": job.requested_mode,
|
||||
"exclusions": job.exclusions,
|
||||
"retention": job.retention,
|
||||
"enabled": job.enabled,
|
||||
"allow_empty": job.allow_empty,
|
||||
"state": job.state,
|
||||
}
|
||||
|
||||
@app.get("/api/v2/audit")
|
||||
async def list_audit(
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
|
||||
Reference in New Issue
Block a user