feat(simulation): add portable gaussian provider connector
This commit is contained in:
@@ -0,0 +1,414 @@
|
|||||||
|
"""Portable connector for the external DC Gaussian Pipeline service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
from urllib.parse import quote, unquote, urljoin, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
TUS_VERSION: Final = "1.0.0"
|
||||||
|
BUILD_REQUEST_SCHEMA: Final = "gaussian-pipeline.build-request/v1"
|
||||||
|
JOB_SCHEMA: Final = "gaussian-pipeline.job/v1"
|
||||||
|
RESULT_SCHEMA: Final = "gaussian-pipeline.build-result/v1"
|
||||||
|
CAPABILITIES_SCHEMA: Final = "gaussian-pipeline.capabilities/v1"
|
||||||
|
SAFE_UPLOAD_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||||
|
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024
|
||||||
|
MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024
|
||||||
|
MAX_RETRIES: Final = 3
|
||||||
|
|
||||||
|
|
||||||
|
class GaussianPipelineGatewayError(RuntimeError):
|
||||||
|
"""The configured Gaussian provider violated or rejected its connector contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class GaussianPipelineConfigurationError(GaussianPipelineGatewayError):
|
||||||
|
"""The provider connector configuration is incomplete or unsafe."""
|
||||||
|
|
||||||
|
|
||||||
|
class GaussianPipelineUnavailableError(GaussianPipelineGatewayError):
|
||||||
|
"""The provider cannot currently be reached."""
|
||||||
|
|
||||||
|
|
||||||
|
class GaussianPipelineIntegrityError(GaussianPipelineGatewayError):
|
||||||
|
"""Transferred bytes do not match their immutable descriptor."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GaussianSourceUpload:
|
||||||
|
upload_id: str
|
||||||
|
filename: str
|
||||||
|
format: str
|
||||||
|
sha256: str
|
||||||
|
byte_length: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"upload_id": self.upload_id,
|
||||||
|
"filename": self.filename,
|
||||||
|
"format": self.format,
|
||||||
|
"sha256": self.sha256,
|
||||||
|
"byte_length": self.byte_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GaussianPipelineGateway:
|
||||||
|
"""TUS and JSON client with bounded responses and independent digest checks."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
token_file: Path,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
|
chunk_bytes: int = DEFAULT_CHUNK_BYTES,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.endpoint = _endpoint(endpoint)
|
||||||
|
self.token_file = token_file.expanduser().absolute()
|
||||||
|
self.token = _read_token(self.token_file)
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise GaussianPipelineConfigurationError("provider timeout must be positive")
|
||||||
|
if chunk_bytes <= 0 or chunk_bytes > 64 * 1024 * 1024:
|
||||||
|
raise GaussianPipelineConfigurationError("provider upload chunk size is invalid")
|
||||||
|
self.chunk_bytes = chunk_bytes
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=self.endpoint,
|
||||||
|
headers={"Authorization": f"Bearer {self.token}"},
|
||||||
|
timeout=httpx.Timeout(timeout_seconds),
|
||||||
|
transport=transport,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> GaussianPipelineGateway:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def capabilities(self) -> dict[str, Any]:
|
||||||
|
document = self._json("GET", "/v1/capabilities")
|
||||||
|
if (
|
||||||
|
document.get("schema_version") != CAPABILITIES_SCHEMA
|
||||||
|
or document.get("service") != "ndc-gaussian-pipeline"
|
||||||
|
):
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian provider capabilities do not match v1")
|
||||||
|
return document
|
||||||
|
|
||||||
|
def upload_source(
|
||||||
|
self,
|
||||||
|
source_path: Path,
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
source_format: str,
|
||||||
|
sha256: str,
|
||||||
|
) -> GaussianSourceUpload:
|
||||||
|
source_candidate = source_path.expanduser().absolute()
|
||||||
|
if source_candidate.is_symlink() or not source_candidate.is_file():
|
||||||
|
raise GaussianPipelineIntegrityError("Gaussian source must be one regular file")
|
||||||
|
source = source_candidate.resolve()
|
||||||
|
if source_format not in {"lcc", "lcc2"}:
|
||||||
|
raise GaussianPipelineIntegrityError("Gaussian source format must be lcc or lcc2")
|
||||||
|
if Path(filename).name != filename or not filename.lower().endswith(f".{source_format}"):
|
||||||
|
raise GaussianPipelineIntegrityError("Gaussian source filename is unsafe")
|
||||||
|
if SHA256_PATTERN.fullmatch(sha256) is None:
|
||||||
|
raise GaussianPipelineIntegrityError("Gaussian source digest is invalid")
|
||||||
|
byte_length = source.stat().st_size
|
||||||
|
if byte_length <= 0:
|
||||||
|
raise GaussianPipelineIntegrityError("Gaussian source is empty")
|
||||||
|
if _sha256(source) != sha256:
|
||||||
|
raise GaussianPipelineIntegrityError("Gaussian source digest does not match")
|
||||||
|
|
||||||
|
metadata = _tus_metadata(
|
||||||
|
{"filename": filename, "format": source_format, "sha256": sha256}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self._client.post(
|
||||||
|
"/v1/uploads",
|
||||||
|
headers={
|
||||||
|
"Tus-Resumable": TUS_VERSION,
|
||||||
|
"Upload-Length": str(byte_length),
|
||||||
|
"Upload-Metadata": metadata,
|
||||||
|
},
|
||||||
|
content=b"",
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise _unavailable("Gaussian upload could not be created", exc) from exc
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
if location is None:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian upload response omitted Location")
|
||||||
|
upload_url = urljoin(str(response.request.url), location)
|
||||||
|
if not _is_provider_upload_url(self.endpoint, upload_url):
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian upload Location escaped the provider")
|
||||||
|
upload_id = unquote(urlparse(upload_url).path.rsplit("/", 1)[-1])
|
||||||
|
if SAFE_UPLOAD_ID.fullmatch(upload_id) is None:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian upload id is invalid")
|
||||||
|
self._send_file(source, upload_url, byte_length)
|
||||||
|
return GaussianSourceUpload(
|
||||||
|
upload_id=upload_id,
|
||||||
|
filename=filename,
|
||||||
|
format=source_format,
|
||||||
|
sha256=sha256,
|
||||||
|
byte_length=byte_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
def submit_build(self, document: Mapping[str, object]) -> dict[str, Any]:
|
||||||
|
if document.get("schema_version") != BUILD_REQUEST_SCHEMA:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian build request schema is invalid")
|
||||||
|
result = self._json("POST", "/v1/jobs", document=document)
|
||||||
|
if result.get("schema_version") != JOB_SCHEMA:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian job response does not match v1")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> dict[str, Any]:
|
||||||
|
_safe_id(job_id, "job id")
|
||||||
|
result = self._json("GET", f"/v1/jobs/{quote(job_id, safe='')}")
|
||||||
|
if result.get("schema_version") != JOB_SCHEMA or result.get("job_id") != job_id:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian job identity does not match")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_result(self, job_id: str) -> dict[str, Any]:
|
||||||
|
_safe_id(job_id, "job id")
|
||||||
|
result = self._json("GET", f"/v1/jobs/{quote(job_id, safe='')}/result")
|
||||||
|
if result.get("schema_version") != RESULT_SCHEMA or result.get("job_id") != job_id:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian result identity does not match")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def download_artifact(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
descriptor: Mapping[str, object],
|
||||||
|
destination: Path,
|
||||||
|
) -> Path:
|
||||||
|
_safe_id(job_id, "job id")
|
||||||
|
logical_path = descriptor.get("logical_path")
|
||||||
|
expected_sha256 = descriptor.get("sha256")
|
||||||
|
expected_bytes = descriptor.get("byte_length")
|
||||||
|
if (
|
||||||
|
not isinstance(logical_path, str)
|
||||||
|
or not logical_path
|
||||||
|
or logical_path.startswith("/")
|
||||||
|
or "\\" in logical_path
|
||||||
|
or any(part in {"", ".", ".."} for part in logical_path.split("/"))
|
||||||
|
):
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian artifact logical path is invalid")
|
||||||
|
if (
|
||||||
|
not isinstance(expected_sha256, str)
|
||||||
|
or SHA256_PATTERN.fullmatch(expected_sha256) is None
|
||||||
|
):
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian artifact digest is invalid")
|
||||||
|
if (
|
||||||
|
not isinstance(expected_bytes, int)
|
||||||
|
or isinstance(expected_bytes, bool)
|
||||||
|
or expected_bytes < 0
|
||||||
|
):
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian artifact byte length is invalid")
|
||||||
|
target = destination.expanduser().resolve()
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_name(f".{target.name}.partial-{os.getpid()}")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
byte_length = 0
|
||||||
|
temporary_created = False
|
||||||
|
try:
|
||||||
|
with self._client.stream(
|
||||||
|
"GET",
|
||||||
|
f"/v1/jobs/{quote(job_id, safe='')}/artifacts/{quote(logical_path, safe='/')}",
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with temporary.open("xb") as output:
|
||||||
|
temporary_created = True
|
||||||
|
for chunk in response.iter_bytes(chunk_size=1024 * 1024):
|
||||||
|
output.write(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
byte_length += len(chunk)
|
||||||
|
if byte_length != expected_bytes or digest.hexdigest() != expected_sha256:
|
||||||
|
raise GaussianPipelineIntegrityError(
|
||||||
|
"Gaussian artifact bytes do not match the result manifest"
|
||||||
|
)
|
||||||
|
temporary.replace(target)
|
||||||
|
return target
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise _unavailable("Gaussian artifact download failed", exc) from exc
|
||||||
|
finally:
|
||||||
|
if temporary_created:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _send_file(self, source: Path, upload_url: str, byte_length: int) -> None:
|
||||||
|
offset = self._upload_offset(upload_url, byte_length)
|
||||||
|
retries = 0
|
||||||
|
with source.open("rb") as input_file:
|
||||||
|
while offset < byte_length:
|
||||||
|
input_file.seek(offset)
|
||||||
|
chunk = input_file.read(min(self.chunk_bytes, byte_length - offset))
|
||||||
|
if not chunk:
|
||||||
|
raise GaussianPipelineIntegrityError(
|
||||||
|
"Gaussian source ended before upload length"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self._client.patch(
|
||||||
|
upload_url,
|
||||||
|
headers={
|
||||||
|
"Tus-Resumable": TUS_VERSION,
|
||||||
|
"Upload-Offset": str(offset),
|
||||||
|
"Content-Type": "application/offset+octet-stream",
|
||||||
|
},
|
||||||
|
content=chunk,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
next_offset = _offset(response.headers.get("Upload-Offset"), byte_length)
|
||||||
|
if next_offset != offset + len(chunk):
|
||||||
|
raise GaussianPipelineGatewayError(
|
||||||
|
"Gaussian upload returned a non-contiguous offset"
|
||||||
|
)
|
||||||
|
offset = next_offset
|
||||||
|
retries = 0
|
||||||
|
except httpx.TransportError as exc:
|
||||||
|
retries += 1
|
||||||
|
if retries > MAX_RETRIES:
|
||||||
|
raise _unavailable(
|
||||||
|
"Gaussian upload failed after resume attempts", exc
|
||||||
|
) from exc
|
||||||
|
offset = self._upload_offset(upload_url, byte_length)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 409 and retries < MAX_RETRIES:
|
||||||
|
retries += 1
|
||||||
|
offset = self._upload_offset(upload_url, byte_length)
|
||||||
|
continue
|
||||||
|
raise _unavailable("Gaussian upload was rejected", exc) from exc
|
||||||
|
|
||||||
|
def _upload_offset(self, upload_url: str, byte_length: int) -> int:
|
||||||
|
try:
|
||||||
|
response = self._client.head(
|
||||||
|
upload_url,
|
||||||
|
headers={"Tus-Resumable": TUS_VERSION},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise _unavailable("Gaussian upload offset is unavailable", exc) from exc
|
||||||
|
return _offset(response.headers.get("Upload-Offset"), byte_length)
|
||||||
|
|
||||||
|
def _json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
document: Mapping[str, object] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
response = self._client.request(method, path, json=document)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise _unavailable("Gaussian provider request failed", exc) from exc
|
||||||
|
if len(response.content) > MAX_JSON_RESPONSE_BYTES:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian provider JSON response is too large")
|
||||||
|
try:
|
||||||
|
value = response.json()
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian provider response is not JSON") from exc
|
||||||
|
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian provider response must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def configured_gaussian_pipeline_gateway() -> GaussianPipelineGateway | None:
|
||||||
|
endpoint = os.environ.get("MISSIONCORE_GAUSSIAN_ENDPOINT")
|
||||||
|
token_file = os.environ.get("MISSIONCORE_GAUSSIAN_TOKEN_FILE")
|
||||||
|
if endpoint is None and token_file is None:
|
||||||
|
return None
|
||||||
|
if not endpoint or not token_file:
|
||||||
|
raise GaussianPipelineConfigurationError(
|
||||||
|
"MISSIONCORE_GAUSSIAN_ENDPOINT and MISSIONCORE_GAUSSIAN_TOKEN_FILE must be set together"
|
||||||
|
)
|
||||||
|
return GaussianPipelineGateway(endpoint, Path(token_file))
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(value: str) -> str:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if (
|
||||||
|
parsed.scheme not in {"http", "https"}
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or parsed.path not in {"", "/"}
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
raise GaussianPipelineConfigurationError("Gaussian provider endpoint is invalid")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_provider_upload_url(endpoint: str, upload_url: str) -> bool:
|
||||||
|
provider = urlparse(endpoint)
|
||||||
|
upload = urlparse(upload_url)
|
||||||
|
return (
|
||||||
|
upload.scheme == provider.scheme
|
||||||
|
and upload.hostname == provider.hostname
|
||||||
|
and upload.port == provider.port
|
||||||
|
and upload.username is None
|
||||||
|
and upload.password is None
|
||||||
|
and upload.path.startswith("/v1/uploads/")
|
||||||
|
and not upload.params
|
||||||
|
and not upload.query
|
||||||
|
and not upload.fragment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_token(token_file: Path) -> str:
|
||||||
|
try:
|
||||||
|
metadata = token_file.lstat()
|
||||||
|
if token_file.is_symlink() or not token_file.is_file():
|
||||||
|
raise GaussianPipelineConfigurationError("Gaussian token must be one regular file")
|
||||||
|
token = token_file.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise GaussianPipelineConfigurationError("Gaussian token file is unavailable") from exc
|
||||||
|
if metadata.st_size > 4096 or not 32 <= len(token) <= 4096:
|
||||||
|
raise GaussianPipelineConfigurationError("Gaussian token length is invalid")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _tus_metadata(values: Mapping[str, str]) -> str:
|
||||||
|
return ",".join(
|
||||||
|
f"{key} {base64.b64encode(value.encode('utf-8')).decode('ascii')}"
|
||||||
|
for key, value in values.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _offset(value: str | None, byte_length: int) -> int:
|
||||||
|
if value is None or not value.isdigit():
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian upload offset is invalid")
|
||||||
|
offset = int(value)
|
||||||
|
if not 0 <= offset <= byte_length:
|
||||||
|
raise GaussianPipelineGatewayError("Gaussian upload offset is outside the source")
|
||||||
|
return offset
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_id(value: str, label: str) -> None:
|
||||||
|
if SAFE_UPLOAD_ID.fullmatch(value) is None:
|
||||||
|
raise GaussianPipelineGatewayError(f"Gaussian {label} is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewayError:
|
||||||
|
if isinstance(error, httpx.TransportError):
|
||||||
|
return GaussianPipelineUnavailableError(message)
|
||||||
|
return GaussianPipelineGatewayError(message)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Read-only Mission Core projection of the external Gaussian build provider."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from k1link.simulation.gaussian_pipeline_gateway import (
|
||||||
|
GaussianPipelineGateway,
|
||||||
|
GaussianPipelineGatewayError,
|
||||||
|
configured_gaussian_pipeline_gateway,
|
||||||
|
)
|
||||||
|
|
||||||
|
ProviderFactory = Callable[[], GaussianPipelineGateway | None]
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationWorldProviderStatus(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
schema_version: Literal["missioncore.simulation-world-provider-status/v1"] = (
|
||||||
|
"missioncore.simulation-world-provider-status/v1"
|
||||||
|
)
|
||||||
|
provider_id: Literal["gaussian-pipeline"] = "gaussian-pipeline"
|
||||||
|
configured: bool
|
||||||
|
state: Literal["ready", "unavailable"]
|
||||||
|
capabilities: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
def build_simulation_world_provider_router(
|
||||||
|
provider_factory: ProviderFactory = configured_gaussian_pipeline_gateway,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/simulation-worlds/provider",
|
||||||
|
response_model=SimulationWorldProviderStatus,
|
||||||
|
)
|
||||||
|
def provider_status() -> SimulationWorldProviderStatus:
|
||||||
|
provider: GaussianPipelineGateway | None = None
|
||||||
|
try:
|
||||||
|
provider = provider_factory()
|
||||||
|
if provider is None:
|
||||||
|
return SimulationWorldProviderStatus(
|
||||||
|
configured=False,
|
||||||
|
state="unavailable",
|
||||||
|
capabilities=None,
|
||||||
|
)
|
||||||
|
return SimulationWorldProviderStatus(
|
||||||
|
configured=True,
|
||||||
|
state="ready",
|
||||||
|
capabilities=provider.capabilities(),
|
||||||
|
)
|
||||||
|
except GaussianPipelineGatewayError:
|
||||||
|
return SimulationWorldProviderStatus(
|
||||||
|
configured=True,
|
||||||
|
state="unavailable",
|
||||||
|
capabilities=None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if provider is not None:
|
||||||
|
provider.close()
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.simulation.gaussian_pipeline_gateway import (
|
||||||
|
BUILD_REQUEST_SCHEMA,
|
||||||
|
GaussianPipelineGateway,
|
||||||
|
GaussianPipelineGatewayError,
|
||||||
|
GaussianPipelineIntegrityError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_file(tmp_path: Path) -> Path:
|
||||||
|
token = tmp_path / "gaussian.token"
|
||||||
|
token.write_text("t" * 64, encoding="utf-8")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_uploads_with_tus_and_reads_provider_contract(tmp_path: Path) -> None:
|
||||||
|
uploaded = bytearray()
|
||||||
|
metadata: dict[str, str] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.headers["authorization"] == f"Bearer {'t' * 64}"
|
||||||
|
if request.method == "GET" and request.url.path == "/v1/capabilities":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"schema_version": "gaussian-pipeline.capabilities/v1",
|
||||||
|
"service": "ndc-gaussian-pipeline",
|
||||||
|
"provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.method == "POST" and request.url.path == "/v1/uploads":
|
||||||
|
for item in request.headers["upload-metadata"].split(","):
|
||||||
|
key, encoded = item.split(" ", 1)
|
||||||
|
metadata[key] = base64.b64decode(encoded).decode("utf-8")
|
||||||
|
return httpx.Response(201, headers={"Location": "/v1/uploads/upload-001"})
|
||||||
|
if request.method == "HEAD" and request.url.path == "/v1/uploads/upload-001":
|
||||||
|
return httpx.Response(200, headers={"Upload-Offset": str(len(uploaded))})
|
||||||
|
if request.method == "PATCH" and request.url.path == "/v1/uploads/upload-001":
|
||||||
|
assert int(request.headers["upload-offset"]) == len(uploaded)
|
||||||
|
uploaded.extend(request.content)
|
||||||
|
return httpx.Response(204, headers={"Upload-Offset": str(len(uploaded))})
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
source = tmp_path / "yard.lcc2"
|
||||||
|
source.write_bytes(b"portable-gaussian-source")
|
||||||
|
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
with GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
_token_file(tmp_path),
|
||||||
|
chunk_bytes=5,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
) as gateway:
|
||||||
|
assert gateway.capabilities()["service"] == "ndc-gaussian-pipeline"
|
||||||
|
descriptor = gateway.upload_source(
|
||||||
|
source,
|
||||||
|
filename="yard.lcc2",
|
||||||
|
source_format="lcc2",
|
||||||
|
sha256=digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bytes(uploaded) == source.read_bytes()
|
||||||
|
assert metadata == {"filename": "yard.lcc2", "format": "lcc2", "sha256": digest}
|
||||||
|
assert descriptor.to_dict() == {
|
||||||
|
"upload_id": "upload-001",
|
||||||
|
"filename": "yard.lcc2",
|
||||||
|
"format": "lcc2",
|
||||||
|
"sha256": digest,
|
||||||
|
"byte_length": len(uploaded),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_rejects_local_source_digest_mismatch(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "yard.lcc"
|
||||||
|
source.write_bytes(b"source")
|
||||||
|
with (
|
||||||
|
GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
_token_file(tmp_path),
|
||||||
|
transport=httpx.MockTransport(lambda _request: httpx.Response(500)),
|
||||||
|
) as gateway,
|
||||||
|
pytest.raises(GaussianPipelineIntegrityError, match="digest does not match"),
|
||||||
|
):
|
||||||
|
gateway.upload_source(
|
||||||
|
source,
|
||||||
|
filename="yard.lcc",
|
||||||
|
source_format="lcc",
|
||||||
|
sha256="a" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_rejects_upload_location_outside_provider(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "yard.lcc"
|
||||||
|
source.write_bytes(b"source")
|
||||||
|
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
201,
|
||||||
|
headers={"Location": "https://attacker.invalid/v1/uploads/stolen"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
_token_file(tmp_path),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
) as gateway,
|
||||||
|
pytest.raises(GaussianPipelineGatewayError, match="escaped the provider"),
|
||||||
|
):
|
||||||
|
gateway.upload_source(
|
||||||
|
source,
|
||||||
|
filename="yard.lcc",
|
||||||
|
source_format="lcc",
|
||||||
|
sha256=digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_rejects_symlink_source_and_token(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source.lcc"
|
||||||
|
source.write_bytes(b"source")
|
||||||
|
source_link = tmp_path / "source-link.lcc"
|
||||||
|
source_link.symlink_to(source)
|
||||||
|
token = _token_file(tmp_path)
|
||||||
|
token_link = tmp_path / "token-link"
|
||||||
|
token_link.symlink_to(token)
|
||||||
|
|
||||||
|
with pytest.raises(GaussianPipelineGatewayError, match="token must be one regular file"):
|
||||||
|
GaussianPipelineGateway("http://gaussian.test", token_link)
|
||||||
|
|
||||||
|
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
with (
|
||||||
|
GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
token,
|
||||||
|
transport=httpx.MockTransport(lambda _request: httpx.Response(500)),
|
||||||
|
) as gateway,
|
||||||
|
pytest.raises(GaussianPipelineIntegrityError, match="one regular file"),
|
||||||
|
):
|
||||||
|
gateway.upload_source(
|
||||||
|
source_link,
|
||||||
|
filename="source.lcc",
|
||||||
|
source_format="lcc",
|
||||||
|
sha256=digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_submits_tracks_and_imports_digest_bound_artifact(tmp_path: Path) -> None:
|
||||||
|
artifact = b"preview-sog"
|
||||||
|
artifact_sha = hashlib.sha256(artifact).hexdigest()
|
||||||
|
job_id = "gsp-20260825200000-deadbeef"
|
||||||
|
request_document: dict[str, object] = {
|
||||||
|
"schema_version": BUILD_REQUEST_SCHEMA,
|
||||||
|
"idempotency_key": "missioncore-build-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == "/v1/jobs":
|
||||||
|
assert json.loads(request.content) == request_document
|
||||||
|
return httpx.Response(
|
||||||
|
202,
|
||||||
|
json={"schema_version": "gaussian-pipeline.job/v1", "job_id": job_id},
|
||||||
|
)
|
||||||
|
if request.method == "GET" and request.url.path == f"/v1/jobs/{job_id}":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"schema_version": "gaussian-pipeline.job/v1", "job_id": job_id},
|
||||||
|
)
|
||||||
|
if request.method == "GET" and request.url.path == f"/v1/jobs/{job_id}/result":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"schema_version": "gaussian-pipeline.build-result/v1", "job_id": job_id},
|
||||||
|
)
|
||||||
|
if request.method == "GET" and request.url.path.endswith("/artifacts/preview.sog"):
|
||||||
|
return httpx.Response(200, content=artifact)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
with GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
_token_file(tmp_path),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
) as gateway:
|
||||||
|
assert gateway.submit_build(request_document)["job_id"] == job_id
|
||||||
|
assert gateway.get_job(job_id)["job_id"] == job_id
|
||||||
|
assert gateway.get_result(job_id)["job_id"] == job_id
|
||||||
|
destination = gateway.download_artifact(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"logical_path": "preview.sog",
|
||||||
|
"sha256": artifact_sha,
|
||||||
|
"byte_length": len(artifact),
|
||||||
|
},
|
||||||
|
tmp_path / "import" / "preview.sog",
|
||||||
|
)
|
||||||
|
assert destination.read_bytes() == artifact
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGateway
|
||||||
|
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway(tmp_path: Path, status: int = 200) -> GaussianPipelineGateway:
|
||||||
|
token = tmp_path / "token"
|
||||||
|
token.write_text("x" * 64, encoding="utf-8")
|
||||||
|
|
||||||
|
def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
if status != 200:
|
||||||
|
return httpx.Response(status)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"schema_version": "gaussian-pipeline.capabilities/v1",
|
||||||
|
"service": "ndc-gaussian-pipeline",
|
||||||
|
"provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
token,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_status_is_explicitly_unconfigured() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(build_simulation_world_provider_router(lambda: None))
|
||||||
|
response = TestClient(app).get("/api/v1/simulation-worlds/provider")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"schema_version": "missioncore.simulation-world-provider-status/v1",
|
||||||
|
"provider_id": "gaussian-pipeline",
|
||||||
|
"configured": False,
|
||||||
|
"state": "unavailable",
|
||||||
|
"capabilities": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_status_projects_ready_capabilities(tmp_path: Path) -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(build_simulation_world_provider_router(lambda: _gateway(tmp_path)))
|
||||||
|
response = TestClient(app).get("/api/v1/simulation-worlds/provider")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["state"] == "ready"
|
||||||
|
assert response.json()["capabilities"]["provider"]["version"] == "3.3.3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_status_fails_closed_without_leaking_transport_error(tmp_path: Path) -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(build_simulation_world_provider_router(lambda: _gateway(tmp_path, 503)))
|
||||||
|
response = TestClient(app).get("/api/v1/simulation-worlds/provider")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["configured"] is True
|
||||||
|
assert response.json()["state"] == "unavailable"
|
||||||
|
assert response.json()["capabilities"] is None
|
||||||
Reference in New Issue
Block a user