feat: prepare ARR for controlled public deployment
This commit is contained in:
115
arr_storage/grants.py
Normal file
115
arr_storage/grants.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Opaque, short-lived, single-read grants for the runtime fetch provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from arr_ingestion.contracts import OPAQUE_ID_RE, IngestionError
|
||||
from arr_storage.store import ManagedObjectStore
|
||||
|
||||
|
||||
MAX_READ_GRANT_TTL_SECONDS = 300
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpaqueReadGrant:
|
||||
handle: str
|
||||
job_id: str
|
||||
expires_at: datetime
|
||||
max_reads: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class _GrantRecord:
|
||||
public: OpaqueReadGrant
|
||||
object_key: str
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
class InMemoryReadGrantBroker:
|
||||
"""Reference broker for tests; production must use a shared atomic grant ledger."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ManagedObjectStore,
|
||||
*,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self._lock = threading.Lock()
|
||||
self._records: Dict[str, _GrantRecord] = {}
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
object_key: str,
|
||||
original_filename: str,
|
||||
ttl_seconds: int = MAX_READ_GRANT_TTL_SECONDS,
|
||||
) -> OpaqueReadGrant:
|
||||
if not isinstance(job_id, str) or not OPAQUE_ID_RE.fullmatch(job_id):
|
||||
raise IngestionError("READ_GRANT_INVALID", "read grant job is invalid")
|
||||
if (
|
||||
not isinstance(ttl_seconds, int)
|
||||
or isinstance(ttl_seconds, bool)
|
||||
or not 1 <= ttl_seconds <= MAX_READ_GRANT_TTL_SECONDS
|
||||
):
|
||||
raise IngestionError("READ_GRANT_INVALID", "read grant lifetime is invalid")
|
||||
descriptor = self._store.inspect_committed(object_key, original_filename)
|
||||
if descriptor.job_id != job_id or descriptor.role != "source_xml":
|
||||
raise IngestionError("READ_GRANT_INVALID", "read grant target is invalid")
|
||||
now = self._aware_now()
|
||||
public = OpaqueReadGrant(
|
||||
handle="arr_file_" + secrets.token_urlsafe(24),
|
||||
job_id=job_id,
|
||||
expires_at=now + timedelta(seconds=ttl_seconds),
|
||||
)
|
||||
with self._lock:
|
||||
self._records[public.handle] = _GrantRecord(public, object_key)
|
||||
return public
|
||||
|
||||
def materialize(
|
||||
self,
|
||||
*,
|
||||
handle: str,
|
||||
job_id: str,
|
||||
destination: Path,
|
||||
max_bytes: int,
|
||||
) -> None:
|
||||
now = self._aware_now()
|
||||
with self._lock:
|
||||
record = self._records.get(handle)
|
||||
if (
|
||||
record is None
|
||||
or record.consumed
|
||||
or record.public.job_id != job_id
|
||||
or now >= record.public.expires_at
|
||||
):
|
||||
raise IngestionError("READ_GRANT_INVALID", "read grant is unavailable")
|
||||
record.consumed = True
|
||||
object_key = record.object_key
|
||||
self._store.materialize(object_key, destination, max_bytes)
|
||||
|
||||
def purge_expired(self) -> int:
|
||||
now = self._aware_now()
|
||||
with self._lock:
|
||||
expired = [
|
||||
handle
|
||||
for handle, record in self._records.items()
|
||||
if record.consumed or now >= record.public.expires_at
|
||||
]
|
||||
for handle in expired:
|
||||
del self._records[handle]
|
||||
return len(expired)
|
||||
|
||||
def _aware_now(self) -> datetime:
|
||||
value = self._clock()
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError("grant clock must return a timezone-aware datetime")
|
||||
return value.astimezone(timezone.utc)
|
||||
Reference in New Issue
Block a user