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"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Linux x86_64 / CPython 3.11 AI runtime lock.
|
||||
#
|
||||
# Resolved on Linux for the production CUDA 12.8 image. Every artifact is
|
||||
# pinned by version and SHA-256 so installation fails closed on index drift.
|
||||
certifi==2026.7.22 --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775
|
||||
charset-normalizer==3.5.1 --hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8
|
||||
contourpy==1.3.3 --hash=sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db
|
||||
cuda-bindings==12.9.7 --hash=sha256:c6496a88d84b1209d6651b0370c19c26319e157c22f6d018bf9a358cd8049041
|
||||
cuda-pathfinder==1.8.0 --hash=sha256:c44e574dc997fae2814721d1ae97d0fd6db76db82decbe9b753bf75de53f515e
|
||||
cuda-toolkit==12.8.1 --hash=sha256:adc7906af4ecbf9a352f9dca5734eceb21daec281ccfcf5675e1d2f724fc2cba
|
||||
cycler==0.12.1 --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30
|
||||
filelock==3.32.5 --hash=sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2
|
||||
fonttools==4.64.0 --hash=sha256:ff7aff4637fbf71394df139c63ccfe08a47aa4252d2f91224ddb3335c716c925
|
||||
fsspec==2026.7.0 --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279
|
||||
idna==3.19 --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
|
||||
jinja2==3.1.6 --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
|
||||
kiwisolver==1.5.1 --hash=sha256:95a02752aa032eef4aed01cda6d9b687c669bd0396bf4519eef8bba22a286720
|
||||
markupsafe==3.0.3 --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf
|
||||
matplotlib==3.11.1 --hash=sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83
|
||||
mpmath==1.3.0 --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c
|
||||
networkx==3.6.1 --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
|
||||
numpy==2.4.6 --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93
|
||||
nvidia-cublas-cu12==12.8.4.1 --hash=sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142
|
||||
nvidia-cuda-cupti-cu12==12.8.90 --hash=sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182
|
||||
nvidia-cuda-nvrtc-cu12==12.8.93 --hash=sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994
|
||||
nvidia-cuda-runtime-cu12==12.8.90 --hash=sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90
|
||||
nvidia-cudnn-cu12==9.19.0.56 --hash=sha256:ac6ad90a075bb33a94f2b4cf4622eac13dd4dc65cf6dd9c7572a318516a36625
|
||||
nvidia-cufft-cu12==11.3.3.83 --hash=sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74
|
||||
nvidia-cufile-cu12==1.13.1.3 --hash=sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc
|
||||
nvidia-curand-cu12==10.3.9.90 --hash=sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9
|
||||
nvidia-cusolver-cu12==11.7.3.90 --hash=sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450
|
||||
nvidia-cusparse-cu12==12.5.8.93 --hash=sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b
|
||||
nvidia-cusparselt-cu12==0.7.1 --hash=sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623
|
||||
nvidia-ml-py==13.610.43 --hash=sha256:f13c72698edef492f985cc225f14faafe68ae065a2e407f45bdf6f4b9b43fde8
|
||||
nvidia-nccl-cu12==2.28.9 --hash=sha256:485776daa8447da5da39681af455aa3b2c2586ddcf4af8772495e7c532c7e5ab
|
||||
nvidia-nvjitlink-cu12==12.8.93 --hash=sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88
|
||||
nvidia-nvshmem-cu12==3.4.5 --hash=sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd
|
||||
nvidia-nvtx-cu12==12.8.90 --hash=sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f
|
||||
opencv-python==5.0.0.93 --hash=sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
pillow==12.3.0 --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd
|
||||
polars==1.44.1 --hash=sha256:1fa62fc1c88fba77a68b28291b5aabdd69e5f38b34e59721a064ae3169b59bb5
|
||||
polars-runtime-32==1.44.1 --hash=sha256:eea4283be8e60822d890dbda20588fe59b4172b508bd5ebf3471e531ca9f50d7
|
||||
psutil==7.2.2 --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9
|
||||
pyparsing==3.3.2 --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d
|
||||
python-dateutil==2.9.0.post0 --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
||||
pyyaml==6.0.3 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d
|
||||
requests==2.34.2 --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
|
||||
setuptools==81.0.0 --hash=sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6
|
||||
six==1.17.0 --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274
|
||||
sympy==1.14.0 --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5
|
||||
torch==2.11.0+cu128 --hash=sha256:c9a7ca4c74fae10a58e6175b4b2cea953f9322bb6562bbf339ad6a05f52190ad
|
||||
torchvision==0.26.0+cu128 --hash=sha256:8f2629d056570c929b0a1d5473d9cb0320b90bda1764bda353553a72cc6b2069
|
||||
triton==3.6.0 --hash=sha256:e021e87e8f266c6f87bf379b669c43c2800aac6247bdf5d518cb553e3c403c00
|
||||
typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
||||
ultralytics==8.4.99 --hash=sha256:477727b3f07de28f1f34888c7661b7cc3b6bf3e55b9675d7b3eadfa6f4747385
|
||||
ultralytics-thop==2.1.6 --hash=sha256:23f7b8ad124fa3432c1a7de9279102c4fdda699216032a7dff49f87ec3d1a3af
|
||||
urllib3==2.7.0 --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
wheel==0.47.0 --hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced
|
||||
@@ -0,0 +1,4 @@
|
||||
# Build tooling used after the runtime dependency installation.
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
setuptools==81.0.0 --hash=sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6
|
||||
wheel==0.47.0 --hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced
|
||||
@@ -50,6 +50,7 @@ def main() -> int:
|
||||
tile_manifest_path=args.tile_manifest_path,
|
||||
assume_dependencies=args.assume_dependencies,
|
||||
check_model_load=args.check_model_load,
|
||||
allow_offline_model_load=True,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
|
||||
@@ -1182,7 +1182,7 @@ def test_one_workflow_is_byte_reproducible_complete_and_fail_closed(
|
||||
)
|
||||
assert input_manifest["product_baseline"]["validation_status"] == "not_evaluable"
|
||||
assert input_manifest["product_baseline"]["artifacts"] == []
|
||||
assert "docs/accuracy-program/status.json" not in {
|
||||
assert "fixtures/accuracy/readiness/status.json" not in {
|
||||
item["path"] for item in input_manifest["inputs"]
|
||||
}
|
||||
assert input_manifest["readiness_snapshot"]["source_paths"][
|
||||
@@ -1199,11 +1199,11 @@ def test_one_workflow_is_byte_reproducible_complete_and_fail_closed(
|
||||
def test_readiness_snapshot_ignores_phase4_bookkeeping_but_binds_active_model(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
status_path = tmp_path / "docs/accuracy-program/status.json"
|
||||
scan_path = tmp_path / "artifacts/evidence/accuracy/P3/full-scan-manifest.json"
|
||||
leakage_path = tmp_path / "artifacts/evidence/accuracy/P3/leakage-report.json"
|
||||
status_path = tmp_path / "fixtures/accuracy/readiness/status.json"
|
||||
scan_path = tmp_path / "fixtures/accuracy/readiness/full-scan-manifest.json"
|
||||
leakage_path = tmp_path / "fixtures/accuracy/readiness/leakage-report.json"
|
||||
status_path.parent.mkdir(parents=True)
|
||||
scan_path.parent.mkdir(parents=True)
|
||||
scan_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
status = {
|
||||
"generated_at": "2026-08-02T00:00:00+02:00",
|
||||
"documents": ["old.md"],
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID, uuid4
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.public_demo import PUBLIC_DEMO_PROJECT_ID
|
||||
from app.db.session import get_db
|
||||
from app.main import create_app
|
||||
from app.schemas.demo import DemoWorkflowResponse
|
||||
@@ -172,7 +173,7 @@ def test_operator_login_can_require_https(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_guest_login_exposes_models_but_rejects_management_and_cross_project_requests(monkeypatch) -> None:
|
||||
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
project_id = PUBLIC_DEMO_PROJECT_ID
|
||||
demo = DemoWorkflowResponse(
|
||||
project_id=project_id,
|
||||
area_id=UUID("00000000-0000-0000-0000-000000000124"),
|
||||
@@ -252,7 +253,7 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
|
||||
|
||||
|
||||
def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monkeypatch) -> None:
|
||||
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
project_id = PUBLIC_DEMO_PROJECT_ID
|
||||
other_project_id = UUID("00000000-0000-0000-0000-000000000999")
|
||||
source_dataset_id = UUID("00000000-0000-0000-0000-000000000125")
|
||||
target_dataset_id = UUID("00000000-0000-0000-0000-000000000126")
|
||||
@@ -342,7 +343,7 @@ def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monke
|
||||
|
||||
|
||||
def test_guest_can_prepare_tiles_and_queue_project_scoped_detection(monkeypatch) -> None:
|
||||
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
project_id = PUBLIC_DEMO_PROJECT_ID
|
||||
raster_dataset_id = UUID("00000000-0000-0000-0000-000000000127")
|
||||
manifest_path = "/app/storage/tiles/demo/manifest.json"
|
||||
demo = DemoWorkflowResponse(
|
||||
|
||||
@@ -48,11 +48,14 @@ def test_all_in_one_dockerfile_can_opt_into_ai_dependencies_without_base_install
|
||||
assert "ARG GEOINTEL_INSTALL_AI=false" in dockerfile
|
||||
assert "COPY backend/pyproject.toml /app/" in dockerfile
|
||||
assert "COPY backend/requirements-runtime.lock /app/" in dockerfile
|
||||
assert "COPY backend/requirements-ai-linux.lock /app/" in dockerfile
|
||||
assert "COPY backend/requirements-build-tools.lock /app/" in dockerfile
|
||||
assert "COPY backend/pyproject.toml backend/README.md /app/" not in dockerfile
|
||||
assert "GeoIntel backend package metadata" in dockerfile
|
||||
assert "--require-hashes -r requirements-runtime.lock" in dockerfile
|
||||
assert "ARG GEOINTEL_ULTRALYTICS_VERSION=" in dockerfile
|
||||
assert '"ultralytics==$GEOINTEL_ULTRALYTICS_VERSION"' in dockerfile
|
||||
assert "--require-hashes" in dockerfile
|
||||
assert "-r requirements-ai-linux.lock" in dockerfile
|
||||
assert "-r requirements-build-tools.lock" in dockerfile
|
||||
assert "python scripts/gis_import_smoke.py" in dockerfile
|
||||
assert "yolo_preflight.py" in dockerfile
|
||||
assert "libxcb1" in dockerfile
|
||||
@@ -472,9 +475,10 @@ def test_all_in_one_dockerfile_caches_dependencies_and_pins_driver_compatible_cu
|
||||
|
||||
assert metadata_copy_index < placeholder_readme_index < dependency_install_index < backend_copy_index < smoke_index
|
||||
assert "GEOINTEL_TORCH_INDEX_URL=https://download.pytorch.org/whl/cu128" in dockerfile
|
||||
assert "GEOINTEL_TORCH_VERSION=2.11.0" in dockerfile
|
||||
assert "GEOINTEL_TORCHVISION_VERSION=0.26.0" in dockerfile
|
||||
assert '--index-url "$GEOINTEL_TORCH_INDEX_URL"' in dockerfile
|
||||
assert '--extra-index-url "$GEOINTEL_TORCH_INDEX_URL"' in dockerfile
|
||||
ai_lock = (ROOT / "backend" / "requirements-ai-linux.lock").read_text(encoding="utf-8")
|
||||
assert "torch==2.11.0+cu128 --hash=sha256:" in ai_lock
|
||||
assert "torchvision==0.26.0+cu128 --hash=sha256:" in ai_lock
|
||||
|
||||
|
||||
def test_unraid_deploy_passes_ai_build_arg_and_yolo_runtime_env() -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.public_demo import PUBLIC_DEMO_PROJECT_ID
|
||||
from app.db.session import get_db
|
||||
from app.main import create_app
|
||||
from app.models import AnalysisRun, Dataset, Detection, Export, Job, Segmentation
|
||||
@@ -21,7 +22,7 @@ from app.services.detection_service import DetectionService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
|
||||
|
||||
GUEST_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000123")
|
||||
GUEST_PROJECT_ID = PUBLIC_DEMO_PROJECT_ID
|
||||
OTHER_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000999")
|
||||
DATASET_ID = UUID("00000000-0000-0000-0000-000000000201")
|
||||
DETECTION_RUN_ID = UUID("00000000-0000-0000-0000-000000000202")
|
||||
|
||||
@@ -175,11 +175,14 @@ def test_release_image_uses_locked_non_ai_dependencies_and_npm_ci() -> None:
|
||||
|
||||
assert "RUN npm ci" in dockerfile
|
||||
assert "COPY backend/requirements-runtime.lock /app/" in dockerfile
|
||||
assert "COPY backend/requirements-ai-linux.lock /app/" in dockerfile
|
||||
assert "COPY backend/requirements-build-tools.lock /app/" in dockerfile
|
||||
assert "pip install --no-cache-dir --require-hashes -r requirements-runtime.lock" in dockerfile
|
||||
assert "ARG GEOINTEL_ULTRALYTICS_VERSION=8.4.99" in dockerfile
|
||||
assert '"ultralytics==$GEOINTEL_ULTRALYTICS_VERSION"' in dockerfile
|
||||
assert "ARG GEOINTEL_SETUPTOOLS_VERSION=81.0.0" in dockerfile
|
||||
assert "ARG GEOINTEL_WHEEL_VERSION=0.47.0" in dockerfile
|
||||
assert "-r requirements-ai-linux.lock" in dockerfile
|
||||
assert "-r requirements-build-tools.lock" in dockerfile
|
||||
assert "--require-hashes" in dockerfile
|
||||
assert "ultralytics==8.4.99 --hash=sha256:" in read("backend/requirements-ai-linux.lock")
|
||||
assert "torch==2.11.0+cu128 --hash=sha256:" in read("backend/requirements-ai-linux.lock")
|
||||
assert "COPY deploy/unraid/gosu-setpriv /usr/local/bin/gosu" in dockerfile
|
||||
assert "&& pip check" in dockerfile
|
||||
|
||||
|
||||
@@ -21,5 +21,5 @@ def test_evidence_bundle_script_accepts_browser_calibration_summary_export() ->
|
||||
assert "root_project_id = summary.get(\"project_id\")" in script
|
||||
assert "quality_check_ids" in script
|
||||
assert "Browser Detection Lab calibration summary" in readme
|
||||
assert "bash scripts/export_detection_calibration_evidence.sh http://192.168.123.45:1202 ./detection-calibration-summary.json" in readme
|
||||
assert "bash scripts/export_detection_calibration_evidence.sh http://192.0.2.10:1202 ./detection-calibration-summary.json" in readme
|
||||
assert "[x] Allow the evidence bundle script to consume Detection Lab calibration summary exports" in todo
|
||||
|
||||
@@ -206,6 +206,7 @@ def test_yolo_preflight_can_explicitly_smoke_load_local_model(tmp_path: Path) ->
|
||||
tile_manifest_path=str(manifest_path),
|
||||
yolo_adapter_class=AvailableAdapter,
|
||||
check_model_load=True,
|
||||
allow_offline_model_load=True,
|
||||
)
|
||||
|
||||
assert result["status"] == "ready"
|
||||
@@ -231,6 +232,7 @@ def test_yolo_preflight_reports_explicit_model_load_failure(tmp_path: Path) -> N
|
||||
tile_manifest_path=str(_manifest(tmp_path)),
|
||||
yolo_adapter_class=FailingLoadAdapter,
|
||||
check_model_load=True,
|
||||
allow_offline_model_load=True,
|
||||
)
|
||||
|
||||
assert result["status"] == "model_load_failed"
|
||||
|
||||
@@ -74,13 +74,13 @@ def test_demo_workflow_service_supports_container_fixture_mount() -> None:
|
||||
assert "_quality_check_matches_expected" in service
|
||||
|
||||
|
||||
def test_demo_workflow_prefers_complete_existing_demo_project() -> None:
|
||||
def test_demo_workflow_uses_reserved_server_owned_project_identity() -> None:
|
||||
service = (DemoWorkflowService._repo_root() / "backend" / "app" / "services" / "demo_workflow_service.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "_has_complete_demo_state" in service
|
||||
assert "order_by(Project.created_at.asc())" in service
|
||||
assert "if DemoWorkflowService._has_complete_demo_state(db, project.id):" in service
|
||||
assert "return projects[0] if projects else None" in service
|
||||
assert "db.get(Project, DemoWorkflowService.PROJECT_ID)" in service
|
||||
assert "PUBLIC_DEMO_PROJECT_MARKER" in service
|
||||
assert "PUBLIC_DEMO_IDENTITY_CONFLICT" in service
|
||||
assert "id=DemoWorkflowService.PROJECT_ID" in service
|
||||
|
||||
|
||||
def test_explicit_demo_seed_reactivates_an_archived_fixture_project() -> None:
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_unraid_all_in_one_runtime_starts_embedded_postgis_backend_and_nginx() -
|
||||
nginx_config = (ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(encoding="utf-8")
|
||||
dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
|
||||
|
||||
assert "FROM postgres:16-bookworm AS runtime" in dockerfile
|
||||
assert "FROM postgres:16-bookworm@sha256:" in dockerfile
|
||||
assert "postgresql-16-postgis-3" in dockerfile
|
||||
assert "postgresql-16-postgis-3-scripts" in dockerfile
|
||||
assert "ln -sf /usr/bin/python3.11 /usr/local/bin/python3" in dockerfile
|
||||
|
||||
@@ -13,9 +13,9 @@ def test_export_handoff_cards_use_width_aware_grid() -> None:
|
||||
assert "repeat(auto-fit, minmax(7.5rem, 1fr))" in css
|
||||
|
||||
|
||||
def test_live_workspace_smoke_artifacts_are_documented() -> None:
|
||||
execution_log = (ROOT / "docs" / "CODEX_EXECUTION_LOG.md").read_text(encoding="utf-8")
|
||||
def test_public_readme_documents_desktop_and_mobile_workspace() -> None:
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "Sprint 66 Live workspace smoke polish" in execution_log
|
||||
assert "desktop and mobile screenshots" in execution_log
|
||||
assert "no console warnings/errors" in execution_log
|
||||
assert "geointel-workbench-wide.png" in readme
|
||||
assert "geointel-workbench-mobile.png" in readme
|
||||
assert "Mobiele werkruimte" in readme
|
||||
|
||||
Reference in New Issue
Block a user