feat: prepare ARR for controlled public deployment
This commit is contained in:
212
arr_storage/contracts.py
Normal file
212
arr_storage/contracts.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Provider-neutral contracts for private ARR object storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import BinaryIO, Mapping, Optional, Protocol
|
||||
|
||||
from arr_ingestion.contracts import (
|
||||
ARTIFACT_SIZE_LIMITS,
|
||||
OPAQUE_ID_RE,
|
||||
ROLE_CONTRACTS,
|
||||
ArtifactRef,
|
||||
IngestionError,
|
||||
)
|
||||
|
||||
|
||||
OBJECT_METADATA_SCHEMA = "1.0"
|
||||
OBJECT_STATES = frozenset({"staged", "committed"})
|
||||
CANONICAL_OBJECT_FILENAMES = {
|
||||
"source_xml": "source.xml",
|
||||
"daily_report": "daily-report.xlsx",
|
||||
"result_json": "result.json",
|
||||
"structured_result_json": "structured-result.json",
|
||||
"exception_report": "exception-report.xlsx",
|
||||
}
|
||||
PREFIX_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
|
||||
|
||||
def valid_object_key(value: object) -> bool:
|
||||
if not isinstance(value, str) or not value or len(value) > 1024:
|
||||
return False
|
||||
if (
|
||||
value.startswith("/")
|
||||
or value.endswith("/")
|
||||
or "\\" in value
|
||||
or "//" in value
|
||||
or any(ord(char) < 32 or ord(char) == 127 for char in value)
|
||||
):
|
||||
return False
|
||||
path = PurePosixPath(value)
|
||||
return not path.is_absolute() and "." not in path.parts and ".." not in path.parts
|
||||
|
||||
|
||||
def valid_delivery_filename(role: str, value: object) -> bool:
|
||||
if role not in ROLE_CONTRACTS or not isinstance(value, str):
|
||||
return False
|
||||
_file_kind, extension, _mime_type = ROLE_CONTRACTS[role]
|
||||
return (
|
||||
0 < len(value) <= 255
|
||||
and PurePosixPath(value).name == value
|
||||
and value not in {".", ".."}
|
||||
and "/" not in value
|
||||
and "\\" not in value
|
||||
and not any(ord(char) < 32 or ord(char) == 127 for char in value)
|
||||
and value.lower().endswith(extension)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectAddress:
|
||||
job_id: str
|
||||
attempt_no: int
|
||||
state: str
|
||||
role: str
|
||||
|
||||
|
||||
class ObjectKeyPolicy:
|
||||
"""Builds and parses the only accepted ARR processing object-key shape."""
|
||||
|
||||
def __init__(self, prefix: str = "arr") -> None:
|
||||
parts = tuple(prefix.split("/"))
|
||||
if not parts or any(not PREFIX_SEGMENT_RE.fullmatch(part) for part in parts):
|
||||
raise ValueError("object prefix is invalid")
|
||||
self._parts = parts
|
||||
self.prefix = "/".join(parts)
|
||||
|
||||
def build(self, address: ObjectAddress) -> str:
|
||||
self._validate_address(address)
|
||||
filename = CANONICAL_OBJECT_FILENAMES[address.role]
|
||||
return "/".join(
|
||||
self._parts
|
||||
+ (
|
||||
"jobs",
|
||||
address.job_id,
|
||||
"attempts",
|
||||
f"{address.attempt_no:04d}",
|
||||
address.state,
|
||||
address.role,
|
||||
filename,
|
||||
)
|
||||
)
|
||||
|
||||
def parse(self, object_key: str) -> ObjectAddress:
|
||||
if not valid_object_key(object_key):
|
||||
raise IngestionError("ARTIFACT_REFERENCE_INVALID", "object key is invalid")
|
||||
parts = tuple(object_key.split("/"))
|
||||
tail = parts[len(self._parts) :]
|
||||
if parts[: len(self._parts)] != self._parts or len(tail) != 7:
|
||||
raise IngestionError("ARTIFACT_REFERENCE_INVALID", "object key is invalid")
|
||||
jobs, job_id, attempts, attempt_text, state, role, filename = tail
|
||||
if jobs != "jobs" or attempts != "attempts" or not attempt_text.isdigit():
|
||||
raise IngestionError("ARTIFACT_REFERENCE_INVALID", "object key is invalid")
|
||||
attempt_no = int(attempt_text)
|
||||
address = ObjectAddress(job_id, attempt_no, state, role)
|
||||
self._validate_address(address)
|
||||
if attempt_text != f"{attempt_no:04d}" or filename != CANONICAL_OBJECT_FILENAMES[role]:
|
||||
raise IngestionError("ARTIFACT_REFERENCE_INVALID", "object key is invalid")
|
||||
return address
|
||||
|
||||
@staticmethod
|
||||
def _validate_address(address: ObjectAddress) -> None:
|
||||
if (
|
||||
not isinstance(address.job_id, str)
|
||||
or not OPAQUE_ID_RE.fullmatch(address.job_id)
|
||||
or not isinstance(address.attempt_no, int)
|
||||
or isinstance(address.attempt_no, bool)
|
||||
or not 1 <= address.attempt_no <= 9999
|
||||
or address.state not in OBJECT_STATES
|
||||
or address.role not in CANONICAL_OBJECT_FILENAMES
|
||||
):
|
||||
raise IngestionError("ARTIFACT_REFERENCE_INVALID", "object address is invalid")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendObject:
|
||||
object_key: str
|
||||
byte_size: int
|
||||
metadata: Mapping[str, str]
|
||||
etag: Optional[str] = None
|
||||
version_id: Optional[str] = None
|
||||
|
||||
|
||||
class BackendError(RuntimeError):
|
||||
"""Normalized backend failure that never includes provider response text."""
|
||||
|
||||
def __init__(self, kind: str):
|
||||
super().__init__(kind)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
class ObjectBackend(Protocol):
|
||||
"""Adapter seam implemented by a filesystem, OSS, or S3-compatible client."""
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
object_key: str,
|
||||
source: str,
|
||||
mime_type: str,
|
||||
metadata: Mapping[str, str],
|
||||
*,
|
||||
if_absent: bool,
|
||||
) -> BackendObject:
|
||||
...
|
||||
|
||||
def head(self, object_key: str) -> BackendObject:
|
||||
...
|
||||
|
||||
def open_reader(self, object_key: str) -> BinaryIO:
|
||||
...
|
||||
|
||||
def copy_object(
|
||||
self,
|
||||
source_key: str,
|
||||
destination_key: str,
|
||||
metadata: Mapping[str, str],
|
||||
*,
|
||||
if_absent: bool,
|
||||
) -> BackendObject:
|
||||
...
|
||||
|
||||
def delete_object(self, object_key: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredObject:
|
||||
object_key: str
|
||||
job_id: str
|
||||
attempt_no: int
|
||||
state: str
|
||||
role: str
|
||||
original_filename: str
|
||||
sha256: str
|
||||
byte_size: int
|
||||
mime_type: str
|
||||
etag: Optional[str] = None
|
||||
version_id: Optional[str] = None
|
||||
|
||||
def to_artifact_ref(self) -> ArtifactRef:
|
||||
if self.state != "committed":
|
||||
raise IngestionError(
|
||||
"ARTIFACT_NOT_COMMITTED", "only committed objects may enter a delivery"
|
||||
)
|
||||
file_kind, _extension, _mime_type = ROLE_CONTRACTS[self.role]
|
||||
return ArtifactRef(
|
||||
role=self.role,
|
||||
file_kind=file_kind,
|
||||
object_key=self.object_key,
|
||||
original_filename=self.original_filename,
|
||||
sha256=self.sha256,
|
||||
byte_size=self.byte_size,
|
||||
mime_type=self.mime_type,
|
||||
)
|
||||
|
||||
|
||||
def role_limit(role: str) -> int:
|
||||
try:
|
||||
return ARTIFACT_SIZE_LIMITS[role]
|
||||
except KeyError:
|
||||
raise IngestionError("ARTIFACT_REFERENCE_INVALID", "artifact role is invalid") from None
|
||||
Reference in New Issue
Block a user