Archive location (Gitea issue 7): ARCHIVE_ROOT, Admin file-locations API, archive-copy job, doctor check

A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker.
A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 17:50:04 -06:00
co-authored by Claude Opus 5.5
parent 3fdf063b70
commit da31b0a87a
21 changed files with 1537 additions and 11 deletions
+141
View File
@@ -0,0 +1,141 @@
"""Admin: File locations (Gitea issue 7). The archive folder under ARCHIVE_ROOT, its check, and the archive copies.
Every route needs the admin role and a password session. Writes answer 409 `archive_off` while ARCHIVE_ROOT is unset.
The check and the save are audited (`archive.checked`, `admin.file_locations_updated`); "Retry now" is audited as
`admin.archive_retry` through the job request.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
from starlette.responses import JSONResponse
from ..domain import archive
from .deps import Db, Principal, admin_principal, get_db, settings_of
from .errors import ApiError
from .jobs import request_run
from .packets import WRITE
router = APIRouter(prefix="/api/admin/file-locations", tags=["admin"])
class CheckBody(BaseModel):
model_config = ConfigDict(extra="forbid")
archive_dir: str = Field(max_length=archive.DIR_MAX + 100)
class PutBody(BaseModel):
model_config = ConfigDict(extra="forbid")
archive_dir: str = Field(max_length=archive.DIR_MAX + 100)
version: int = Field(ge=0)
def _out(conn: Any, request: Request) -> dict[str, Any]:
root = archive.root_of(settings_of(request))
archive_dir, version, _, _ = archive.load(conn)
resolved = None
if root is not None and archive_dir is not None:
try:
resolved = str(archive.resolve_dir(root, archive_dir)[1])
except archive.PathRefused:
resolved = None
last = archive.last_check(conn)
return {
"enabled": root is not None,
"root": str(root) if root is not None else None,
"archive_dir": archive_dir,
"version": version,
"resolved": resolved,
"last_check": {k: last[k] for k in ("ok", "at", "by", "message")} if last else None,
"copies": archive.copies_status(conn),
}
def _root_or_409(request: Request) -> Any:
root = archive.root_of(settings_of(request))
if root is None:
raise ApiError(409, "archive_off", archive.OFF_MESSAGE)
return root
@router.get("")
def file_locations(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return _out(d.conn, request)
@router.post("/check", dependencies=WRITE)
async def file_locations_check(
body: CheckBody,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
root = _root_or_409(request)
rel, result = await run_in_threadpool(archive.check, root, body.archive_dir)
await run_in_threadpool(
archive.audit_check, d.conn, result, archive_dir=rel or body.archive_dir, source="admin", who=p
)
if rel is None:
# The path rules refused it (not a failed probe): 422 with the reason, still audited above.
raise ApiError(422, "bad_path", result.message)
return result.as_dict()
@router.put("", dependencies=WRITE)
async def file_locations_put(
body: PutBody,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Any:
root = _root_or_409(request)
conn = d.conn
rel, result = await run_in_threadpool(archive.check, root, body.archive_dir)
await run_in_threadpool(
archive.audit_check, conn, result, archive_dir=rel or body.archive_dir, source="save", who=p
)
if rel is None:
raise ApiError(422, "bad_path", result.message)
if not result.ok:
raise ApiError(422, "check_failed", result.message, extra={"check": result.as_dict()})
def save() -> dict[str, Any]:
try:
archive.save(conn, who=p, archive_dir=rel, version=body.version)
except archive.VersionConflict:
raise ApiError(
409,
"version_conflict",
"Someone else changed the file locations. Reload and try again.",
extra={"current": jsonable_encoder(_out(conn, request))},
) from None
with conn.transaction():
archive.request_copy(conn, p.user_id, "archive folder saved")
return _out(conn, request)
return await run_in_threadpool(save)
@router.post("/retry", dependencies=WRITE)
def file_locations_retry(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> JSONResponse:
_root_or_409(request)
conn = d.conn
with conn.transaction():
reset = archive.reset_backoff(conn)
out = request_run(conn, p, archive.JOB, note="Retry now (File locations)", action="admin.archive_retry")
return JSONResponse(status_code=202, content={**out, "reset": reset})