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