Prepare GeoIntel for public release
Managed validation / Managed repository validation (pull_request) Successful in 1m46s
GeoIntel release gates / Compile, test, contracts and builds (pull_request) Successful in 1m51s
GeoIntel release gates / Python and npm vulnerability policy (pull_request) Successful in 20s
GeoIntel release gates / Production AI image, SBOM and container scan (pull_request) Successful in 15m3s
GeoIntel release gates / Deploy exact gated revision to Unraid (pull_request) Skipped
Managed validation / Managed repository validation (pull_request) Successful in 1m46s
GeoIntel release gates / Compile, test, contracts and builds (pull_request) Successful in 1m51s
GeoIntel release gates / Python and npm vulnerability policy (pull_request) Successful in 20s
GeoIntel release gates / Production AI image, SBOM and container scan (pull_request) Successful in 15m3s
GeoIntel release gates / Deploy exact gated revision to Unraid (pull_request) Skipped
This commit is contained in:
@@ -269,6 +269,19 @@ def guest_login(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
client_host = _client_host(request)
|
||||
retry_after = AuthService.consume_guest_request(
|
||||
f"guest-login:{client_host}",
|
||||
max_requests=settings.guest_login_requests_per_minute,
|
||||
)
|
||||
if retry_after:
|
||||
raise AppError(
|
||||
code="GUEST_LOGIN_RATE_LIMITED",
|
||||
message="Too many guest sessions were requested. Try again later.",
|
||||
details={"retry_after_seconds": retry_after},
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
demo = DemoWorkflowService.seed(db)
|
||||
token = AuthService.create_session_token(
|
||||
settings.guest_display_name,
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.public_demo import is_public_demo_project
|
||||
from app.db.session import get_db
|
||||
from app.models import Area, Project
|
||||
from app.schemas import (
|
||||
@@ -163,6 +164,12 @@ async def upload_dataset(
|
||||
source_version: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if is_public_demo_project(project_id):
|
||||
raise AppError(
|
||||
code="PUBLIC_DEMO_UPLOAD_FORBIDDEN",
|
||||
message="Operator uploads are not accepted in the public demo project.",
|
||||
status_code=403,
|
||||
)
|
||||
if area_id is not None:
|
||||
area = db.get(Area, area_id)
|
||||
if not area:
|
||||
|
||||
@@ -55,12 +55,14 @@ def get_yolo_preflight(
|
||||
tile_manifest_path: str | None = None,
|
||||
check_model_load: bool = False,
|
||||
model_asset_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return envelope(
|
||||
YoloPreflightService.run(
|
||||
tile_manifest_path=tile_manifest_path,
|
||||
check_model_load=check_model_load,
|
||||
model_asset_id=model_asset_id,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -47,21 +47,18 @@ def _database_checks() -> dict[str, str]:
|
||||
with get_engine().connect() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
postgis_version = connection.execute(
|
||||
connection.execute(
|
||||
text("SELECT PostGIS_Version()")
|
||||
).scalar_one()
|
||||
checks["postgis"] = f"ok:{postgis_version}"
|
||||
checks["postgis"] = "ok"
|
||||
database_head = connection.execute(
|
||||
text("SELECT version_num FROM alembic_version")
|
||||
).scalar_one()
|
||||
expected_heads = _expected_migration_heads()
|
||||
if len(expected_heads) == 1 and database_head == expected_heads[0]:
|
||||
checks["migration"] = f"ok:{database_head}"
|
||||
checks["migration"] = "ok"
|
||||
else:
|
||||
checks["migration"] = (
|
||||
f"degraded:database={database_head};"
|
||||
f"expected={','.join(expected_heads) or 'none'}"
|
||||
)
|
||||
checks["migration"] = "degraded"
|
||||
except Exception:
|
||||
return checks
|
||||
return checks
|
||||
@@ -94,9 +91,7 @@ def _readiness_payload() -> HealthResponse:
|
||||
return HealthResponse(
|
||||
status="ok" if ready else "degraded",
|
||||
service="geointel-backend",
|
||||
version=settings.app_version,
|
||||
build_sha=settings.build_sha,
|
||||
build_time=settings.build_time,
|
||||
version="public",
|
||||
database=checks["database"],
|
||||
postgis=checks["postgis"],
|
||||
migration=checks["migration"],
|
||||
@@ -107,13 +102,10 @@ def _readiness_payload() -> HealthResponse:
|
||||
|
||||
@router.get("/health/live", response_model=HealthResponse)
|
||||
def liveness() -> HealthResponse:
|
||||
settings = get_settings()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service="geointel-backend",
|
||||
version=settings.app_version,
|
||||
build_sha=settings.build_sha,
|
||||
build_time=settings.build_time,
|
||||
version="public",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,24 @@ class Settings(BaseSettings):
|
||||
le=86_400,
|
||||
validation_alias="GEOINTEL_GUEST_SESSION_TTL_SECONDS",
|
||||
)
|
||||
guest_login_requests_per_minute: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
le=60,
|
||||
validation_alias="GEOINTEL_GUEST_LOGIN_REQUESTS_PER_MINUTE",
|
||||
)
|
||||
guest_compute_requests_per_minute: int = Field(
|
||||
default=4,
|
||||
ge=1,
|
||||
le=120,
|
||||
validation_alias="GEOINTEL_GUEST_COMPUTE_REQUESTS_PER_MINUTE",
|
||||
)
|
||||
guest_compute_max_concurrency: int = Field(
|
||||
default=2,
|
||||
ge=1,
|
||||
le=16,
|
||||
validation_alias="GEOINTEL_GUEST_COMPUTE_MAX_CONCURRENCY",
|
||||
)
|
||||
database_url: str = Field(
|
||||
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
|
||||
validation_alias="DATABASE_URL",
|
||||
@@ -77,6 +95,24 @@ class Settings(BaseSettings):
|
||||
le=256,
|
||||
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
|
||||
)
|
||||
max_raster_pixels: int = Field(
|
||||
default=40_000_000,
|
||||
ge=1,
|
||||
le=500_000_000,
|
||||
validation_alias="GEOINTEL_MAX_RASTER_PIXELS",
|
||||
)
|
||||
max_raster_bands: int = Field(
|
||||
default=16,
|
||||
ge=1,
|
||||
le=256,
|
||||
validation_alias="GEOINTEL_MAX_RASTER_BANDS",
|
||||
)
|
||||
max_decoded_raster_mb: int = Field(
|
||||
default=1024,
|
||||
ge=16,
|
||||
le=8192,
|
||||
validation_alias="GEOINTEL_MAX_DECODED_RASTER_MB",
|
||||
)
|
||||
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
||||
orthophoto_wms_url: str = Field(
|
||||
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# Stable server-owned identity: a public session must never attach itself to an
|
||||
# operator project merely because the display names happen to match.
|
||||
PUBLIC_DEMO_PROJECT_ID = UUID("6f7e6f12-9b62-4a3f-a5a0-4b3bb6b2c901")
|
||||
PUBLIC_DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
|
||||
PUBLIC_DEMO_PROJECT_MARKER = "geointel:public-demo:v1"
|
||||
|
||||
|
||||
def is_public_demo_project(project_id: UUID) -> bool:
|
||||
return project_id == PUBLIC_DEMO_PROJECT_ID
|
||||
@@ -127,6 +127,7 @@ def create_app() -> FastAPI:
|
||||
token = set_request_id(request_id)
|
||||
started_at = time.perf_counter()
|
||||
raw_path = str(request.scope.get("path") or "")
|
||||
guest_compute_acquired = False
|
||||
try:
|
||||
host = request.headers.get("host", "")
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
@@ -234,6 +235,20 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
is_read_request = request.method in {"GET", "HEAD", "OPTIONS"}
|
||||
if is_read_request:
|
||||
if (
|
||||
normalized_path == f"{settings.api_prefix}/detection/yolo/preflight"
|
||||
and request.query_params.get("check_model_load", "").lower() in {"1", "true", "yes", "on"}
|
||||
):
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_MODEL_LOAD_FORBIDDEN",
|
||||
"Model loading is available to authenticated operators only.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
guest_scoped_analysis_read = (
|
||||
query_project_id == str(principal.project_id)
|
||||
and normalized_path.startswith(
|
||||
@@ -325,6 +340,37 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
retry_after = AuthService.consume_guest_request(
|
||||
f"guest-compute:{principal.session_id}",
|
||||
max_requests=settings.guest_compute_requests_per_minute,
|
||||
)
|
||||
if retry_after:
|
||||
response = JSONResponse(
|
||||
status_code=429,
|
||||
content=_to_error_payload(
|
||||
"GUEST_COMPUTE_RATE_LIMITED",
|
||||
"The public demo compute budget is temporarily exhausted.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["retry-after"] = str(retry_after)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
guest_compute_acquired = AuthService.try_acquire_guest_compute(
|
||||
max_concurrency=settings.guest_compute_max_concurrency,
|
||||
)
|
||||
if not guest_compute_acquired:
|
||||
response = JSONResponse(
|
||||
status_code=429,
|
||||
content=_to_error_payload(
|
||||
"GUEST_COMPUTE_BUSY",
|
||||
"The public demo is already processing its maximum number of jobs.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["retry-after"] = "10"
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request_id
|
||||
logger.info(
|
||||
@@ -337,6 +383,8 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
if guest_compute_acquired:
|
||||
AuthService.release_guest_compute()
|
||||
reset_request_id(token)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
|
||||
@@ -8,17 +8,19 @@ import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.public_demo import PUBLIC_DEMO_PROJECT_ID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthPrincipal:
|
||||
username: str
|
||||
expires_at: int
|
||||
session_id: str = field(default_factory=lambda: secrets.token_urlsafe(12))
|
||||
role: Literal["operator", "guest"] = "operator"
|
||||
project_id: UUID | None = None
|
||||
|
||||
@@ -30,6 +32,10 @@ class AuthService:
|
||||
FAILURE_WINDOW_SECONDS = 300
|
||||
_failures: dict[str, deque[float]] = {}
|
||||
_failure_lock = threading.Lock()
|
||||
_guest_requests: dict[str, deque[float]] = {}
|
||||
_guest_request_lock = threading.Lock()
|
||||
_active_guest_compute = 0
|
||||
_guest_compute_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _b64_encode(value: bytes) -> str:
|
||||
@@ -159,8 +165,9 @@ class AuthService:
|
||||
issued_at = int(payload.get("iat") or 0)
|
||||
version = int(payload.get("v") or 0)
|
||||
role_value = str(payload.get("role") or "operator")
|
||||
session_id = str(payload.get("jti") or "")
|
||||
current = int(time.time() if now is None else now)
|
||||
if version not in {1, 2} or role_value not in {"operator", "guest"}:
|
||||
if version not in {1, 2} or role_value not in {"operator", "guest"} or not session_id:
|
||||
return None
|
||||
role = cast(Literal["operator", "guest"], role_value)
|
||||
if issued_at <= 0 or issued_at > current + 60 or expires_at <= current:
|
||||
@@ -178,11 +185,14 @@ class AuthService:
|
||||
if not raw_project_id:
|
||||
return None
|
||||
project_id = UUID(str(raw_project_id))
|
||||
if project_id != PUBLIC_DEMO_PROJECT_ID:
|
||||
return None
|
||||
if expires_at - issued_at > max_ttl:
|
||||
return None
|
||||
return AuthPrincipal(
|
||||
username=username,
|
||||
expires_at=expires_at,
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
project_id=project_id,
|
||||
)
|
||||
@@ -215,3 +225,36 @@ class AuthService:
|
||||
def clear_failures(cls, key: str) -> None:
|
||||
with cls._failure_lock:
|
||||
cls._failures.pop(key, None)
|
||||
|
||||
@classmethod
|
||||
def consume_guest_request(
|
||||
cls,
|
||||
key: str,
|
||||
*,
|
||||
max_requests: int,
|
||||
window_seconds: int = 60,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Record a guest action and return Retry-After seconds when limited."""
|
||||
current = time.monotonic() if now is None else now
|
||||
with cls._guest_request_lock:
|
||||
attempts = cls._guest_requests.setdefault(key, deque())
|
||||
while attempts and current - attempts[0] >= window_seconds:
|
||||
attempts.popleft()
|
||||
if len(attempts) >= max_requests:
|
||||
return max(1, int(window_seconds - (current - attempts[0])))
|
||||
attempts.append(current)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def try_acquire_guest_compute(cls, *, max_concurrency: int) -> bool:
|
||||
with cls._guest_compute_lock:
|
||||
if cls._active_guest_compute >= max_concurrency:
|
||||
return False
|
||||
cls._active_guest_compute += 1
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def release_guest_compute(cls) -> None:
|
||||
with cls._guest_compute_lock:
|
||||
cls._active_guest_compute = max(0, cls._active_guest_compute - 1)
|
||||
|
||||
@@ -10,6 +10,12 @@ from uuid import UUID, uuid4
|
||||
from geoalchemy2.shape import from_shape
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.public_demo import (
|
||||
PUBLIC_DEMO_PROJECT_ID,
|
||||
PUBLIC_DEMO_PROJECT_MARKER,
|
||||
PUBLIC_DEMO_PROJECT_NAME,
|
||||
)
|
||||
from app.models import Area, Dataset, DatasetVersion, Metric, Project, QualityCheck
|
||||
from app.schemas.demo import DemoWorkflowResponse
|
||||
from app.services.derived_dataset_governance_service import DerivedDatasetGovernanceService
|
||||
@@ -23,7 +29,8 @@ from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_mult
|
||||
|
||||
|
||||
class DemoWorkflowService:
|
||||
PROJECT_NAME = "GeoIntel Demo - Building QA"
|
||||
PROJECT_ID = PUBLIC_DEMO_PROJECT_ID
|
||||
PROJECT_NAME = PUBLIC_DEMO_PROJECT_NAME
|
||||
AREA_NAME = "Demo AOI - Geel buildings"
|
||||
REFERENCE_FILENAME = "demo_reference_buildings.geojson"
|
||||
CANDIDATE_FILENAME = "demo_predicted_buildings.geojson"
|
||||
@@ -91,17 +98,19 @@ class DemoWorkflowService:
|
||||
|
||||
@staticmethod
|
||||
def _find_existing_project(db: Session) -> Project | None:
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.name == DemoWorkflowService.PROJECT_NAME)
|
||||
.filter(Project.status != "deleted")
|
||||
.order_by(Project.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
for project in projects:
|
||||
if DemoWorkflowService._has_complete_demo_state(db, project.id):
|
||||
return project
|
||||
return projects[0] if projects else None
|
||||
project = db.get(Project, DemoWorkflowService.PROJECT_ID)
|
||||
if project is None:
|
||||
return None
|
||||
if (
|
||||
project.name != DemoWorkflowService.PROJECT_NAME
|
||||
or project.description != PUBLIC_DEMO_PROJECT_MARKER
|
||||
):
|
||||
raise AppError(
|
||||
code="PUBLIC_DEMO_IDENTITY_CONFLICT",
|
||||
message="The reserved public-demo project identity is already in use.",
|
||||
status_code=409,
|
||||
)
|
||||
return project
|
||||
|
||||
@staticmethod
|
||||
def _activate_explicit_demo_project(db: Session, project: Project | None) -> Project | None:
|
||||
@@ -487,9 +496,9 @@ class DemoWorkflowService:
|
||||
created = True
|
||||
else:
|
||||
project = Project(
|
||||
id=uuid4(),
|
||||
id=DemoWorkflowService.PROJECT_ID,
|
||||
name=DemoWorkflowService.PROJECT_NAME,
|
||||
description="Offline fixture workflow: reference buildings, predicted buildings and persisted QA metrics.",
|
||||
description=PUBLIC_DEMO_PROJECT_MARKER,
|
||||
region="Kempen",
|
||||
status="active",
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Literal
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.public_demo import is_public_demo_project
|
||||
from app.models import Project
|
||||
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
|
||||
@@ -50,6 +51,12 @@ class ProjectService:
|
||||
|
||||
@staticmethod
|
||||
def update_project(db: Session, project_id: uuid.UUID, payload: ProjectUpdate) -> ProjectRead | None:
|
||||
if is_public_demo_project(project_id):
|
||||
raise AppError(
|
||||
code="PUBLIC_DEMO_IMMUTABLE",
|
||||
message="The public demo project identity cannot be edited.",
|
||||
status_code=409,
|
||||
)
|
||||
project = db.get(Project, project_id)
|
||||
if not project or project.status == "deleted":
|
||||
return None
|
||||
@@ -71,6 +78,12 @@ class ProjectService:
|
||||
|
||||
@staticmethod
|
||||
def delete_project(db: Session, project_id: uuid.UUID) -> bool:
|
||||
if is_public_demo_project(project_id):
|
||||
raise AppError(
|
||||
code="PUBLIC_DEMO_IMMUTABLE",
|
||||
message="The public demo project identity cannot be deleted.",
|
||||
status_code=409,
|
||||
)
|
||||
project = db.get(Project, project_id)
|
||||
if not project or project.status == "deleted":
|
||||
return False
|
||||
|
||||
@@ -466,7 +466,11 @@ class RasterOperationsService:
|
||||
height = int(source.height)
|
||||
preview_width, preview_height = RasterOperationsService._preview_dimensions(width, height)
|
||||
if not preview_path.exists():
|
||||
data = source.read(1)
|
||||
data = source.read(
|
||||
1,
|
||||
out_shape=(preview_height, preview_width),
|
||||
resampling=rasterio.enums.Resampling.nearest,
|
||||
)
|
||||
try:
|
||||
preview_width, preview_height = RasterOperationsService._write_preview_image(
|
||||
data=data,
|
||||
|
||||
@@ -2,9 +2,26 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
_DTYPE_BYTES = {
|
||||
"uint8": 1,
|
||||
"int8": 1,
|
||||
"uint16": 2,
|
||||
"int16": 2,
|
||||
"uint32": 4,
|
||||
"int32": 4,
|
||||
"float32": 4,
|
||||
"uint64": 8,
|
||||
"int64": 8,
|
||||
"float64": 8,
|
||||
"complex64": 8,
|
||||
"complex128": 16,
|
||||
}
|
||||
|
||||
|
||||
def _import_rasterio():
|
||||
import importlib
|
||||
|
||||
@@ -26,6 +43,33 @@ def extract_raster_metadata(path: str) -> dict:
|
||||
dataset_path = Path(path)
|
||||
try:
|
||||
with rasterio.open(dataset_path) as dataset:
|
||||
settings = get_settings()
|
||||
width = int(dataset.width)
|
||||
height = int(dataset.height)
|
||||
band_count = int(dataset.count)
|
||||
pixel_count = width * height
|
||||
dtype_bytes = max((_DTYPE_BYTES.get(str(dtype), 16) for dtype in dataset.dtypes), default=16)
|
||||
decoded_bytes = pixel_count * band_count * dtype_bytes
|
||||
if (
|
||||
width <= 0
|
||||
or height <= 0
|
||||
or band_count <= 0
|
||||
or pixel_count > settings.max_raster_pixels
|
||||
or band_count > settings.max_raster_bands
|
||||
or decoded_bytes > settings.max_decoded_raster_mb * 1024 * 1024
|
||||
):
|
||||
raise AppError(
|
||||
code="RASTER_RESOURCE_LIMIT_EXCEEDED",
|
||||
message="Raster dimensions or decoded size exceed the configured processing budget.",
|
||||
details={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"band_count": band_count,
|
||||
"pixel_count": pixel_count,
|
||||
"estimated_decoded_bytes": decoded_bytes,
|
||||
},
|
||||
status_code=413,
|
||||
)
|
||||
nodata = dataset.nodata
|
||||
if isinstance(nodata, (list, tuple)):
|
||||
nodata_value = [None if value is None else float(value) for value in nodata]
|
||||
@@ -35,9 +79,9 @@ def extract_raster_metadata(path: str) -> dict:
|
||||
transform = dataset.transform.to_gdal() if hasattr(dataset, "transform") else None
|
||||
return {
|
||||
"driver": dataset.driver,
|
||||
"width": int(dataset.width),
|
||||
"height": int(dataset.height),
|
||||
"band_count": int(dataset.count),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"band_count": band_count,
|
||||
"crs": str(dataset.crs) if dataset.crs else None,
|
||||
"bounds": list(dataset.bounds),
|
||||
"resolution": list(dataset.res),
|
||||
@@ -45,6 +89,8 @@ def extract_raster_metadata(path: str) -> dict:
|
||||
"nodata": nodata_value,
|
||||
"transform": list(transform) if transform is not None else None,
|
||||
}
|
||||
except AppError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if isinstance(exc, errors.RasterioIOError):
|
||||
raise AppError(code="INVALID_RASTER", message="Uploaded raster file is invalid", status_code=400) from exc
|
||||
|
||||
@@ -23,6 +23,8 @@ class YoloPreflightService:
|
||||
assume_dependencies: bool = False,
|
||||
check_model_load: bool = False,
|
||||
model_asset_id: str | None = None,
|
||||
db: Any | None = None,
|
||||
allow_offline_model_load: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
resolved_settings = settings or get_settings()
|
||||
selected_asset = None
|
||||
@@ -126,6 +128,21 @@ class YoloPreflightService:
|
||||
|
||||
if check_model_load:
|
||||
try:
|
||||
if db is not None:
|
||||
RuntimeModelProvenanceService.validate_for_production_runtime(
|
||||
db=db,
|
||||
model_path=model_path,
|
||||
model_id=resolved_settings.yolo_model_id,
|
||||
task_type="object_detection",
|
||||
expected_model_version=resolved_settings.yolo_model_version,
|
||||
allowed_frameworks=("ultralytics/pytorch", "ultralytics", "pytorch"),
|
||||
)
|
||||
elif not allow_offline_model_load:
|
||||
raise AppError(
|
||||
code="MODEL_PROVENANCE_DATABASE_REQUIRED",
|
||||
message="Model compatibility loading requires production provenance or an explicit offline operator command.",
|
||||
status_code=409,
|
||||
)
|
||||
yolo_adapter_class(resolved_settings).load_model(model_path)
|
||||
except AppError as exc:
|
||||
result["status"] = "model_load_failed"
|
||||
|
||||
Reference in New Issue
Block a user