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.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
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.cli import build_alembic_config
|
||||||
from backup_tool.config import Settings
|
from backup_tool.config import Settings
|
||||||
from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine
|
from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine
|
||||||
@@ -21,9 +22,11 @@ from backup_tool.db.models import (
|
|||||||
ApiToken,
|
ApiToken,
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
IdempotencyRecord,
|
IdempotencyRecord,
|
||||||
|
Job,
|
||||||
Repository,
|
Repository,
|
||||||
Secret,
|
Secret,
|
||||||
Session,
|
Session,
|
||||||
|
Source,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from backup_tool.repository import (
|
from backup_tool.repository import (
|
||||||
@@ -78,6 +81,23 @@ class LoginInput(BaseModel):
|
|||||||
password: str = Field(min_length=1, max_length=1024)
|
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):
|
class TokenInput(BaseModel):
|
||||||
scopes: list[str] = Field(min_length=1)
|
scopes: list[str] = Field(min_length=1)
|
||||||
expires_at: datetime | None = None
|
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(409, "repository_policy_immutable", "Repository policy is immutable.")
|
||||||
raise Problem(422, "validation_failed", "No mutable fields supplied.")
|
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")
|
@app.get("/api/v2/audit")
|
||||||
async def list_audit(
|
async def list_audit(
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
|
|||||||
@@ -14,37 +14,54 @@ PASSWORD = "correct-horse-battery-staple"
|
|||||||
async def login(client: httpx.AsyncClient) -> dict[str, str]:
|
async def login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
return {"X-CSRF-Token": response.json()["csrf_token"]}
|
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_local_source_probe_archive_and_repository_targeted_job(tmp_path: Path) -> None:
|
async def test_local_source_probe_archive_and_repository_targeted_job(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
source_root = tmp_path / "sources"
|
source_root = tmp_path / "sources"
|
||||||
source_root.mkdir()
|
source_root.mkdir()
|
||||||
(source_root / "data.txt").write_text("contents")
|
(source_root / "data.txt").write_text("contents")
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
|
key = tmp_path / "master.key"
|
||||||
|
key.write_bytes(b"x" * 32)
|
||||||
|
key.chmod(0o600)
|
||||||
|
repositories = tmp_path / "repositories"
|
||||||
|
restore = tmp_path / "restore"
|
||||||
|
repositories.mkdir()
|
||||||
|
restore.mkdir()
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
data_dir=tmp_path / "data",
|
data_dir=data_dir,
|
||||||
database_url=f"sqlite+aiosqlite:///{tmp_path / 'data' / 'db.sqlite'}",
|
database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}",
|
||||||
repository_roots=(tmp_path / "repositories",),
|
repository_roots=(repositories,),
|
||||||
local_source_roots=(source_root,),
|
local_source_roots=(source_root,),
|
||||||
restore_roots=(tmp_path / "restore",),
|
restore_roots=(restore,),
|
||||||
master_key_file=tmp_path / "master.key",
|
master_key_file=key,
|
||||||
)
|
)
|
||||||
settings.master_key_file.write_bytes(b"x" * 32)
|
|
||||||
settings.master_key_file.chmod(0o600)
|
|
||||||
for root in (*settings.repository_roots, *settings.restore_roots):
|
|
||||||
root.mkdir(parents=True)
|
|
||||||
app = create_app(settings)
|
app = create_app(settings)
|
||||||
|
from backup_tool.db.models import Base
|
||||||
|
|
||||||
|
async with app.state.engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
headers = await login(client)
|
headers = await login(client)
|
||||||
repository = await client.post(
|
repository = await client.post(
|
||||||
"/api/v2/repositories", json={"name": "repo", "relative_path": "main"}, headers=headers
|
"/api/v2/repositories",
|
||||||
|
json={"name": "repo", "relative_path": "main"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert repository.status_code == 201
|
assert repository.status_code == 201
|
||||||
source = await client.post(
|
source = await client.post(
|
||||||
"/api/v2/sources",
|
"/api/v2/sources",
|
||||||
json={"name": "local", "kind": "local", "public_config": {"root": str(source_root)}},
|
json={
|
||||||
|
"name": "local",
|
||||||
|
"kind": "local",
|
||||||
|
"public_config": {"root": str(source_root)},
|
||||||
|
},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert source.status_code == 201
|
assert source.status_code == 201
|
||||||
@@ -54,14 +71,25 @@ async def test_local_source_probe_archive_and_repository_targeted_job(tmp_path:
|
|||||||
assert probe.json()["entry_count"] == 1
|
assert probe.json()["entry_count"] == 1
|
||||||
job = await client.post(
|
job = await client.post(
|
||||||
"/api/v2/jobs",
|
"/api/v2/jobs",
|
||||||
json={"name": "job", "source_id": source_id, "repository_id": repository.json()["id"], "requested_mode": "full", "exclusions": ["*.tmp"], "retention": {}, "enabled": True, "allow_empty": False},
|
json={
|
||||||
|
"name": "job",
|
||||||
|
"source_id": source_id,
|
||||||
|
"repository_id": repository.json()["id"],
|
||||||
|
"requested_mode": "full",
|
||||||
|
"exclusions": ["*.tmp"],
|
||||||
|
"retention": {},
|
||||||
|
"enabled": True,
|
||||||
|
"allow_empty": False,
|
||||||
|
},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert job.status_code == 201
|
assert job.status_code == 201
|
||||||
assert "destination_path" not in job.json()
|
assert "destination_path" not in job.json()
|
||||||
archived = await client.delete(f"/api/v2/sources/{source_id}", headers=headers)
|
archived = await client.delete(f"/api/v2/sources/{source_id}", headers=headers)
|
||||||
assert archived.status_code == 204
|
assert archived.status_code == 204
|
||||||
assert (await client.post(f"/api/v2/sources/{source_id}/probe", headers=headers)).status_code == 409
|
assert (
|
||||||
|
await client.post(f"/api/v2/sources/{source_id}/probe", headers=headers)
|
||||||
|
).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -70,21 +98,39 @@ async def test_local_source_rejects_unallowlisted_root(tmp_path: Path) -> None:
|
|||||||
allowed.mkdir()
|
allowed.mkdir()
|
||||||
outside = tmp_path / "outside"
|
outside = tmp_path / "outside"
|
||||||
outside.mkdir()
|
outside.mkdir()
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
key = tmp_path / "master.key"
|
key = tmp_path / "master.key"
|
||||||
key.write_bytes(b"x" * 32)
|
key.write_bytes(b"x" * 32)
|
||||||
key.chmod(0o600)
|
key.chmod(0o600)
|
||||||
|
repositories = tmp_path / "repositories"
|
||||||
|
restore = tmp_path / "restore"
|
||||||
|
repositories.mkdir()
|
||||||
|
restore.mkdir()
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
data_dir=tmp_path / "data",
|
data_dir=data_dir,
|
||||||
database_url=f"sqlite+aiosqlite:///{tmp_path / 'data' / 'db.sqlite'}",
|
database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}",
|
||||||
repository_roots=(tmp_path / "repositories",),
|
repository_roots=(repositories,),
|
||||||
local_source_roots=(allowed,),
|
local_source_roots=(allowed,),
|
||||||
restore_roots=(tmp_path / "restore",),
|
restore_roots=(restore,),
|
||||||
master_key_file=key,
|
master_key_file=key,
|
||||||
)
|
)
|
||||||
settings.repository_roots[0].mkdir()
|
|
||||||
settings.restore_roots[0].mkdir()
|
|
||||||
app = create_app(settings)
|
app = create_app(settings)
|
||||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
from backup_tool.db.models import Base
|
||||||
|
|
||||||
|
async with app.state.engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="https://test"
|
||||||
|
) as client:
|
||||||
headers = await login(client)
|
headers = await login(client)
|
||||||
response = await client.post("/api/v2/sources", json={"name":"bad","kind":"local","public_config":{"root":str(outside)}}, headers=headers)
|
response = await client.post(
|
||||||
|
"/api/v2/sources",
|
||||||
|
json={
|
||||||
|
"name": "bad",
|
||||||
|
"kind": "local",
|
||||||
|
"public_config": {"root": str(outside)},
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|||||||
Reference in New Issue
Block a user