Full platform remediation #7

Merged
Jens merged 11 commits from codex/full-platform-remediation into main 2026-08-30 09:30:42 +00:00
1359 changed files with 97096 additions and 210850 deletions
+6
View File
@@ -4,6 +4,7 @@ GEOINTEL_API_PREFIX=/api/v1
DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1 DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1
STORAGE_ROOT=./storage STORAGE_ROOT=./storage
MAX_UPLOAD_MB=500 MAX_UPLOAD_MB=500
GEOINTEL_MAX_IN_MEMORY_VECTOR_MB=64
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
# Optional single-operator access gate. Store only a PBKDF2-SHA256 hash and # Optional single-operator access gate. Store only a PBKDF2-SHA256 hash and
@@ -14,6 +15,11 @@ GEOINTEL_AUTH_USERNAME=
GEOINTEL_AUTH_PASSWORD_HASH= GEOINTEL_AUTH_PASSWORD_HASH=
GEOINTEL_AUTH_SESSION_SECRET= GEOINTEL_AUTH_SESSION_SECRET=
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200 GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
GEOINTEL_PUBLIC_BASE_URL=http://localhost:1202
GEOINTEL_AUTHENTIK_ISSUER=
GEOINTEL_AUTHENTIK_CLIENT_ID=
GEOINTEL_AUTHENTIK_CLIENT_SECRET=
GEOINTEL_AUTHENTIK_ALLOWED_EMAIL=
GEOINTEL_GUEST_ACCESS_ENABLED=true GEOINTEL_GUEST_ACCESS_ENABLED=true
GEOINTEL_GUEST_DISPLAY_NAME=Gast GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200 GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
+44 -75
View File
@@ -20,12 +20,24 @@ concurrency:
jobs: jobs:
full: full:
name: full # Gitea Actions does not consistently evaluate the GitHub-style `||`
# expression for pull-request runs without workflow inputs.
name: Managed repository validation
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate repository with a bounded profile - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
cache: pip
cache-dependency-path: backend/requirements-ci.lock
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Validate the requested profile against the real projects
shell: bash shell: bash
env: env:
REQUESTED_PROFILE: ${{ inputs.profile }} REQUESTED_PROFILE: ${{ inputs.profile }}
@@ -42,77 +54,34 @@ jobs:
echo "Unresolved merge markers detected" >&2 echo "Unresolved merge markers detected" >&2
exit 1 exit 1
fi fi
python scripts/verify_repository_layout.py
if [[ -f pyproject.toml || -f requirements.txt ]]; then python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-ci.lock
# Compile only tracked Python sources. Running compileall after a python -m pip install --disable-pip-version-check --no-deps -e backend
# Node install would otherwise traverse node_modules and turn a (cd frontend && npm ci)
# lightweight baseline into a large runner workload.
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
if [[ -f uv.lock ]]; then
python -m venv "${RUNNER_TEMP}/managed-uv"
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
"${managed_uv}" sync --locked
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
"${managed_uv}" run python -m pytest
fi
fi
if [[ "${profile}" == lint || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
"${managed_uv}" run python -m ruff check .
fi
fi
elif [[ -f requirements.txt ]]; then
python -m venv "${RUNNER_TEMP}/managed-python"
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
"${managed_python}" -m pytest
fi
fi
fi
fi
# Prepare Python before invoking Node scripts. Polyglot repositories case "${profile}" in
# commonly delegate their test script to Python and need the managed test)
# virtual environment to be active first. (cd backend && python -m pytest -W error::DeprecationWarning)
if [[ -f package.json ]]; then (cd frontend && npm run test:unit)
corepack enable ;;
if [[ -f pnpm-lock.yaml ]]; then lint)
pnpm install --frozen-lockfile python -m ruff check backend scripts tests
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test (cd frontend && npm run lint --if-present)
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint ;;
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck typecheck)
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build (cd frontend && npm run typecheck)
elif [[ -f package-lock.json ]]; then ;;
npm ci build)
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test python -m compileall backend/app
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint (cd frontend && npm run build)
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then ;;
npm run --if-present typecheck security)
fi python -m pip install --disable-pip-version-check pip-audit==2.10.1
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build bash scripts/audit_python_dependencies.sh
fi (cd frontend && npm audit --audit-level=high)
fi ;;
full)
if [[ -f go.mod ]]; then PYTHON_BIN=python bash scripts/run_readiness_check.sh
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then ;;
go test ./... esac
fi
fi
if [[ -f Cargo.toml ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
cargo test --locked
fi
fi
if compgen -G '*.sln' >/dev/null; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
dotnet test --configuration Release
fi
fi
+66 -20
View File
@@ -1,6 +1,7 @@
name: GeoIntel release gates name: GeoIntel release gates
on: on:
pull_request:
push: push:
branches: [main, develop] branches: [main, develop]
workflow_dispatch: workflow_dispatch:
@@ -10,27 +11,30 @@ permissions:
concurrency: concurrency:
group: geointel-release-${{ gitea.ref }} group: geointel-release-${{ gitea.ref }}
cancel-in-progress: true # A cancelled HTTP caller does not terminate the allowlisted controller
# process that already owns the production lock. Queue a newer revision
# instead of orphaning an in-flight backup or deploy.
cancel-in-progress: false
jobs: jobs:
quality: quality:
name: Compile, test, contracts and builds name: Compile, test, contracts and builds
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Secret scan - name: Secret scan
run: >- run: >-
docker run --rm docker run --rm
--volume "$PWD:/repo:ro" --volume "$PWD:/repo:ro"
trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3 trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3
filesystem /repo --only-verified --no-update filesystem /repo --only-verified --no-update
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip cache: pip
cache-dependency-path: backend/requirements-ci.lock cache-dependency-path: backend/requirements-ci.lock
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "20" node-version: "20"
cache: npm cache: npm
@@ -57,7 +61,9 @@ jobs:
docker compose config > artifacts/docker-compose.resolved.yml docker compose config > artifacts/docker-compose.resolved.yml
- name: Publish quality evidence - name: Publish quality evidence
if: always() if: always()
uses: actions/upload-artifact@v4 # Gitea Actions currently exposes the GHES-compatible artifact API;
# upload-artifact v4 deliberately refuses that API.
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with: with:
name: quality-evidence name: quality-evidence
path: | path: |
@@ -71,13 +77,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip cache: pip
cache-dependency-path: backend/requirements-ci.lock cache-dependency-path: backend/requirements-ci.lock
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "20" node-version: "20"
cache: npm cache: npm
@@ -94,7 +100,7 @@ jobs:
npm audit --audit-level=high --json > ../artifacts/npm-audit.json npm audit --audit-level=high --json > ../artifacts/npm-audit.json
- name: Publish dependency evidence - name: Publish dependency evidence
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with: with:
name: dependency-audits name: dependency-audits
path: | path: |
@@ -105,41 +111,81 @@ jobs:
retention-days: 30 retention-days: 30
container: container:
name: GIS image, SBOM and container scan name: Production AI image, SBOM and container scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 120
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build non-AI release image - name: Build production AI release image
env: env:
RELEASE_SHA: ${{ gitea.sha }} RELEASE_SHA: ${{ gitea.sha }}
run: | run: |
mkdir -p artifacts mkdir -p artifacts
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
APP_VERSION="$(tr -d '[:space:]' < VERSION)"
docker build \ docker build \
-f deploy/unraid/Dockerfile.all-in-one \ -f deploy/unraid/Dockerfile.all-in-one \
--build-arg GEOINTEL_INSTALL_AI=false \ --build-arg GEOINTEL_INSTALL_AI=true \
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \ --build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \ --build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
-t "geointel-ci:$RELEASE_SHA-gis" \ --build-arg GEOINTEL_APP_VERSION="$APP_VERSION" \
-t "geointel-ci:$RELEASE_SHA-ai" \
. .
docker image inspect "geointel-ci:$RELEASE_SHA-gis" > artifacts/image-inspect.json IMAGE_ID="$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")"
printf '%s\n' "$IMAGE_ID" > artifacts/image-id.txt
docker image inspect "$IMAGE_ID" > artifacts/image-inspect.json
- name: Generate SPDX SBOM - name: Generate SPDX SBOM
env: env:
RELEASE_SHA: ${{ gitea.sha }} RELEASE_SHA: ${{ gitea.sha }}
run: bash scripts/generate_container_sbom.sh "geointel-ci:$RELEASE_SHA-gis" GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
SYFT_PARALLELISM: "1"
run: |
IMAGE_ID="$(cat artifacts/image-id.txt)"
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
bash scripts/generate_container_sbom.sh "$IMAGE_ID"
- name: Enforce container vulnerability policy - name: Enforce container vulnerability policy
env: env:
RELEASE_SHA: ${{ gitea.sha }} RELEASE_SHA: ${{ gitea.sha }}
run: bash scripts/scan_container_image.sh "geointel-ci:$RELEASE_SHA-gis" GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
run: |
IMAGE_ID="$(cat artifacts/image-id.txt)"
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
bash scripts/scan_container_image.sh "$IMAGE_ID"
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
- name: Remove temporary image archive
if: always()
run: >-
rm -f -- artifacts/geointel-image.tar
artifacts/geointel-image.tar.image-id
artifacts/geointel-image.tar.partial.*
- name: Publish container evidence - name: Publish container evidence
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with: with:
name: container-evidence name: container-evidence
path: | path: |
artifacts/image-inspect.json artifacts/image-inspect.json
artifacts/image-id.txt
artifacts/geointel-sbom.spdx.json artifacts/geointel-sbom.spdx.json
artifacts/geointel-container-vulnerabilities.json artifacts/geointel-container-vulnerabilities.json
if-no-files-found: warn if-no-files-found: warn
retention-days: 30 retention-days: 30
deploy:
name: Deploy exact gated revision to Unraid
needs: [quality, dependency-audit, container]
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
runs-on: unraid-deploy
# The first byte-complete storage snapshot can exceed 100 GiB. Keep the
# gated caller attached for the full conservative backup/build window;
# the controller and deploy script still serialize every mutation.
timeout-minutes: 720
steps:
- name: Deploy only after every release gate is green
run: |
set -euo pipefail
docker exec gitea-deploy-control \
/opt/gitea-deploy/deploy.py deploy \
"${{ gitea.repository }}" "${{ gitea.sha }}"
-31
View File
@@ -1,31 +0,0 @@
name: Unraid autoredeploy
on:
push:
branches: [main]
paths-ignore:
- ".gitea/**"
- "docs/**"
- "**/*.md"
workflow_dispatch:
concurrency:
group: unraid-production-geointel
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy geointel
runs-on: unraid-deploy
timeout-minutes: 180
steps:
- name: Deploy exact Gitea revision
run: |
set -euo pipefail
docker exec gitea-deploy-control \
/opt/gitea-deploy/deploy.py deploy \
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
+41 -18
View File
@@ -20,19 +20,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Secret scan - name: Secret scan
run: >- run: >-
docker run --rm docker run --rm
--volume "$PWD:/repo:ro" --volume "$PWD:/repo:ro"
trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3 trufflesecurity/trufflehog:3.79.0@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3
filesystem /repo --only-verified --no-update filesystem /repo --only-verified --no-update
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip cache: pip
cache-dependency-path: backend/requirements-ci.lock cache-dependency-path: backend/requirements-ci.lock
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "20" node-version: "20"
cache: npm cache: npm
@@ -59,7 +59,7 @@ jobs:
docker compose config > artifacts/docker-compose.resolved.yml docker compose config > artifacts/docker-compose.resolved.yml
- name: Publish quality evidence - name: Publish quality evidence
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: quality-evidence name: quality-evidence
path: | path: |
@@ -73,13 +73,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip cache: pip
cache-dependency-path: backend/requirements-ci.lock cache-dependency-path: backend/requirements-ci.lock
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "20" node-version: "20"
cache: npm cache: npm
@@ -96,7 +96,7 @@ jobs:
npm audit --audit-level=high --json > ../artifacts/npm-audit.json npm audit --audit-level=high --json > ../artifacts/npm-audit.json
- name: Publish dependency evidence - name: Publish dependency evidence
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: dependency-audits name: dependency-audits
path: | path: |
@@ -107,40 +107,63 @@ jobs:
retention-days: 30 retention-days: 30
container: container:
name: GIS image, SBOM and container scan name: Production AI image, SBOM and container scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 120
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build non-AI release image - name: Build production AI release image
env: env:
RELEASE_SHA: ${{ github.sha }} RELEASE_SHA: ${{ github.sha }}
run: | run: |
mkdir -p artifacts mkdir -p artifacts
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
APP_VERSION="$(tr -d '[:space:]' < VERSION)"
docker build \ docker build \
-f deploy/unraid/Dockerfile.all-in-one \ -f deploy/unraid/Dockerfile.all-in-one \
--build-arg GEOINTEL_INSTALL_AI=false \ --build-arg GEOINTEL_INSTALL_AI=true \
--build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \ --build-arg GEOINTEL_BUILD_SHA="$RELEASE_SHA" \
--build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \ --build-arg GEOINTEL_BUILD_TIME="$BUILD_TIME" \
-t "geointel-ci:$RELEASE_SHA-gis" \ --build-arg GEOINTEL_APP_VERSION="$APP_VERSION" \
-t "geointel-ci:$RELEASE_SHA-ai" \
. .
docker image inspect "geointel-ci:$RELEASE_SHA-gis" > artifacts/image-inspect.json IMAGE_ID="$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")"
printf '%s\n' "$IMAGE_ID" > artifacts/image-id.txt
docker image inspect "$IMAGE_ID" > artifacts/image-inspect.json
- name: Generate SPDX SBOM - name: Generate SPDX SBOM
env: env:
RELEASE_SHA: ${{ github.sha }} RELEASE_SHA: ${{ github.sha }}
run: bash scripts/generate_container_sbom.sh "geointel-ci:$RELEASE_SHA-gis" GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
SYFT_PARALLELISM: "1"
run: |
IMAGE_ID="$(cat artifacts/image-id.txt)"
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
bash scripts/generate_container_sbom.sh "$IMAGE_ID"
- name: Enforce container vulnerability policy - name: Enforce container vulnerability policy
env: env:
RELEASE_SHA: ${{ github.sha }} RELEASE_SHA: ${{ github.sha }}
run: bash scripts/scan_container_image.sh "geointel-ci:$RELEASE_SHA-gis" GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar
GEOINTEL_KEEP_IMAGE_ARCHIVE: "true"
run: |
IMAGE_ID="$(cat artifacts/image-id.txt)"
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
bash scripts/scan_container_image.sh "$IMAGE_ID"
test "$(docker image inspect --format '{{.Id}}' "geointel-ci:$RELEASE_SHA-ai")" = "$IMAGE_ID"
- name: Remove temporary image archive
if: always()
run: >-
rm -f -- artifacts/geointel-image.tar
artifacts/geointel-image.tar.image-id
artifacts/geointel-image.tar.partial.*
- name: Publish container evidence - name: Publish container evidence
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: container-evidence name: container-evidence
path: | path: |
artifacts/image-inspect.json artifacts/image-inspect.json
artifacts/image-id.txt
artifacts/geointel-sbom.spdx.json artifacts/geointel-sbom.spdx.json
artifacts/geointel-container-vulnerabilities.json artifacts/geointel-container-vulnerabilities.json
if-no-files-found: warn if-no-files-found: warn
+5
View File
@@ -4,6 +4,8 @@ __pycache__/
.venv/ .venv/
venv/ venv/
.env .env
.env.*
!.env.example
*.egg-info/ *.egg-info/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
@@ -35,6 +37,9 @@ build/
!/artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/** !/artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/**
!/artifacts/evidence/accuracy/P2/ !/artifacts/evidence/accuracy/P2/
!/artifacts/evidence/accuracy/P2/** !/artifacts/evidence/accuracy/P2/**
!/artifacts/evidence/accuracy/model-training/
/artifacts/evidence/accuracy/model-training/*
!/artifacts/evidence/accuracy/model-training/20260830-independent-ai-visual-review.json
/.cache/ /.cache/
/datasets/raw/* /datasets/raw/*
/datasets/processed/* /datasets/processed/*
+36 -9
View File
@@ -48,8 +48,10 @@ België plus de juridisch benoemde Belgische maritieme zones.
## Product in beeld ## Product in beeld
De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele
Unraid-productieomgeving. De gastmodus toont een beperkte, alleen-lezen Unraid-productieomgeving. De gastmodus toont een projectgebonden demowerkruimte
demowerkruimte; operatorfuncties en nieuwe analyses blijven afgeschermd. met dezelfde kaart-, bron-, model-, analyse-, QA- en exportflow als een
operator. Alleen beheer, instellingen, uploads, bronconfiguratie,
projectbeheer en evidence-review blijven afgeschermd.
### Interactieve projectketen ### Interactieve projectketen
@@ -68,6 +70,11 @@ resultaten zijn vanuit dezelfde ruimtelijke context bereikbaar.
![GeoIntel kaartwerkruimte](docs/assets/portfolio/geointel-workbench-map.png) ![GeoIntel kaartwerkruimte](docs/assets/portfolio/geointel-workbench-map.png)
Op een breed scherm krijgt de kaart extra ruimte terwijl de themakolom en de
controleerbare analysestappen zichtbaar blijven.
![GeoIntel brede kaartwerkruimte](docs/assets/portfolio/geointel-workbench-wide.png)
### Kwaliteit vóór resultaat ### Kwaliteit vóór resultaat
QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan
@@ -147,9 +154,16 @@ Open daarna `http://localhost:1202`.
### Gastdemo ### Gastdemo
Met `GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een Met `GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een
kortlevende, alleen-lezen demosessie. Die toont uitsluitend het ingestelde kortlevende, projectgebonden demosessie. De gast heeft binnen het ingestelde
demoproject en bewaard kwaliteitsbewijs; dit is geen multi-user- of demoproject dezelfde kaart-, bronselectie-, modelselectie-, analyse-, QA- en
tenantisolatie. exportmogelijkheden als een operator. Alleen beheerfuncties zoals instellingen,
uploads, bronconfiguratie, projectbeheer en evidence-review blijven geblokkeerd;
dit is geen multi-user- of tenantisolatie.
Een optionele Authentik OIDC-login kan naast de lokale operatorlogin worden
ingeschakeld. GeoIntel gebruikt daarbij PKCE, state, nonce, issuer-/audience-
controle en één expliciet toegelaten, geverifieerd e-mailadres. De lokale
operatorlogin blijft altijd het herstelpad.
## NVIDIA/CUDA ## NVIDIA/CUDA
@@ -176,11 +190,23 @@ GeoIntel behandelt officiële gebouwgrondvlakken niet automatisch als perfecte
daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage, daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage,
bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd. bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd.
Een directe polygon-overlay maakt bovendien zichtbaar of de officiële geometrie Een directe polygon-overlay maakt bovendien zichtbaar of de officiële geometrie
op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen. op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen. Voor
productresultaten blijft de taakgeschikte officiële bron doorslaggevend; een
AI-detectie is controleerbaar voorstelbewijs zolang een taakgebonden releasegate
niet aantoonbaar anders beslist.
De huidige V66 Vlaamse uitbreiding blijft kandidaatdata. Automatische SAM2- en De onafhankelijke AI-visuele hercontrole van 30 augustus 2026 vond nog
edge-alignmentproeven zijn afgewezen omdat ze nog vegetatie, wegen of schaduwen blokkerende referentiegeometrie, dense/nested labels, tile-edge-onduidelijkheid
als dak konden selecteren. Die resultaten zijn dus niet getraind of gepromoveerd. en onvoldoende onafhankelijke pure-background/hard-negative dekking. Deze
controle is uitdrukkelijk geen menselijke acceptatie. Nieuwe training en
modelpromotie blijven daarom geblokkeerd; er wordt geen 100%-accuratieclaim
gemaakt.
Modelactivatie vereist naast het oudere diagnostische promotierapport ook een
geslaagd, governed Phase-4/5 release-gaterapport dat exact dezelfde candidate
key, model-SHA-256 en benchmarkmanifest-SHA-256 bindt. Zie
[de actuele accuracy-status](docs/accuracy-program/status.json) en het
[visuele reviewbewijs](artifacts/evidence/accuracy/model-training/20260830-independent-ai-visual-review.json).
## Portfolio case study ## Portfolio case study
@@ -258,6 +284,7 @@ Actuele rasterassets:
| `geointel-landing-hero.png` | Desktop hero en projectintroductie | | `geointel-landing-hero.png` | Desktop hero en projectintroductie |
| `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering | | `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering |
| `geointel-workbench-map.png` | Kaartgerichte gastwerkruimte | | `geointel-workbench-map.png` | Kaartgerichte gastwerkruimte |
| `geointel-workbench-wide.png` | Brede kaartwerkruimte voor desktopportfolio's |
| `geointel-workbench-quality.png` | QA/QC, metrics en objectbewijs | | `geointel-workbench-quality.png` | QA/QC, metrics en objectbewijs |
| `geointel-landing-mobile.png` | Mobiele landing | | `geointel-landing-mobile.png` | Mobiele landing |
| `geointel-workbench-mobile.png` | Mobiele kaartworkflow | | `geointel-workbench-mobile.png` | Mobiele kaartworkflow |
@@ -0,0 +1,284 @@
{
"audited_repository_head": "0e3c1b20e931941c346da08f6fa8847e674bdadb",
"claim_boundary": "This manifest proves retained-file identity and completeness. It does not establish model accuracy, human label acceptance, split independence or release readiness.",
"evidence_file_count": 29,
"evidence_files": [
{
"path": "artifacts/evidence/accuracy/P1/alembic-heads.txt",
"role": "execution_log",
"sha256": "da4521233c6718fc7a5865c53904e73685fbdce65a1449b19cd0dc2e40d037ed",
"size_bytes": 116
},
{
"path": "artifacts/evidence/accuracy/P1/alembic-offline-upgrade.sql",
"role": "migration_evidence",
"sha256": "e8905b881890cf95885a7515e3d9dcf2a7a0a24c4edbc57e98a363edcb22dd0e",
"size_bytes": 20199
},
{
"path": "artifacts/evidence/accuracy/P1/backend-ci-entrypoint.txt",
"role": "execution_log",
"sha256": "0ac774ec19b4ff0a15142aab5f1db68c2592a230401d794ae7d040320e3ac0c0",
"size_bytes": 1601
},
{
"path": "artifacts/evidence/accuracy/P1/backend-full-suite.junit.xml",
"role": "test_report",
"sha256": "a01d0b5b6974d7ed184f46e8e35ac3b06c9a5db565e45e140b5f799819396b08",
"size_bytes": 207086
},
{
"path": "artifacts/evidence/accuracy/P1/backend-full-suite.txt",
"role": "execution_log",
"sha256": "8521ed48b17b382752418750b3ea374831958fb063e83ef87048212e0fd5ea69",
"size_bytes": 23972
},
{
"path": "artifacts/evidence/accuracy/P1/commands-and-results.md",
"role": "execution_log",
"sha256": "451d4cfc2d6d67937411b23192d9418ef6721af678aed1b0d5cb82113d82efc5",
"size_bytes": 8427
},
{
"path": "artifacts/evidence/accuracy/P1/evidence-manifest.json",
"role": "structured_inventory",
"sha256": "1145e43ace4ed326f3138571189d580e416b280ded4354b148904b20603e1913",
"size_bytes": 10713
},
{
"path": "artifacts/evidence/accuracy/P1/forensic-reproductions.json",
"role": "defect_reproduction",
"sha256": "f6349199a15ae789092d3d65c17a39a9b32ea3ed557571c0c228c4be3cf7235e",
"size_bytes": 3533
},
{
"path": "artifacts/evidence/accuracy/P1/frontend-build.txt",
"role": "execution_log",
"sha256": "c3b8e10ef177ac1c2dc045bd710df1caeb46f8922a721291be431b304abbc079",
"size_bytes": 2356
},
{
"path": "artifacts/evidence/accuracy/P1/frontend-lint.txt",
"role": "lint_evidence",
"sha256": "1115b013515c753e2dfb73abdad9024aec7b4c2337d117c7e1181341fef15c7f",
"size_bytes": 418
},
{
"path": "artifacts/evidence/accuracy/P1/frontend-typecheck.txt",
"role": "execution_log",
"sha256": "3891c85c77b5ff50a1eb6d27a2a65d40c2c05423768734efd9d980f3784d68fa",
"size_bytes": 163
},
{
"path": "artifacts/evidence/accuracy/P1/frontend-vitest-unit.txt",
"role": "test_evidence",
"sha256": "6341bfa51ca3f4fe5ec7d4af7239c3c5e1a29e6bfe8bdfae85e824a2a6482ad0",
"size_bytes": 2774
},
{
"path": "artifacts/evidence/accuracy/P1/frontend-vitest.txt",
"role": "test_evidence",
"sha256": "8a84afa48cc59e473725ed105a3f88da24f001cdcb794d942ede2ab8f0d48e48",
"size_bytes": 333
},
{
"path": "artifacts/evidence/accuracy/P1/golden-qa-reproducibility.json",
"role": "test_evidence",
"sha256": "576e5667a989c34086db3a2bc57003115a8a61b14e9f49f5407ee380baf0829d",
"size_bytes": 505
},
{
"path": "artifacts/evidence/accuracy/P1/golden-qa-run-1.json",
"role": "test_evidence",
"sha256": "ec96862b024987b59e56579920361582e718126b07885732335ac1b4d51b61bc",
"size_bytes": 5242
},
{
"path": "artifacts/evidence/accuracy/P1/golden-qa-run-2.json",
"role": "test_evidence",
"sha256": "4f900711a706dd56dd16b368e261761051f6d52a38bfd421e7d1c5f66ee51e49",
"size_bytes": 5242
},
{
"path": "artifacts/evidence/accuracy/P1/local-artifact-inventory.json",
"role": "structured_inventory",
"sha256": "0e187dbf3e91ee8e35567d6c8cd6e1cadfa6275d5908b829253491f3804adf7d",
"size_bytes": 75535
},
{
"path": "artifacts/evidence/accuracy/P1/openapi-contract-audit.txt",
"role": "execution_log",
"sha256": "5af3d8f00be3f57fc309bc198fa3995d1eae7270a5f210b3e94d1aeeb653119d",
"size_bytes": 212
},
{
"path": "artifacts/evidence/accuracy/P1/phase1-baseline-summary.json",
"role": "structured_inventory",
"sha256": "7d5960df64bdd4fb9b9d9d7a93de5f9cfd770f4dcf21454899345afe3ab12342",
"size_bytes": 1143
},
{
"path": "artifacts/evidence/accuracy/P1/phase1-tooling-tests.txt",
"role": "test_evidence",
"sha256": "a9bf261e1a811ad3e8bfa8edc439a11f00b46bc157f9ab6fc970033a87f45748",
"size_bytes": 250
},
{
"path": "artifacts/evidence/accuracy/P1/repository-inventory.json",
"role": "structured_inventory",
"sha256": "07fa9356f298f9fe360e2e362ce9f3d558a9a66544fed3a5567cd9a5666451c7",
"size_bytes": 27695
},
{
"path": "artifacts/evidence/accuracy/P1/repository-ruff-baseline.json",
"role": "lint_evidence",
"sha256": "de6617f030e49550e714c49b6e14bf291bf85016fd58086e9ca38b33a52252e9",
"size_bytes": 59359
},
{
"path": "artifacts/evidence/accuracy/P1/repository-ruff-baseline.txt",
"role": "lint_evidence",
"sha256": "cd654cea65710167beff8307cb71533fb645f25247ef46e7adc421bd520c9191",
"size_bytes": 57870
},
{
"path": "artifacts/evidence/accuracy/P1/static-risk-signals.json",
"role": "structured_inventory",
"sha256": "729f0a5d3eece61be8e26d44b5dfa29c6cbf21eb16b8694a2d4dc45c94720757",
"size_bytes": 22467
},
{
"path": "artifacts/evidence/accuracy/P1/tower-gpu-inference-smoke.json",
"role": "runtime_evidence",
"sha256": "692a9fa193d589123b042110d7e80755f0c6634854f3134c291fc6083adb7b77",
"size_bytes": 5675
},
{
"path": "artifacts/evidence/accuracy/P1/tower-key-artifact-hashes.json",
"role": "structured_inventory",
"sha256": "4d88d350604b377682da4d928c37ffb00ea1a32d2690d9b2bd54117363930414",
"size_bytes": 949
},
{
"path": "artifacts/evidence/accuracy/P1/tower-ml-data-lineage-snapshot.json",
"role": "lineage_evidence",
"sha256": "d80275e8198ce63366d2a2d44eb8fba1f27c29d85aaaaedb94991d8c56febfb6",
"size_bytes": 71659
},
{
"path": "artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot-detailed.json",
"role": "runtime_evidence",
"sha256": "744389b6c384a9fb3a9e16e56f1477f3b752e11a5b98e1fad7df47b4703a9ca1",
"size_bytes": 14946
},
{
"path": "artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot.json",
"role": "runtime_evidence",
"sha256": "aa7681d4a19ca20b7b442c509b3f56cb97bf239b2b5cc6a1fb1ca140d5320e9e",
"size_bytes": 10127
}
],
"evidence_root": "artifacts/evidence/accuracy/P1",
"evidence_total_bytes": 640567,
"generated_at": "2026-08-30T04:01:05.890308+00:00",
"program_file_count": 16,
"program_files": [
{
"path": "docs/accuracy-program/00-execution-contract.md",
"role": "phase1_program",
"sha256": "d6c71dbccbb1e1f3bda2835cf9f53c3a5a92c173e9136d5cfccf007ec2c28684",
"size_bytes": 8720
},
{
"path": "docs/accuracy-program/01-system-inventory.md",
"role": "phase1_program",
"sha256": "2b27c28ca06ab8e8a36f12cccc8df23d5e83894e39b8e33406e6a6c05092cf22",
"size_bytes": 15708
},
{
"path": "docs/accuracy-program/02-data-lineage.md",
"role": "phase1_program",
"sha256": "8af25f30d43863438678b3e867c9bf0a318cdac78e12c891254fb2570e2d9d1d",
"size_bytes": 20409
},
{
"path": "docs/accuracy-program/03-baseline-and-gaps.md",
"role": "phase1_program",
"sha256": "ad3c2aae2944443d0f5d09b5c415b6202109071a6f194bb8b09b7df30332e3b3",
"size_bytes": 21253
},
{
"path": "docs/accuracy-program/04-risk-register.md",
"role": "phase1_program",
"sha256": "f3c0d35b95fd180a391950c2a6c9339aa3e4ace0e8512affbd242d159ca1e574",
"size_bytes": 23851
},
{
"path": "docs/accuracy-program/05-metric-framework.md",
"role": "phase1_program",
"sha256": "18abfc21964b7d98848d8ae513afce242184980c9517c9184d40efd9165ea00d",
"size_bytes": 28053
},
{
"path": "docs/accuracy-program/06-implementation-roadmap.md",
"role": "phase1_program",
"sha256": "c76ffebee4a23796ff221e6d4c5b5138f99d55a8b1422e897058b12a78a29ad9",
"size_bytes": 23695
},
{
"path": "docs/accuracy-program/status.json",
"role": "phase1_program",
"sha256": "01a468bd9e796b23ee58dbadebb7800b5318d99e37497a694bb24835802f088d",
"size_bytes": 17224
},
{
"path": "scripts/build_accuracy_phase1_evidence_manifest.py",
"role": "phase1_program",
"sha256": "882a935b29f787fc11a9f7b528392c1727463be24adcb0cac47a09fdb69a8382",
"size_bytes": 5523
},
{
"path": "scripts/collect_accuracy_phase1_inference_smoke.py",
"role": "phase1_program",
"sha256": "6d81541b7aa83f3c03ba5320a8075892eee658905ee981ba727e59f3b1dc792a",
"size_bytes": 6756
},
{
"path": "scripts/collect_accuracy_phase1_ml_lineage.py",
"role": "phase1_program",
"sha256": "3b1d3e45d45f1d4ca1211d1b2c70d9d93e15abc3b3ecfbd2e8302f645d6db7e1",
"size_bytes": 14080
},
{
"path": "scripts/collect_accuracy_phase1_runtime.py",
"role": "phase1_program",
"sha256": "a907779306076dccdf7fcf45f554e4778f5fab0e59d6af55673322775a1f5b4f",
"size_bytes": 13387
},
{
"path": "scripts/reproduce_accuracy_phase1_findings.py",
"role": "phase1_program",
"sha256": "c0f34e6bcaf73dcdada6e5b529a2e8e2177b1e97ee039c586d6ae51bb743015f",
"size_bytes": 9447
},
{
"path": "scripts/run_accuracy_phase1_baseline.py",
"role": "phase1_program",
"sha256": "fcbd280245ccc598cab7ec5ca6448fbb350f7fa59aecc20177fdb0069c416643",
"size_bytes": 14498
},
{
"path": "scripts/verify_accuracy_phase1_evidence.py",
"role": "phase1_program",
"sha256": "fc72c315d26b8b7542fe01666aef56a8538d09090f2c0c877c4dd8a33c93e556",
"size_bytes": 4925
},
{
"path": "tests/test_accuracy_phase1_baseline.py",
"role": "phase1_program",
"sha256": "868f433b7de61e9ab3cd7aa9ad5bb2d29e8286afdcd68274ed290909ec605c7f",
"size_bytes": 3009
}
],
"schema_version": 1
}
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,15 @@
{ {
"anomaly_category_counts": { "anomaly_category_counts": {
"freshness.missing": 9, "freshness.missing": 29,
"lineage.missing": 58, "lineage.missing": 577,
"manifest.zero_bbox": 6, "manifest.zero_bbox": 6,
"raster.unreadable": 96, "raster.unreadable": 96,
"scope.unreachable": 3 "scope.unreachable": 3
}, },
"datasets": { "datasets": {
"geointel.artifact.generic": { "geointel.artifact.generic": {
"anomaly_count": 57, "anomaly_count": 491,
"item_count": 106, "item_count": 620,
"paths": [ "paths": [
"artifacts/codex-review/v31/contact_sheet_001.png", "artifacts/codex-review/v31/contact_sheet_001.png",
"artifacts/codex-review/v31/qa.json", "artifacts/codex-review/v31/qa.json",
@@ -73,6 +73,510 @@
"artifacts/detection-calibration-smoke/20260801T214300Z/evidence/threshold_0p35_qc-high_evidence_response.json", "artifacts/detection-calibration-smoke/20260801T214300Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260801T214300Z/mock-bin/curl", "artifacts/detection-calibration-smoke/20260801T214300Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260801T214300Z/summary/detection-calibration-summary.json", "artifacts/detection-calibration-smoke/20260801T214300Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T102427Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T102427Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T104514Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T104514Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T105815Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T105815Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T110331Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T110331Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T111348Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T111348Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T111836Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T111836Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T112539Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T112539Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T112647Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T112647Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T115647Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T115647Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T120018Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T120018Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T120441Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T120441Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T121027Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T121027Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T123611Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T123611Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T124019Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T124019Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T124513Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T124513Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T124803Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T124803Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T125438Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T125438Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T125917Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T125917Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130040Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T130040Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130155Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T130155Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130259Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T130259Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130445Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T130445Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130734Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T130734Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T130844Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T130844Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T131017Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T131017Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T131311Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T131311Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T132031Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T132031Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T132646Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T132646Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T135219Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T135219Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T135655Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T135655Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T141523Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T141523Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T163418Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T163418Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T163634Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T163634Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T163758Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T163758Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T164824Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T164824Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T165120Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T165120Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T165349Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T165349Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T165522Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T165522Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T170052Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T170052Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T170548Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T170548Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T170746Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T170746Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T171039Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T171039Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T172555Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T172555Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T173304Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T173304Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T173618Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T173618Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T174225Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T174225Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T174604Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T174604Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T183714Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T183714Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T184216Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T184216Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T184531Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T184531Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T191008Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T191008Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T191239Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T191239Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T194442Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T194442Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T195723Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T195723Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T200243Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T200243Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T200507Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T200507Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T201658Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T201658Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T202637Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T202637Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T202903Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T202903Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203146Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T203146Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203349Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T203349Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203532Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T203532Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T203714Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T203714Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T212235Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T212235Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T213013Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T213013Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T213303Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T213303Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T213500Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T213500Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T220721Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T220721Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T221248Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T221248Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260822T225049Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260822T225049Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260829T235505Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260829T235505Z/summary/detection-calibration-summary.json",
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence_requests.tsv",
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence_review.html",
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence_summary.json",
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/threshold_0p15_qc-low_evidence_response.json",
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/threshold_0p35_qc-high_evidence_response.json",
"artifacts/detection-calibration-smoke/20260829T235746Z/mock-bin/curl",
"artifacts/detection-calibration-smoke/20260829T235746Z/summary/detection-calibration-summary.json",
"artifacts/evidence/accuracy/P1/alembic-heads.txt", "artifacts/evidence/accuracy/P1/alembic-heads.txt",
"artifacts/evidence/accuracy/P1/alembic-offline-upgrade.sql", "artifacts/evidence/accuracy/P1/alembic-offline-upgrade.sql",
"artifacts/evidence/accuracy/P1/backend-ci-entrypoint.txt", "artifacts/evidence/accuracy/P1/backend-ci-entrypoint.txt",
@@ -92,6 +596,16 @@
"artifacts/evidence/accuracy/P1/repository-ruff-baseline.json", "artifacts/evidence/accuracy/P1/repository-ruff-baseline.json",
"artifacts/evidence/accuracy/P1/repository-ruff-baseline.txt", "artifacts/evidence/accuracy/P1/repository-ruff-baseline.txt",
"artifacts/evidence/accuracy/P1/static-risk-signals.json", "artifacts/evidence/accuracy/P1/static-risk-signals.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/reference-implementation-baseline.json",
"artifacts/evidence/accuracy/P4/reference-implementation-baseline.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/reference-implementation-baseline.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/reference-implementation-baseline.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/internal-spatial-leakage-audit.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/visual-review-summary.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/internal-spatial-leakage-audit.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/operator_yolo_label_qa_summary_r2.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-source-expanded-summary.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-source-summary.json",
"artifacts/model-review/building-be-v1-candidate/corpus-spec.json", "artifacts/model-review/building-be-v1-candidate/corpus-spec.json",
"artifacts/model-review/building-be-v1-pilot/corpus-spec.json", "artifacts/model-review/building-be-v1-pilot/corpus-spec.json",
"artifacts/v42-review/contact_sheet_001.png", "artifacts/v42-review/contact_sheet_001.png",
@@ -119,7 +633,7 @@
"storage/uploads/.gitkeep" "storage/uploads/.gitkeep"
], ],
"severity_counts": { "severity_counts": {
"major": 56, "major": 490,
"minor": 1 "minor": 1
} }
}, },
@@ -170,8 +684,8 @@
} }
}, },
"geointel.manifest.json": { "geointel.manifest.json": {
"anomaly_count": 3, "anomaly_count": 35,
"item_count": 13, "item_count": 141,
"paths": [ "paths": [
"artifacts/evidence/accuracy/P1/evidence-manifest.json", "artifacts/evidence/accuracy/P1/evidence-manifest.json",
"artifacts/evidence/accuracy/P1/forensic-reproductions.json", "artifacts/evidence/accuracy/P1/forensic-reproductions.json",
@@ -185,16 +699,149 @@
"artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot.json", "artifacts/evidence/accuracy/P1/tower-runtime-database-snapshot.json",
"artifacts/evidence/accuracy/P2/evidence-manifest.json", "artifacts/evidence/accuracy/P2/evidence-manifest.json",
"artifacts/evidence/accuracy/P2/postgres-migration-guards.json", "artifacts/evidence/accuracy/P2/postgres-migration-guards.json",
"artifacts/evidence/accuracy/P2/source-contract-inventory.json" "artifacts/evidence/accuracy/P2/source-contract-inventory.json",
"artifacts/evidence/accuracy/P4/baseline-raw-predictions.json",
"artifacts/evidence/accuracy/P4/benchmark-manifest.json",
"artifacts/evidence/accuracy/P4/development-split-manifest.json",
"artifacts/evidence/accuracy/P4/evidence-manifest.json",
"artifacts/evidence/accuracy/P4/failure-gallery.json",
"artifacts/evidence/accuracy/P4/leakage-gate-report.json",
"artifacts/evidence/accuracy/P4/metric-report.json",
"artifacts/evidence/accuracy/P4/protected-split-manifest.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/acceptance-gates.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/aoi-metrics.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/baseline-raw-predictions.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/benchmark-manifest.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/calibration-metrics.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/candidate-vs-incumbent.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/development-split-manifest.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/error-taxonomy.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/evaluation-contract.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/evidence-manifest.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/failure-gallery.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/generation-status.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/human-review-summary.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/input-manifest.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/latency-and-reliability.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/leakage-gate-report.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/metric-report.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/object-metrics.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/protected-split-manifest.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/release-gate-report.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/split-and-leakage-audit.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/stratified-metrics.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/tile-metrics.json",
"artifacts/evidence/accuracy/P4/reference-harness-v2/workflow-summary.json",
"artifacts/evidence/accuracy/P4/release-gate-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/acceptance-gates.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/aoi-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/baseline-raw-predictions.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/benchmark-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/calibration-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/candidate-vs-incumbent.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/development-split-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/error-taxonomy.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/evaluation-contract.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/evidence-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/failure-gallery.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/generation-status.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/human-review-summary.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/input-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/latency-and-reliability.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/leakage-gate-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/metric-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/object-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/protected-split-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/release-gate-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/split-and-leakage-audit.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/stratified-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/tile-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.0-e9dd2b6e3c1a9a12a222/workflow-summary.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/acceptance-gates.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/aoi-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/baseline-raw-predictions.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/benchmark-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/calibration-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/candidate-vs-incumbent.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/development-split-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/error-taxonomy.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/evaluation-contract.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/evidence-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/failure-gallery.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/generation-status.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/human-review-summary.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/input-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/latency-and-reliability.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/leakage-gate-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/metric-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/object-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/protected-split-manifest.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/release-gate-report.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/split-and-leakage-audit.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/stratified-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/tile-metrics.json",
"artifacts/evidence/accuracy/P4/runs/p4-2.0.1-9677d0ef37db82bcf39b/workflow-summary.json",
"artifacts/evidence/accuracy/P4/workflow-summary.json",
"artifacts/evidence/accuracy/model-training/20260809-ai-assisted-visual-review.json",
"artifacts/evidence/accuracy/model-training/20260809-reviewedexp6-ai-assisted-review-ledger.json",
"artifacts/evidence/accuracy/model-training/20260809-reviewedexp6-minpx4-derived-corpus.json",
"artifacts/evidence/accuracy/model-training/20260809-reviewedexp6-provenance-migration-audit.json",
"artifacts/evidence/accuracy/model-training/20260809-v67-training-decision.json",
"artifacts/evidence/accuracy/model-training/20260809-v68-checkpoint-and-threshold-review.json",
"artifacts/evidence/accuracy/model-training/20260809-v69-nonoverlap-checkpoint-review.json",
"artifacts/evidence/accuracy/model-training/20260809-v70-evaluation-independence-gate.json",
"artifacts/evidence/accuracy/model-training/20260809-v71-full-lineage-independence-gate.json",
"artifacts/evidence/accuracy/model-training/20260809-v71-model-lineage-receipt.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration-summary.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/active-stratified.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/active-threshold-extension.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/challenger-stratified.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/checkpoint-matrix.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/corpus-NO_TRAINING.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/corpus-freeze.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/operator_samples_manifest.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/provisioning-spec.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/spatial-independence.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/yolo-NO_TRAINING.json",
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/yolo_tile_dataset_summary.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/EXPERIMENTAL_ONLY.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/aggressive-training-summary.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/aggressive-v72-evaluation-matrix.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/controlled-v72-evaluation-matrix.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/corpus-freeze.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/experimental_dataset_summary.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/operator_samples_manifest.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/outcome.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/provisioning-spec.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/v72-v73-spatial-independence.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/visual-review.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/active-error-report.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/decision.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.10.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.15.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.20.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.30.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/filter-0.50.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/proposal-filter.experimental.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-auto-optimizer.experimental.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-auto-v72-matrix.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-low-lr-v72-matrix.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-low-lr.experimental.json",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/rehearsal-v74.experimental.json"
], ],
"severity_counts": { "severity_counts": {
"minor": 3 "major": 12,
"minor": 23
} }
}, },
"geointel.model.pytorch": { "geointel.model.pytorch": {
"anomaly_count": 0, "anomaly_count": 1,
"item_count": 6, "item_count": 10,
"paths": [ "paths": [
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/contact_sheet_001.png",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_001.png",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_002.png",
"artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/active-error-contact-sheet.png",
"artifacts/model-review/building-be-v1-pilot/building-be-v2-background.png", "artifacts/model-review/building-be-v1-pilot/building-be-v2-background.png",
"artifacts/model-review/building-be-v1-pilot/building-be-v2-cal.png", "artifacts/model-review/building-be-v1-pilot/building-be-v2-cal.png",
"artifacts/model-review/building-be-v1-pilot/building-be-v2-contact.png", "artifacts/model-review/building-be-v1-pilot/building-be-v2-contact.png",
@@ -202,7 +849,9 @@
"artifacts/model-review/building-be-v1-pilot/contact_sheet_001.png", "artifacts/model-review/building-be-v1-pilot/contact_sheet_001.png",
"models/.gitkeep" "models/.gitkeep"
], ],
"severity_counts": {} "severity_counts": {
"major": 1
}
}, },
"geointel.raster.geotiff": { "geointel.raster.geotiff": {
"anomaly_count": 101, "anomaly_count": 101,
@@ -350,8 +999,8 @@
} }
}, },
"geointel.vector.geojson": { "geointel.vector.geojson": {
"anomaly_count": 8, "anomaly_count": 80,
"item_count": 8, "item_count": 80,
"paths": [ "paths": [
"artifacts/detection-calibration-smoke/20260801T130251Z/evidence/calibration_evidence.geojson", "artifacts/detection-calibration-smoke/20260801T130251Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260801T154642Z/evidence/calibration_evidence.geojson", "artifacts/detection-calibration-smoke/20260801T154642Z/evidence/calibration_evidence.geojson",
@@ -360,13 +1009,85 @@
"artifacts/detection-calibration-smoke/20260801T210556Z/evidence/calibration_evidence.geojson", "artifacts/detection-calibration-smoke/20260801T210556Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260801T213357Z/evidence/calibration_evidence.geojson", "artifacts/detection-calibration-smoke/20260801T213357Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260801T213707Z/evidence/calibration_evidence.geojson", "artifacts/detection-calibration-smoke/20260801T213707Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260801T214300Z/evidence/calibration_evidence.geojson" "artifacts/detection-calibration-smoke/20260801T214300Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T102427Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T104514Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T105815Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T110331Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T111348Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T111836Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T112539Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T112647Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T115647Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T120018Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T120441Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T121027Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T123611Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T124019Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T124513Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T124803Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T125438Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T125917Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T130040Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T130155Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T130259Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T130445Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T130734Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T130844Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T131017Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T131311Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T132031Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T132646Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T135219Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T135655Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T141523Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T163418Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T163634Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T163758Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T164824Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T165120Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T165349Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T165522Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T170052Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T170548Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T170746Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T171039Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T172555Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T173304Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T173618Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T174225Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T174604Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T183714Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T184216Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T184531Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T191008Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T191239Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T194442Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T195723Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T200243Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T200507Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T201658Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T202637Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T202903Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T203146Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T203349Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T203532Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T203714Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T212235Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T213013Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T213303Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T213500Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T220721Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T221248Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260822T225049Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260829T235505Z/evidence/calibration_evidence.geojson",
"artifacts/detection-calibration-smoke/20260829T235746Z/evidence/calibration_evidence.geojson"
], ],
"severity_counts": { "severity_counts": {
"major": 8 "major": 80
} }
} }
}, },
"scan_id": "p3-46ee3f8d3a2dc52b", "scan_id": "p3-eb67185e61107cc7",
"schema_version": 1 "schema_version": 1
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -9,9 +9,12 @@
"split_independence_proven": false "split_independence_proven": false
}, },
"same_checksum_across_splits": {}, "same_checksum_across_splits": {},
"scan_id": "p3-46ee3f8d3a2dc52b", "scan_id": "p3-eb67185e61107cc7",
"schema_version": 1, "schema_version": 1,
"source_spatial_audit_files": [], "source_spatial_audit_files": [
"artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/internal-spatial-leakage-audit.json",
"artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/internal-spatial-leakage-audit.json"
],
"spatial_overlap_checks": "not_proven_without_AOI_split_geometry", "spatial_overlap_checks": "not_proven_without_AOI_split_geometry",
"status": "attention" "status": "attention"
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
{ {
"items": { "items": {
"DHMV|unknown": 5, "DHMV|unknown": 11,
"GRB|unknown": 4, "GRB|unknown": 18,
"Orthophoto|unknown": 34, "Orthophoto|unknown": 34,
"unknown|declared": 6, "unknown|declared": 7,
"unknown|unavailable": 3, "unknown|unavailable": 3,
"unknown|unknown": 246 "unknown|unknown": 943
}, },
"scan_id": "p3-46ee3f8d3a2dc52b", "scan_id": "p3-eb67185e61107cc7",
"schema_version": 1 "schema_version": 1
} }
@@ -0,0 +1,84 @@
{
"schema_version": 1,
"review_id": "20260830-independent-ai-visual-review",
"reviewed_at": "2026-08-30T00:00:00+02:00",
"reviewer_kind": "independent_ai_visual_inspection",
"human_reviewer": false,
"claim_boundary": "Visual inspection evidence only. This is not a human acceptance ledger, protected-test result, accuracy measurement or model-promotion approval.",
"inputs": [
{
"path": "artifacts/evidence/accuracy/model-training/20260809-v72-fresh-calibration/contact_sheet_001.png",
"sha256": "f7734c5a9db8837adeda7f8e0f1a2942148330abfcb588d41dc245017ec2435a"
},
{
"path": "artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_001.png",
"sha256": "4e092afac6bbff820908f73acd2a29b6d327d7476c3b2ae5001302a0bde4a24f"
},
{
"path": "artifacts/evidence/accuracy/model-training/20260810-v73-flanders-remediation/contact_sheet_002.png",
"sha256": "ef4e4a9663b4ac1920bee76bf0ca40c69d6232ddeb40d75c6963a17540b8d211"
},
{
"path": "artifacts/evidence/accuracy/model-training/20260810-v74-root-cause/active-error-contact-sheet.png",
"sha256": "bb5e4809802643cc8b673a86f3027ed06aa0430fbe79b1d33718c21d5feb269d"
},
{
"path": "storage/operator-data/model-review/reviewedexp6-corpus/full-ai-review-r1/contact_sheet_001.png",
"sha256": "688b97a6e82e6b906b7966ed3e2b43685786e89d50549188bfdcc3837d042eb2"
},
{
"path": "storage/operator-data/model-review/reviewedexp6-corpus/nested-pairs-review-r2/contact_sheet_001.png",
"sha256": "2a6bdd542b8ac49bcd5ac4d9c84999982461463ddd3c2fe0dc7332ceeb7a5e60"
},
{
"path": "storage/operator-data/model-review/reviewedexp6-corpus/tile-edge-review-r1/contact_sheet_001.png",
"sha256": "5d96c5a0a732077ed16b22d33bed998b37bd33b5536b1491e778999a2a6638dd"
}
],
"findings": [
{
"severity": "blocker",
"category": "reference_geometry_semantics",
"observation": "Many yellow reference rectangles are coarse axis-aligned extents rather than roof or building footprints; several include vegetation, roads, fields or multiple structures.",
"impact": "A detector trained against these boxes is penalized for geometrically correct roof localization and rewarded for oversized detections."
},
{
"severity": "blocker",
"category": "dense_and_nested_labels",
"observation": "Dense urban and industrial samples contain strongly overlapping, nested and grouped boxes without an unambiguous single-object labelling rule.",
"impact": "Label conflict and inconsistent object granularity make precision, recall and NMS behaviour unreliable."
},
{
"severity": "critical",
"category": "tile_edge_and_visibility",
"observation": "Multiple labelled structures are clipped by tile boundaries or only partly visible, while edge inclusion rules are not consistently evident.",
"impact": "This creates avoidable false-negative and duplicate-detection pressure at inference tile seams."
},
{
"severity": "critical",
"category": "hard_negative_coverage",
"observation": "Industrial rails, paved surfaces, tree canopy and tree-shadow remain recurrent false-positive contexts in the active-error sheet; representative pure-background coverage remains insufficient, especially for Brussels.",
"impact": "The candidate has no defensible low-false-positive operating point across Belgium."
},
{
"severity": "critical",
"category": "model_error_balance",
"observation": "The active-error review shows simultaneous false positives and false negatives across industrial, vegetated and sparse-rural contexts.",
"impact": "Threshold tuning alone cannot repair the observed error pattern."
}
],
"decision": {
"corpus_accepted": false,
"training_gate": "blocked",
"promotion_allowed": false,
"human_review_satisfied": false,
"required_before_next_governed_training": [
"re-extract building labels from task-appropriate authoritative geometry with an explicit object-granularity rule",
"resolve dense and nested labels and publish a checksum-bound review ledger",
"apply deterministic tile-edge visibility and ownership rules",
"add independent pure-background and hard-negative strata for Flanders, Wallonia and Brussels",
"prove AOI and derived-image independence across train, validation, test and challenge splits",
"obtain representative human acceptance before opening protected evaluation"
]
}
}
+2
View File
@@ -8,3 +8,5 @@ storage
dist dist
node_modules node_modules
.env .env
.env.*
!.env.example
+5 -1
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.guest_scope import assert_guest_project_scope
from app.core.errors import AppError from app.core.errors import AppError
from app.db.session import get_db from app.db.session import get_db
from app.models import Dataset from app.models import Dataset
@@ -18,12 +19,15 @@ router = APIRouter(prefix="/analysis", tags=["analysis"])
@router.post("/change-detection", response_model=Envelope[JobRead]) @router.post("/change-detection", response_model=Envelope[JobRead])
def run_change_detection( def run_change_detection(
payload: ChangeDetectionRequest, payload: ChangeDetectionRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
source_dataset = db.get(Dataset, payload.source_dataset_id) source_dataset = db.get(Dataset, payload.source_dataset_id)
if not source_dataset: if not source_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404) raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404)
assert_guest_project_scope(request, source_dataset.project_id)
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source") ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
ChangeDetectionService._get_project_vector_dataset(db, payload.target_dataset_id, source_dataset.project_id, "Target")
job = JobService.run_sync_job( job = JobService.run_sync_job(
db=db, db=db,
project_id=source_dataset.project_id, project_id=source_dataset.project_id,
+91 -1
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Request, Response, status from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
@@ -10,14 +12,22 @@ from app.core.errors import AppError
from app.db.session import get_db from app.db.session import get_db
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
from app.services.auth_service import AuthPrincipal, AuthService from app.services.auth_service import AuthPrincipal, AuthService
from app.services.authentik_oidc_service import AuthentikOidcService
from app.services.demo_workflow_service import DemoWorkflowService from app.services.demo_workflow_service import DemoWorkflowService
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
COOKIE_NAME = "geointel_session" COOKIE_NAME = "geointel_session"
OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow"
logger = logging.getLogger("geointel.auth")
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession: def _session_from_principal(
principal: AuthPrincipal,
*,
guest_access_enabled: bool,
authentik_enabled: bool,
) -> AuthSession:
return AuthSession( return AuthSession(
authentication_required=True, authentication_required=True,
authenticated=True, authenticated=True,
@@ -25,6 +35,7 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC), expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
role=principal.role, role=principal.role,
guest_access_enabled=guest_access_enabled, guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
guest_project_id=principal.project_id, guest_project_id=principal.project_id,
) )
@@ -32,11 +43,13 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
def _session_payload(request: Request) -> AuthSession: def _session_payload(request: Request) -> AuthSession:
settings = get_settings() settings = get_settings()
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
authentik_enabled = AuthentikOidcService(settings).enabled
if not settings.auth_enabled: if not settings.auth_enabled:
return AuthSession( return AuthSession(
authentication_required=False, authentication_required=False,
authenticated=True, authenticated=True,
guest_access_enabled=False, guest_access_enabled=False,
authentik_enabled=False,
) )
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings) principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
if principal is None: if principal is None:
@@ -44,10 +57,12 @@ def _session_payload(request: Request) -> AuthSession:
authentication_required=True, authentication_required=True,
authenticated=False, authenticated=False,
guest_access_enabled=guest_access_enabled, guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
) )
return _session_from_principal( return _session_from_principal(
principal, principal,
guest_access_enabled=guest_access_enabled, guest_access_enabled=guest_access_enabled,
authentik_enabled=authentik_enabled,
) )
@@ -120,10 +135,83 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
data=_session_from_principal( data=_session_from_principal(
principal, principal,
guest_access_enabled=settings.guest_access_enabled, guest_access_enabled=settings.guest_access_enabled,
authentik_enabled=AuthentikOidcService(settings).enabled,
) )
) )
@router.get("/authentik/start")
def authentik_start(request: Request) -> RedirectResponse:
settings = get_settings()
service = AuthentikOidcService(settings)
try:
location, flow = service.start()
except Exception as exc:
logger.warning("Authentik authorization start failed: %s", type(exc).__name__)
raise AppError(
code="AUTHENTIK_UNAVAILABLE",
message="Authentik is momenteel niet beschikbaar.",
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
) from exc
response = RedirectResponse(location, status_code=status.HTTP_302_FOUND)
response.set_cookie(
OIDC_FLOW_COOKIE_NAME,
flow,
max_age=600,
httponly=True,
secure=True,
samesite="lax",
path=f"{settings.api_prefix}/auth/authentik",
)
return response
@router.get("/authentik/callback")
def authentik_callback(
request: Request,
code: str = "",
state: str = "",
) -> RedirectResponse:
settings = get_settings()
service = AuthentikOidcService(settings)
base_url = settings.public_base_url.rstrip("/")
try:
service.finish(
code=code,
state=state,
flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""),
)
token = AuthService.create_session_token(
settings.auth_username or "operator",
settings,
)
except Exception as exc:
logger.warning("Authentik callback rejected: %s", type(exc).__name__)
response = RedirectResponse(
f"{base_url}/?authentik=error",
status_code=status.HTTP_302_FOUND,
)
else:
response = RedirectResponse(
f"{base_url}/",
status_code=status.HTTP_302_FOUND,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.auth_session_ttl_seconds,
)
response.delete_cookie(
OIDC_FLOW_COOKIE_NAME,
path=f"{settings.api_prefix}/auth/authentik",
secure=True,
httponly=True,
samesite="lax",
)
return response
@router.post("/guest", response_model=AuthSessionEnvelope) @router.post("/guest", response_model=AuthSessionEnvelope)
def guest_login( def guest_login(
request: Request, request: Request,
@@ -163,6 +251,7 @@ def guest_login(
data=_session_from_principal( data=_session_from_principal(
principal, principal,
guest_access_enabled=True, guest_access_enabled=True,
authentik_enabled=AuthentikOidcService(settings).enabled,
) )
) )
@@ -176,5 +265,6 @@ def logout(response: Response) -> AuthSessionEnvelope:
authentication_required=settings.auth_enabled, authentication_required=settings.auth_enabled,
authenticated=not settings.auth_enabled, authenticated=not settings.auth_enabled,
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled, guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
authentik_enabled=AuthentikOidcService(settings).enabled,
) )
) )
+7 -3
View File
@@ -5,13 +5,13 @@ from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response
from fastapi import UploadFile from fastapi import UploadFile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Area, Project from app.core.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.db.session import get_db from app.db.session import get_db
from app.models import Area, Project
from app.schemas import ( from app.schemas import (
BathymetryPartitionFinalizationResult, BathymetryPartitionFinalizationResult,
BathymetrySourceProbeRead, BathymetrySourceProbeRead,
@@ -1172,11 +1172,14 @@ def raster_tile_dataset(
project_id: UUID, project_id: UUID,
dataset_id: UUID, dataset_id: UUID,
payload: RasterTileRequest, payload: RasterTileRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
dataset = DatasetService.get_dataset(db, dataset_id) dataset = DatasetService.get_dataset(db, dataset_id)
if dataset.project_id != project_id: if dataset.project_id != project_id:
raise HTTPException(status_code=404, detail="Dataset not found") raise HTTPException(status_code=404, detail="Dataset not found")
principal = getattr(request.state, "auth_principal", None)
guest_max_tiles = get_settings().yolo_max_tiles if getattr(principal, "role", None) == "guest" else None
job = _run_job_sync( job = _run_job_sync(
db=db, db=db,
project_id=project_id, project_id=project_id,
@@ -1189,6 +1192,7 @@ def raster_tile_dataset(
tile_size=payload.tile_size, tile_size=payload.tile_size,
overlap=payload.overlap, overlap=payload.overlap,
output_name=payload.output_name, output_name=payload.output_name,
max_tiles=guest_max_tiles,
), ),
) )
return envelope(job) return envelope(job)
+64 -2
View File
@@ -1,4 +1,6 @@
from pydantic import Field, field_validator, model_validator from urllib.parse import urlsplit
from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,6 +24,14 @@ class Settings(BaseSettings):
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME") auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH") auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET") auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER")
authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID")
authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET")
authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL")
public_base_url: str = Field(
default="http://localhost:1202",
validation_alias="GEOINTEL_PUBLIC_BASE_URL",
)
auth_session_ttl_seconds: int = Field( auth_session_ttl_seconds: int = Field(
default=43_200, default=43_200,
ge=900, ge=900,
@@ -54,7 +64,18 @@ class Settings(BaseSettings):
allow_external_artifact_paths: bool = Field( allow_external_artifact_paths: bool = Field(
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS" default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
) )
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB") max_upload_mb: int = Field(
default=500,
ge=1,
le=2_048,
validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"),
)
max_in_memory_vector_mb: int = Field(
default=64,
ge=1,
le=256,
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
)
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED") orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
orthophoto_wms_url: str = Field( orthophoto_wms_url: str = Field(
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms", default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
@@ -470,6 +491,47 @@ class Settings(BaseSettings):
self.guest_display_name = self.guest_display_name.strip() self.guest_display_name = self.guest_display_name.strip()
if not self.guest_display_name: if not self.guest_display_name:
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank") raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
for field_name in (
"authentik_issuer",
"authentik_client_id",
"authentik_client_secret",
"authentik_allowed_email",
):
value = getattr(self, field_name)
setattr(self, field_name, value.strip() if value else None)
self.public_base_url = self.public_base_url.strip().rstrip("/")
authentik_values = (
self.authentik_issuer,
self.authentik_client_id,
self.authentik_client_secret,
self.authentik_allowed_email,
)
if any(authentik_values) and not all(authentik_values):
raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together")
if all(authentik_values):
if not self.auth_enabled:
raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured")
for label, value in (
("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer),
("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url),
):
parsed = urlsplit(str(value))
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment")
public_url = urlsplit(self.public_base_url)
if public_url.path not in ("", "/"):
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path")
if "@" not in str(self.authentik_allowed_email) or any(
character.isspace() for character in str(self.authentik_allowed_email)
):
raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address")
if not self.auth_enabled: if not self.auth_enabled:
return self return self
if not (self.auth_username or "").strip(): if not (self.auth_username or "").strip():
+8 -2
View File
@@ -26,7 +26,7 @@ from app.services.aoi_operation_worker import AoiOperationWorker
logger = logging.getLogger("geointel") logger = logging.getLogger("geointel")
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]") UNSAFE_HOST = re.compile(r"[/\\@?#\s\x00-\x1f\x7f]")
def _to_error_payload( def _to_error_payload(
@@ -157,6 +157,8 @@ def create_app() -> FastAPI:
f"{settings.api_prefix}/auth/login", f"{settings.api_prefix}/auth/login",
f"{settings.api_prefix}/auth/guest", f"{settings.api_prefix}/auth/guest",
f"{settings.api_prefix}/auth/logout", f"{settings.api_prefix}/auth/logout",
f"{settings.api_prefix}/auth/authentik/start",
f"{settings.api_prefix}/auth/authentik/callback",
} }
direct_loopback_request = ( direct_loopback_request = (
request.client is not None request.client is not None
@@ -261,6 +263,7 @@ def create_app() -> FastAPI:
guest_safe_post_paths = { guest_safe_post_paths = {
f"{settings.api_prefix}/demo/workflow", f"{settings.api_prefix}/demo/workflow",
f"{settings.api_prefix}/external/coverage/resolve", f"{settings.api_prefix}/external/coverage/resolve",
f"{settings.api_prefix}/analysis/change-detection",
} }
guest_scoped_analysis_post_paths = { guest_scoped_analysis_post_paths = {
f"{settings.api_prefix}/detection/run", f"{settings.api_prefix}/detection/run",
@@ -274,7 +277,10 @@ def create_app() -> FastAPI:
f"{settings.api_prefix}/exports/map-result", f"{settings.api_prefix}/exports/map-result",
} }
guest_safe_post_suffixes = ( guest_safe_post_suffixes = (
"/acquire",
"/vector/select", "/vector/select",
"/vector/select/derive",
"/raster/tile",
"/raster/bathymetry/select", "/raster/bathymetry/select",
"/raster/terrain/select", "/raster/terrain/select",
"/raster/flood-hazard/select", "/raster/flood-hazard/select",
@@ -313,7 +319,7 @@ def create_app() -> FastAPI:
status_code=403, status_code=403,
content=_to_error_payload( content=_to_error_payload(
"GUEST_READ_ONLY", "GUEST_READ_ONLY",
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.", "Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.",
request_id=request_id, request_id=request_id,
), ),
) )
+1 -1
View File
@@ -1 +1 @@
from app.models import * from app.models import * # noqa: F403 - legacy compatibility shim re-exports the package API
+1
View File
@@ -14,6 +14,7 @@ class AreaCreate(BaseModel):
class AreaUpdate(BaseModel): class AreaUpdate(BaseModel):
name: str | None = None name: str | None = None
geometry: dict | None = None
crs: str | None = None crs: str | None = None
+1
View File
@@ -21,6 +21,7 @@ class AuthSession(BaseModel):
expires_at: datetime | None = None expires_at: datetime | None = None
role: Literal["operator", "guest"] | None = None role: Literal["operator", "guest"] | None = None
guest_access_enabled: bool = False guest_access_enabled: bool = False
authentik_enabled: bool = False
guest_project_id: UUID | None = None guest_project_id: UUID | None = None
+4
View File
@@ -42,6 +42,10 @@ class ModelAssetRead(BaseModel):
size_bytes: int size_bytes: int
sha256: str sha256: str
active: bool active: bool
runtime_available: bool
runtime_status: str
governed_validation_status: str
promotion_status: str
status: str status: str
limitation_message: str limitation_message: str
will_download_models: bool = False will_download_models: bool = False
+179 -31
View File
@@ -12,7 +12,10 @@ from app.schemas.spw_terrain import SpwTerrainAcquireRequest
from app.schemas.official_vector import OfficialVectorAcquireRequest from app.schemas.official_vector import OfficialVectorAcquireRequest
from app.schemas.flood_hazard import FloodHazardAcquireRequest from app.schemas.flood_hazard import FloodHazardAcquireRequest
from app.schemas.thematic_raster import ThematicRasterAcquireRequest from app.schemas.thematic_raster import ThematicRasterAcquireRequest
from app.schemas.bathymetry import BathymetryProfileAcquireRequest, MdkBathymetryAcquireRequest from app.schemas.bathymetry import (
BathymetryProfileAcquireRequest,
MdkBathymetryAcquireRequest,
)
from app.schemas.job import JobCreate from app.schemas.job import JobCreate
from app.schemas.operations import VectorSelectionBBox from app.schemas.operations import VectorSelectionBBox
from app.schemas.orthophoto import OrthophotoAcquireRequest from app.schemas.orthophoto import OrthophotoAcquireRequest
@@ -20,12 +23,20 @@ from app.services.aoi_operation_service import AoiOperationService
from app.services.grb_acquisition_service import GrbAcquisitionService from app.services.grb_acquisition_service import GrbAcquisitionService
from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.dhmv_acquisition_service import DhmvAcquisitionService
from app.services.spw_terrain_service import SpwTerrainService from app.services.spw_terrain_service import SpwTerrainService
from app.services.official_vector_acquisition_service import OfficialVectorAcquisitionService from app.services.official_vector_acquisition_service import (
OfficialVectorAcquisitionService,
)
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService from app.services.thematic_raster_acquisition_service import (
ThematicRasterAcquisitionService,
)
from app.services.walous_land_cover_service import WalousLandCoverService from app.services.walous_land_cover_service import WalousLandCoverService
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService from app.services.bathymetry_profile_acquisition_service import (
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService BathymetryProfileAcquisitionService,
)
from app.services.mdk_bathymetry_acquisition_service import (
MdkBathymetryAcquisitionService,
)
from app.services.job_service import JobService from app.services.job_service import JobService
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
@@ -40,7 +51,9 @@ class AoiOperationExecutor:
AoiOperationService._refresh_parent(db, operation_id) AoiOperationService._refresh_parent(db, operation_id)
return AoiOperationService.read(db, project_id, operation_id) return AoiOperationService.read(db, project_id, operation_id)
operation = db.get(AoiOperation, operation_id) operation = db.get(AoiOperation, operation_id)
child = JobService.create_job(db, JobCreate( child = JobService.create_job(
db,
JobCreate(
job_type=f"aoi.{operation.operation_type}.partition", job_type=f"aoi.{operation.operation_type}.partition",
project_id=project_id, project_id=project_id,
parameters_json={ parameters_json={
@@ -50,55 +63,190 @@ class AoiOperationExecutor:
"provider_key": partition.provider_key, "provider_key": partition.provider_key,
"product_key": partition.product_key, "product_key": partition.product_key,
}, },
)) ),
)
partition = db.get(AoiOperationPartition, partition.id) partition = db.get(AoiOperationPartition, partition.id)
partition.child_job_id = child.id partition.child_job_id = child.id
db.add(partition); db.commit() db.add(partition)
db.commit()
JobService.mark_running(db, child.id) JobService.mark_running(db, child.id)
try: try:
result = AoiOperationExecutor._dispatch(db, project_id, operation, partition) result = AoiOperationExecutor._dispatch(
output_id = result.get("output_dataset_id") if isinstance(result, dict) else None db, project_id, operation, partition
JobService.mark_success(db, child.id, result=result, output_dataset_id=UUID(str(output_id)) if output_id else None) )
return AoiOperationService.complete(db, project_id, operation_id, partition.id, result) output_id = (
result.get("output_dataset_id") if isinstance(result, dict) else None
)
JobService.mark_success(
db,
child.id,
result=result,
output_dataset_id=UUID(str(output_id)) if output_id else None,
)
return AoiOperationService.complete(
db, project_id, operation_id, partition.id, result
)
except AppError as exc: except AppError as exc:
JobService.mark_failed(db, child.id, exc.message, {"code": exc.code, "details": exc.details}) JobService.mark_failed(
return AoiOperationService.fail(db, project_id, operation_id, partition.id, exc.message, AoiOperationExecutor._retryable(exc), {"code": exc.code, "details": exc.details}) db, child.id, exc.message, {"code": exc.code, "details": exc.details}
)
return AoiOperationService.fail(
db,
project_id,
operation_id,
partition.id,
exc.message,
AoiOperationExecutor._retryable(exc),
{"code": exc.code, "details": exc.details},
)
except Exception: except Exception:
try: try:
db.rollback() db.rollback()
JobService.mark_failed(db, child.id, "Unexpected partition execution error", {"code": "AOI_PARTITION_INTERNAL_ERROR"}) JobService.mark_failed(
db,
child.id,
"Unexpected partition execution error",
{"code": "AOI_PARTITION_INTERNAL_ERROR"},
)
finally: finally:
AoiOperationService.fail(db, project_id, operation_id, partition.id, "Unexpected partition execution error", True, {"code": "AOI_PARTITION_INTERNAL_ERROR"}) AoiOperationService.fail(
db,
project_id,
operation_id,
partition.id,
"Unexpected partition execution error",
True,
{"code": "AOI_PARTITION_INTERNAL_ERROR"},
)
raise raise
@staticmethod @staticmethod
def _dispatch(db, project_id: UUID, operation: AoiOperation, partition: AoiOperationPartition) -> dict: def _dispatch(
db, project_id: UUID, operation: AoiOperation, partition: AoiOperationPartition
) -> dict:
geometry = to_shape(partition.geometry) geometry = to_shape(partition.geometry)
min_x, min_y, max_x, max_y = geometry.bounds min_x, min_y, max_x, max_y = geometry.bounds
bbox = VectorSelectionBBox(min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y, crs="EPSG:4326") bbox = VectorSelectionBBox(
force_refresh = bool((operation.request_json or {}).get("parameters_json", {}).get("force_refresh", False)) min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y, crs="EPSG:4326"
)
force_refresh = bool(
(operation.request_json or {})
.get("parameters_json", {})
.get("force_refresh", False)
)
if partition.provider_key == "grb": if partition.provider_key == "grb":
return GrbAcquisitionService.acquire(db, project_id, GrbAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return GrbAcquisitionService.acquire(
db,
project_id,
GrbAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "orthophoto": if partition.provider_key == "orthophoto":
return OrthophotoAcquisitionService.acquire(db, project_id, OrthophotoAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return OrthophotoAcquisitionService.acquire(
db,
project_id,
OrthophotoAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "dhmv": if partition.provider_key == "dhmv":
return DhmvAcquisitionService.acquire(db, project_id, DhmvAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return DhmvAcquisitionService.acquire(
db,
project_id,
DhmvAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "spw_terrain": if partition.provider_key == "spw_terrain":
return SpwTerrainService.acquire(db, project_id, SpwTerrainAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return SpwTerrainService.acquire(
db,
project_id,
SpwTerrainAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "official_vector": if partition.provider_key == "official_vector":
return OfficialVectorAcquisitionService.acquire(db, project_id, OfficialVectorAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return OfficialVectorAcquisitionService.acquire(
db,
project_id,
OfficialVectorAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "flood_hazard": if partition.provider_key == "flood_hazard":
return FloodHazardAcquisitionService.acquire(db, project_id, FloodHazardAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return FloodHazardAcquisitionService.acquire(
db,
project_id,
FloodHazardAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "thematic_raster": if partition.provider_key == "thematic_raster":
return ThematicRasterAcquisitionService.acquire(db, project_id, ThematicRasterAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return ThematicRasterAcquisitionService.acquire(
db,
project_id,
ThematicRasterAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "walous": if partition.provider_key == "walous":
return WalousLandCoverService.acquire(db, project_id, ThematicRasterAcquireRequest(bbox=bbox, area_id=operation.area_id, product_key=partition.product_key, force_refresh=force_refresh)) return WalousLandCoverService.acquire(
db,
project_id,
ThematicRasterAcquireRequest(
bbox=bbox,
area_id=operation.area_id,
product_key=partition.product_key,
force_refresh=force_refresh,
),
)
if partition.provider_key == "bathymetry_profiles": if partition.provider_key == "bathymetry_profiles":
return BathymetryProfileAcquisitionService.acquire(db, project_id, BathymetryProfileAcquireRequest(bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh)) return BathymetryProfileAcquisitionService.acquire(
db,
project_id,
BathymetryProfileAcquireRequest(
bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh
),
)
if partition.provider_key == "mdk_bathymetry": if partition.provider_key == "mdk_bathymetry":
return MdkBathymetryAcquisitionService.acquire(db, project_id, MdkBathymetryAcquireRequest(bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh)) return MdkBathymetryAcquisitionService.acquire(
raise AppError(code="AOI_PROVIDER_UNSUPPORTED", message="No governed AOI executor is registered for this provider", details={"provider_key": partition.provider_key}, status_code=422) db,
project_id,
MdkBathymetryAcquireRequest(
bbox=bbox, area_id=operation.area_id, force_refresh=force_refresh
),
)
raise AppError(
code="AOI_PROVIDER_UNSUPPORTED",
message="No governed AOI executor is registered for this provider",
details={"provider_key": partition.provider_key},
status_code=422,
)
@staticmethod @staticmethod
def _retryable(error: AppError) -> bool: def _retryable(error: AppError) -> bool:
return error.status_code >= 500 or error.code.endswith(("TIMEOUT", "UNAVAILABLE", "TLS_ERROR")) return error.status_code >= 500 or error.code.endswith(
("TIMEOUT", "UNAVAILABLE", "TLS_ERROR")
)
+272 -67
View File
@@ -22,8 +22,11 @@ class AoiOperationService:
_to_metric = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) _to_metric = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
_to_wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) _to_wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
SCOPE_AREA_NAMES = { SCOPE_AREA_NAMES = {
"belgium": "Belgium land", "flanders": "Flanders", "wallonia": "Wallonia", "belgium": "Belgium land",
"brussels": "Brussels-Capital Region", "belgian_north_sea": "Belgian part of the North Sea", "flanders": "Flanders",
"wallonia": "Wallonia",
"brussels": "Brussels-Capital Region",
"belgian_north_sea": "Belgian part of the North Sea",
"territorial_sea": "Belgian territorial sea (0-12 nautical miles)", "territorial_sea": "Belgian territorial sea (0-12 nautical miles)",
"exclusive_economic_zone": "Belgian exclusive economic zone beyond territorial sea", "exclusive_economic_zone": "Belgian exclusive economic zone beyond territorial sea",
"continental_shelf": "Belgian continental shelf beyond territorial sea", "continental_shelf": "Belgian continental shelf beyond territorial sea",
@@ -32,13 +35,19 @@ class AoiOperationService:
@staticmethod @staticmethod
def create(db, project_id: UUID, payload: AoiOperationCreate) -> dict: def create(db, project_id: UUID, payload: AoiOperationCreate) -> dict:
if db.get(Project, project_id) is None: if db.get(Project, project_id) is None:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) raise AppError(
code="PROJECT_NOT_FOUND", message="Project not found", status_code=404
)
geometry = AoiOperationService._resolve_geometry(db, project_id, payload) geometry = AoiOperationService._resolve_geometry(db, project_id, payload)
if payload.coverage_zone: if payload.coverage_zone:
geometry = AoiOperationService._clip_to_zone(db, project_id, geometry, payload.coverage_zone) geometry = AoiOperationService._clip_to_zone(
db, project_id, geometry, payload.coverage_zone
)
geometry = AoiOperationService._as_multipolygon(geometry) geometry = AoiOperationService._as_multipolygon(geometry)
metric_geometry = transform(AoiOperationService._to_metric.transform, geometry) metric_geometry = transform(AoiOperationService._to_metric.transform, geometry)
partition_side_m = AoiOperationService._partition_side(payload.provider_key, payload.max_partition_side_m) partition_side_m = AoiOperationService._partition_side(
payload.provider_key, payload.max_partition_side_m
)
cells = AoiOperationService._partition(metric_geometry, partition_side_m) cells = AoiOperationService._partition(metric_geometry, partition_side_m)
operation_id = uuid4() operation_id = uuid4()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -53,7 +62,9 @@ class AoiOperationService:
plan_json={ plan_json={
"partition_strategy": "epsg31370_square_grid_intersection_v1", "partition_strategy": "epsg31370_square_grid_intersection_v1",
"max_partition_side_m": partition_side_m, "max_partition_side_m": partition_side_m,
"budget_source": "governed_provider_registry" if payload.max_partition_side_m is None else "stricter_operator_override", "budget_source": "governed_provider_registry"
if payload.max_partition_side_m is None
else "stricter_operator_override",
"partition_count": len(cells), "partition_count": len(cells),
"provider_key": payload.provider_key, "provider_key": payload.provider_key,
"product_key": payload.product_key, "product_key": payload.product_key,
@@ -65,13 +76,21 @@ class AoiOperationService:
wgs84 = transform(AoiOperationService._to_wgs84.transform, cell) wgs84 = transform(AoiOperationService._to_wgs84.transform, cell)
wgs84 = AoiOperationService._as_multipolygon(wgs84) wgs84 = AoiOperationService._as_multipolygon(wgs84)
digest = sha256(wgs84.wkb).hexdigest()[:20] digest = sha256(wgs84.wkb).hexdigest()[:20]
db.add(AoiOperationPartition( db.add(
id=uuid4(), operation_id=operation_id, AoiOperationPartition(
id=uuid4(),
operation_id=operation_id,
partition_key=f"{payload.provider_key}:{payload.product_key}:{ordinal:05d}:{digest}", partition_key=f"{payload.provider_key}:{payload.product_key}:{ordinal:05d}:{digest}",
provider_key=payload.provider_key, product_key=payload.product_key, provider_key=payload.provider_key,
ordinal=ordinal, status="queued", geometry=from_shape(wgs84, srid=4326), product_key=payload.product_key,
attempt_count=0, max_attempts=payload.max_attempts, created_at=now, ordinal=ordinal,
)) status="queued",
geometry=from_shape(wgs84, srid=4326),
attempt_count=0,
max_attempts=payload.max_attempts,
created_at=now,
)
)
db.commit() db.commit()
return AoiOperationService.read(db, project_id, operation_id) return AoiOperationService.read(db, project_id, operation_id)
@@ -79,13 +98,32 @@ class AoiOperationService:
def _clip_to_zone(db, project_id: UUID, geometry, zone: str): def _clip_to_zone(db, project_id: UUID, geometry, zone: str):
area_name = AoiOperationService.SCOPE_AREA_NAMES.get(zone) area_name = AoiOperationService.SCOPE_AREA_NAMES.get(zone)
if area_name is None: if area_name is None:
raise AppError(code="AOI_COVERAGE_ZONE_UNSUPPORTED", message="Unknown governed coverage zone", details={"coverage_zone": zone}, status_code=422) raise AppError(
scope = db.query(Area).filter(Area.project_id == project_id, Area.name == area_name).first() code="AOI_COVERAGE_ZONE_UNSUPPORTED",
message="Unknown governed coverage zone",
details={"coverage_zone": zone},
status_code=422,
)
scope = (
db.query(Area)
.filter(Area.project_id == project_id, Area.name == area_name)
.first()
)
if scope is None: if scope is None:
raise AppError(code="AOI_COVERAGE_ZONE_NOT_MATERIALIZED", message="The governed coverage-zone geometry is not persisted in this project", details={"coverage_zone": zone}, status_code=409) raise AppError(
code="AOI_COVERAGE_ZONE_NOT_MATERIALIZED",
message="The governed coverage-zone geometry is not persisted in this project",
details={"coverage_zone": zone},
status_code=409,
)
clipped = geometry.intersection(to_shape(scope.geometry)) clipped = geometry.intersection(to_shape(scope.geometry))
if clipped.is_empty: if clipped.is_empty:
raise AppError(code="AOI_OUTSIDE_PROVIDER_ZONE", message="The AOI does not intersect the provider coverage zone", details={"coverage_zone": zone}, status_code=422) raise AppError(
code="AOI_OUTSIDE_PROVIDER_ZONE",
message="The AOI does not intersect the provider coverage zone",
details={"coverage_zone": zone},
status_code=422,
)
return clipped return clipped
@staticmethod @staticmethod
@@ -94,19 +132,30 @@ class AoiOperationService:
return MultiPolygon([geometry]) return MultiPolygon([geometry])
if isinstance(geometry, MultiPolygon): if isinstance(geometry, MultiPolygon):
return geometry return geometry
polygons = [part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)] polygons = [
part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)
]
if not polygons: if not polygons:
raise AppError(code="AOI_GEOMETRY_EMPTY", message="AOI contains no polygonal area after clipping", status_code=422) raise AppError(
code="AOI_GEOMETRY_EMPTY",
message="AOI contains no polygonal area after clipping",
status_code=422,
)
return MultiPolygon(polygons) return MultiPolygon(polygons)
@staticmethod @staticmethod
def _partition_side(provider_key: str, requested: float | None) -> float: def _partition_side(provider_key: str, requested: float | None) -> float:
settings = get_settings() settings = get_settings()
def raster_side(max_side_m: float, max_pixels: int, resolution_m: float) -> float:
def raster_side(
max_side_m: float, max_pixels: int, resolution_m: float
) -> float:
# Keep every square grid cell within both the provider's spatial # Keep every square grid cell within both the provider's spatial
# extent limit and its decoded-pixel budget. The small safety # extent limit and its decoded-pixel budget. The small safety
# margin absorbs ceil/edge rounding in the acquisition services. # margin absorbs ceil/edge rounding in the acquisition services.
pixel_limited_side = math.sqrt(float(max_pixels)) * float(resolution_m) * 0.99 pixel_limited_side = (
math.sqrt(float(max_pixels)) * float(resolution_m) * 0.99
)
return min(float(max_side_m), pixel_limited_side) return min(float(max_side_m), pixel_limited_side)
budgets = { budgets = {
@@ -142,22 +191,37 @@ class AoiOperationService:
"mdk_bathymetry": 20_000.0, "mdk_bathymetry": 20_000.0,
} }
if provider_key not in budgets: if provider_key not in budgets:
raise AppError(code="AOI_PROVIDER_UNSUPPORTED", message="No governed partition budget is registered for this provider", details={"provider_key": provider_key}, status_code=422) raise AppError(
code="AOI_PROVIDER_UNSUPPORTED",
message="No governed partition budget is registered for this provider",
details={"provider_key": provider_key},
status_code=422,
)
governed = budgets[provider_key] governed = budgets[provider_key]
return min(governed, float(requested)) if requested is not None else governed return min(governed, float(requested)) if requested is not None else governed
@staticmethod @staticmethod
def _resolve_geometry(db, project_id: UUID, payload: AoiOperationCreate): def _resolve_geometry(db, project_id: UUID, payload: AoiOperationCreate):
if (payload.area_id is None) == (payload.bbox is None): if (payload.area_id is None) == (payload.bbox is None):
raise AppError(code="AOI_SELECTION_REQUIRED", message="Provide exactly one area_id or bbox", status_code=422) raise AppError(
code="AOI_SELECTION_REQUIRED",
message="Provide exactly one area_id or bbox",
status_code=422,
)
if payload.area_id is not None: if payload.area_id is not None:
area = db.get(Area, payload.area_id) area = db.get(Area, payload.area_id)
if area is None or area.project_id != project_id: if area is None or area.project_id != project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) raise AppError(
code="AREA_NOT_FOUND", message="Area not found", status_code=404
)
return to_shape(area.geometry) return to_shape(area.geometry)
bbox = payload.bbox bbox = payload.bbox
if bbox is None or bbox.crs != "EPSG:4326": if bbox is None or bbox.crs != "EPSG:4326":
raise AppError(code="INVALID_AOI_CRS", message="AOI bbox must use EPSG:4326", status_code=422) raise AppError(
code="INVALID_AOI_CRS",
message="AOI bbox must use EPSG:4326",
status_code=422,
)
return box(bbox.min_x, bbox.min_y, bbox.max_x, bbox.max_y) return box(bbox.min_x, bbox.min_y, bbox.max_x, bbox.max_y)
@staticmethod @staticmethod
@@ -166,11 +230,26 @@ class AoiOperationService:
columns = max(1, math.ceil((max_x - min_x) / side_m)) columns = max(1, math.ceil((max_x - min_x) / side_m))
rows = max(1, math.ceil((max_y - min_y) / side_m)) rows = max(1, math.ceil((max_y - min_y) / side_m))
if columns * rows > AoiOperationService.MAX_PARTITIONS: if columns * rows > AoiOperationService.MAX_PARTITIONS:
raise AppError(code="AOI_PARTITION_LIMIT_EXCEEDED", message="AOI requires too many bounded partitions", details={"candidate_count": columns * rows, "max_partitions": AoiOperationService.MAX_PARTITIONS}, status_code=422) raise AppError(
code="AOI_PARTITION_LIMIT_EXCEEDED",
message="AOI requires too many bounded partitions",
details={
"candidate_count": columns * rows,
"max_partitions": AoiOperationService.MAX_PARTITIONS,
},
status_code=422,
)
partitions = [] partitions = []
for row in range(rows): for row in range(rows):
for column in range(columns): for column in range(columns):
clipped = geometry.intersection(box(min_x + column * side_m, min_y + row * side_m, min(min_x + (column + 1) * side_m, max_x), min(min_y + (row + 1) * side_m, max_y))) clipped = geometry.intersection(
box(
min_x + column * side_m,
min_y + row * side_m,
min(min_x + (column + 1) * side_m, max_x),
min(min_y + (row + 1) * side_m, max_y),
)
)
if not clipped.is_empty and clipped.area > 0: if not clipped.is_empty and clipped.area > 0:
partitions.append(clipped) partitions.append(clipped)
return partitions return partitions
@@ -179,98 +258,224 @@ class AoiOperationService:
def read(db, project_id: UUID, operation_id: UUID) -> dict: def read(db, project_id: UUID, operation_id: UUID) -> dict:
operation = db.get(AoiOperation, operation_id) operation = db.get(AoiOperation, operation_id)
if operation is None or operation.project_id != project_id: if operation is None or operation.project_id != project_id:
raise AppError(code="AOI_OPERATION_NOT_FOUND", message="AOI operation not found", status_code=404) raise AppError(
partitions = db.query(AoiOperationPartition).filter(AoiOperationPartition.operation_id == operation_id).order_by(AoiOperationPartition.ordinal).all() code="AOI_OPERATION_NOT_FOUND",
message="AOI operation not found",
status_code=404,
)
partitions = (
db.query(AoiOperationPartition)
.filter(AoiOperationPartition.operation_id == operation_id)
.order_by(AoiOperationPartition.ordinal)
.all()
)
counts = Counter(partition.status for partition in partitions) counts = Counter(partition.status for partition in partitions)
complete = counts["success"] + counts["skipped"] complete = counts["success"] + counts["skipped"]
return { return {
"id": operation.id, "project_id": operation.project_id, "area_id": operation.area_id, "id": operation.id,
"parent_job_id": operation.parent_job_id, "operation_type": operation.operation_type, "project_id": operation.project_id,
"status": operation.status, "request_json": operation.request_json, "plan_json": operation.plan_json, "area_id": operation.area_id,
"result_json": operation.result_json, "error_message": operation.error_message, "parent_job_id": operation.parent_job_id,
"operation_type": operation.operation_type,
"status": operation.status,
"request_json": operation.request_json,
"plan_json": operation.plan_json,
"result_json": operation.result_json,
"error_message": operation.error_message,
"progress": round(complete / len(partitions), 6) if partitions else 0.0, "progress": round(complete / len(partitions), 6) if partitions else 0.0,
"partition_counts": dict(counts), "partitions": partitions, "partition_counts": dict(counts),
"created_at": operation.created_at, "started_at": operation.started_at, "finished_at": operation.finished_at, "partitions": partitions,
"created_at": operation.created_at,
"started_at": operation.started_at,
"finished_at": operation.finished_at,
} }
@staticmethod @staticmethod
def list(db, project_id: UUID, limit: int = 50) -> dict: def list(db, project_id: UUID, limit: int = 50) -> dict:
rows = db.query(AoiOperation).filter(AoiOperation.project_id == project_id).order_by(AoiOperation.created_at.desc()).limit(limit).all() rows = (
return {"items": [AoiOperationService.read(db, project_id, row.id) for row in rows], "total": len(rows)} db.query(AoiOperation)
.filter(AoiOperation.project_id == project_id)
.order_by(AoiOperation.created_at.desc())
.limit(limit)
.all()
)
return {
"items": [AoiOperationService.read(db, project_id, row.id) for row in rows],
"total": len(rows),
}
@staticmethod @staticmethod
def claim_next(db, project_id: UUID, operation_id: UUID): def claim_next(db, project_id: UUID, operation_id: UUID):
operation = db.get(AoiOperation, operation_id) operation = db.get(AoiOperation, operation_id)
if operation is None or operation.project_id != project_id: if operation is None or operation.project_id != project_id:
raise AppError(code="AOI_OPERATION_NOT_FOUND", message="AOI operation not found", status_code=404) raise AppError(
partition = db.query(AoiOperationPartition).filter(AoiOperationPartition.operation_id == operation_id, AoiOperationPartition.status == "queued").order_by(AoiOperationPartition.ordinal).with_for_update(skip_locked=True).first() code="AOI_OPERATION_NOT_FOUND",
message="AOI operation not found",
status_code=404,
)
partition = (
db.query(AoiOperationPartition)
.filter(
AoiOperationPartition.operation_id == operation_id,
AoiOperationPartition.status == "queued",
)
.order_by(AoiOperationPartition.ordinal)
.with_for_update(skip_locked=True)
.first()
)
if partition is None: if partition is None:
return None return None
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
partition.status = "running"; partition.started_at = now; partition.attempt_count += 1; partition.error_message = None partition.status = "running"
operation.status = "running"; operation.started_at = operation.started_at or now partition.started_at = now
db.add(partition); db.add(operation); db.commit(); db.refresh(partition) partition.attempt_count += 1
partition.error_message = None
operation.status = "running"
operation.started_at = operation.started_at or now
db.add(partition)
db.add(operation)
db.commit()
db.refresh(partition)
return partition return partition
@staticmethod @staticmethod
def checkpoint(db, project_id: UUID, operation_id: UUID, partition_id: UUID, checkpoint: dict): def checkpoint(
partition = AoiOperationService._partition_row(db, project_id, operation_id, partition_id) db, project_id: UUID, operation_id: UUID, partition_id: UUID, checkpoint: dict
):
partition = AoiOperationService._partition_row(
db, project_id, operation_id, partition_id
)
if partition.status != "running": if partition.status != "running":
raise AppError(code="AOI_PARTITION_NOT_RUNNING", message="Only a running partition can be checkpointed", status_code=409) raise AppError(
partition.checkpoint_json = checkpoint; db.add(partition); db.commit(); db.refresh(partition) code="AOI_PARTITION_NOT_RUNNING",
message="Only a running partition can be checkpointed",
status_code=409,
)
partition.checkpoint_json = checkpoint
db.add(partition)
db.commit()
db.refresh(partition)
return partition return partition
@staticmethod @staticmethod
def complete(db, project_id: UUID, operation_id: UUID, partition_id: UUID, result: dict, skipped: bool = False): def complete(
partition = AoiOperationService._partition_row(db, project_id, operation_id, partition_id) db,
project_id: UUID,
operation_id: UUID,
partition_id: UUID,
result: dict,
skipped: bool = False,
):
partition = AoiOperationService._partition_row(
db, project_id, operation_id, partition_id
)
if partition.status == "success" or partition.status == "skipped": if partition.status == "success" or partition.status == "skipped":
return AoiOperationService.read(db, project_id, operation_id) return AoiOperationService.read(db, project_id, operation_id)
if partition.status != "running": if partition.status != "running":
raise AppError(code="AOI_PARTITION_NOT_RUNNING", message="Only a running partition can complete", status_code=409) raise AppError(
partition.status = "skipped" if skipped else "success"; partition.result_json = result; partition.finished_at = datetime.now(timezone.utc) code="AOI_PARTITION_NOT_RUNNING",
db.add(partition); db.commit(); AoiOperationService._refresh_parent(db, operation_id) message="Only a running partition can complete",
status_code=409,
)
partition.status = "skipped" if skipped else "success"
partition.result_json = result
partition.finished_at = datetime.now(timezone.utc)
db.add(partition)
db.commit()
AoiOperationService._refresh_parent(db, operation_id)
return AoiOperationService.read(db, project_id, operation_id) return AoiOperationService.read(db, project_id, operation_id)
@staticmethod @staticmethod
def fail(db, project_id: UUID, operation_id: UUID, partition_id: UUID, message: str, retryable: bool, details: dict): def fail(
partition = AoiOperationService._partition_row(db, project_id, operation_id, partition_id) db,
partition.error_message = message; partition.result_json = {"details": details} project_id: UUID,
partition.status = "queued" if retryable and partition.attempt_count < partition.max_attempts else "failed" operation_id: UUID,
partition.finished_at = None if partition.status == "queued" else datetime.now(timezone.utc) partition_id: UUID,
db.add(partition); db.commit(); AoiOperationService._refresh_parent(db, operation_id) message: str,
retryable: bool,
details: dict,
):
partition = AoiOperationService._partition_row(
db, project_id, operation_id, partition_id
)
partition.error_message = message
partition.result_json = {"details": details}
partition.status = (
"queued"
if retryable and partition.attempt_count < partition.max_attempts
else "failed"
)
partition.finished_at = (
None if partition.status == "queued" else datetime.now(timezone.utc)
)
db.add(partition)
db.commit()
AoiOperationService._refresh_parent(db, operation_id)
return AoiOperationService.read(db, project_id, operation_id) return AoiOperationService.read(db, project_id, operation_id)
@staticmethod @staticmethod
def _partition_row(db, project_id, operation_id, partition_id): def _partition_row(db, project_id, operation_id, partition_id):
operation = db.get(AoiOperation, operation_id); partition = db.get(AoiOperationPartition, partition_id) operation = db.get(AoiOperation, operation_id)
if operation is None or operation.project_id != project_id or partition is None or partition.operation_id != operation_id: partition = db.get(AoiOperationPartition, partition_id)
raise AppError(code="AOI_PARTITION_NOT_FOUND", message="AOI partition not found", status_code=404) if (
operation is None
or operation.project_id != project_id
or partition is None
or partition.operation_id != operation_id
):
raise AppError(
code="AOI_PARTITION_NOT_FOUND",
message="AOI partition not found",
status_code=404,
)
return partition return partition
@staticmethod @staticmethod
def _refresh_parent(db, operation_id): def _refresh_parent(db, operation_id):
operation = db.get(AoiOperation, operation_id) operation = db.get(AoiOperation, operation_id)
partitions = db.query(AoiOperationPartition).filter(AoiOperationPartition.operation_id == operation_id).order_by(AoiOperationPartition.ordinal).all() partitions = (
db.query(AoiOperationPartition)
.filter(AoiOperationPartition.operation_id == operation_id)
.order_by(AoiOperationPartition.ordinal)
.all()
)
statuses = [partition.status for partition in partitions] statuses = [partition.status for partition in partitions]
output_dataset_ids = [] output_dataset_ids = []
for partition in partitions: for partition in partitions:
output_id = (partition.result_json or {}).get("output_dataset_id") if isinstance(partition.result_json, dict) else None output_id = (
(partition.result_json or {}).get("output_dataset_id")
if isinstance(partition.result_json, dict)
else None
)
if output_id and str(output_id) not in output_dataset_ids: if output_id and str(output_id) not in output_dataset_ids:
output_dataset_ids.append(str(output_id)) output_dataset_ids.append(str(output_id))
operation.result_json = { operation.result_json = {
"partition_count": len(partitions), "partition_count": len(partitions),
"completed_partition_count": sum(status in {"success", "skipped"} for status in statuses), "completed_partition_count": sum(
status in {"success", "skipped"} for status in statuses
),
"failed_partition_count": statuses.count("failed"), "failed_partition_count": statuses.count("failed"),
"output_dataset_ids": output_dataset_ids, "output_dataset_ids": output_dataset_ids,
"merge_contract": "source_aware_spatial_union", "merge_contract": "source_aware_spatial_union",
"vector_deduplication": "source_feature_id_then_geometry", "vector_deduplication": "source_feature_id_then_geometry",
"raster_deduplication": "governed_mosaic_grid", "raster_deduplication": "governed_mosaic_grid",
"complete_coverage": bool(statuses) and all(status in {"success", "skipped"} for status in statuses), "complete_coverage": bool(statuses)
and all(status in {"success", "skipped"} for status in statuses),
} }
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if statuses and all(status in {"success", "skipped"} for status in statuses): if statuses and all(status in {"success", "skipped"} for status in statuses):
operation.status = "success"; operation.finished_at = now; operation.error_message = None operation.status = "success"
elif "failed" in statuses and not any(status in {"queued", "running"} for status in statuses): operation.finished_at = now
operation.status = "partial" if any(status in {"success", "skipped"} for status in statuses) else "failed"; operation.finished_at = now operation.error_message = None
elif "failed" in statuses and not any(
status in {"queued", "running"} for status in statuses
):
operation.status = (
"partial"
if any(status in {"success", "skipped"} for status in statuses)
else "failed"
)
operation.finished_at = now
operation.error_message = "One or more bounded source partitions failed; inspect partition evidence." operation.error_message = "One or more bounded source partitions failed; inspect partition evidence."
db.add(operation); db.commit() db.add(operation)
db.commit()
+28 -7
View File
@@ -9,7 +9,7 @@ from shapely.geometry import mapping
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Area, Dataset, Project, VectorFeature from app.models import Area, Dataset, Project, VectorFeature
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_area_to_epsg4326
class AreaService: class AreaService:
@@ -126,7 +126,11 @@ class AreaService:
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
try: try:
multipolygon = normalize_to_multipolygon(payload.geometry) multipolygon, original_crs = normalize_area_to_epsg4326(
payload.geometry,
payload.crs or "EPSG:4326",
)
metric_area = area_m2(multipolygon)
except ValueError as exc: except ValueError as exc:
raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc
@@ -134,8 +138,8 @@ class AreaService:
project_id=project_id, project_id=project_id,
name=payload.name.strip() or "Unnamed area", name=payload.name.strip() or "Unnamed area",
geometry=from_shape(multipolygon, srid=4326), geometry=from_shape(multipolygon, srid=4326),
original_crs=payload.crs or "EPSG:4326", original_crs=original_crs,
area_m2=area_m2(multipolygon), area_m2=metric_area,
bbox=from_shape(geometry_bbox_polygon(multipolygon), srid=4326), bbox=from_shape(geometry_bbox_polygon(multipolygon), srid=4326),
) )
db.add(area) db.add(area)
@@ -157,11 +161,28 @@ class AreaService:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
changed = False changed = False
if payload.name: if payload.name is not None and payload.name.strip():
area.name = payload.name.strip() or area.name area.name = payload.name.strip() or area.name
changed = True changed = True
if payload.crs: if payload.crs is not None and payload.geometry is None:
area.original_crs = payload.crs raise AppError(
code="INVALID_AREA_CRS_UPDATE",
message="crs can only be supplied together with replacement geometry",
status_code=422,
)
if payload.geometry is not None:
try:
multipolygon, original_crs = normalize_area_to_epsg4326(
payload.geometry,
payload.crs or "EPSG:4326",
)
metric_area = area_m2(multipolygon)
except ValueError as exc:
raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc
area.geometry = from_shape(multipolygon, srid=4326)
area.original_crs = original_crs
area.area_m2 = metric_area
area.bbox = from_shape(geometry_bbox_polygon(multipolygon), srid=4326)
changed = True changed = True
if not changed: if not changed:
raise AppError(code="NO_CHANGES", message="No updatable fields provided", status_code=422) raise AppError(code="NO_CHANGES", message="No updatable fields provided", status_code=422)
@@ -0,0 +1,207 @@
from __future__ import annotations
import base64
import hashlib
import json
import secrets
from typing import Any
from urllib.error import HTTPError
from urllib.parse import urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
import jwt
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.core.config import Settings
MAX_OIDC_JSON_BYTES = 1_048_576
class _RejectRedirects(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201
return None
class AuthentikOidcService:
def __init__(self, settings: Settings):
self.settings = settings
self.issuer = (settings.authentik_issuer or "").rstrip("/")
self.serializer = URLSafeTimedSerializer(
settings.auth_session_secret or "",
salt="geointel-authentik-v1",
)
@property
def enabled(self) -> bool:
return bool(
self.issuer
and self.settings.authentik_client_id
and self.settings.authentik_client_secret
and self.settings.authentik_allowed_email
)
@property
def redirect_uri(self) -> str:
return (
f"{self.settings.public_base_url.rstrip('/')}"
f"{self.settings.api_prefix}/auth/authentik/callback"
)
@staticmethod
def _origin(url: str) -> tuple[str, str, int]:
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError("OIDC URLs must use absolute HTTPS URLs")
return parsed.scheme, parsed.hostname.casefold(), parsed.port or 443
def _validate_endpoint(self, url: str) -> str:
parsed = urlsplit(url)
if (
self._origin(url) != self._origin(self.issuer)
or parsed.username
or parsed.password
or parsed.fragment
):
raise ValueError("OIDC endpoint is outside the configured issuer origin")
return url
def _fetch_json(
self,
url: str,
data: dict[str, str] | None = None,
) -> dict[str, Any]:
self._validate_endpoint(url)
encoded = urlencode(data).encode("utf-8") if data is not None else None
headers = {"Accept": "application/json"}
if encoded is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
request = Request(url, data=encoded, headers=headers)
try:
with build_opener(_RejectRedirects()).open(request, timeout=10) as response:
declared_length = response.headers.get("Content-Length")
if declared_length and int(declared_length) > MAX_OIDC_JSON_BYTES:
raise ValueError("OIDC response exceeds the configured size limit")
raw = response.read(MAX_OIDC_JSON_BYTES + 1)
except HTTPError as exc:
raise ValueError("OIDC endpoint returned an HTTP error or redirect") from exc
if len(raw) > MAX_OIDC_JSON_BYTES:
raise ValueError("OIDC response exceeds the configured size limit")
payload = json.loads(raw)
if not isinstance(payload, dict):
raise ValueError("OIDC endpoint did not return a JSON object")
return payload
def _discovery(self) -> dict[str, Any]:
document = self._fetch_json(
f"{self.issuer}/.well-known/openid-configuration"
)
if str(document.get("issuer", "")).rstrip("/") != self.issuer:
raise ValueError("OIDC issuer mismatch")
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
endpoint = document.get(key)
if not isinstance(endpoint, str):
raise ValueError(f"OIDC discovery is missing {key}")
self._validate_endpoint(endpoint)
return document
def start(self) -> tuple[str, str]:
if not self.enabled:
raise ValueError("Authentik is not configured")
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(48)
flow = self.serializer.dumps(
{"state": state, "nonce": nonce, "verifier": verifier}
)
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
discovery = self._discovery()
query = urlencode(
{
"client_id": self.settings.authentik_client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
)
return f"{discovery['authorization_endpoint']}?{query}", flow
def finish(self, *, code: str, state: str, flow_cookie: str) -> dict[str, Any]:
if not self.enabled or not code:
raise ValueError("OIDC flow is incomplete")
try:
flow = self.serializer.loads(flow_cookie, max_age=600)
except (BadSignature, SignatureExpired) as exc:
raise ValueError("Invalid OIDC flow") from exc
if not isinstance(flow, dict):
raise ValueError("Invalid OIDC flow payload")
if not state or not secrets.compare_digest(state, str(flow.get("state", ""))):
raise ValueError("OIDC state mismatch")
verifier = str(flow.get("verifier", ""))
nonce = str(flow.get("nonce", ""))
if not verifier or not nonce:
raise ValueError("OIDC flow payload is incomplete")
discovery = self._discovery()
token_response = self._fetch_json(
str(discovery["token_endpoint"]),
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"client_id": self.settings.authentik_client_id or "",
"client_secret": self.settings.authentik_client_secret or "",
"code_verifier": verifier,
},
)
token = str(token_response.get("id_token", ""))
if not token:
raise ValueError("OIDC token response has no ID token")
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not header.get("kid"):
raise ValueError("OIDC ID token uses an unsupported signing header")
jwks = self._fetch_json(str(discovery["jwks_uri"]))
matching_keys = [
key
for key in jwks.get("keys", [])
if isinstance(key, dict) and key.get("kid") == header["kid"]
]
if len(matching_keys) != 1:
raise ValueError("OIDC signing key is missing or ambiguous")
signing_key = jwt.PyJWK.from_dict(matching_keys[0]).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=self.settings.authentik_client_id,
issuer=discovery["issuer"],
options={
"require": [
"exp",
"iat",
"iss",
"aud",
"sub",
"nonce",
"email",
"email_verified",
]
},
)
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
raise ValueError("OIDC nonce mismatch")
email = str(claims.get("email", "")).strip().casefold()
allowed = str(self.settings.authentik_allowed_email or "").strip().casefold()
if claims.get("email_verified") is not True or not secrets.compare_digest(
email, allowed
):
raise ValueError("OIDC identity is not authorized")
return claims
@@ -7,7 +7,7 @@ import math
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
@@ -112,7 +112,6 @@ class BathymetryRasterAnalysisService:
try: try:
import numpy as np import numpy as np
import rasterio import rasterio
from rasterio.features import geometry_mask
from rasterio.mask import mask from rasterio.mask import mask
except ImportError as exc: except ImportError as exc:
raise AppError( raise AppError(
+98 -28
View File
@@ -17,6 +17,7 @@ from shapely.geometry import MultiPoint, shape
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.errors import AppError from app.core.errors import AppError
from app.core.config import get_settings
from app.models import Area, Dataset, DatasetVersion, Project from app.models import Area, Dataset, DatasetVersion, Project
from app.services.data_contract_validation import ( from app.services.data_contract_validation import (
ContractKind, ContractKind,
@@ -476,6 +477,29 @@ class DatasetService:
content_type=content_type, content_type=content_type,
) )
@classmethod
def _persist_vector_source_evidence_from_path(
cls,
*,
project_id: UUID,
dataset_id: UUID,
original_filename: str,
source_path: str | Path,
content_type: str | None,
) -> dict[str, Any]:
safe_filename = StorageService._safe_filename(original_filename)
evidence_path = (
StorageService.dataset_root(str(project_id), str(dataset_id), "vector")
/ "provenance"
/ f"{dataset_id}_source_{safe_filename}"
)
return StorageService.persist_file_from_path(
str(evidence_path),
source_path,
original_filename=safe_filename,
content_type=content_type,
)
@classmethod @classmethod
def _record_vector_source_evidence( def _record_vector_source_evidence(
cls, cls,
@@ -853,6 +877,52 @@ class DatasetService:
raise AppError(code="INVALID_UPLOAD", message="Missing file name", status_code=400) raise AppError(code="INVALID_UPLOAD", message="Missing file name", status_code=400)
return filename return filename
@staticmethod
async def _stage_upload(
*,
project_id: UUID,
dataset_id: uuid.UUID,
dataset_type: str,
filename: str,
file: UploadFile,
) -> dict[str, Any]:
settings = get_settings()
max_upload_mb = int(settings.max_upload_mb)
if DatasetService._canonical_dataset_type(dataset_type) == "vector":
max_upload_mb = min(max_upload_mb, int(settings.max_in_memory_vector_mb))
return await StorageService.persist_upload_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type=dataset_type,
original_filename=filename,
upload=file,
content_type=file.content_type,
max_bytes=max_upload_mb * 1024 * 1024,
)
@staticmethod
def _read_staged_vector_bytes(storage_info: dict[str, Any]) -> bytes:
settings = get_settings()
max_bytes = min(
int(settings.max_upload_mb),
int(settings.max_in_memory_vector_mb),
) * 1024 * 1024
path = Path(str(storage_info["storage_path"]))
with path.open("rb") as stream:
content = stream.read(max_bytes + 1)
if len(content) > max_bytes:
StorageService.remove_dataset_file(str(path))
raise AppError(
code="UPLOAD_TOO_LARGE",
message="Vector upload exceeds the bounded in-memory parsing limit.",
details={
"max_bytes": max_bytes,
"max_in_memory_vector_mb": max_bytes // (1024 * 1024),
},
status_code=413,
)
return content
@staticmethod @staticmethod
def list_datasets(db: Session, project_id: UUID, limit: int = 50, offset: int = 0) -> tuple[list[DatasetCreateResponse], int]: def list_datasets(db: Session, project_id: UUID, limit: int = 50, offset: int = 0) -> tuple[list[DatasetCreateResponse], int]:
total = db.query(Dataset).filter(Dataset.project_id == project_id).count() total = db.query(Dataset).filter(Dataset.project_id == project_id).count()
@@ -1135,15 +1205,15 @@ class DatasetService:
status_code=415, status_code=415,
) )
raw = await file.read() dataset_id = uuid.uuid4()
storage_info = StorageService.persist_dataset_file( storage_info = await DatasetService._stage_upload(
project_id=str(project_id), project_id=project_id,
dataset_id=str(dataset_id := uuid.uuid4()), dataset_id=dataset_id,
dataset_type=canonical_type, dataset_type=canonical_type,
original_filename=filename, filename=filename,
content=raw, file=file,
content_type=file.content_type,
) )
raw = DatasetService._read_staged_vector_bytes(storage_info) if canonical_type == "vector" else None
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
vector_payload: dict[str, Any] | None = None vector_payload: dict[str, Any] | None = None
@@ -1151,6 +1221,7 @@ class DatasetService:
try: try:
status = "validating" status = "validating"
if canonical_type == "vector": if canonical_type == "vector":
assert raw is not None
try: try:
text = raw.decode("utf-8") text = raw.decode("utf-8")
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc:
@@ -1319,8 +1390,15 @@ class DatasetService:
temporal_granularity=temporal_granularity, temporal_granularity=temporal_granularity,
source_version=source_version, source_version=source_version,
) )
raw = await file.read() dataset_id = uuid.uuid4()
checksum_sha256 = StorageService.calculate_checksum_sha256(raw) storage_info = await DatasetService._stage_upload(
project_id=project_id,
dataset_id=dataset_id,
dataset_type=canonical_type,
filename=filename,
file=file,
)
checksum_sha256 = storage_info["checksum_sha256"]
ingest_key = DatasetService._ingest_key( ingest_key = DatasetService._ingest_key(
project_id=project_id, project_id=project_id,
source_key="manual", source_key="manual",
@@ -1333,6 +1411,7 @@ class DatasetService:
) )
existing = DatasetService._find_existing_ingest(db, project_id, ingest_key) existing = DatasetService._find_existing_ingest(db, project_id, ingest_key)
if existing is not None: if existing is not None:
StorageService.remove_dataset_file(storage_info["storage_path"])
return DatasetService._to_response(existing) return DatasetService._to_response(existing)
raw_source_metadata = dict(source_metadata or {}) raw_source_metadata = dict(source_metadata or {})
@@ -1361,8 +1440,7 @@ class DatasetService:
} }
) )
dataset_id = uuid.uuid4() raw = DatasetService._read_staged_vector_bytes(storage_info) if canonical_type == "vector" else None
storage_info: dict[str, Any] | None = None
storage_content = raw storage_content = raw
source_evidence: dict[str, Any] | None = None source_evidence: dict[str, Any] | None = None
imported_at = datetime.now(timezone.utc) imported_at = datetime.now(timezone.utc)
@@ -1372,6 +1450,7 @@ class DatasetService:
parser_error: tuple[str, str] | None = None parser_error: tuple[str, str] | None = None
try: try:
if canonical_type == "vector": if canonical_type == "vector":
assert raw is not None
try: try:
payload = json.loads(raw.decode("utf-8")) payload = json.loads(raw.decode("utf-8"))
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc:
@@ -1394,22 +1473,22 @@ class DatasetService:
) )
if DatasetService._vector_storage_requires_canonicalization(source_crs): if DatasetService._vector_storage_requires_canonicalization(source_crs):
storage_content = DatasetService._canonical_vector_storage_bytes(canonical_vector_payload) storage_content = DatasetService._canonical_vector_storage_bytes(canonical_vector_payload)
source_evidence = DatasetService._persist_vector_source_evidence( source_evidence = DatasetService._persist_vector_source_evidence_from_path(
project_id=project_id, project_id=project_id,
dataset_id=dataset_id, dataset_id=dataset_id,
original_filename=filename, original_filename=filename,
content=raw, source_path=storage_info["storage_path"],
content_type=file.content_type, content_type=file.content_type,
) )
else:
storage_info = StorageService.persist_dataset_file( storage_info = StorageService.persist_dataset_file(
project_id=str(project_id), project_id=str(project_id),
dataset_id=str(dataset_id), dataset_id=str(dataset_id),
dataset_type=canonical_type, dataset_type=canonical_type,
original_filename=filename, original_filename=filename,
content=raw, content=storage_content,
content_type=file.content_type, content_type=file.content_type,
) )
else:
metadata = extract_raster_metadata(storage_info["storage_path"]) metadata = extract_raster_metadata(storage_info["storage_path"])
metadata["dataset_type"] = "raster" metadata["dataset_type"] = "raster"
source_crs = metadata.get("crs") source_crs = metadata.get("crs")
@@ -1422,16 +1501,7 @@ class DatasetService:
"processing_code": code, "processing_code": code,
} }
if storage_info is None: computed_storage_checksum_sha256 = storage_info["checksum_sha256"]
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type=canonical_type,
original_filename=filename,
content=storage_content,
content_type=file.content_type,
)
computed_storage_checksum_sha256 = StorageService.calculate_checksum_sha256(storage_content)
if source_evidence is not None: if source_evidence is not None:
resolved_source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS resolved_source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS
DatasetService._record_vector_source_evidence( DatasetService._record_vector_source_evidence(
@@ -1502,7 +1572,7 @@ class DatasetService:
feature_collection=canonical_vector_payload or {"type": "FeatureCollection", "features": []}, feature_collection=canonical_vector_payload or {"type": "FeatureCollection", "features": []},
checksum_sha256=storage_info["checksum_sha256"], checksum_sha256=storage_info["checksum_sha256"],
computed_checksum_sha256=computed_storage_checksum_sha256, computed_checksum_sha256=computed_storage_checksum_sha256,
content=storage_content, content=None,
source_registry_id=str(source_registry.id), source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id), source_snapshot_id=str(source_snapshot.id),
imported_at=imported_at, imported_at=imported_at,
@@ -1541,8 +1611,8 @@ class DatasetService:
bounds=DatasetService._extract_raster_bounds_json(metadata), bounds=DatasetService._extract_raster_bounds_json(metadata),
resolution=resolution, resolution=resolution,
checksum_sha256=storage_info["checksum_sha256"], checksum_sha256=storage_info["checksum_sha256"],
computed_checksum_sha256=checksum_sha256, computed_checksum_sha256=storage_info["checksum_sha256"],
content=raw, content=None,
source_registry_id=str(source_registry.id), source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id), source_snapshot_id=str(source_snapshot.id),
imported_at=imported_at, imported_at=imported_at,
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter from collections import Counter
from typing import Any
from uuid import UUID from uuid import UUID
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
+27
View File
@@ -33,6 +33,7 @@ from app.services.storage_service import StorageService
from app.services.quality_service import QualityService from app.services.quality_service import QualityService
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
from app.services.temporal_compatibility_service import TemporalCompatibilityService from app.services.temporal_compatibility_service import TemporalCompatibilityService
from app.services.tile_manifest_service import TileManifestService
from app.services.yolo_adapter import YoloDetectionAdapter from app.services.yolo_adapter import YoloDetectionAdapter
@@ -968,6 +969,18 @@ class DetectionService:
yolo_adapter_class: Type[YoloDetectionAdapter], yolo_adapter_class: Type[YoloDetectionAdapter],
) -> tuple[list[Detection], dict[str, Any]]: ) -> tuple[list[Detection], dict[str, Any]]:
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings) manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
dataset = db.get(Dataset, dataset_id)
if dataset is None:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
manifest_binding = TileManifestService.validate_for_inference(
db,
dataset,
manifest,
manifest_path=tile_manifest_path or "",
settings=settings,
error_prefix="DETECTION",
)
DetectionService._attach_tile_manifest_binding(analysis_run, job, manifest_binding)
model_path = Path(settings.yolo_model_path or "").expanduser() model_path = Path(settings.yolo_model_path or "").expanduser()
runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime( runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime(
db=db, db=db,
@@ -1075,9 +1088,23 @@ class DetectionService:
"tile_edge_truncated_count": len(candidates) - len(edge_filtered_candidates), "tile_edge_truncated_count": len(candidates) - len(edge_filtered_candidates),
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold), "duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
"containment_suppression_threshold": float(settings.yolo_containment_nms_threshold), "containment_suppression_threshold": float(settings.yolo_containment_nms_threshold),
"tile_manifest_binding": manifest_binding,
"runtime_model_provenance": runtime_model_provenance.as_dict(), "runtime_model_provenance": runtime_model_provenance.as_dict(),
} }
@staticmethod
def _attach_tile_manifest_binding(
analysis_run: AnalysisRun,
job: Job,
binding: dict[str, Any],
) -> None:
analysis_parameters = dict(analysis_run.parameters_json or {})
analysis_parameters["tile_manifest_binding"] = dict(binding)
analysis_run.parameters_json = analysis_parameters
job_parameters = dict(job.parameters_json or {})
job_parameters["tile_manifest_binding"] = dict(binding)
job.parameters_json = job_parameters
@staticmethod @staticmethod
def _attach_runtime_model_provenance( def _attach_runtime_model_provenance(
analysis_run: AnalysisRun, analysis_run: AnalysisRun,
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from xml.etree import ElementTree from xml.etree import ElementTree
@@ -232,7 +232,6 @@ class FloodHazardAnalysisService:
try: try:
import numpy as np import numpy as np
import rasterio import rasterio
from rasterio.features import geometry_mask
from rasterio.mask import mask from rasterio.mask import mask
except ImportError as exc: except ImportError as exc:
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for flood-hazard analysis", status_code=503) from exc raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for flood-hazard analysis", status_code=503) from exc
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
@@ -6,11 +6,12 @@ import ssl
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.request import Request, urlopen from urllib.request import Request
from xml.etree import ElementTree from xml.etree import ElementTree
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.schemas.bathymetry import BathymetrySourceProbeRead from app.schemas.bathymetry import BathymetrySourceProbeRead
from app.services.outbound_request_guard import guarded_opener
class MdkBathymetryProbeService: class MdkBathymetryProbeService:
@@ -79,11 +79,15 @@ class ModelAssetCatalogService:
size_bytes=path.stat().st_size, size_bytes=path.stat().st_size,
sha256=ModelAssetCatalogService._sha256(path), sha256=ModelAssetCatalogService._sha256(path),
active=active_model_path == resolved_path, active=active_model_path == resolved_path,
status="approved" if active_model_path == resolved_path else "available", runtime_available=True,
runtime_status="active" if active_model_path == resolved_path else "available",
governed_validation_status="not_verified_by_catalog",
promotion_status="not_verified_by_catalog",
status="runtime_active" if active_model_path == resolved_path else "runtime_available",
limitation_message=( limitation_message=(
"Approved local runtime model asset. GeoIntel will not download or mutate model weights." "Active local runtime model asset. Runtime selection is not evidence of governed validation or promotion."
if active_model_path == resolved_path if active_model_path == resolved_path
else "Local development model asset. Configure it explicitly before production use." else "Local runtime model asset. Governed validation and promotion are not established by this catalog."
), ),
will_download_models=False, will_download_models=False,
) )
@@ -11,7 +11,7 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -16,6 +16,7 @@ from app.models import Area, Dataset, DatasetVersion
from app.services.derived_dataset_governance_service import DerivedDatasetGovernanceService from app.services.derived_dataset_governance_service import DerivedDatasetGovernanceService
from app.services.raster_service import extract_raster_metadata from app.services.raster_service import extract_raster_metadata
from app.services.storage_service import StorageService from app.services.storage_service import StorageService
from app.services.tile_manifest_service import TileManifestService, canonical_manifest_json
def _import_rasterio(): def _import_rasterio():
@@ -70,6 +71,39 @@ class RasterOperationsService:
raise AppError(code="DATASET_FILE_MISSING", message="Stored raster file missing", status_code=404) raise AppError(code="DATASET_FILE_MISSING", message="Stored raster file missing", status_code=404)
return dataset return dataset
@staticmethod
def _validate_storage_checksum(dataset: Dataset) -> None:
"""Refuse tiling pixels that no longer match the governed Dataset row."""
expected_checksum = str(dataset.checksum_sha256 or "").strip().lower()
governed_artifact = bool(getattr(dataset, "data_contract_key", None))
valid_checksum = len(expected_checksum) == 64 and all(character in "0123456789abcdef" for character in expected_checksum)
if not valid_checksum:
if expected_checksum or governed_artifact:
raise AppError(
code="DATASET_STORAGE_CHECKSUM_UNVERIFIABLE",
message="Governed raster storage requires a valid SHA-256 checksum before tiling.",
details={"checksum_sha256": dataset.checksum_sha256},
status_code=409,
)
return
digest = sha256()
with Path(str(dataset.storage_path)).open("rb") as stream:
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
digest.update(chunk)
actual_checksum = digest.hexdigest()
if actual_checksum != expected_checksum:
raise AppError(
code="DATASET_STORAGE_CHECKSUM_MISMATCH",
message="Raster dataset storage no longer matches its validated checksum.",
details={
"expected_checksum_sha256": expected_checksum,
"actual_checksum_sha256": actual_checksum,
},
status_code=409,
)
@staticmethod @staticmethod
def _raster_dependencies() -> tuple[Any, Any]: def _raster_dependencies() -> tuple[Any, Any]:
try: try:
@@ -970,14 +1004,17 @@ class RasterOperationsService:
tile_size: int = 512, tile_size: int = 512,
overlap: int = 64, overlap: int = 64,
output_name: str | None = None, output_name: str | None = None,
max_tiles: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
RasterOperationsService._validate_tile_request(tile_size=tile_size, overlap=overlap) RasterOperationsService._validate_tile_request(tile_size=tile_size, overlap=overlap)
if max_tiles is not None and max_tiles <= 0:
raise AppError(code="INVALID_PARAMETERS", message="max_tiles must be positive", status_code=400)
dataset = RasterOperationsService._load_dataset(db, dataset_id) dataset = RasterOperationsService._load_dataset(db, dataset_id)
RasterOperationsService._validate_storage_checksum(dataset)
rasterio, _ = RasterOperationsService._raster_dependencies() rasterio, _ = RasterOperationsService._raster_dependencies()
tile_set_id = str(uuid.uuid4()) tile_set_id = str(uuid.uuid4())
tile_root = StorageService.raster_tiles_root(str(dataset.project_id), str(dataset.id), tile_set_id) tile_root = StorageService.raster_tiles_root(str(dataset.project_id), str(dataset.id), tile_set_id)
tile_root.mkdir(parents=True, exist_ok=True)
manifest_tiles: list[dict[str, Any]] = [] manifest_tiles: list[dict[str, Any]] = []
tile_paths: list[str] = [] tile_paths: list[str] = []
@@ -997,9 +1034,23 @@ class RasterOperationsService:
source_width = int(source.width) source_width = int(source.width)
source_height = int(source.height) source_height = int(source.height)
step = max(1, tile_size - overlap) step = max(1, tile_size - overlap)
x_offsets = RasterOperationsService._tile_offsets(source_width, tile_size, step)
y_offsets = RasterOperationsService._tile_offsets(source_height, tile_size, step)
expected_tile_count = len(x_offsets) * len(y_offsets)
if max_tiles is not None and expected_tile_count > max_tiles:
raise AppError(
code="RASTER_TILE_LIMIT_EXCEEDED",
message="Raster tile generation exceeds the guest analysis limit.",
details={
"expected_tile_count": expected_tile_count,
"max_tiles": max_tiles,
},
status_code=422,
)
tile_root.mkdir(parents=True, exist_ok=True)
tile_index = 0 tile_index = 0
for yoff in range(0, source_height, step): for yoff in y_offsets:
for xoff in range(0, source_width, step): for xoff in x_offsets:
tile_width = min(tile_size, source_width - xoff) tile_width = min(tile_size, source_width - xoff)
tile_height = min(tile_size, source_height - yoff) tile_height = min(tile_size, source_height - yoff)
if tile_width <= 0 or tile_height <= 0: if tile_width <= 0 or tile_height <= 0:
@@ -1022,6 +1073,7 @@ class RasterOperationsService:
tile_dest.write(tile_data) tile_dest.write(tile_data)
tile_paths.append(str(tile_path)) tile_paths.append(str(tile_path))
tile_integrity = TileManifestService.tile_integrity(tile_path)
manifest_tiles.append( manifest_tiles.append(
{ {
"path": str(tile_path), "path": str(tile_path),
@@ -1030,6 +1082,7 @@ class RasterOperationsService:
"transform": [float(item) for item in transform.to_gdal()], "transform": [float(item) for item in transform.to_gdal()],
"crs": source_crs, "crs": source_crs,
"index": tile_index, "index": tile_index,
**tile_integrity,
}, },
) )
tile_index += 1 tile_index += 1
@@ -1044,9 +1097,8 @@ class RasterOperationsService:
bounds = source_metadata.get("bounds", [0.0, 0.0, 0.0, 0.0]) bounds = source_metadata.get("bounds", [0.0, 0.0, 0.0, 0.0])
manifest_crs = source_crs or source_metadata.get("crs") or dataset.crs manifest_crs = source_crs or source_metadata.get("crs") or dataset.crs
manifest_payload = { manifest_payload = {
**TileManifestService.dataset_binding(db, dataset),
"tile_set_id": tile_set_id, "tile_set_id": tile_set_id,
"source_dataset_id": str(dataset.id),
"source_raster_id": str(dataset.id),
"crs": manifest_crs, "crs": manifest_crs,
"source_crs": manifest_crs, "source_crs": manifest_crs,
"dataset_crs": dataset.crs, "dataset_crs": dataset.crs,
@@ -1066,7 +1118,7 @@ class RasterOperationsService:
"tile_server": None, "tile_server": None,
} }
manifest_path = tile_root / "manifest.json" manifest_path = tile_root / "manifest.json"
manifest_path.write_text(json.dumps(manifest_payload), encoding="utf-8") manifest_path.write_text(canonical_manifest_json(manifest_payload), encoding="utf-8")
return { return {
"dataset_id": str(dataset.id), "dataset_id": str(dataset.id),
@@ -1079,3 +1131,17 @@ class RasterOperationsService:
"count": len(manifest_tiles), "count": len(manifest_tiles),
"manifest": manifest_payload, "manifest": manifest_payload,
} }
@staticmethod
def _tile_offsets(dimension: int, tile_size: int, step: int) -> list[int]:
"""Return full-tile starts plus one unique edge-aligned final start."""
if dimension <= 0 or tile_size <= 0 or step <= 0:
raise AppError(code="INVALID_PARAMETERS", message="Raster tile dimensions must be positive", status_code=400)
if dimension <= tile_size:
return [0]
final_start = dimension - tile_size
offsets = list(range(0, final_start + 1, step))
if offsets[-1] != final_start:
offsets.append(final_start)
return offsets
@@ -8,7 +8,6 @@ from typing import Any
from uuid import UUID from uuid import UUID
from pyproj import Transformer from pyproj import Transformer
from shapely.geometry import mapping
from shapely.ops import transform as shapely_transform from shapely.ops import transform as shapely_transform
from app.core.errors import AppError from app.core.errors import AppError
@@ -116,7 +115,6 @@ class RasterPartitionAnalysisService:
try: try:
import numpy as np import numpy as np
import rasterio import rasterio
from rasterio.features import geometry_mask
from rasterio.merge import merge from rasterio.merge import merge
except ImportError as exc: except ImportError as exc:
raise AppError( raise AppError(
@@ -34,6 +34,7 @@ from app.services.segmentation_adapter import (
SamSegmentationAdapter, SamSegmentationAdapter,
YoloSegmentationAdapter, YoloSegmentationAdapter,
) )
from app.services.tile_manifest_service import TileManifestService
class SegmentationService: class SegmentationService:
@@ -749,6 +750,18 @@ class SegmentationService:
sam_adapter_class: type[SamSegmentationAdapter], sam_adapter_class: type[SamSegmentationAdapter],
) -> tuple[list[Segmentation], dict[str, Any]]: ) -> tuple[list[Segmentation], dict[str, Any]]:
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings) manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
dataset = db.get(Dataset, dataset_id)
if dataset is None:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
manifest_binding = TileManifestService.validate_for_inference(
db,
dataset,
manifest,
manifest_path=tile_manifest_path or "",
settings=settings,
error_prefix="SEGMENTATION",
)
DetectionService._attach_tile_manifest_binding(analysis_run, job, manifest_binding)
if model_name == settings.sam_model_id: if model_name == settings.sam_model_id:
model_path = Path(settings.sam_model_path or "").expanduser() model_path = Path(settings.sam_model_path or "").expanduser()
allowed_frameworks = ("ultralytics/sam", "sam", "ultralytics", "pytorch") allowed_frameworks = ("ultralytics/sam", "sam", "ultralytics", "pytorch")
@@ -861,6 +874,7 @@ class SegmentationService:
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold), "containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold),
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()), "tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
"tile_manifest_binding": manifest_binding,
"runtime_model_provenance": runtime_model_provenance.as_dict(), "runtime_model_provenance": runtime_model_provenance.as_dict(),
} }
@@ -10,7 +10,7 @@ from threading import Lock
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from xml.etree import ElementTree from xml.etree import ElementTree
+99
View File
@@ -10,6 +10,8 @@ from app.core.errors import AppError
class StorageService: class StorageService:
UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024
@staticmethod @staticmethod
def _base_dir() -> Path: def _base_dir() -> Path:
return Path(get_settings().storage_root).resolve() return Path(get_settings().storage_root).resolve()
@@ -143,6 +145,75 @@ class StorageService:
} }
return metadata return metadata
@staticmethod
async def persist_upload_file(
*,
project_id: str,
dataset_id: str,
dataset_type: str,
original_filename: str,
upload: Any,
content_type: str | None,
max_bytes: int,
chunk_size: int | None = None,
) -> dict[str, Any]:
"""Stream an UploadFile to governed storage with a hard byte limit.
The reverse proxy limit is defense in depth. This backend boundary is
authoritative as direct/loopback requests can bypass that proxy.
"""
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
resolved_chunk_size = chunk_size or StorageService.UPLOAD_CHUNK_SIZE
if resolved_chunk_size <= 0:
raise ValueError("chunk_size must be positive")
normalized_type = StorageService.normalize_dataset_type(dataset_type)
file_path = Path(
StorageService.dataset_file_path(
project_id,
dataset_id,
normalized_type,
original_filename,
)
)
file_path.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
size_bytes = 0
try:
with file_path.open("wb") as stream:
while True:
chunk = await upload.read(resolved_chunk_size)
if not chunk:
break
size_bytes += len(chunk)
if size_bytes > max_bytes:
raise AppError(
code="UPLOAD_TOO_LARGE",
message="Upload exceeds the configured backend size limit.",
details={
"max_bytes": max_bytes,
"max_upload_mb": max_bytes // (1024 * 1024),
},
status_code=413,
)
stream.write(chunk)
digest.update(chunk)
except Exception:
file_path.unlink(missing_ok=True)
parent = file_path.parent
if parent.exists() and parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
raise
return {
"original_filename": StorageService._safe_filename(original_filename),
"stored_filename": file_path.name,
"content_type": content_type or "application/octet-stream",
"size_bytes": size_bytes,
"checksum_sha256": digest.hexdigest(),
"storage_path": str(file_path),
}
@staticmethod @staticmethod
def persist_dataset_file_from_path( def persist_dataset_file_from_path(
project_id: str, project_id: str,
@@ -198,6 +269,34 @@ class StorageService:
} }
return metadata return metadata
@staticmethod
def persist_file_from_path(
storage_path: str,
source_path: str | Path,
original_filename: str,
content_type: str | None,
) -> dict[str, Any]:
source = Path(source_path).resolve()
if not source.is_file():
raise FileNotFoundError(f"Source artifact does not exist: {source}")
target = Path(storage_path)
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
size_bytes = 0
with source.open("rb") as input_stream, target.open("wb") as output_stream:
for chunk in iter(lambda: input_stream.read(StorageService.UPLOAD_CHUNK_SIZE), b""):
output_stream.write(chunk)
digest.update(chunk)
size_bytes += len(chunk)
return {
"original_filename": StorageService._safe_filename(original_filename),
"stored_filename": target.name,
"content_type": content_type or "application/octet-stream",
"size_bytes": size_bytes,
"checksum_sha256": digest.hexdigest(),
"storage_path": str(target),
}
@staticmethod @staticmethod
def remove_dataset_file(path: str) -> None: def remove_dataset_file(path: str) -> None:
target = Path(path) target = Path(path)
@@ -111,7 +111,6 @@ class TerrainAnalysisService:
try: try:
import numpy as np import numpy as np
import rasterio import rasterio
from rasterio.features import geometry_mask
from rasterio.mask import mask from rasterio.mask import mask
except ImportError as exc: except ImportError as exc:
raise AppError( raise AppError(
@@ -11,7 +11,7 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request
from uuid import UUID from uuid import UUID
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
@@ -407,7 +407,6 @@ class ThematicRasterAcquisitionService:
if len(coverages) == 1: if len(coverages) == 1:
return coverages[0] return coverages[0]
try: try:
import rasterio
from rasterio.io import MemoryFile from rasterio.io import MemoryFile
from rasterio.merge import merge from rasterio.merge import merge
except ImportError as exc: except ImportError as exc:
@@ -96,7 +96,6 @@ class ThematicRasterAnalysisService:
try: try:
import numpy as np import numpy as np
import rasterio import rasterio
from rasterio.features import geometry_mask
from rasterio.mask import mask from rasterio.mask import mask
except ImportError as exc: except ImportError as exc:
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for thematic raster analysis", status_code=503) from exc raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for thematic raster analysis", status_code=503) from exc
@@ -0,0 +1,485 @@
from __future__ import annotations
from hashlib import sha256
import json
from math import isfinite
from pathlib import Path
from typing import Any
from geoalchemy2.shape import to_shape
from pyproj import CRS, Transformer
from shapely.geometry import box
from shapely.ops import transform as shapely_transform
from shapely.ops import unary_union
from app.core.errors import AppError
from app.models import Area, Dataset, DatasetVersion
from app.services.storage_service import StorageService
class TileManifestService:
"""Versioned provenance and integrity contract for inference tile sets."""
CONTRACT_KEY = "geointel.raster.tile-manifest"
CONTRACT_VERSION = "2.0.0"
_BINDING_FIELDS = (
"source_dataset_id",
"source_dataset_checksum_sha256",
"source_dataset_size_bytes",
"source_registry_id",
"source_snapshot_id",
"source_snapshot_checksum_sha256",
"data_contract_key",
"data_contract_version",
"source_version",
"dataset_version_id",
"dataset_version",
"dataset_version_checksum_sha256",
"source_area_id",
"source_area_geometry_sha256",
)
_REQUIRED_INFERENCE_BINDING_FIELDS = (
"source_dataset_checksum_sha256",
"source_registry_id",
"source_snapshot_id",
"source_snapshot_checksum_sha256",
"data_contract_key",
"data_contract_version",
"dataset_version_id",
"dataset_version",
"dataset_version_checksum_sha256",
)
_CHECKSUM_FIELDS = (
"source_dataset_checksum_sha256",
"source_snapshot_checksum_sha256",
"dataset_version_checksum_sha256",
)
@staticmethod
def file_sha256(path: str | Path) -> str:
digest = sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _latest_dataset_version(dataset: Dataset) -> DatasetVersion | None:
versions = list(dataset.versions or [])
if not versions:
return None
return max(versions, key=lambda item: (int(item.version or 0), str(item.id or "")))
@staticmethod
def _source_snapshot_checksum(dataset: Dataset) -> str | None:
snapshot = dataset.source_snapshot
checksum = getattr(snapshot, "checksum_sha256", None) if snapshot is not None else None
return str(checksum).lower() if checksum else None
@staticmethod
def _area_geometry_binding(db, dataset: Dataset) -> tuple[str | None, str | None]:
if dataset.area_id is None:
return None, None
area = db.get(Area, dataset.area_id)
if area is None or area.geometry is None:
return str(dataset.area_id), None
geometry = to_shape(area.geometry)
return str(dataset.area_id), sha256(geometry.wkb).hexdigest()
@classmethod
def dataset_binding(cls, db, dataset: Dataset) -> dict[str, Any]:
version = cls._latest_dataset_version(dataset)
area_id, area_geometry_sha256 = cls._area_geometry_binding(db, dataset)
return {
"manifest_contract_key": cls.CONTRACT_KEY,
"manifest_contract_version": cls.CONTRACT_VERSION,
"source_dataset_id": str(dataset.id),
"source_raster_id": str(dataset.id),
"source_dataset_checksum_sha256": (
str(dataset.checksum_sha256).lower() if dataset.checksum_sha256 else None
),
"source_dataset_size_bytes": dataset.size_bytes,
"source_registry_id": str(dataset.source_registry_id) if dataset.source_registry_id else None,
"source_snapshot_id": str(dataset.source_snapshot_id) if dataset.source_snapshot_id else None,
"source_snapshot_checksum_sha256": cls._source_snapshot_checksum(dataset),
"data_contract_key": dataset.data_contract_key,
"data_contract_version": dataset.data_contract_version,
"source_version": dataset.source_version,
"dataset_version_id": str(version.id) if version is not None and version.id else None,
"dataset_version": int(version.version) if version is not None and version.version is not None else None,
"dataset_version_checksum_sha256": (
str(version.checksum_sha256).lower()
if version is not None and version.checksum_sha256
else None
),
"source_area_id": area_id,
"source_area_geometry_sha256": area_geometry_sha256,
}
@staticmethod
def tile_integrity(path: str | Path) -> dict[str, Any]:
resolved = Path(path)
return {
"size_bytes": resolved.stat().st_size,
"sha256": TileManifestService.file_sha256(resolved),
}
@staticmethod
def _error(
error_prefix: str,
suffix: str,
message: str,
*,
details: dict[str, Any] | None = None,
) -> AppError:
return AppError(
code=f"{error_prefix}_TILE_MANIFEST_{suffix}",
message=message,
details=details,
status_code=422,
)
@staticmethod
def _bounds_values(value: Any) -> tuple[float, float, float, float] | None:
if isinstance(value, dict):
aliases = (
("min_x", "min_y", "max_x", "max_y"),
("minx", "miny", "maxx", "maxy"),
("left", "bottom", "right", "top"),
)
selected = next(
([value.get(key) for key in keys] for keys in aliases if all(key in value for key in keys)),
None,
)
elif isinstance(value, (list, tuple)) and len(value) == 4:
selected = list(value)
else:
return None
try:
bounds = tuple(float(item) for item in selected) if selected is not None else None
except (TypeError, ValueError):
return None
if bounds is None or not all(isfinite(item) for item in bounds):
return None
if bounds[0] >= bounds[2] or bounds[1] >= bounds[3]:
return None
return bounds
@staticmethod
def _to_epsg4326(bounds: tuple[float, float, float, float], raw_crs: Any):
source_crs = CRS.from_user_input(raw_crs)
geometry = box(*bounds)
if not source_crs.equals(CRS.from_epsg(4326)):
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
geometry = shapely_transform(transformer.transform, geometry)
if geometry.is_empty or not geometry.is_valid:
raise ValueError("Bounds do not form a valid transformed geometry")
if not all(isfinite(float(value)) for value in geometry.bounds):
raise ValueError("Bounds transform to non-finite coordinates")
return geometry
@classmethod
def _manifest_coverage(cls, manifest: dict[str, Any], *, error_prefix: str):
tiles = manifest.get("tiles")
default_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs")
parts = []
for index, tile in enumerate(tiles if isinstance(tiles, list) else []):
if not isinstance(tile, dict):
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Tile manifest entries must be objects with explicit spatial metadata.",
details={"tile_index": index},
)
bounds = cls._bounds_values(tile.get("bounds"))
raw_crs = tile.get("crs") or default_crs
if bounds is None or not raw_crs:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Every inference tile requires finite bounds and an explicit CRS.",
details={"tile_index": index},
)
try:
parts.append(cls._to_epsg4326(bounds, raw_crs))
except Exception as exc:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Inference tile bounds or CRS could not be normalized to EPSG:4326.",
details={"tile_index": index, "reason": str(exc)},
) from exc
coverage = unary_union(parts)
if coverage.is_empty or not coverage.is_valid:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Inference tile union is empty or invalid.",
)
min_x, min_y, max_x, max_y = coverage.bounds
if min_x < -180 or min_y < -90 or max_x > 180 or max_y > 90:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Inference tile union falls outside EPSG:4326 bounds.",
details={"bounds": list(coverage.bounds)},
)
return coverage
@classmethod
def _validate_binding(cls, db, dataset: Dataset, manifest: dict[str, Any], *, error_prefix: str) -> dict[str, Any]:
if (
manifest.get("manifest_contract_key") != cls.CONTRACT_KEY
or manifest.get("manifest_contract_version") != cls.CONTRACT_VERSION
):
raise cls._error(
error_prefix,
"PROVENANCE_MISMATCH",
"Inference requires a versioned GeoIntel tile-manifest contract.",
details={
"required_contract": f"{cls.CONTRACT_KEY}@{cls.CONTRACT_VERSION}",
"manifest_contract": (
f"{manifest.get('manifest_contract_key')}@{manifest.get('manifest_contract_version')}"
),
},
)
expected = cls.dataset_binding(db, dataset)
missing = [
field
for field in cls._REQUIRED_INFERENCE_BINDING_FIELDS
if expected.get(field) in {None, ""}
]
invalid_checksums = [
field
for field in cls._CHECKSUM_FIELDS
if len(str(expected.get(field) or "")) != 64
or any(character not in "0123456789abcdef" for character in str(expected.get(field) or "").lower())
]
if missing or invalid_checksums:
raise cls._error(
error_prefix,
"PROVENANCE_MISMATCH",
"The requested Dataset lacks complete immutable provenance for inference tiling.",
details={
"missing_fields": missing,
"invalid_checksum_fields": invalid_checksums,
},
)
manifest_dataset_id = manifest.get("source_dataset_id") or manifest.get("source_raster_id")
if str(manifest_dataset_id or "") != expected["source_dataset_id"]:
raise cls._error(
error_prefix,
"DATASET_MISMATCH",
"Tile manifest belongs to a different raster Dataset.",
details={
"requested_dataset_id": expected["source_dataset_id"],
"manifest_dataset_id": manifest_dataset_id,
},
)
mismatches = {}
for field in cls._BINDING_FIELDS:
expected_value = expected.get(field)
if expected_value is None or field == "source_dataset_id":
continue
observed_value = manifest.get(field)
if str(observed_value) != str(expected_value):
mismatches[field] = {"expected": expected_value, "observed": observed_value}
if mismatches:
raise cls._error(
error_prefix,
"PROVENANCE_MISMATCH",
"Tile manifest provenance no longer matches the requested Dataset snapshot.",
details={"mismatches": mismatches},
)
return expected
@classmethod
def _validate_tile_files(
cls,
manifest: dict[str, Any],
manifest_path: Path,
*,
settings,
error_prefix: str,
) -> list[str]:
resolved_paths: list[str] = []
seen_paths: set[Path] = set()
for index, tile in enumerate(manifest["tiles"]):
raw_path = tile.get("path") if isinstance(tile, dict) else None
if not isinstance(raw_path, str) or not raw_path.strip():
raise cls._error(
error_prefix,
"TILE_INTEGRITY_MISMATCH",
"Every inference tile requires a path and immutable integrity evidence.",
details={"tile_index": index},
)
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = manifest_path.parent / candidate
candidate = StorageService.assert_within_storage_root(
candidate,
label="raster tile",
settings=settings,
)
if not candidate.is_file():
raise cls._error(
error_prefix,
"TILE_INTEGRITY_MISMATCH",
"An inference tile referenced by the manifest does not exist.",
details={"tile_index": index, "tile_path": str(candidate)},
)
if candidate in seen_paths:
raise cls._error(
error_prefix,
"TILE_INTEGRITY_MISMATCH",
"A tile path occurs more than once in the inference manifest.",
details={"tile_index": index, "tile_path": str(candidate)},
)
seen_paths.add(candidate)
observed_size = candidate.stat().st_size
expected_size = tile.get("size_bytes")
expected_checksum = str(tile.get("sha256") or "").strip().lower()
if expected_size != observed_size or len(expected_checksum) != 64:
raise cls._error(
error_prefix,
"TILE_INTEGRITY_MISMATCH",
"Tile size/checksum evidence is missing or no longer matches the staged file.",
details={
"tile_index": index,
"expected_size_bytes": expected_size,
"observed_size_bytes": observed_size,
},
)
observed_checksum = cls.file_sha256(candidate)
if observed_checksum != expected_checksum:
raise cls._error(
error_prefix,
"TILE_INTEGRITY_MISMATCH",
"Tile checksum no longer matches the immutable manifest evidence.",
details={
"tile_index": index,
"expected_sha256": expected_checksum,
"observed_sha256": observed_checksum,
},
)
resolved_paths.append(str(candidate))
declared_count = manifest.get("count")
if declared_count != len(resolved_paths):
raise cls._error(
error_prefix,
"TILE_INTEGRITY_MISMATCH",
"Tile manifest count does not match its tile records.",
details={"declared_count": declared_count, "tile_count": len(resolved_paths)},
)
return resolved_paths
@classmethod
def _validate_scope(
cls,
db,
dataset: Dataset,
manifest: dict[str, Any],
coverage,
*,
error_prefix: str,
) -> None:
manifest_bounds = cls._bounds_values(manifest.get("bounds"))
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs")
dataset_bounds = cls._bounds_values(dataset.bounds_json)
if dataset_bounds is None and isinstance(dataset.metadata_json, dict):
dataset_bounds = cls._bounds_values(
dataset.metadata_json.get("bounds_json") or dataset.metadata_json.get("bounds")
)
if manifest_bounds is None or not manifest_crs or dataset_bounds is None or not dataset.crs:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Dataset and tile manifest require explicit CRS and finite bounds for inference.",
)
try:
manifest_extent = cls._to_epsg4326(manifest_bounds, manifest_crs)
dataset_extent = cls._to_epsg4326(dataset_bounds, dataset.crs)
except Exception as exc:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Dataset or manifest bounds could not be normalized to EPSG:4326.",
details={"reason": str(exc)},
) from exc
tolerance = max(dataset_extent.bounds[2] - dataset_extent.bounds[0], dataset_extent.bounds[3] - dataset_extent.bounds[1]) * 1e-7 + 1e-10
if not manifest_extent.buffer(tolerance).covers(coverage):
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Tile union exceeds the extent declared by its manifest.",
details={"tile_union_bounds": list(coverage.bounds), "manifest_bounds": list(manifest_extent.bounds)},
)
if not dataset_extent.buffer(tolerance).covers(coverage):
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Tile union exceeds the persisted Dataset extent.",
details={"tile_union_bounds": list(coverage.bounds), "dataset_bounds": list(dataset_extent.bounds)},
)
if dataset.area_id is not None:
area = db.get(Area, dataset.area_id)
if area is None or area.geometry is None:
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Dataset references an Area that is unavailable for inference-scope validation.",
details={"area_id": str(dataset.area_id)},
)
area_geometry = to_shape(area.geometry)
if area_geometry.is_empty or not area_geometry.is_valid or not coverage.intersects(area_geometry):
raise cls._error(
error_prefix,
"SCOPE_MISMATCH",
"Tile union does not overlap the persisted Dataset Area.",
details={"area_id": str(dataset.area_id), "tile_union_bounds": list(coverage.bounds)},
)
@classmethod
def validate_for_inference(
cls,
db,
dataset: Dataset,
manifest: dict[str, Any],
*,
manifest_path: str | Path,
settings,
error_prefix: str,
) -> dict[str, Any]:
resolved_manifest_path = StorageService.assert_within_storage_root(
manifest_path,
label="tile manifest",
settings=settings,
)
expected = cls._validate_binding(db, dataset, manifest, error_prefix=error_prefix)
resolved_paths = cls._validate_tile_files(
manifest,
resolved_manifest_path,
settings=settings,
error_prefix=error_prefix,
)
coverage = cls._manifest_coverage(manifest, error_prefix=error_prefix)
cls._validate_scope(db, dataset, manifest, coverage, error_prefix=error_prefix)
return {
"manifest_contract_key": cls.CONTRACT_KEY,
"manifest_contract_version": cls.CONTRACT_VERSION,
"manifest_path": str(resolved_manifest_path),
"manifest_sha256": cls.file_sha256(resolved_manifest_path),
"source_dataset_id": expected["source_dataset_id"],
"source_dataset_checksum_sha256": expected.get("source_dataset_checksum_sha256"),
"source_snapshot_id": expected.get("source_snapshot_id"),
"dataset_version_id": expected.get("dataset_version_id"),
"source_area_id": expected.get("source_area_id"),
"tile_count": len(resolved_paths),
"tile_union_bounds_epsg4326": [float(value) for value in coverage.bounds],
}
def canonical_manifest_json(payload: dict[str, Any]) -> str:
"""Stable serializer shared by the writer and manifest-hash tests."""
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+81 -20
View File
@@ -1,43 +1,101 @@
from __future__ import annotations from __future__ import annotations
from math import isfinite
from numbers import Real
from typing import Any from typing import Any
from pyproj import Transformer from pyproj import CRS, Transformer
from shapely import force_2d from shapely import force_2d, get_coordinates
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon, box, shape from shapely.geometry import MultiPolygon, box, shape
from shapely.ops import transform from shapely.ops import transform
from shapely.validation import make_valid
# This is a deliberately broad guard envelope around Belgium and the Belgian
# North Sea. Exact legal/regional clipping remains the responsibility of the
# persisted coverage Areas; this boundary prevents an AOI with valid-looking
# but globally misplaced coordinates from entering the workbench.
BELGIUM_AND_NORTH_SEA_GUARD_BOUNDS = (1.5, 48.5, 7.5, 52.5)
def _raw_coordinates_are_finite(value: Any) -> bool:
if isinstance(value, (list, tuple)):
return bool(value) and all(_raw_coordinates_are_finite(item) for item in value)
return isinstance(value, Real) and not isinstance(value, bool) and isfinite(float(value))
def normalize_to_multipolygon(raw_geometry: dict[str, Any]) -> MultiPolygon: def normalize_to_multipolygon(raw_geometry: dict[str, Any]) -> MultiPolygon:
if isinstance(raw_geometry, dict) and "coordinates" in raw_geometry:
if not _raw_coordinates_are_finite(raw_geometry["coordinates"]):
raise ValueError("Geometry coordinates must be finite numbers")
try:
geom = force_2d(shape(raw_geometry)) geom = force_2d(shape(raw_geometry))
except Exception as exc:
raise ValueError("Geometry is not valid GeoJSON") from exc
coordinates = get_coordinates(geom, include_z=False)
if coordinates.size == 0 or not all(isfinite(float(value)) for row in coordinates for value in row):
raise ValueError("Geometry coordinates must be finite numbers")
if geom.is_empty: if geom.is_empty:
raise ValueError("Geometry is empty") raise ValueError("Geometry is empty")
if not geom.is_valid: if not geom.is_valid:
geom = make_valid(geom) raise ValueError("Geometry is invalid")
if not geom.is_valid:
raise ValueError("Geometry is invalid and could not be repaired")
if geom.geom_type == "Polygon": if geom.geom_type == "Polygon":
return MultiPolygon([geom]) return MultiPolygon([geom])
if geom.geom_type == "MultiPolygon": if geom.geom_type == "MultiPolygon":
return MultiPolygon(geom.geoms) return MultiPolygon(geom.geoms)
if isinstance(geom, GeometryCollection):
polygons = [g for g in geom.geoms if isinstance(g, Polygon)]
multipolygons = [g for g in geom.geoms if g.geom_type == "MultiPolygon"]
if not polygons and not multipolygons:
raise ValueError("Only polygon geometries are supported for AOI")
normalized = []
normalized.extend(polygons)
for mp in multipolygons:
normalized.extend(mp.geoms)
return MultiPolygon(normalized)
raise ValueError("Only Polygon or MultiPolygon geometries are accepted") raise ValueError("Only Polygon or MultiPolygon geometries are accepted")
def normalize_area_to_epsg4326(
raw_geometry: dict[str, Any],
source_crs: str,
) -> tuple[MultiPolygon, str]:
"""Validate an AOI and normalize its declared CRS to canonical WGS84.
The returned CRS string preserves the caller's declaration for provenance;
the returned geometry is always finite, polygonal and stored as EPSG:4326.
"""
declared_crs = str(source_crs or "").strip()
if not declared_crs:
raise ValueError("Area CRS is required")
try:
parsed_crs = CRS.from_user_input(declared_crs)
except Exception as exc:
raise ValueError("Area CRS is unknown or invalid") from exc
if not (parsed_crs.is_geographic or parsed_crs.is_projected):
raise ValueError("Area CRS must be a geographic or projected two-dimensional CRS")
if len(parsed_crs.axis_info) != 2:
raise ValueError("Area CRS must have exactly two spatial axes")
geometry = normalize_to_multipolygon(raw_geometry)
target_crs = CRS.from_epsg(4326)
if not parsed_crs.equals(target_crs):
try:
transformer = Transformer.from_crs(parsed_crs, target_crs, always_xy=True)
geometry = normalize_to_multipolygon(
transform(transformer.transform, geometry).__geo_interface__
)
except ValueError:
raise
except Exception as exc:
raise ValueError("Area geometry could not be transformed to EPSG:4326") from exc
min_x, min_y, max_x, max_y = geometry.bounds
if not all(isfinite(value) for value in (min_x, min_y, max_x, max_y)):
raise ValueError("Transformed area geometry contains non-finite coordinates")
world_bounds = (-180.0, -90.0, 180.0, 90.0)
if min_x < world_bounds[0] or min_y < world_bounds[1] or max_x > world_bounds[2] or max_y > world_bounds[3]:
raise ValueError("Transformed area geometry falls outside the EPSG:4326 coordinate domain")
guard = box(*BELGIUM_AND_NORTH_SEA_GUARD_BOUNDS)
if not guard.intersects(geometry):
raise ValueError("Area geometry falls outside Belgium and the Belgian North Sea workbench domain")
if not guard.covers(geometry):
raise ValueError("Area geometry must remain within the Belgium and Belgian North Sea workbench domain")
return geometry, declared_crs
def area_bounds_multipolygon(geom: MultiPolygon): def area_bounds_multipolygon(geom: MultiPolygon):
return { return {
"min_x": float(geom.bounds[0]), "min_x": float(geom.bounds[0]),
@@ -52,7 +110,10 @@ def area_m2(geom: MultiPolygon) -> float:
Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True).transform, Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True).transform,
geom, geom,
) )
return float(projected.area) result = float(projected.area)
if not isfinite(result) or result <= 0:
raise ValueError("Area geometry must have a finite positive surface")
return result
def geometry_bbox_polygon(geom: MultiPolygon): def geometry_bbox_polygon(geom: MultiPolygon):
+4 -2
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.112.0", "fastapi>=0.112.0",
"starlette>=0.46.0,<1.0.0", "starlette>=1.3.1,<2.0.0",
"uvicorn[standard]>=0.30.6", "uvicorn[standard]>=0.30.6",
"SQLAlchemy>=2.0.34", "SQLAlchemy>=2.0.34",
"psycopg[binary]>=3.2.1", "psycopg[binary]>=3.2.1",
@@ -16,6 +16,8 @@ dependencies = [
"shapely>=2.0.4", "shapely>=2.0.4",
"pyproj>=3.6.1", "pyproj>=3.6.1",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"itsdangerous>=2.2.0",
"PyJWT[crypto]>=2.10.1",
"rdflib>=7.1,<8", "rdflib>=7.1,<8",
"alembic>=1.13.2", "alembic>=1.13.2",
] ]
@@ -37,7 +39,7 @@ ai = [
"ultralytics>=8.3,<9", "ultralytics>=8.3,<9",
"torch>=2.4", "torch>=2.4",
] ]
dev = ["pytest>=8.3.2", "httpx>=0.27.0", "ruff>=0.6.9"] dev = ["pytest>=8.3.2", "httpx>=0.27.0", "httpx2>=2.0.0", "ruff>=0.6.9"]
[project.scripts] [project.scripts]
geointel-backend = "app.main:main" geointel-backend = "app.main:main"
+187 -5
View File
@@ -2,9 +2,9 @@
# This file is autogenerated by pip-compile with Python 3.11 # This file is autogenerated by pip-compile with Python 3.11
# by the following command: # by the following command:
# #
# pip-compile --extra=dev --extra=gis --generate-hashes --output-file=requirements-ci.lock --strip-extras pyproject.toml # pip-compile --extra=dev --extra=gis --generate-hashes --no-index --output-file=requirements-ci.lock --strip-extras pyproject.toml
# #
# geointel-input-sha256: 03c20efedd96474cbe62591b7b70cdad2681688b618bdd76731bd4cfaf85b3d4 # geointel-input-sha256: e1dd11f5b30f4c8c902f33476282da48da386d36093cab68212edc69e75df8e3
affine==2.4.0 \ affine==2.4.0 \
--hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \ --hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \
--hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea --hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea
@@ -26,6 +26,7 @@ anyio==4.14.2 \
--hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
# via # via
# httpx # httpx
# httpx2
# starlette # starlette
# watchfiles # watchfiles
attrs==26.1.0 \ attrs==26.1.0 \
@@ -41,6 +42,108 @@ certifi==2026.6.17 \
# pyogrio # pyogrio
# pyproj # pyproj
# rasterio # rasterio
cffi==2.1.1 \
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
--hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
--hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
--hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
--hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
--hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
--hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
--hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
--hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
--hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
--hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
--hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
--hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
--hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
--hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
--hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
--hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
--hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
--hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
--hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
--hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
--hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
--hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
--hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
--hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
--hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
--hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
--hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
--hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
--hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
--hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
--hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
--hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
--hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
--hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
--hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
--hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
--hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
--hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
--hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
--hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
--hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
--hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
--hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
--hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
--hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
--hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
--hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
--hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
--hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
--hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
--hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
--hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
--hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
--hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
--hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
--hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
--hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
--hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
--hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
--hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
--hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
--hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
--hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
--hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
--hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
--hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
--hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
--hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
--hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
--hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
--hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
--hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
--hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
--hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
--hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
--hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
--hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
--hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
--hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
--hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
--hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
--hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
--hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
--hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
--hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
--hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
--hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
--hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
--hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
--hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
--hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
--hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
--hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
--hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
--hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
--hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
--hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
# via cryptography
click==8.4.2 \ click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
@@ -57,6 +160,54 @@ cligj==0.7.2 \
--hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \ --hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \
--hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df --hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df
# via rasterio # via rasterio
cryptography==50.0.1 \
--hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
--hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
--hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
--hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
--hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
--hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
--hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
--hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
--hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
--hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
--hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
--hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
--hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
--hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
--hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
--hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
--hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
--hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
--hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \
--hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \
--hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
--hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
--hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \
--hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
--hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
--hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \
--hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
--hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
--hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
--hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
--hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
--hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
--hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
--hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
--hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
--hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
--hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
--hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \
--hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
--hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
--hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
--hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
--hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
# via pyjwt
fastapi==0.139.2 \ fastapi==0.139.2 \
--hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \ --hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \
--hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c --hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c
@@ -155,11 +306,16 @@ h11==0.16.0 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via # via
# httpcore # httpcore
# httpcore2
# uvicorn # uvicorn
httpcore==1.0.9 \ httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx # via httpx
httpcore2==2.12.0 \
--hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \
--hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648
# via httpx2
httptools==0.8.0 \ httptools==0.8.0 \
--hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \ --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \
--hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \ --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \
@@ -216,16 +372,25 @@ httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via geointel-backend (pyproject.toml) # via geointel-backend (pyproject.toml)
httpx2==2.12.0 \
--hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \
--hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36
# via geointel-backend (pyproject.toml)
idna==3.18 \ idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
# via # via
# anyio # anyio
# httpx # httpx
# httpx2
iniconfig==2.3.0 \ iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest # via pytest
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via geointel-backend (pyproject.toml)
mako==1.3.12 \ mako==1.3.12 \
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \ --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a --hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
@@ -613,6 +778,10 @@ psycopg-binary==3.3.4 \
--hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \ --hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7 --hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
# via psycopg # via psycopg
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pydantic==2.13.4 \ pydantic==2.13.4 \
--hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
--hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
@@ -750,6 +919,12 @@ pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via pytest # via pytest
pyjwt==2.13.0 \
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
# via
# geointel-backend (pyproject.toml)
# pyjwt
pyogrio==0.13.0 \ pyogrio==0.13.0 \
--hash=sha256:1b91f6d6e6757a6ea84b9459d24f479dcb52bbf4ebcdb16baf39e49d2836a1cf \ --hash=sha256:1b91f6d6e6757a6ea84b9459d24f479dcb52bbf4ebcdb16baf39e49d2836a1cf \
--hash=sha256:220a988ce2a26591d6db5c775b07289d4f54cabdf274cc048f0e17a0b9d5be14 \ --hash=sha256:220a988ce2a26591d6db5c775b07289d4f54cabdf274cc048f0e17a0b9d5be14 \
@@ -1127,12 +1302,18 @@ sqlalchemy==2.0.51 \
# alembic # alembic
# geoalchemy2 # geoalchemy2
# geointel-backend (pyproject.toml) # geointel-backend (pyproject.toml)
starlette==0.52.1 \ starlette==1.6.0 \
--hash=sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74 \ --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933 --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via # via
# fastapi # fastapi
# geointel-backend (pyproject.toml) # geointel-backend (pyproject.toml)
truststore==0.10.4 \
--hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \
--hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981
# via
# httpcore2
# httpx2
typing-extensions==4.16.0 \ typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
@@ -1140,6 +1321,7 @@ typing-extensions==4.16.0 \
# alembic # alembic
# anyio # anyio
# fastapi # fastapi
# httpx2
# psycopg # psycopg
# pydantic # pydantic
# pydantic-core # pydantic-core
+169 -5
View File
@@ -2,9 +2,9 @@
# This file is autogenerated by pip-compile with Python 3.11 # This file is autogenerated by pip-compile with Python 3.11
# by the following command: # by the following command:
# #
# pip-compile --extra=gis --generate-hashes --output-file=requirements-runtime.lock --strip-extras pyproject.toml # pip-compile --extra=gis --generate-hashes --no-index --output-file=requirements-runtime.lock --strip-extras pyproject.toml
# #
# geointel-input-sha256: 0d0d2cdceb01da58354610f03b5ce244523130cf9fd29e3f8988dbe684838e8b # geointel-input-sha256: 30318170074aad9b91f570cccc574eaaad0eded5814a464d1207a94d2e1d3317
affine==2.4.0 \ affine==2.4.0 \
--hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \ --hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \
--hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea --hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea
@@ -38,6 +38,108 @@ certifi==2026.6.17 \
# pyogrio # pyogrio
# pyproj # pyproj
# rasterio # rasterio
cffi==2.1.1 \
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
--hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
--hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
--hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
--hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
--hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
--hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
--hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
--hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
--hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
--hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
--hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
--hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
--hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
--hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
--hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
--hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
--hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
--hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
--hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
--hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
--hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
--hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
--hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
--hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
--hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
--hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
--hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
--hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
--hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
--hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
--hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
--hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
--hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
--hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
--hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
--hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
--hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
--hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
--hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
--hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
--hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
--hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
--hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
--hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
--hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
--hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
--hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
--hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
--hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
--hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
--hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
--hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
--hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
--hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
--hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
--hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
--hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
--hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
--hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
--hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
--hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
--hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
--hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
--hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
--hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
--hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
--hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
--hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
--hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
--hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
--hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
--hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
--hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
--hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
--hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
--hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
--hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
--hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
--hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
--hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
--hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
--hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
--hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
--hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
--hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
--hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
--hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
--hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
--hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
--hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
--hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
--hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
--hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
--hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
--hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
--hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
--hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
# via cryptography
click==8.4.2 \ click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
@@ -54,6 +156,54 @@ cligj==0.7.2 \
--hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \ --hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \
--hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df --hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df
# via rasterio # via rasterio
cryptography==50.0.1 \
--hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
--hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
--hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
--hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
--hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
--hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
--hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
--hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
--hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
--hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
--hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
--hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
--hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
--hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
--hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
--hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
--hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
--hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
--hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \
--hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \
--hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
--hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
--hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \
--hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
--hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
--hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \
--hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
--hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
--hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
--hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
--hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
--hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
--hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
--hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
--hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
--hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
--hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
--hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \
--hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
--hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
--hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
--hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
--hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
# via pyjwt
fastapi==0.139.2 \ fastapi==0.139.2 \
--hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \ --hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \
--hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c --hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c
@@ -207,6 +357,10 @@ idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
# via anyio # via anyio
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via geointel-backend (pyproject.toml)
mako==1.3.12 \ mako==1.3.12 \
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \ --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a --hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
@@ -589,6 +743,10 @@ psycopg-binary==3.3.4 \
--hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \ --hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7 --hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
# via psycopg # via psycopg
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pydantic==2.13.4 \ pydantic==2.13.4 \
--hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
--hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
@@ -722,6 +880,12 @@ pydantic-settings==2.14.2 \
--hash=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 \ --hash=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 \
--hash=sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f --hash=sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f
# via geointel-backend (pyproject.toml) # via geointel-backend (pyproject.toml)
pyjwt==2.13.0 \
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
# via
# geointel-backend (pyproject.toml)
# pyjwt
pyogrio==0.13.0 \ pyogrio==0.13.0 \
--hash=sha256:1b91f6d6e6757a6ea84b9459d24f479dcb52bbf4ebcdb16baf39e49d2836a1cf \ --hash=sha256:1b91f6d6e6757a6ea84b9459d24f479dcb52bbf4ebcdb16baf39e49d2836a1cf \
--hash=sha256:220a988ce2a26591d6db5c775b07289d4f54cabdf274cc048f0e17a0b9d5be14 \ --hash=sha256:220a988ce2a26591d6db5c775b07289d4f54cabdf274cc048f0e17a0b9d5be14 \
@@ -1075,9 +1239,9 @@ sqlalchemy==2.0.51 \
# alembic # alembic
# geoalchemy2 # geoalchemy2
# geointel-backend (pyproject.toml) # geointel-backend (pyproject.toml)
starlette==0.52.1 \ starlette==1.6.0 \
--hash=sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74 \ --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933 --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via # via
# fastapi # fastapi
# geointel-backend (pyproject.toml) # geointel-backend (pyproject.toml)
+7 -4
View File
@@ -14,10 +14,13 @@ SCRIPTS_ROOT = REPOSITORY_ROOT / "scripts"
if SCRIPTS_ROOT.is_dir(): if SCRIPTS_ROOT.is_dir():
sys.path.insert(0, str(SCRIPTS_ROOT)) sys.path.insert(0, str(SCRIPTS_ROOT))
from app.core.config import get_settings from app.core.config import get_settings # noqa: E402 - imported after backend path bootstrap
from app.db.session import SessionLocal from app.db.session import SessionLocal # noqa: E402 - imported after backend path bootstrap
from app.models import Export, Project from app.models import Export, Project # noqa: E402 - imported after backend path bootstrap
from release_backup_guard import require_confirmation, verify_current_backup from release_backup_guard import ( # noqa: E402 - imported after scripts path bootstrap
require_confirmation,
verify_current_backup,
)
DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA" DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
+1
View File
@@ -40,6 +40,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
"components/map/useMapWorkspaceViewModel.ts", "components/map/useMapWorkspaceViewModel.ts",
"components/map/MapExplorerView.tsx", "components/map/MapExplorerView.tsx",
"components/map/MapAdvancedWorkbench.tsx", "components/map/MapAdvancedWorkbench.tsx",
"components/map/MunicipalitySearch.tsx",
"hooks/useMapImageOverlays.ts", "hooks/useMapImageOverlays.ts",
"hooks/useMapRectangleSelection.ts", "hooks/useMapRectangleSelection.ts",
"hooks/useFullGisWorkflow.ts", "hooks/useFullGisWorkflow.ts",
@@ -9,7 +9,13 @@ from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "run_accuracy_phase3_full_data_scan.py" SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "run_accuracy_phase3_full_data_scan.py"
def run_scan(repo: Path, output: Path, *, resume: bool = False) -> dict: def run_scan(
repo: Path,
output: Path,
*,
resume: bool = False,
unreachable_scopes: tuple[str, ...] = (),
) -> dict:
command = [ command = [
sys.executable, sys.executable,
str(SCRIPT), str(SCRIPT),
@@ -24,6 +30,8 @@ def run_scan(repo: Path, output: Path, *, resume: bool = False) -> dict:
] ]
if resume: if resume:
command.append("--resume") command.append("--resume")
for scope in unreachable_scopes:
command.extend(("--unreachable-scope", scope))
completed = subprocess.run(command, check=True, capture_output=True, text=True) completed = subprocess.run(command, check=True, capture_output=True, text=True)
return json.loads(completed.stdout) return json.loads(completed.stdout)
@@ -57,8 +65,18 @@ def test_phase3_scan_reconciles_and_resumes_deterministically(tmp_path: Path) ->
(data / "broken.tif").write_bytes(b"not a geotiff") (data / "broken.tif").write_bytes(b"not a geotiff")
output = tmp_path / "evidence" output = tmp_path / "evidence"
first = run_scan(tmp_path, output) unreachable_scopes = (
second = run_scan(tmp_path, output, resume=True) "external://tower-corpora=Tower corpora are not mounted in this fixture",
"external://mounted-model-volumes=Model volumes are not mounted in this fixture",
"external://production-postgis-or-api=Production database is not configured in this fixture",
)
first = run_scan(tmp_path, output, unreachable_scopes=unreachable_scopes)
second = run_scan(
tmp_path,
output,
resume=True,
unreachable_scopes=unreachable_scopes,
)
manifest = json.loads((output / "full-scan-manifest.json").read_text(encoding="utf-8")) manifest = json.loads((output / "full-scan-manifest.json").read_text(encoding="utf-8"))
quarantine = json.loads((output / "quarantine-manifest.json").read_text(encoding="utf-8")) quarantine = json.loads((output / "quarantine-manifest.json").read_text(encoding="utf-8"))
@@ -68,3 +86,46 @@ def test_phase3_scan_reconciles_and_resumes_deterministically(tmp_path: Path) ->
assert any(item["path"] == "data/broken.tif" for item in quarantine["items"]) assert any(item["path"] == "data/broken.tif" for item in quarantine["items"])
assert any(item["path"] == "data/invalid.geojson" for item in quarantine["items"]) assert any(item["path"] == "data/invalid.geojson" for item in quarantine["items"])
assert len(manifest["duplicates"]["exact_duplicate_groups"]) == 1 assert len(manifest["duplicates"]["exact_duplicate_groups"]) == 1
def test_phase3_scan_does_not_invent_unreachable_production_boundaries(
tmp_path: Path,
) -> None:
data = tmp_path / "data"
data.mkdir()
(data / "present.json").write_text("{}", encoding="utf-8")
output = tmp_path / "evidence"
result = run_scan(tmp_path, output)
assert result["reconciliation"] == {
"examined": 1,
"skipped": 0,
"unreachable": 0,
"inventory_total": 1,
"reconciles": True,
}
def test_phase3_scan_records_a_missing_requested_root(tmp_path: Path) -> None:
output = tmp_path / "evidence"
completed = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--repo-root",
str(tmp_path),
"--output-dir",
str(output),
"--roots",
"missing-data",
],
check=True,
capture_output=True,
text=True,
)
result = json.loads(completed.stdout)
manifest = json.loads((output / "full-scan-manifest.json").read_text(encoding="utf-8"))
assert result["reconciliation"]["unreachable"] == 1
assert manifest["items"][0]["path"] == "root://missing-data"
+1 -1
View File
@@ -14,7 +14,7 @@ from uuid import uuid4
import pytest import pytest
from app.core.errors import AppError from app.core.errors import AppError
from app.models import AnalysisRun, Detection, Job from app.models import Job
from app.services.analysis_job_worker import AnalysisJobWorker from app.services.analysis_job_worker import AnalysisJobWorker
from app.services.detection_service import DetectionService from app.services.detection_service import DetectionService
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from uuid import uuid4
from geoalchemy2.shape import from_shape, to_shape
from pyproj import Transformer
import pytest
from shapely.geometry import Polygon, mapping
from shapely.ops import transform
from app.core.errors import AppError
from app.models import Area, Project
from app.schemas.area import AreaCreate, AreaUpdate
from app.services.area_service import AreaService
from app.utils.geometry import area_m2, normalize_area_to_epsg4326
class FakeSession:
def __init__(self, objects=None) -> None:
self.objects = objects or {}
self.added = []
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def add(self, item) -> None:
self.added.append(item)
def commit(self) -> None:
self.commits += 1
def refresh(self, item) -> None:
self.refreshes.append(item)
def _wgs84_polygon(offset: float = 0.0) -> Polygon:
return Polygon(
[
(5.00 + offset, 51.00),
(5.01 + offset, 51.00),
(5.01 + offset, 51.01),
(5.00 + offset, 51.01),
(5.00 + offset, 51.00),
]
)
def _to_lambert(geometry: Polygon) -> Polygon:
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
return transform(transformer.transform, geometry)
def test_create_area_transforms_declared_lambert_geometry_before_storage() -> None:
project_id = uuid4()
db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")})
source = _wgs84_polygon()
area = AreaService.create_area(
db,
project_id,
AreaCreate(name="Lambert AOI", geometry=mapping(_to_lambert(source)), crs="EPSG:31370"),
)
stored = to_shape(area.geometry)
assert stored.bounds == pytest.approx(source.bounds, abs=1e-7)
assert area.original_crs == "EPSG:31370"
assert area.area_m2 == pytest.approx(area_m2(normalize_area_to_epsg4326(mapping(source), "EPSG:4326")[0]))
assert area.area_m2 and area.area_m2 > 0
assert to_shape(area.bbox).bounds == pytest.approx(source.bounds, abs=1e-7)
def test_patch_area_replaces_geometry_and_recomputes_all_spatial_fields() -> None:
area_id = uuid4()
project_id = uuid4()
original = _wgs84_polygon()
normalized, _ = normalize_area_to_epsg4326(mapping(original), "EPSG:4326")
area = Area(
id=area_id,
project_id=project_id,
name="Original",
geometry=from_shape(normalized, srid=4326),
bbox=from_shape(normalized.envelope, srid=4326),
original_crs="EPSG:4326",
area_m2=area_m2(normalized),
)
db = FakeSession({(Area, area_id): area})
replacement = _wgs84_polygon(offset=0.05)
updated = AreaService.update_area(
db,
area_id,
AreaUpdate(
name="Replacement",
geometry=mapping(_to_lambert(replacement)),
crs="EPSG:31370",
),
)
assert updated.name == "Replacement"
assert updated.original_crs == "EPSG:31370"
assert to_shape(updated.geometry).bounds == pytest.approx(replacement.bounds, abs=1e-7)
assert to_shape(updated.bbox).bounds == pytest.approx(replacement.bounds, abs=1e-7)
assert updated.area_m2 and updated.area_m2 > 0
assert db.commits == 1
@pytest.mark.parametrize(
("geometry", "crs", "message_fragment"),
[
(mapping(_wgs84_polygon()), "EPSG:not-real", "unknown or invalid"),
(mapping(_wgs84_polygon()), "EPSG:4979", "exactly two spatial axes"),
(
{
"type": "Polygon",
"coordinates": [[[5.0, 51.0], [float("nan"), 51.0], [5.1, 51.1], [5.0, 51.0]]],
},
"EPSG:4326",
"finite",
),
(mapping(Polygon([(10.0, 51.0), (10.1, 51.0), (10.1, 51.1), (10.0, 51.0)])), "EPSG:4326", "workbench domain"),
(
{
"type": "Polygon",
"coordinates": [[[5.0, 51.0], [5.1, 51.1], [5.1, 51.0], [5.0, 51.1], [5.0, 51.0]]],
},
"EPSG:4326",
"invalid",
),
({"type": "Point", "coordinates": [5.0, 51.0]}, "EPSG:4326", "Polygon or MultiPolygon"),
],
)
def test_create_area_rejects_invalid_crs_nonfinite_and_out_of_domain_geometry(
geometry: dict,
crs: str,
message_fragment: str,
) -> None:
project_id = uuid4()
db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")})
with pytest.raises(AppError) as exc_info:
AreaService.create_area(db, project_id, AreaCreate(name="Invalid", geometry=geometry, crs=crs))
assert exc_info.value.code == "INVALID_GEOMETRY"
assert message_fragment in exc_info.value.message
assert db.commits == 0
def test_patch_area_rejects_crs_without_replacement_geometry() -> None:
area_id = uuid4()
area = Area(id=area_id, project_id=uuid4(), name="AOI", original_crs="EPSG:4326")
db = FakeSession({(Area, area_id): area})
with pytest.raises(AppError) as exc_info:
AreaService.update_area(db, area_id, AreaUpdate(crs="EPSG:31370"))
assert exc_info.value.code == "INVALID_AREA_CRS_UPDATE"
assert db.commits == 0
+236 -1
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from uuid import UUID from types import SimpleNamespace
from uuid import UUID, uuid4
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -10,7 +11,13 @@ from app.db.session import get_db
from app.main import create_app from app.main import create_app
from app.schemas.demo import DemoWorkflowResponse from app.schemas.demo import DemoWorkflowResponse
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.change_detection_service import ChangeDetectionService
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.demo_workflow_service import DemoWorkflowService from app.services.demo_workflow_service import DemoWorkflowService
from app.services.raster_operations_service import RasterOperationsService
from app.services.segmentation_service import SegmentationService
from app.services.job_service import JobService
def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient: def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient:
@@ -80,6 +87,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
"expires_at": None, "expires_at": None,
"role": None, "role": None,
"guest_access_enabled": False, "guest_access_enabled": False,
"authentik_enabled": False,
"guest_project_id": None, "guest_project_id": None,
} }
assert protected.status_code == 401 assert protected.status_code == 401
@@ -113,6 +121,7 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m
"expires_at": login.json()["data"]["expires_at"], "expires_at": login.json()["data"]["expires_at"],
"role": "operator", "role": "operator",
"guest_access_enabled": True, "guest_access_enabled": True,
"authentik_enabled": False,
"guest_project_id": None, "guest_project_id": None,
} }
cookie = login.headers["set-cookie"].lower() cookie = login.headers["set-cookie"].lower()
@@ -166,6 +175,18 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
"themes": [], "themes": [],
}, },
) )
bounded_acquisition = client.post(
f"/api/v1/projects/{project_id}/datasets/orthophoto/acquire",
json={},
)
cross_project_acquisition = client.post(
"/api/v1/projects/00000000-0000-0000-0000-000000000999/datasets/orthophoto/acquire",
json={},
)
bounded_derived_selection = client.post(
f"/api/v1/projects/{project_id}/datasets/{demo.candidate_dataset_id}/vector/select/derive",
json={},
)
assert guest_login.status_code == 200 assert guest_login.status_code == 200
assert guest_login.json()["data"]["role"] == "guest" assert guest_login.json()["data"]["role"] == "guest"
@@ -186,6 +207,218 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
assert cross_project_coverage.status_code == 403 assert cross_project_coverage.status_code == 403
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
assert bounded_acquisition.status_code == 422
assert bounded_acquisition.json()["error"] != "GUEST_READ_ONLY"
assert cross_project_acquisition.status_code == 403
assert cross_project_acquisition.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
assert bounded_derived_selection.status_code == 422
assert bounded_derived_selection.json()["error"] != "GUEST_READ_ONLY"
def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monkeypatch) -> None:
project_id = UUID("00000000-0000-0000-0000-000000000123")
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")
cross_project_dataset_id = UUID("00000000-0000-0000-0000-000000000998")
demo = DemoWorkflowResponse(
project_id=project_id,
area_id=UUID("00000000-0000-0000-0000-000000000124"),
reference_dataset_id=source_dataset_id,
candidate_dataset_id=target_dataset_id,
raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"),
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
metric_count=6,
status="ok",
message="Demo ready",
created=False,
)
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
class FakeDb:
def get(self, _model, dataset_id):
bound_project_id = other_project_id if dataset_id == cross_project_dataset_id else project_id
return SimpleNamespace(id=dataset_id, project_id=bound_project_id, dataset_type="vector")
validated_datasets: list[tuple[UUID, UUID, str]] = []
def validate_dataset(_db, dataset_id, requested_project_id, label):
validated_datasets.append((dataset_id, requested_project_id, label))
return SimpleNamespace(id=dataset_id, project_id=requested_project_id, dataset_type="vector")
monkeypatch.setattr(
ChangeDetectionService,
"_get_project_vector_dataset",
staticmethod(validate_dataset),
)
monkeypatch.setattr(
JobService,
"run_sync_job",
staticmethod(
lambda **kwargs: SimpleNamespace(
id=uuid4(),
job_type=kwargs["job_type"],
status="success",
project_id=kwargs["project_id"],
dataset_id=source_dataset_id,
input_dataset_id=source_dataset_id,
output_dataset_id=None,
parameters_json=kwargs["parameters"],
result_json={},
error_message=None,
created_at=None,
started_at=None,
finished_at=None,
)
),
)
client = auth_client(monkeypatch, guest_access=True)
def fake_db():
yield FakeDb()
client.app.dependency_overrides[get_db] = fake_db
assert client.post("/api/v1/auth/guest").status_code == 200
accepted = client.post(
"/api/v1/analysis/change-detection",
json={
"source_dataset_id": str(source_dataset_id),
"target_dataset_id": str(target_dataset_id),
},
)
rejected = client.post(
"/api/v1/analysis/change-detection",
json={
"source_dataset_id": str(cross_project_dataset_id),
"target_dataset_id": str(target_dataset_id),
},
)
assert accepted.status_code == 200
assert accepted.json()["data"]["project_id"] == str(project_id)
assert validated_datasets == [
(source_dataset_id, project_id, "Source"),
(target_dataset_id, project_id, "Target"),
]
assert rejected.status_code == 403
assert rejected.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
def test_guest_can_prepare_tiles_and_queue_project_scoped_detection(monkeypatch) -> None:
project_id = UUID("00000000-0000-0000-0000-000000000123")
raster_dataset_id = UUID("00000000-0000-0000-0000-000000000127")
manifest_path = "/app/storage/tiles/demo/manifest.json"
demo = DemoWorkflowResponse(
project_id=project_id,
area_id=UUID("00000000-0000-0000-0000-000000000124"),
reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"),
candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"),
raster_dataset_id=raster_dataset_id,
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
metric_count=6,
status="ok",
message="Demo ready",
created=False,
)
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
monkeypatch.setattr(
DatasetService,
"get_dataset",
staticmethod(lambda _db, _dataset_id: SimpleNamespace(project_id=project_id)),
)
def job(*, job_type: str, result_json: dict | None = None):
return SimpleNamespace(
id=uuid4(),
job_type=job_type,
status="success" if result_json else "queued",
project_id=project_id,
dataset_id=raster_dataset_id,
input_dataset_id=raster_dataset_id,
output_dataset_id=None,
parameters_json={},
result_json=result_json,
error_message=None,
created_at=None,
started_at=None,
finished_at=None,
)
tile_parameters: dict = {}
def tile(_db, _dataset_id, **kwargs):
tile_parameters.update(kwargs)
return {"manifest_path": manifest_path}
monkeypatch.setattr(RasterOperationsService, "tile", staticmethod(tile))
monkeypatch.setattr(
"app.api.routes.datasets._run_job_sync",
lambda **kwargs: job(job_type="raster.tile", result_json=kwargs["operation"]()),
)
queued_parameters: dict = {}
def enqueue_detection(**kwargs):
queued_parameters.update(kwargs)
return job(job_type="detection.run")
monkeypatch.setattr(DetectionService, "enqueue_detection", staticmethod(enqueue_detection))
queued_segmentation_parameters: dict = {}
def enqueue_segmentation(**kwargs):
queued_segmentation_parameters.update(kwargs)
return job(job_type="segmentation.run")
monkeypatch.setattr(SegmentationService, "enqueue_segmentation", staticmethod(enqueue_segmentation))
client = auth_client(monkeypatch, guest_access=True)
def fake_db():
yield object()
client.app.dependency_overrides[get_db] = fake_db
assert client.post("/api/v1/auth/guest").status_code == 200
tile_response = client.post(
f"/api/v1/projects/{project_id}/datasets/{raster_dataset_id}/raster/tile",
json={"tile_size": 512, "overlap": 64},
)
detection_response = client.post(
f"/api/v1/detection/run-async?project_id={project_id}",
json={
"project_id": str(project_id),
"dataset_id": str(raster_dataset_id),
"model_id": "yolo-configured",
"model_asset_id": "active-model",
"confidence_threshold": 0.15,
"tile_manifest_path": manifest_path,
"parameters_json": {},
},
)
segmentation_response = client.post(
f"/api/v1/segmentation/run-async?project_id={project_id}",
json={
"project_id": str(project_id),
"dataset_id": str(raster_dataset_id),
"model_id": "sam-configured",
"confidence_threshold": 0.5,
"tile_manifest_path": manifest_path,
"parameters_json": {},
},
)
assert tile_response.status_code == 201
assert tile_response.json()["data"]["result_json"]["manifest_path"] == manifest_path
assert detection_response.status_code == 200
assert detection_response.json()["data"]["status"] == "queued"
assert segmentation_response.status_code == 200
assert segmentation_response.json()["data"]["status"] == "queued"
assert queued_parameters["project_id"] == project_id
assert queued_parameters["dataset_id"] == raster_dataset_id
assert queued_parameters["tile_manifest_path"] == manifest_path
assert queued_segmentation_parameters["project_id"] == project_id
assert queued_segmentation_parameters["dataset_id"] == raster_dataset_id
assert queued_segmentation_parameters["tile_manifest_path"] == manifest_path
assert tile_parameters["max_tiles"] == get_settings().yolo_max_tiles
def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None: def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None:
@@ -223,7 +456,9 @@ def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8") browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8")
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
assert '-e GEOINTEL_AUTHENTIK_CLIENT_SECRET="$GEOINTEL_AUTHENTIK_CLIENT_SECRET"' in runner
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
assert "GEOINTEL_AUTHENTIK_CLIENT_SECRET=" in example
assert "GEOINTEL_AUTH_PASSWORD=" not in runner assert "GEOINTEL_AUTH_PASSWORD=" not in runner
assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example
assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner
@@ -0,0 +1,183 @@
from __future__ import annotations
import time
from urllib.parse import parse_qs, urlsplit
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from pydantic import ValidationError
from app.core.config import Settings
from app.services.authentik_oidc_service import (
MAX_OIDC_JSON_BYTES,
AuthentikOidcService,
)
ISSUER = "https://auth.example.test/application/o/geointel"
def configured_settings(**overrides: object) -> Settings:
values: dict[str, object] = {
"auth_enabled": True,
"auth_username": "ITWorx",
"auth_password_hash": "pbkdf2_sha256$1$salt$digest",
"auth_session_secret": "s" * 48,
"authentik_issuer": ISSUER,
"authentik_client_id": "geointel-client",
"authentik_client_secret": "client-secret",
"authentik_allowed_email": "operator@example.test",
"public_base_url": "https://geointel.example.test",
}
values.update(overrides)
return Settings(_env_file=None, **values)
def discovery_document() -> dict[str, str]:
return {
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}/authorize",
"token_endpoint": f"{ISSUER}/token",
"jwks_uri": f"{ISSUER}/jwks",
}
def test_authentik_configuration_is_all_or_nothing_and_https_only() -> None:
with pytest.raises(ValidationError, match="configured together"):
configured_settings(authentik_client_secret=None)
with pytest.raises(ValidationError, match="absolute HTTPS URL"):
configured_settings(authentik_issuer="http://auth.example.test/issuer")
with pytest.raises(ValidationError, match="must not contain a path"):
configured_settings(public_base_url="https://geointel.example.test/app")
def test_start_uses_same_origin_discovery_and_pkce(monkeypatch: pytest.MonkeyPatch) -> None:
service = AuthentikOidcService(configured_settings())
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: discovery_document())
location, flow_cookie = service.start()
parsed = urlsplit(location)
query = parse_qs(parsed.query)
flow = service.serializer.loads(flow_cookie, max_age=600)
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == f"{ISSUER}/authorize"
assert query["redirect_uri"] == [
"https://geointel.example.test/api/v1/auth/authentik/callback"
]
assert query["code_challenge_method"] == ["S256"]
assert query["state"] == [flow["state"]]
assert query["nonce"] == [flow["nonce"]]
assert query["code_challenge"][0]
def test_discovery_rejects_cross_origin_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
service = AuthentikOidcService(configured_settings())
document = discovery_document()
document["jwks_uri"] = "https://attacker.example.test/jwks"
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: document)
with pytest.raises(ValueError, match="outside the configured issuer origin"):
service._discovery()
def test_finish_verifies_signature_nonce_and_exact_allowed_email(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = AuthentikOidcService(configured_settings())
state, nonce, verifier = "state-value", "nonce-value", "verifier-value"
flow_cookie = service.serializer.dumps(
{"state": state, "nonce": nonce, "verifier": verifier}
)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(
private_key.public_key(), as_dict=True
)
public_jwk["kid"] = "operator-key"
now = int(time.time())
token = jwt.encode(
{
"iss": ISSUER,
"aud": "geointel-client",
"sub": "authentik-user-id",
"iat": now,
"exp": now + 300,
"nonce": nonce,
"email": "Operator@Example.Test",
"email_verified": True,
},
private_key,
algorithm="RS256",
headers={"kid": "operator-key"},
)
token_holder = {"value": token}
def fetch(url: str, data: dict[str, str] | None = None) -> dict:
if url.endswith("openid-configuration"):
return discovery_document()
if url.endswith("/token"):
assert data is not None
assert data["code_verifier"] == verifier
return {"id_token": token_holder["value"]}
if url.endswith("/jwks"):
return {"keys": [public_jwk]}
raise AssertionError(url)
monkeypatch.setattr(service, "_fetch_json", fetch)
claims = service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
assert claims["sub"] == "authentik-user-id"
token_holder["value"] = jwt.encode(
{
"iss": ISSUER,
"aud": "geointel-client",
"sub": "different-user",
"iat": now,
"exp": now + 300,
"nonce": nonce,
"email": "other@example.test",
"email_verified": True,
},
private_key,
algorithm="RS256",
headers={"kid": "operator-key"},
)
with pytest.raises(ValueError, match="not authorized"):
service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
with pytest.raises(ValueError, match="state mismatch"):
service.finish(
code="authorization-code",
state="different-state",
flow_cookie=flow_cookie,
)
def test_fetch_json_rejects_declared_oversize_response(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = AuthentikOidcService(configured_settings())
class OversizeResponse:
headers = {"Content-Length": str(MAX_OIDC_JSON_BYTES + 1)}
def __enter__(self):
return self
def __exit__(self, *_args: object) -> None:
return None
def read(self, _size: int) -> bytes:
raise AssertionError("oversized responses must not be read")
class Opener:
def open(self, *_args: object, **_kwargs: object) -> OversizeResponse:
return OversizeResponse()
monkeypatch.setattr(
"app.services.authentik_oidc_service.build_opener",
lambda *_args: Opener(),
)
with pytest.raises(ValueError, match="size limit"):
service._fetch_json(f"{ISSUER}/oversized")
@@ -175,6 +175,31 @@ def test_failed_iteration_builds_train_only_sampling_for_next_checkpoint(tmp_pat
assert command[command.index("--output-dir") + 1].endswith("failure-driven-training") assert command[command.index("--output-dir") + 1].endswith("failure-driven-training")
def test_protected_assessment_uses_frozen_threshold_and_configured_gates(
tmp_path: Path,
) -> None:
command = MODULE.protected_assessment_command(
scripts_dir=tmp_path / "scripts",
calibration=tmp_path / "calibration.json",
test=tmp_path / "test.json",
background=tmp_path / "background.json",
output=tmp_path / "assessment.json",
selected_threshold=0.275,
min_aggregate_f1=0.71,
min_region_f1=0.62,
min_region_precision=0.73,
min_region_recall=0.58,
max_pure_empty_fp=1,
)
assert command[command.index("--selected-threshold") + 1] == "0.275"
assert command[command.index("--min-aggregate-f1") + 1] == "0.71"
assert command[command.index("--min-region-f1") + 1] == "0.62"
assert command[command.index("--min-region-precision") + 1] == "0.73"
assert command[command.index("--min-region-recall") + 1] == "0.58"
assert command[command.index("--max-pure-empty-fp") + 1] == "1"
def test_partial_iteration_resume_uses_exact_checkpoint_and_cuda(tmp_path: Path) -> None: def test_partial_iteration_resume_uses_exact_checkpoint_and_cuda(tmp_path: Path) -> None:
checkpoint = tmp_path / "runs" / "iteration-002" / "weights" / "last.pt" checkpoint = tmp_path / "runs" / "iteration-002" / "weights" / "last.pt"
assert MODULE.resumable_training_command("yolo", checkpoint) == [ assert MODULE.resumable_training_command("yolo", checkpoint) == [
@@ -190,6 +190,17 @@ def test_passed_manual_or_experimental_dataset_cannot_cross_production_boundary(
assert "experimental_source_not_allowed_for_purpose" in exc_info.value.details["reasons"] assert "experimental_source_not_allowed_for_purpose" in exc_info.value.details["reasons"]
def test_fully_governed_demo_fixture_still_cannot_enter_production_inference() -> None:
fixture = _governed_dataset(source_key="fixture", classification="experimental")
fixture.source_metadata = {"fixture": True, "usage": "offline demo raster workflow only"}
with pytest.raises(AppError) as exc_info:
DatasetConsumptionGate.assert_eligible(fixture, purpose="production_inference")
assert exc_info.value.code == "DATASET_PROVENANCE_INCOMPLETE"
assert "experimental_source_not_allowed_for_purpose" in exc_info.value.details["reasons"]
def test_reference_validation_requires_authoritative_ground_truth_reference() -> None: def test_reference_validation_requires_authoritative_ground_truth_reference() -> None:
reference = _governed_dataset() reference = _governed_dataset()
reference.dataset_type = "vector" reference.dataset_type = "vector"
@@ -172,6 +172,25 @@ def test_walloon_runtime_settings_are_editable_in_compose_and_unraid() -> None:
assert "WALOUS_MAX_PIXELS" in content assert "WALOUS_MAX_PIXELS" in content
def test_in_memory_vector_limit_is_propagated_and_validated_in_every_runtime() -> None:
expected = "GEOINTEL_MAX_IN_MEMORY_VECTOR_MB"
for path in (
ROOT / ".env.example",
ROOT / "docker-compose.yml",
ROOT / "docker-compose.unraid.yml",
ROOT / "deploy" / "unraid" / "geointel.env.example",
ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml",
):
assert expected in path.read_text(encoding="utf-8"), path
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(
encoding="utf-8"
)
assert 'GEOINTEL_MAX_IN_MEMORY_VECTOR_MB="${GEOINTEL_MAX_IN_MEMORY_VECTOR_MB:-64}"' in run_script
assert "GEOINTEL_MAX_IN_MEMORY_VECTOR_MB must be between 1 and 256." in run_script
assert '-e GEOINTEL_MAX_IN_MEMORY_VECTOR_MB="$GEOINTEL_MAX_IN_MEMORY_VECTOR_MB"' in run_script
def test_frontend_uses_same_origin_api_proxy_by_default() -> None: def test_frontend_uses_same_origin_api_proxy_by_default() -> None:
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8") api_client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
nginx_config = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8") nginx_config = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8")
@@ -208,6 +227,45 @@ def test_nginx_runtime_allows_long_ai_and_qa_requests() -> None:
assert "proxy_send_timeout 600s;" in config assert "proxy_send_timeout 600s;" in config
def test_nginx_preserves_outer_https_scheme_for_secure_session_cookies() -> None:
configs = (
(ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8"),
(ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(encoding="utf-8"),
)
for config in configs:
assert "geo $geointel_trusted_forwarder" in config
assert "default 0;" in config
assert "172.16.0.0/12 1;" in config
assert 'map "$geointel_trusted_forwarder:$http_x_forwarded_proto"' in config
assert '"1:https" https;' in config
assert "proxy_set_header X-Forwarded-Proto $geointel_forwarded_proto;" in config
assert "proxy_set_header X-Forwarded-Proto $scheme;" not in config
def test_nginx_runtime_sets_security_headers_on_all_cached_locations() -> None:
configs = (
(ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8"),
(ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(
encoding="utf-8"
),
)
required = (
'Content-Security-Policy "frame-ancestors \'none\'" always;',
'X-Frame-Options "DENY" always;',
'X-Content-Type-Options "nosniff" always;',
'Referrer-Policy "strict-origin-when-cross-origin" always;',
'Permissions-Policy "camera=(), microphone=(), geolocation=()" always;',
)
for config in configs:
cached_locations = config.count("add_header Cache-Control")
assert cached_locations >= 2
for header in required:
# Nginx 1.27 locations with Cache-Control do not inherit server-level
# add_header directives, so every cached location repeats the policy.
assert config.count(f"add_header {header}") == cached_locations + 1
def test_compose_does_not_publish_postgis_on_default_host_port() -> None: def test_compose_does_not_publish_postgis_on_default_host_port() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
@@ -17,7 +17,9 @@ import pytest
np = pytest.importorskip("numpy") np = pytest.importorskip("numpy")
from app.services.flood_hazard_analysis_service import FloodHazardCellStatistics from app.services.flood_hazard_analysis_service import ( # noqa: E402 - optional NumPy gate precedes service import
FloodHazardCellStatistics,
)
NODATA = -9999.0 NODATA = -9999.0
+33 -5
View File
@@ -11,10 +11,20 @@ from fastapi.testclient import TestClient
from app.core.config import Settings from app.core.config import Settings
from app.core.errors import AppError from app.core.errors import AppError
from app.main import app from app.main import app
from app.models import AnalysisRun, Dataset, Detection, Job, Project, SourceRegistry, SourceSnapshot from app.models import (
AnalysisRun,
Dataset,
DatasetVersion,
Detection,
Job,
Project,
SourceRegistry,
SourceSnapshot,
)
from app.services.detection_service import DetectionService from app.services.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
from app.services.tile_manifest_service import TileManifestService
class FakeSession: class FakeSession:
@@ -98,6 +108,8 @@ def _project_and_raster_dataset():
source_name="test-derived-raster", source_name="test-derived-raster",
storage_path="storage/uploads/source.tif", storage_path="storage/uploads/source.tif",
checksum_sha256=checksum, checksum_sha256=checksum,
crs="EPSG:4326",
bounds_json={"min_x": 4.0, "min_y": 51.0, "max_x": 5.0, "max_y": 52.0},
source_registry_id=source_registry_id, source_registry_id=source_registry_id,
source_snapshot_id=source_snapshot_id, source_snapshot_id=source_snapshot_id,
data_contract_key="geointel.raster.geotiff", data_contract_key="geointel.raster.geotiff",
@@ -110,17 +122,22 @@ def _project_and_raster_dataset():
) )
dataset.source_registry = source_registry dataset.source_registry = source_registry
dataset.source_snapshot = source_snapshot dataset.source_snapshot = source_snapshot
dataset.versions.append(
DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1, checksum_sha256=checksum)
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset}) db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id return db, project_id, dataset_id
def _manifest(tmp_path: Path) -> Path: def _manifest(tmp_path: Path, db: FakeSession, dataset: Dataset) -> Path:
tile_path = tmp_path / "tile_0000.tif" tile_path = tmp_path / "tile_0000.tif"
tile_path.write_bytes(b"tile") tile_path.write_bytes(b"tile")
binding = TileManifestService.dataset_binding(db, dataset)
manifest_path = tmp_path / "manifest.json" manifest_path = tmp_path / "manifest.json"
manifest_path.write_text( manifest_path.write_text(
json.dumps( json.dumps(
{ {
**binding,
"tile_set_id": "tiles-fixture", "tile_set_id": "tiles-fixture",
"count": 1, "count": 1,
"crs": "EPSG:4326", "crs": "EPSG:4326",
@@ -133,6 +150,7 @@ def _manifest(tmp_path: Path) -> Path:
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01], "transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"crs": "EPSG:4326", "crs": "EPSG:4326",
"index": 0, "index": 0,
**TileManifestService.tile_integrity(tile_path),
} }
], ],
} }
@@ -226,7 +244,11 @@ def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -
assert asset.size_bytes == len(b"local model") assert asset.size_bytes == len(b"local model")
assert len(asset.sha256) == 64 assert len(asset.sha256) == 64
assert asset.active is True assert asset.active is True
assert asset.status == "approved" assert asset.runtime_available is True
assert asset.runtime_status == "active"
assert asset.governed_validation_status == "not_verified_by_catalog"
assert asset.promotion_status == "not_verified_by_catalog"
assert asset.status == "runtime_active"
assert asset.will_download_models is False assert asset.will_download_models is False
@@ -257,7 +279,10 @@ def test_model_asset_catalog_only_exposes_explicit_active_asset_in_runtime(tmp_p
assert response.total == 1 assert response.total == 1
assert response.items[0].filename == active_file.name assert response.items[0].filename == active_file.name
assert response.items[0].active is True assert response.items[0].active is True
assert response.items[0].status == "approved" assert response.items[0].runtime_status == "active"
assert response.items[0].governed_validation_status == "not_verified_by_catalog"
assert response.items[0].promotion_status == "not_verified_by_catalog"
assert response.items[0].status == "runtime_active"
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None: def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
@@ -284,6 +309,9 @@ def test_model_assets_api_returns_canonical_envelope(monkeypatch, tmp_path: Path
assert payload["data"]["total"] == 1 assert payload["data"]["total"] == 1
assert payload["data"]["items"][0]["model_asset_id"] == "building-detector-pt" assert payload["data"]["items"][0]["model_asset_id"] == "building-detector-pt"
assert payload["data"]["items"][0]["active"] is True assert payload["data"]["items"][0]["active"] is True
assert payload["data"]["items"][0]["runtime_status"] == "active"
assert payload["data"]["items"][0]["governed_validation_status"] == "not_verified_by_catalog"
assert payload["data"]["items"][0]["promotion_status"] == "not_verified_by_catalog"
assert payload["data"]["items"][0]["will_download_models"] is False assert payload["data"]["items"][0]["will_download_models"] is False
@@ -309,7 +337,7 @@ def test_detection_run_persists_selected_model_asset_parameters(tmp_path, monkey
model_id="yolo-configured", model_id="yolo-configured",
model_asset_id="building-detector-pt", model_asset_id="building-detector-pt",
confidence_threshold=0.5, confidence_threshold=0.5,
tile_manifest_path=str(_manifest(tmp_path)), tile_manifest_path=str(_manifest(tmp_path, db, db.get(Dataset, dataset_id))),
settings=settings, settings=settings,
yolo_adapter_class=MockYoloAdapter, yolo_adapter_class=MockYoloAdapter,
) )
+3 -3
View File
@@ -18,10 +18,10 @@ import pytest
np = pytest.importorskip("numpy") np = pytest.importorskip("numpy")
rasterio = pytest.importorskip("rasterio") rasterio = pytest.importorskip("rasterio")
from rasterio.transform import from_origin from rasterio.transform import from_origin # noqa: E402 - optional rasterio gate precedes imports
from shapely.geometry import box from shapely.geometry import box # noqa: E402 - optional rasterio gate precedes imports
from app.services.raster_cell_selection import select_cells from app.services.raster_cell_selection import select_cells # noqa: E402 - optional rasterio gate precedes service import
# 100 m cells, origin at the top-left corner of a 3x3 grid. # 100 m cells, origin at the top-left corner of a 3x3 grid.
+101 -2
View File
@@ -1,14 +1,16 @@
from __future__ import annotations from __future__ import annotations
from types import ModuleType, SimpleNamespace from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
from pathlib import Path from pathlib import Path
import importlib import importlib
from hashlib import sha256
from geoalchemy2.shape import from_shape from geoalchemy2.shape import from_shape
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Area, Dataset, DatasetVersion from app.models import Area, Dataset, DatasetVersion
from app.services.raster_operations_service import RasterOperationsService from app.services.raster_operations_service import RasterOperationsService
from app.services.storage_service import StorageService
from app.api.routes.datasets import _run_job_sync from app.api.routes.datasets import _run_job_sync
from shapely.geometry import box from shapely.geometry import box
import pytest import pytest
@@ -688,6 +690,104 @@ def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None:
assert payload["manifest"]["tile_server"] is None assert payload["manifest"]["tile_server"] is None
@pytest.mark.parametrize(
("dimension", "expected"),
[
(512, [0]),
(513, [0, 1]),
(960, [0, 448]),
(961, [0, 448, 449]),
],
)
def test_raster_tile_offsets_use_full_tiles_and_one_unique_edge_start(dimension, expected) -> None:
assert RasterOperationsService._tile_offsets(dimension, tile_size=512, step=448) == expected
def test_raster_tile_rejects_limit_before_creating_output(monkeypatch, tmp_path) -> None:
project_id = uuid4()
dataset_id = uuid4()
source = tmp_path / "large-raster.tif"
source.write_bytes(b"source")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="large-raster.tif",
dataset_type="raster",
source="user_upload",
storage_path=str(source),
original_filename="large-raster.tif",
stored_filename="large-raster.tif",
content_type="image/tiff",
size_bytes=6,
)
db = FakeSession([dataset])
class FakeSource:
width = 2048
height = 2048
count = 1
crs = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return None
fake_rasterio = SimpleNamespace(open=lambda _path: FakeSource())
monkeypatch.setattr(
"app.services.raster_operations_service._import_rasterio",
lambda: (fake_rasterio, SimpleNamespace()),
)
tile_root = tmp_path / "tiles-that-must-not-exist"
monkeypatch.setattr(
StorageService,
"raster_tiles_root",
staticmethod(lambda *_args: tile_root),
)
with pytest.raises(AppError) as error:
RasterOperationsService.tile(db, dataset_id, tile_size=512, overlap=64, max_tiles=1)
assert error.value.code == "RASTER_TILE_LIMIT_EXCEEDED"
assert error.value.details == {"expected_tile_count": 25, "max_tiles": 1}
assert not tile_root.exists()
def test_raster_tile_rejects_changed_source_bytes_before_creating_output(monkeypatch, tmp_path) -> None:
project_id = uuid4()
dataset_id = uuid4()
source = tmp_path / "changed-raster.tif"
source.write_bytes(b"changed")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="changed-raster.tif",
dataset_type="raster",
source="user_upload",
storage_path=str(source),
original_filename="changed-raster.tif",
stored_filename="changed-raster.tif",
content_type="image/tiff",
size_bytes=7,
checksum_sha256=sha256(b"original").hexdigest(),
data_contract_key="raster.generic",
)
db = FakeSession([dataset])
tile_root = tmp_path / "tiles-that-must-not-exist"
monkeypatch.setattr(
StorageService,
"raster_tiles_root",
staticmethod(lambda *_args: tile_root),
)
with pytest.raises(AppError) as error:
RasterOperationsService.tile(db, dataset_id)
assert error.value.code == "DATASET_STORAGE_CHECKSUM_MISMATCH"
assert not tile_root.exists()
def test_raster_clip_persists_derived_dataset(monkeypatch, tmp_path) -> None: def test_raster_clip_persists_derived_dataset(monkeypatch, tmp_path) -> None:
project_id = uuid4() project_id = uuid4()
dataset_id = uuid4() dataset_id = uuid4()
@@ -1393,4 +1493,3 @@ def test_run_job_sync_serializes_index_job_output_dataset_id(monkeypatch) -> Non
assert result["job_type"] == "raster.ndvi" assert result["job_type"] == "raster.ndvi"
assert result["output_dataset_id"] == str(output_dataset_id) assert result["output_dataset_id"] == str(output_dataset_id)
assert result["result_json"]["output_dataset_id"] == str(output_dataset_id) assert result["result_json"]["output_dataset_id"] == str(output_dataset_id)
+212 -1
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import hashlib import hashlib
import importlib.util import importlib.util
import json import json
import os
import subprocess
import sys import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -18,6 +20,8 @@ SCRIPTS = ROOT / "scripts"
def load_script(name: str): def load_script(name: str):
path = SCRIPTS / name path = SCRIPTS / name
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
spec = importlib.util.spec_from_file_location(f"rc10_{path.stem}", path) spec = importlib.util.spec_from_file_location(f"rc10_{path.stem}", path)
assert spec is not None and spec.loader is not None assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
@@ -36,6 +40,9 @@ def write_backup(root: Path, *, created_at: datetime, inventory_mode: str = "sha
"database_password_secure": True, "database_password_secure": True,
"inventory_mode": inventory_mode, "inventory_mode": inventory_mode,
"storage_inventory_requested": True, "storage_inventory_requested": True,
"storage_snapshot_requested": True,
"models_inventory_requested": False,
"models_snapshot_requested": False,
"git_commit": "0123456789abcdef", "git_commit": "0123456789abcdef",
} }
files = { files = {
@@ -48,6 +55,7 @@ def write_backup(root: Path, *, created_at: datetime, inventory_mode: str = "sha
} }
for name, content in files.items(): for name, content in files.items():
(root / name).write_text(content, encoding="utf-8") (root / name).write_text(content, encoding="utf-8")
(root / "storage-snapshot").mkdir()
checksums = [] checksums = []
for name in sorted(files): for name in sorted(files):
digest = hashlib.sha256((root / name).read_bytes()).hexdigest() digest = hashlib.sha256((root / name).read_bytes()).hexdigest()
@@ -337,8 +345,11 @@ def test_cleanup_commands_require_backup_confirmation_and_read_only_mount() -> N
readiness = (SCRIPTS / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (SCRIPTS / "run_readiness_check.sh").read_text(encoding="utf-8")
live_audit = (SCRIPTS / "run_rc10_data_operations_audit.sh").read_text(encoding="utf-8") live_audit = (SCRIPTS / "run_rc10_data_operations_audit.sh").read_text(encoding="utf-8")
assert "DELETE_STORAGE_ARTIFACTS" in generic assert "QUARANTINE_STORAGE_ARTIFACTS" in generic
assert "verify_current_backup" in generic assert "verify_current_backup" in generic
assert "os.link" in generic
assert 'entry["status"] = "linked"' in generic
assert "cleanup-quarantine" in generic
assert "DELETE_DEMO_EXPORTS" in demo assert "DELETE_DEMO_EXPORTS" in demo
assert "verify_current_backup" in demo assert "verify_current_backup" in demo
assert "/app/backups:ro" in compose assert "/app/backups:ro" in compose
@@ -347,6 +358,8 @@ def test_cleanup_commands_require_backup_confirmation_and_read_only_mount() -> N
"release_backup_guard.py", "release_backup_guard.py",
"audit_data_operations.py", "audit_data_operations.py",
"cleanup_storage_artifacts.py", "cleanup_storage_artifacts.py",
"restore_storage_quarantine.py",
"release_backup_snapshot.py",
): ):
assert f"COPY scripts/{name}" in dockerfile assert f"COPY scripts/{name}" in dockerfile
assert f"py_compile scripts/{name}" in readiness assert f"py_compile scripts/{name}" in readiness
@@ -356,3 +369,201 @@ def test_cleanup_commands_require_backup_confirmation_and_read_only_mount() -> N
assert "table-counts-after.tsv" in live_audit assert "table-counts-after.tsv" in live_audit
assert "deleted_count" in live_audit assert "deleted_count" in live_audit
assert "missing_manifest_artifact_count" in live_audit assert "missing_manifest_artifact_count" in live_audit
def test_cleanup_apply_moves_bytes_to_protected_traceable_quarantine(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.syspath_prepend(str(SCRIPTS))
cleanup = load_script("cleanup_storage_artifacts.py")
storage = tmp_path / "storage"
source = storage / "derived" / "orphan.bin"
source.parent.mkdir(parents=True)
source.write_bytes(b"recoverable-derived-artifact")
candidate = SimpleNamespace(
path=source.resolve(),
relative_path="derived/orphan.bin",
size_bytes=source.stat().st_size,
)
now = datetime.now(timezone.utc)
class SessionContext:
def __enter__(self):
return SimpleNamespace()
def __exit__(self, *_args):
return False
monkeypatch.setattr(
cleanup,
"parse_args",
lambda: SimpleNamespace(
storage_root=storage,
minimum_age_days=7,
max_delete=1,
apply=True,
confirm="QUARANTINE_STORAGE_ARTIFACTS",
backup_dir=tmp_path / "backup",
backup_max_age_hours=24.0,
quarantine_root=None,
),
)
monkeypatch.setattr(cleanup, "SessionLocal", lambda: SessionContext())
monkeypatch.setattr(
cleanup,
"build_report",
lambda *_args, **_kwargs: ({"cleanup": {"protected_prefixes": []}}, [candidate]),
)
monkeypatch.setattr(
cleanup,
"verify_current_backup",
lambda *_args, **_kwargs: SimpleNamespace(
release_id="predeploy-test",
created_at=now,
age_hours=0.1,
backup_tool_revision="0123456789abcdef",
),
)
assert cleanup.main() == 0
payload = json.loads(capsys.readouterr().out)
manifest_path = Path(payload["quarantine_manifest"])
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
quarantined_path = storage / payload["quarantined"][0]["quarantine_relative_path"]
assert not source.exists()
assert quarantined_path.read_bytes() == b"recoverable-derived-artifact"
assert manifest["state"] == "complete"
assert manifest["backup_release_id"] == "predeploy-test"
assert manifest["entries"][0]["status"] == "quarantined"
assert payload["deleted_count"] == 0
restore = subprocess.run(
[
sys.executable,
str(SCRIPTS / "restore_storage_quarantine.py"),
"--storage-root",
str(storage),
"--manifest",
str(manifest_path),
"--confirm",
"RESTORE_QUARANTINED_ARTIFACTS",
],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
assert restore.returncode == 0, restore.stderr
assert source.read_bytes() == b"recoverable-derived-artifact"
assert not quarantined_path.exists()
restored_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
assert restored_manifest["state"] == "restored"
assert restored_manifest["entries"][0]["status"] == "restored"
def _write_interrupted_quarantine(
storage: Path,
*,
original_exists: bool,
quarantine_exists: bool,
hard_linked: bool = False,
) -> tuple[Path, Path, Path]:
original = storage / "derived" / "interrupted.bin"
operation = storage / "operator-evidence" / "cleanup-quarantine" / "cleanup-interrupted"
quarantined = operation / "files" / "derived" / "interrupted.bin"
original.parent.mkdir(parents=True, exist_ok=True)
quarantined.parent.mkdir(parents=True, exist_ok=True)
retained = b"interrupted-retained-bytes"
if original_exists:
original.write_bytes(retained)
if quarantine_exists:
if hard_linked:
os.link(original, quarantined)
else:
quarantined.write_bytes(retained)
manifest = operation / "manifest.json"
manifest.write_text(
json.dumps(
{
"schema_version": 1,
"state": "in_progress",
"entries": [
{
"relative_path": "derived/interrupted.bin",
"quarantine_relative_path": quarantined.relative_to(storage).as_posix(),
"size_bytes": len(retained),
"sha256": hashlib.sha256(retained).hexdigest(),
"status": "linked" if hard_linked else "planned",
}
],
}
),
encoding="utf-8",
)
return manifest, original, quarantined
@pytest.mark.parametrize(
("original_exists", "quarantine_exists", "hard_linked"),
((False, True, False), (True, True, True)),
)
def test_quarantine_restore_recovers_each_interrupted_move_window(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
original_exists: bool,
quarantine_exists: bool,
hard_linked: bool,
) -> None:
restore = load_script("restore_storage_quarantine.py")
storage = tmp_path / "storage"
manifest, original, quarantined = _write_interrupted_quarantine(
storage,
original_exists=original_exists,
quarantine_exists=quarantine_exists,
hard_linked=hard_linked,
)
monkeypatch.setattr(
restore,
"parse_args",
lambda: SimpleNamespace(
storage_root=storage,
manifest=manifest,
confirm="RESTORE_QUARANTINED_ARTIFACTS",
),
)
assert restore.main() == 0
assert original.read_bytes() == b"interrupted-retained-bytes"
assert not quarantined.exists()
assert json.loads(manifest.read_text(encoding="utf-8"))["state"] == "restored"
def test_quarantine_restore_never_clobbers_recreated_destination(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
restore = load_script("restore_storage_quarantine.py")
storage = tmp_path / "storage"
manifest, original, quarantined = _write_interrupted_quarantine(
storage,
original_exists=False,
quarantine_exists=True,
)
original.write_bytes(b"new-runtime-bytes")
monkeypatch.setattr(
restore,
"parse_args",
lambda: SimpleNamespace(
storage_root=storage,
manifest=manifest,
confirm="RESTORE_QUARANTINED_ARTIFACTS",
),
)
with pytest.raises(RuntimeError, match="different bytes"):
restore.main()
assert original.read_bytes() == b"new-runtime-bytes"
assert quarantined.read_bytes() == b"interrupted-retained-bytes"
+97 -7
View File
@@ -18,17 +18,16 @@ def test_build_identity_does_not_invalidate_dependency_layers() -> None:
assert 'io.geointel.ai.enabled="${GEOINTEL_INSTALL_AI}"' in dockerfile assert 'io.geointel.ai.enabled="${GEOINTEL_INSTALL_AI}"' in dockerfile
def test_release_deploy_preserves_immutable_and_previous_images() -> None: def test_release_deploy_preserves_immutable_and_backup_specific_rollback_images() -> None:
script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
assert 'GEOINTEL_RELEASE_VARIANT="ai"' in script assert 'GEOINTEL_RELEASE_VARIANT="ai"' in script
assert 'GEOINTEL_RELEASE_VARIANT="gis"' in script assert 'GEOINTEL_RELEASE_VARIANT="gis"' in script
assert 'GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}-${GEOINTEL_RELEASE_VARIANT}"' in script assert 'GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}-${GEOINTEL_RELEASE_VARIANT}"' in script
assert 'GEOINTEL_PREVIOUS_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:previous"' in script assert 'GEOINTEL_PREDEPLOY_ROLLBACK_TAG="${GEOINTEL_IMAGE_REPOSITORY}:rollback-${release_id}"' in script
assert 'release_image_id="$(' in script assert 'docker tag "$current_image_id" "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG"' in script
assert '[ "$current_image_id" != "$release_image_id" ]' in script assert '--rollback-image-tag "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG"' in script
assert 'docker tag "$current_image_id" "$GEOINTEL_PREVIOUS_IMAGE"' in script assert "GEOINTEL_PREVIOUS_IMAGE" not in script
assert "preserving the existing previous image" in script
assert 'if docker image inspect "$GEOINTEL_RELEASE_IMAGE"' in script assert 'if docker image inspect "$GEOINTEL_RELEASE_IMAGE"' in script
assert "Immutable release tag has conflicting metadata" in script assert "Immutable release tag has conflicting metadata" in script
assert "Reusing existing immutable image" in script assert "Reusing existing immutable image" in script
@@ -36,12 +35,80 @@ def test_release_deploy_preserves_immutable_and_previous_images() -> None:
assert "Deployed immutable image" in script assert "Deployed immutable image" in script
def test_release_creates_verified_backup_before_candidate_migrations() -> None:
script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
backup_index = script.index("create_predeploy_backup\n")
scan_index = script.index("scan_release_image\n")
candidate_start_index = script.index('if ! start_image "$GEOINTEL_RELEASE_IMAGE_ID"')
assert scan_index < backup_index
assert backup_index < candidate_start_index
assert script.index("docker build") < backup_index
assert "/mnt/user/appdata/geointel/backups" in script
assert "--inventory-mode sha256" in script
assert "scripts/verify_release_backup.sh" in script
assert "refusing an unbacked migration" in script
assert "Quiescing the current backend" in script
assert "restarting the unchanged current release" in script
assert "preflight_backup_capacity" in script
assert "select_verified_link_dest" in script
assert "release_backup_snapshot.py" in script
assert "run_low_impact()" in script
assert "ionice -c 2 -n 7" in script
assert "nice -n 10" in script
assert "run_low_impact bash scripts/backup_release_state.sh" in script
assert "run_low_impact bash scripts/verify_release_backup.sh" in script
def test_gitea_deploy_waits_for_the_mandatory_large_snapshot() -> None:
workflow = (ROOT / ".gitea" / "workflows" / "release-gates.yml").read_text(encoding="utf-8")
assert "cancel-in-progress: false" in workflow
deploy_job = workflow.split("\n deploy:\n", maxsplit=1)[1]
assert "timeout-minutes: 720" in deploy_job
assert "docker exec gitea-deploy-control" in deploy_job
def test_release_starts_only_the_locally_attested_ai_image() -> None:
script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
assert 'GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-true}"' in script
assert "Production release deployment requires the gated AI image" in script
assert 'bash scripts/generate_container_sbom.sh "$scanned_image_id"' in script
assert 'bash scripts/scan_container_image.sh "$scanned_image_id"' in script
assert 'running_image_id="$(docker inspect --format \'{{.Image}}\' geointel)"' in script
assert 'if [ "$running_image_id" != "$image" ]' in script
assert "deployment-attestation.json" in script
assert "artifacts/release-evidence/deploy" in script
assert "GITEA_COMMIT_SHA" in script
assert "GITHUB_SHA" in script
assert "must contain one full 40-character Git commit SHA" in script
assert 'marker_path="$ROOT/.gitea-deploy/revision"' in script
assert "git rev-parse --show-toplevel" in script
assert "Prepared source revision marker does not match" in script
assert "neither an exact Git checkout nor bound" in script
assert 'running_revision" != "$GEOINTEL_BUILD_SHA"' in script
assert 'running_ai" != "true"' in script
assert '"revision": revision' in script
def test_release_and_container_replacement_are_serialized() -> None: def test_release_and_container_replacement_are_serialized() -> None:
release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8") run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
rollback_script = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(
encoding="utf-8"
)
restore_script = (ROOT / "deploy" / "unraid" / "restore-predeploy-database.sh").read_text(
encoding="utf-8"
)
assert "GEOINTEL_DEPLOY_LOCK_FILE" in release_script assert "GEOINTEL_DEPLOY_LOCK_FILE" in release_script
assert "flock -n 9" in release_script assert "flock -n 9" in release_script
assert "GEOINTEL_DEPLOY_LOCK_FILE" in rollback_script
assert "flock -n 9" in rollback_script
assert "GEOINTEL_DEPLOY_LOCK_FILE" in restore_script
assert "flock -n 9" in restore_script
assert "GEOINTEL_DEPLOY_LOCK_HELD=true" in release_script
assert "GEOINTEL_CONTAINER_LOCK_FILE" in run_script assert "GEOINTEL_CONTAINER_LOCK_FILE" in run_script
assert "flock -w 300 8" in run_script assert "flock -w 300 8" in run_script
assert "GeoIntel container removal did not complete within 60 seconds" in run_script assert "GeoIntel container removal did not complete within 60 seconds" in run_script
@@ -89,13 +156,35 @@ def test_manual_rollback_reuses_persistent_paths_and_requires_existing_image() -
rollback = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(encoding="utf-8") rollback = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(encoding="utf-8")
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8") run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
assert "geointel-all-in-one:previous" in rollback assert "Backup manifest lacks an immutable rollback image ID" in rollback
assert 'get("image_id", "")' in rollback
assert 'docker image inspect "$GEOINTEL_ROLLBACK_IMAGE"' in rollback assert 'docker image inspect "$GEOINTEL_ROLLBACK_IMAGE"' in rollback
assert 'GEOINTEL_IMAGE="$GEOINTEL_ROLLBACK_IMAGE"' in rollback assert 'GEOINTEL_IMAGE="$GEOINTEL_ROLLBACK_IMAGE"' in rollback
assert "restore-predeploy-database.sh" in rollback
assert "--confirm-production-database-restore" in rollback
assert "Image-only rollback" in rollback
assert '-v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data"' in run_script assert '-v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data"' in run_script
assert '-v "${GEOINTEL_STORAGE_PATH}:/app/storage"' in run_script assert '-v "${GEOINTEL_STORAGE_PATH}:/app/storage"' in run_script
def test_same_revision_redeploy_rolls_back_by_backup_bound_image_id() -> None:
release = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8")
rollback = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(
encoding="utf-8"
)
restore = (ROOT / "deploy" / "unraid" / "restore-predeploy-database.sh").read_text(
encoding="utf-8"
)
backup = (ROOT / "scripts" / "backup_release_state.sh").read_text(encoding="utf-8")
assert "current_image_id" in release
assert "release_image_id" not in release
assert '"rollback_image_tag": ${ROLLBACK_IMAGE_TAG@Q} or None' in backup
assert 'if [ -z "$RESTORE_IMAGE" ]; then\n RESTORE_IMAGE="$BACKUP_IMAGE_ID"' in restore
assert 'GEOINTEL_ROLLBACK_IMAGE="$(python3 - "$BACKUP_DIR/manifest.json"' in rollback
assert "geointel-all-in-one:previous" not in release + rollback + restore
def test_readiness_checks_all_release_shell_entrypoints() -> None: def test_readiness_checks_all_release_shell_entrypoints() -> None:
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
@@ -107,6 +196,7 @@ def test_readiness_checks_all_release_shell_entrypoints() -> None:
"deploy/unraid/run-dockerman-container.sh", "deploy/unraid/run-dockerman-container.sh",
"deploy/unraid/deploy-release.sh", "deploy/unraid/deploy-release.sh",
"deploy/unraid/rollback-dockerman-container.sh", "deploy/unraid/rollback-dockerman-container.sh",
"deploy/unraid/restore-predeploy-database.sh",
): ):
assert f"bash -n {path}" in readiness assert f"bash -n {path}" in readiness
+76 -5
View File
@@ -48,25 +48,95 @@ def test_ci_runs_complete_release_and_supply_chain_gates() -> None:
assert "pip-audit==2.10.1" in workflow assert "pip-audit==2.10.1" in workflow
assert "audit_python_dependencies.sh" in workflow assert "audit_python_dependencies.sh" in workflow
assert "npm audit --audit-level=high" in workflow assert "npm audit --audit-level=high" in workflow
assert "GEOINTEL_INSTALL_AI=false" in workflow assert "GEOINTEL_INSTALL_AI=true" in workflow
assert "geointel-ci:$RELEASE_SHA-ai" in workflow
assert "artifacts/image-id.txt" in workflow
assert 'scan_container_image.sh "$IMAGE_ID"' in workflow
assert "generate_container_sbom.sh" in workflow assert "generate_container_sbom.sh" in workflow
assert "scan_container_image.sh" in workflow assert "scan_container_image.sh" in workflow
assert "actions/upload-artifact@v4" in workflow assert "GEOINTEL_IMAGE_ARCHIVE: artifacts/geointel-image.tar" in workflow
assert "Remove temporary image archive" in workflow
assert context in workflow assert context in workflow
assert (
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"
in read(".github/workflows/release-gates.yml")
)
assert (
"actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de"
in read(".gitea/workflows/release-gates.yml")
)
def test_gitea_production_deploy_depends_on_every_release_gate() -> None:
release = read(".gitea/workflows/release-gates.yml")
legacy_deploy = ROOT / ".gitea" / "workflows" / "unraid-deploy.yml"
assert "pull_request:" in release
assert "needs: [quality, dependency-audit, container]" in release
assert "gitea.event_name == 'push'" in release
assert "gitea.ref == 'refs/heads/main'" in release
assert "/opt/gitea-deploy/deploy.py deploy" in release
assert not legacy_deploy.exists()
assert "workflow_dispatch:" in release
def test_release_workflows_pin_third_party_actions_to_reviewed_commits() -> None:
shared = (
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
"actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065",
"actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020",
)
for path in (".gitea/workflows/release-gates.yml", ".github/workflows/release-gates.yml"):
workflow = read(path)
for action in shared:
assert action in workflow
assert (
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"
in read(".github/workflows/release-gates.yml")
)
assert (
"actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de"
in read(".gitea/workflows/release-gates.yml")
)
def test_managed_validation_targets_the_actual_backend_and_frontend_projects() -> None:
workflow = read(".gitea/workflows/managed-validation.yml")
assert "backend/requirements-ci.lock" in workflow
assert "frontend/package-lock.json" in workflow
assert "python -m pytest -W error::DeprecationWarning" in workflow
assert "cd frontend && npm run test:unit" in workflow
assert "python -m ruff check backend scripts tests" in workflow
assert "python scripts/verify_repository_layout.py" in workflow
assert "[[ -f pyproject.toml" not in workflow
def test_scanner_images_are_versioned_and_digest_pinned() -> None: def test_scanner_images_are_versioned_and_digest_pinned() -> None:
sbom = read("scripts/generate_container_sbom.sh") sbom = read("scripts/generate_container_sbom.sh")
scan = read("scripts/scan_container_image.sh") scan = read("scripts/scan_container_image.sh")
assert "anchore/syft:v1.44.0@sha256:" in sbom assert "anchore/syft:v1.44.0@sha256:" in sbom
assert "--user 0:0" in sbom
assert 'docker save "$IMAGE_ID"' in sbom
assert '"docker-archive:$WORKDIR/$IMAGE_ARCHIVE"' in sbom
assert 'SYFT_PARALLELISM=${SYFT_PARALLELISM:-1}' in sbom
assert "--select-catalogers=-binary" in sbom
assert '--volumes-from "$HOSTNAME"' in sbom
assert 'ARCHIVE_ID_FILE="${IMAGE_ARCHIVE}.image-id"' in sbom
assert "/var/run/docker.sock" not in sbom
assert "aquasec/trivy:0.70.0@sha256:" in scan assert "aquasec/trivy:0.70.0@sha256:" in scan
assert "--severity HIGH,CRITICAL" in scan assert "--severity HIGH,CRITICAL" in scan
assert "--ignore-unfixed" in scan assert "--ignore-unfixed" in scan
assert "--timeout 20m" in scan assert "--timeout 20m" in scan
assert "--scanners vuln" in scan assert "--scanners vuln" in scan
assert '-v "$IGNORE_FILE:$CONTAINER_IGNORE_FILE:ro"' in scan assert 'docker save "$IMAGE_ID"' in scan
assert '--input "$WORKDIR/$IMAGE_ARCHIVE"' in scan
assert '--volumes-from "$HOSTNAME"' in scan
assert '--ignorefile "$CONTAINER_IGNORE_FILE"' in scan assert '--ignorefile "$CONTAINER_IGNORE_FILE"' in scan
assert "/var/run/docker.sock" not in scan
assert "--skip-files /usr/local/bin/gosu" in scan assert "--skip-files /usr/local/bin/gosu" in scan
assert "final filesystem replaces it with the audited setpriv shell wrapper" in scan assert "final filesystem replaces it with the audited setpriv shell wrapper" in scan
assert "geointel-container-vulnerabilities.json" in scan assert "geointel-container-vulnerabilities.json" in scan
@@ -86,11 +156,12 @@ def test_readiness_guards_lock_and_supply_chain_entrypoints() -> None:
assert f"bash -n {path}" in readiness assert f"bash -n {path}" in readiness
def test_python_audit_exceptions_are_timeboxed_and_full_evidence_is_kept() -> None: def test_python_audit_policy_has_no_active_exceptions_and_keeps_full_evidence() -> None:
policy = read("security/pip-audit-exceptions.json") policy = read("security/pip-audit-exceptions.json")
audit_script = read("scripts/audit_python_dependencies.sh") audit_script = read("scripts/audit_python_dependencies.sh")
assert '"review_by": "2026-08-31"' in policy assert '"schema_version": 1' in policy
assert '"advisories": []' in policy
assert "pip-audit-full.json" in audit_script assert "pip-audit-full.json" in audit_script
assert "pip-audit-policy.json" in audit_script assert "pip-audit-policy.json" in audit_script
assert "--ignore-vuln" in audit_script assert "--ignore-vuln" in audit_script
@@ -20,12 +20,35 @@ def test_backup_is_atomic_read_only_and_checksum_bound() -> None:
assert "--no-owner" in script assert "--no-owner" in script
assert "CHECKSUMS.sha256" in script assert "CHECKSUMS.sha256" in script
assert "database-password" not in script.lower() assert "database-password" not in script.lower()
assert 'git -C "$ROOT" rev-parse HEAD' in script assert 'for required in docker python3 sha256sum; do' in script
assert 'git -C "$ROOT" status --porcelain=v1' in script assert 'for required in docker python3 sha256sum git; do' not in script
assert "GITEA_COMMIT_SHA" in script
assert "GITHUB_SHA" in script
assert "GEOINTEL_BUILD_SHA" in script
assert 'if command -v git >/dev/null 2>&1' in script
assert "mv \"$PARTIAL\" \"$FINAL\"" in script assert "mv \"$PARTIAL\" \"$FINAL\"" in script
assert "rm -rf -- \"$PARTIAL\"" in script assert "rm -rf -- \"$PARTIAL\"" in script
assert "DROP DATABASE" not in script assert "DROP DATABASE" not in script
assert "pg_restore --clean" not in script assert "pg_restore --clean" not in script
assert "/mnt/user/appdata/geointel/backups" in script
assert "release_backup_snapshot.py" in script
assert "storage-snapshot" not in script # labels are composed without unsafe path interpolation
assert "--link-dest-backup" in script
assert "--rollback-image-tag" in script
assert '"rollback_image_tag": ${ROLLBACK_IMAGE_TAG@Q} or None' in script
def test_backup_binds_prepared_source_without_requiring_dot_git() -> None:
script = read("backup_release_state.sh")
controller_resolution = script.index('local gitea_sha="${GITEA_COMMIT_SHA:-}"')
optional_git_fallback = script.index('if command -v git >/dev/null 2>&1')
docker_access = script.index("docker inspect -f '{{.State.Running}}'")
assert controller_resolution < optional_git_fallback < docker_access
assert 'SOURCE_REVISION="$explicit_sha"' in script
assert '"backup_tool_revision": ${SOURCE_REVISION@Q}' in script
assert '"running_image_revision": ${RUNNING_IMAGE_REVISION@Q}' in script
assert "Cannot bind backup to a source revision" in script
def test_backup_verification_is_read_only() -> None: def test_backup_verification_is_read_only() -> None:
@@ -55,9 +78,11 @@ def test_release_safety_scripts_have_valid_bash_syntax() -> None:
"backup_release_state.sh", "backup_release_state.sh",
"verify_release_backup.sh", "verify_release_backup.sh",
"restore_release_backup_smoke.sh", "restore_release_backup_smoke.sh",
"../deploy/unraid/restore-predeploy-database.sh",
): ):
script_path = f"scripts/{name}" if not name.startswith("../") else name.removeprefix("../")
result = subprocess.run( result = subprocess.run(
["bash", "-n", f"scripts/{name}"], ["bash", "-n", script_path],
cwd=ROOT, cwd=ROOT,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -66,6 +91,30 @@ def test_release_safety_scripts_have_valid_bash_syntax() -> None:
assert result.returncode == 0, f"{name}: {result.stderr}" assert result.returncode == 0, f"{name}: {result.stderr}"
def test_production_restore_is_explicit_bounded_and_verified() -> None:
script = (ROOT / "deploy" / "unraid" / "restore-predeploy-database.sh").read_text(
encoding="utf-8"
)
assert "--confirm-production-database-restore" in script
assert "/mnt/user/appdata/geointel/backups" in script
assert "backup.relative_to(root)" in script
assert "sha256sum -c CHECKSUMS.sha256" in script
assert '"$RESTORE_PROOF_DB"' in script
assert "pg_restore" in script
assert "Restored Alembic head" in script
assert "Restored count mismatch" in script
assert "pg_restore --clean" not in script
assert "geointel_restore_proof_" in script
assert "Isolated predeploy restore proof passed" in script
assert "ALTER DATABASE" in script
assert "Pre-restore production database retained" in script
drop_start = script.index("dropdb --if-exists --force")
drop_command = script[drop_start : script.index("\n fi", drop_start)]
assert '"$RESTORE_PROOF_DB"' in drop_command
assert '"$GEOINTEL_POSTGRES_DB"' not in drop_command
def test_readiness_gate_checks_release_safety_scripts() -> None: def test_readiness_gate_checks_release_safety_scripts() -> None:
readiness = read("run_readiness_check.sh") readiness = read("run_readiness_check.sh")
+3 -2
View File
@@ -1,11 +1,12 @@
from pathlib import Path from pathlib import Path
def test_backend_keeps_starlette_on_the_supported_pre_httpx2_line() -> None: def test_backend_uses_patched_starlette_and_explicit_httpx2_test_client() -> None:
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
content = pyproject.read_text(encoding="utf-8") content = pyproject.read_text(encoding="utf-8")
assert '"starlette>=0.46.0,<1.0.0"' in content assert '"starlette>=1.3.1,<2.0.0"' in content
assert '"httpx2>=2.0.0"' in content
def test_readiness_gate_treats_deprecation_warnings_as_errors() -> None: def test_readiness_gate_treats_deprecation_warnings_as_errors() -> None:
@@ -0,0 +1,83 @@
from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "release_backup_snapshot.py"
def load_snapshot_module():
spec = importlib.util.spec_from_file_location("release_backup_snapshot_test", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_snapshot_is_byte_complete_and_reuses_only_verified_backup_bytes(tmp_path: Path) -> None:
snapshot = load_snapshot_module()
source = tmp_path / "source"
source.mkdir()
(source / "same.bin").write_bytes(b"unchanged")
(source / "changed.bin").write_bytes(b"before")
(source / "empty").mkdir()
prior = tmp_path / "prior"
prior_manifest = tmp_path / "prior.tsv"
snapshot.create_snapshot(source, prior, prior_manifest, label="storage")
snapshot.verify_snapshot(prior, prior_manifest)
(source / "changed.bin").write_bytes(b"after")
current = tmp_path / "current"
current_manifest = tmp_path / "current.tsv"
snapshot.create_snapshot(
source,
current,
current_manifest,
label="storage",
link_dest_snapshot=prior,
link_dest_manifest=prior_manifest,
)
snapshot.verify_snapshot(current, current_manifest)
assert os.path.samefile(prior / "same.bin", current / "same.bin")
assert not os.path.samefile(prior / "changed.bin", current / "changed.bin")
assert (current / "changed.bin").read_bytes() == b"after"
assert (current / "empty").is_dir()
def test_snapshot_rejects_symlinked_content(tmp_path: Path) -> None:
snapshot = load_snapshot_module()
source = tmp_path / "source"
source.mkdir()
target = source / "target.bin"
target.write_bytes(b"target")
try:
(source / "link.bin").symlink_to(target)
except OSError:
pytest.skip("Symlink creation is unavailable on this host")
with pytest.raises(RuntimeError, match="refuses symlinked content"):
snapshot.create_snapshot(source, tmp_path / "snapshot", tmp_path / "manifest.tsv", label="storage")
def test_snapshot_verification_rejects_changed_retained_bytes(tmp_path: Path) -> None:
snapshot = load_snapshot_module()
source = tmp_path / "source"
source.mkdir()
(source / "artifact.bin").write_bytes(b"retained")
retained = tmp_path / "snapshot"
manifest = tmp_path / "manifest.tsv"
snapshot.create_snapshot(source, retained, manifest, label="storage")
(retained / "artifact.bin").chmod(0o644)
(retained / "artifact.bin").write_bytes(b"tampered")
with pytest.raises(RuntimeError, match="checksum differs"):
snapshot.verify_snapshot(retained, manifest)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "verify_repository_layout.py"
SPEC = importlib.util.spec_from_file_location("verify_repository_layout", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def test_nested_repository_mirror_is_absent() -> None:
assert MODULE.nested_mirror_markers(ROOT) == []
def test_nested_repository_mirror_is_detected(tmp_path: Path) -> None:
for marker in MODULE.CANONICAL_MARKERS:
path = tmp_path / "geointel" / marker
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fixture", encoding="utf-8")
assert MODULE.nested_mirror_markers(tmp_path) == list(MODULE.CANONICAL_MARKERS)
+21 -2
View File
@@ -1,3 +1,4 @@
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
@@ -6,8 +7,16 @@ from app.main import app
client = TestClient(app) client = TestClient(app)
def test_invalid_host_request_target_is_rejected_canonically() -> None: @pytest.mark.parametrize(
response = client.get("/health/live", headers={"host": "trusted.example/@admin"}) "host",
[
"trusted.example/@admin",
"trusted.example?shadow=admin",
"trusted.example#shadow",
],
)
def test_invalid_host_request_target_is_rejected_canonically(host: str) -> None:
response = client.get("/health/live", headers={"host": host})
assert response.status_code == 400 assert response.status_code == 400
assert response.headers["x-request-id"] assert response.headers["x-request-id"]
@@ -15,6 +24,16 @@ def test_invalid_host_request_target_is_rejected_canonically() -> None:
assert response.json()["request_id"] == response.headers["x-request-id"] assert response.json()["request_id"] == response.headers["x-request-id"]
@pytest.mark.parametrize(
"host",
["localhost:1202", "127.0.0.1:8000", "[::1]:8000", "testserver"],
)
def test_normal_host_forms_remain_available(host: str) -> None:
response = client.get("/health/live", headers={"host": host})
assert response.status_code == 200
def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None: def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None:
response = client.post( response = client.post(
"/api/v1/datasets/upload", "/api/v1/datasets/upload",
@@ -9,11 +9,12 @@ import pytest
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
from app.core.config import Settings from app.core.config import Settings
from app.models import Dataset, Project, Segmentation, SourceRegistry, SourceSnapshot from app.models import Dataset, DatasetVersion, Project, Segmentation, SourceRegistry, SourceSnapshot
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
from app.services.model_registry_service import ModelRegistryService from app.services.model_registry_service import ModelRegistryService
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
from app.services.segmentation_service import SegmentationService from app.services.segmentation_service import SegmentationService
from app.services.tile_manifest_service import TileManifestService
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -122,6 +123,8 @@ def _project_and_dataset(dataset_type: str = "raster"):
source_name="digitaal_vlaanderen_orthophoto", source_name="digitaal_vlaanderen_orthophoto",
storage_path="storage/uploads/ortho.tif", storage_path="storage/uploads/ortho.tif",
checksum_sha256=checksum, checksum_sha256=checksum,
crs="EPSG:4326",
bounds_json={"min_x": 4.0, "min_y": 51.0, "max_x": 5.0, "max_y": 52.0},
source_registry_id=source_id, source_registry_id=source_id,
source_snapshot_id=snapshot_id, source_snapshot_id=snapshot_id,
data_contract_key="geointel.raster.geotiff", data_contract_key="geointel.raster.geotiff",
@@ -134,6 +137,9 @@ def _project_and_dataset(dataset_type: str = "raster"):
) )
dataset.source_registry = source dataset.source_registry = source
dataset.source_snapshot = snapshot dataset.source_snapshot = snapshot
dataset.versions.append(
DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1, checksum_sha256=checksum)
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset}) db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id return db, project_id, dataset_id
@@ -249,28 +255,36 @@ def _write_configured_model_sidecars(
) )
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path: def _manifest(
tmp_path: Path,
tile_count: int = 1,
*,
db: FakeSession | None = None,
dataset: Dataset | None = None,
) -> Path:
tiles = [] tiles = []
for index in range(tile_count): for index in range(tile_count):
tile_path = tmp_path / f"tile_{index:04d}.tif" tile_path = tmp_path / f"tile_{index:04d}.tif"
tile_path.write_bytes(b"fixture") tile_path.write_bytes(b"fixture")
tiles.append( tile = {
{
"path": str(tile_path), "path": str(tile_path),
"pixel_window": [0, 0, 100, 100], "pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0], "bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01], "transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"crs": "EPSG:4326", "crs": "EPSG:4326",
"index": index, "index": index,
**TileManifestService.tile_integrity(tile_path),
} }
) tiles.append(tile)
binding = TileManifestService.dataset_binding(db or FakeSession(), dataset) if dataset is not None else {}
manifest_path = tmp_path / "manifest.json" manifest_path = tmp_path / "manifest.json"
manifest_path.write_text( manifest_path.write_text(
json.dumps( json.dumps(
{ {
**binding,
"tile_set_id": "tiles-fixture", "tile_set_id": "tiles-fixture",
"source_dataset_id": str(uuid4()), "source_dataset_id": binding.get("source_dataset_id", str(uuid4())),
"source_raster_id": str(uuid4()), "source_raster_id": binding.get("source_raster_id", str(uuid4())),
"crs": "EPSG:4326", "crs": "EPSG:4326",
"bounds": [4.0, 51.0, 5.0, 52.0], "bounds": [4.0, 51.0, 5.0, 52.0],
"tile_size": 100, "tile_size": 100,
@@ -424,7 +438,9 @@ def test_configured_segmentation_rejects_unbound_model_snapshot_before_adapter_l
dataset_id=dataset_id, dataset_id=dataset_id,
model_id="yolo-seg-configured", model_id="yolo-seg-configured",
confidence_threshold=0.5, confidence_threshold=0.5,
tile_manifest_path=str(_manifest(tmp_path)), tile_manifest_path=str(
_manifest(tmp_path, db=db, dataset=db.get(Dataset, dataset_id))
),
settings=settings, settings=settings,
yolo_seg_adapter_class=NeverLoadSegAdapter, yolo_seg_adapter_class=NeverLoadSegAdapter,
sam_adapter_class=ClassAgnosticSamAdapter, sam_adapter_class=ClassAgnosticSamAdapter,
@@ -440,7 +456,7 @@ def test_configured_yolo_seg_run_persists_georeferenced_masks(tmp_path: Path) ->
db, project_id, dataset_id = _project_and_dataset() db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path) settings = _settings(tmp_path)
_write_configured_model_sidecars(tmp_path, settings, include_sam=False, db=db) _write_configured_model_sidecars(tmp_path, settings, include_sam=False, db=db)
manifest_path = _manifest(tmp_path) manifest_path = _manifest(tmp_path, db=db, dataset=db.get(Dataset, dataset_id))
response = SegmentationService.run_segmentation( response = SegmentationService.run_segmentation(
db=db, db=db,
@@ -479,7 +495,7 @@ def test_configured_sam_run_is_class_agnostic(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset() db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path) settings = _settings(tmp_path)
_write_configured_model_sidecars(tmp_path, settings, include_yolo=False, db=db) _write_configured_model_sidecars(tmp_path, settings, include_yolo=False, db=db)
manifest_path = _manifest(tmp_path) manifest_path = _manifest(tmp_path, db=db, dataset=db.get(Dataset, dataset_id))
response = SegmentationService.run_segmentation( response = SegmentationService.run_segmentation(
db=db, db=db,
@@ -15,14 +15,18 @@ import pytest
np = pytest.importorskip("numpy") np = pytest.importorskip("numpy")
rasterio = pytest.importorskip("rasterio") rasterio = pytest.importorskip("rasterio")
from pyproj import Transformer from pyproj import Transformer # noqa: E402 - optional rasterio gate precedes geospatial imports
from rasterio.transform import from_origin from rasterio.transform import from_origin # noqa: E402 - optional rasterio gate precedes geospatial imports
from app.core.config import Settings from app.core.config import Settings # noqa: E402 - optional rasterio gate precedes app imports
from app.models import Dataset from app.models import Dataset # noqa: E402 - optional rasterio gate precedes app imports
from app.schemas.flood_hazard import FloodHazardSelectionRequest from app.schemas.flood_hazard import FloodHazardSelectionRequest # noqa: E402 - optional rasterio gate precedes app imports
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService from app.services.flood_hazard_acquisition_service import ( # noqa: E402 - optional rasterio gate precedes app imports
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService FloodHazardAcquisitionService,
)
from app.services.flood_hazard_analysis_service import ( # noqa: E402 - optional rasterio gate precedes app imports
FloodHazardAnalysisService,
)
TO_4326 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) TO_4326 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
@@ -14,7 +14,10 @@ def test_raster_tile_manifest_can_handoff_to_segmentation_lab() -> None:
assert "segmentationTileManifestPath" in hook assert "segmentationTileManifestPath" in hook
assert "setSegmentationTileManifestPath" in hook assert "setSegmentationTileManifestPath" in hook
assert "tile_manifest_path: segmentationTileManifestPath.trim() || null" in hook assert "let manifestPath = segmentationTileManifestPath.trim()" in hook
assert "datasetsApi.rasterInspect(projectId, datasetId)" in hook
assert "datasetsApi.rasterTile(projectId, datasetId" in hook
assert "tile_manifest_path: manifestPath" in hook
assert "segmentationTileManifestPath={segmentationTileManifestPath}" in app assert "segmentationTileManifestPath={segmentationTileManifestPath}" in app
assert "onSetTileManifestPath={setSegmentationTileManifestPath}" in app assert "onSetTileManifestPath={setSegmentationTileManifestPath}" in app
assert "onUseTileManifestForSegmentation: useRasterTileManifestForSegmentation" in app assert "onUseTileManifestForSegmentation: useRasterTileManifestForSegmentation" in app
@@ -24,4 +27,3 @@ def test_raster_tile_manifest_can_handoff_to_segmentation_lab() -> None:
assert "Gebruik voor segmentatie" in raster_controls assert "Gebruik voor segmentatie" in raster_controls
assert "disabled={!latestRasterTileManifestPath}" in raster_controls assert "disabled={!latestRasterTileManifestPath}" in raster_controls
assert "Beeldtegelmanifest" in segmentation_lab assert "Beeldtegelmanifest" in segmentation_lab
assert "Beeldtegelmanifest" in segmentation_lab
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import re
import uuid import uuid
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -11,7 +10,7 @@ from shapely.geometry import Polygon, box
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Dataset, VectorFeature from app.models import Dataset, VectorFeature
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature from tests.frontend_contract import assert_calls, assert_wired, read_map_workspace, read_feature
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -12,8 +12,12 @@ class FakeUploadFile:
filename = "real-orthophoto.tif" filename = "real-orthophoto.tif"
content_type = "image/tiff" content_type = "image/tiff"
async def read(self) -> bytes: def __init__(self) -> None:
return b"fake-raster" self._content = b"fake-raster"
async def read(self, size: int) -> bytes:
chunk, self._content = self._content[:size], self._content[size:]
return chunk
class FakeSession: class FakeSession:
@@ -40,16 +44,19 @@ def test_raster_upload_maps_metadata_bounds_resolution_and_bands(monkeypatch) ->
project_id = uuid4() project_id = uuid4()
db = FakeSession(project_id) db = FakeSession(project_id)
monkeypatch.setattr( async def persist_upload_file(**_kwargs):
"app.services.dataset_service.StorageService.persist_dataset_file", return {
lambda **_: {
"storage_path": "/tmp/real-orthophoto.tif", "storage_path": "/tmp/real-orthophoto.tif",
"original_filename": "real-orthophoto.tif", "original_filename": "real-orthophoto.tif",
"stored_filename": "real-orthophoto.tif", "stored_filename": "real-orthophoto.tif",
"content_type": "image/tiff", "content_type": "image/tiff",
"size_bytes": 11, "size_bytes": 11,
"checksum_sha256": "checksum", "checksum_sha256": "checksum",
}, }
monkeypatch.setattr(
"app.services.dataset_service.StorageService.persist_upload_file",
persist_upload_file,
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.dataset_service.extract_raster_metadata", "app.services.dataset_service.extract_raster_metadata",
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -51,11 +52,27 @@ def _write_report(path: Path, *, promotion_status: str = "promote_candidate") ->
) )
def _write_governed_release_gate(path: Path, model_file: Path, **overrides: object) -> None:
payload: dict[str, object] = {
"status": "pass",
"product_benchmark_status": "pass",
"promotion_allowed": True,
"phase_decision": "ready",
"candidate_key": CANDIDATE_KEY,
"candidate_model_sha256": hashlib.sha256(model_file.read_bytes()).hexdigest(),
"benchmark_manifest_sha256": "b" * 64,
}
payload.update(overrides)
path.write_text(json.dumps(payload), encoding="utf-8")
def _run_activation(tmp_path: Path, *extra_args: str) -> subprocess.CompletedProcess[str]: def _run_activation(tmp_path: Path, *extra_args: str) -> subprocess.CompletedProcess[str]:
models_dir = tmp_path / "models" models_dir = tmp_path / "models"
_write_model(models_dir) model_file = _write_model(models_dir)
report_path = tmp_path / "promotion_report.json" report_path = tmp_path / "promotion_report.json"
_write_report(report_path) _write_report(report_path)
release_gate_path = tmp_path / "phase4-release-gate.json"
_write_governed_release_gate(release_gate_path, model_file)
env_file = tmp_path / ".env" env_file = tmp_path / ".env"
env_file.write_text("GEOINTEL_ENV=production\nYOLO_ENABLED=false\n", encoding="utf-8") env_file.write_text("GEOINTEL_ENV=production\nYOLO_ENABLED=false\n", encoding="utf-8")
@@ -67,6 +84,8 @@ def _run_activation(tmp_path: Path, *extra_args: str) -> subprocess.CompletedPro
str(report_path), str(report_path),
"--candidate-key", "--candidate-key",
CANDIDATE_KEY, CANDIDATE_KEY,
"--phase4-release-gate-report",
str(release_gate_path),
"--models-dir", "--models-dir",
str(models_dir), str(models_dir),
"--container-model-dir", "--container-model-dir",
@@ -94,6 +113,9 @@ def test_promoted_yolo_activation_dry_run_validates_report_and_model(tmp_path: P
assert payload["will_download_models"] is False assert payload["will_download_models"] is False
assert payload["candidate"]["candidate_key"] == CANDIDATE_KEY assert payload["candidate"]["candidate_key"] == CANDIDATE_KEY
assert payload["candidate"]["threshold"] == 0.35 assert payload["candidate"]["threshold"] == 0.35
assert payload["selected_model_sha256"] == hashlib.sha256(
(tmp_path / "models" / "geointel-building-yolov8s-aoi1024bg512r3e50.pt").read_bytes()
).hexdigest()
assert payload["env_updates"]["YOLO_ENABLED"] == "true" assert payload["env_updates"]["YOLO_ENABLED"] == "true"
assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/geointel-building-yolov8s-aoi1024bg512r3e50.pt" assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/geointel-building-yolov8s-aoi1024bg512r3e50.pt"
@@ -114,9 +136,11 @@ def test_promoted_yolo_activation_apply_updates_env_file(tmp_path: Path) -> None
def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) -> None: def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) -> None:
models_dir = tmp_path / "models" models_dir = tmp_path / "models"
_write_model(models_dir) model_file = _write_model(models_dir)
report_path = tmp_path / "promotion_report.json" report_path = tmp_path / "promotion_report.json"
_write_report(report_path, promotion_status="reject") _write_report(report_path, promotion_status="reject")
release_gate_path = tmp_path / "phase4-release-gate.json"
_write_governed_release_gate(release_gate_path, model_file)
result = subprocess.run( result = subprocess.run(
[ [
@@ -126,6 +150,8 @@ def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) ->
str(report_path), str(report_path),
"--candidate-key", "--candidate-key",
CANDIDATE_KEY, CANDIDATE_KEY,
"--phase4-release-gate-report",
str(release_gate_path),
"--models-dir", "--models-dir",
str(models_dir), str(models_dir),
"--env-file", "--env-file",
@@ -145,6 +171,52 @@ def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) ->
assert "rejection_reasons" in payload assert "rejection_reasons" in payload
def test_promoted_yolo_activation_rejects_failed_or_unbound_phase4_gate(
tmp_path: Path,
) -> None:
models_dir = tmp_path / "models"
model_file = _write_model(models_dir)
report_path = tmp_path / "promotion_report.json"
_write_report(report_path)
release_gate_path = tmp_path / "phase4-release-gate.json"
_write_governed_release_gate(
release_gate_path,
model_file,
status="fail",
promotion_allowed=False,
candidate_model_sha256="0" * 64,
)
result = subprocess.run(
[
"python",
str(SCRIPT),
"--promotion-report",
str(report_path),
"--phase4-release-gate-report",
str(release_gate_path),
"--candidate-key",
CANDIDATE_KEY,
"--models-dir",
str(models_dir),
"--env-file",
str(tmp_path / ".env"),
"--json",
],
cwd=ROOT,
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert result.returncode == 3
payload = json.loads(result.stdout)
assert payload["status"] == "governed_release_not_approved"
assert "phase4_release_gate_not_passed" in payload["rejection_reasons"]
assert "governed_candidate_model_sha256_mismatch" in payload["rejection_reasons"]
def test_readiness_gate_compiles_promoted_activation_script() -> None: def test_readiness_gate_compiles_promoted_activation_script() -> None:
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
@@ -18,7 +18,7 @@ def test_frontend_declares_national_scope_as_primary_operating_focus() -> None:
navigation = read_feature("shell") navigation = read_feature("shell")
assert "NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'" in focus assert "NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'" in focus
assert "NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'" in focus assert "NATIONAL_WORKSPACE_REGION = 'België en Belgische Noordzee'" in focus
assert "NATIONAL_MAP_CENTER" in focus assert "NATIONAL_MAP_CENTER" in focus
assert "return nationalProject.id" in project_hook assert "return nationalProject.id" in project_hook
assert "hasMappedAnalysisContext(data)" in project_hook assert "hasMappedAnalysisContext(data)" in project_hook
@@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature from tests.frontend_contract import assert_calls, assert_wired, read_map_workspace, read_feature
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
+2 -1
View File
@@ -137,7 +137,8 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None:
assert "Kempen (28 gemeenten)" in workspace assert "Kempen (28 gemeenten)" in workspace
assert 'aria-label="Regio"' not in workspace assert 'aria-label="Regio"' not in workspace
assert 'aria-label="Ingeladen regiobereik"' in workspace assert 'aria-label="Ingeladen regiobereik"' in workspace
assert "Zoek optioneel een gemeente" in workspace assert 'aria-label="Optioneel een gemeente zoeken"' in workspace
assert 'placeholder="Gemeentenaam of NIS-code"' in workspace
assert "projects={projects}" in app assert "projects={projects}" in app
map_props = app.split("<MapWorkspace", maxsplit=1)[1].split("/>", maxsplit=1)[0] map_props = app.split("<MapWorkspace", maxsplit=1)[1].split("/>", maxsplit=1)[0]
assert "onSelectProject={selectProject}" not in map_props assert "onSelectProject={selectProject}" not in map_props
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature from tests.frontend_contract import assert_calls, read_map_workspace, read_feature
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -20,7 +20,8 @@ def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> Non
assert "const municipalityProject" not in project_hook assert "const municipalityProject" not in project_hook
assert 'aria-label="Regio"' not in map_workspace assert 'aria-label="Regio"' not in map_workspace
assert 'aria-label="Ingeladen regiobereik"' in map_workspace assert 'aria-label="Ingeladen regiobereik"' in map_workspace
assert "Zoek optioneel een gemeente" in map_workspace assert 'aria-label="Optioneel een gemeente zoeken"' in map_workspace
assert 'placeholder="Gemeentenaam of NIS-code"' in map_workspace
assert 'aria-label="Werkgebied"' in map_workspace assert 'aria-label="Werkgebied"' in map_workspace
@@ -251,7 +251,8 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
assert "statbel: 'Statbel'" in display assert "statbel: 'Statbel'" in display
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
assert "getDatasetSourceDisplayName(resultDataset)" in workspace assert "getDatasetSourceDisplayName(resultDataset)" in workspace
assert "Zoek optioneel een gemeente" in workspace assert 'aria-label="Optioneel een gemeente zoeken"' in workspace
assert 'placeholder="Gemeentenaam of NIS-code"' in workspace
assert "latestDatasetBySeries" in catalog assert "latestDatasetBySeries" in catalog
assert "Historische meetmomenten" in catalog assert "Historische meetmomenten" in catalog
assert "getDatasetSourceDisplayName(dataset)" in catalog assert "getDatasetSourceDisplayName(dataset)" in catalog
@@ -20,6 +20,7 @@ from app.db.session import get_db
from app.main import app from app.main import app
from app.models import Area, Dataset, DatasetVersion, Job, Project, SourceRegistry, SourceSnapshot from app.models import Area, Dataset, DatasetVersion, Job, Project, SourceRegistry, SourceSnapshot
from app.schemas.orthophoto import OrthophotoAcquireRequest from app.schemas.orthophoto import OrthophotoAcquireRequest
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
from tests.frontend_contract import read_feature from tests.frontend_contract import read_feature
@@ -262,6 +263,9 @@ def test_regional_orthophoto_products_bind_provider_and_governed_scope(
assert snapshot.checksum_sha256 == dataset.checksum_sha256 assert snapshot.checksum_sha256 == dataset.checksum_sha256
assert snapshot.ingest_status == "ingested" assert snapshot.ingest_status == "ingested"
assert snapshot.freshness_status == "current" assert snapshot.freshness_status == "current"
dataset.source_registry = source
dataset.source_snapshot = snapshot
assert DatasetConsumptionGate.assert_eligible(dataset, purpose="production_inference").eligible is True
prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="1971"), settings) prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="1971"), settings)
assert prepared["params"]["LAYERS"] == "OKZPAN71VL" assert prepared["params"]["LAYERS"] == "OKZPAN71VL"
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace from tests.frontend_contract import assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import json
import sys import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -7,7 +7,6 @@ from uuid import uuid4
import numpy as np import numpy as np
import pytest import pytest
import rasterio
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pyproj import Transformer from pyproj import Transformer
from rasterio.io import MemoryFile from rasterio.io import MemoryFile
@@ -252,7 +252,6 @@ def test_refresh_api_and_frontend_remain_explicit_only() -> None:
def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> None: def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> None:
root = Path(__file__).resolve().parents[2]
workspace = read_map_workspace() workspace = read_map_workspace()
observed_sort = workspace.index("const observedAtDifference") observed_sort = workspace.index("const observedAtDifference")
feature_tiebreaker = workspace.index("right.feature_count", observed_sort) feature_tiebreaker = workspace.index("right.feature_count", observed_sort)
@@ -262,7 +261,6 @@ def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> Non
def test_map_workspace_restores_theme_from_selected_dataset() -> None: def test_map_workspace_restores_theme_from_selected_dataset() -> None:
root = Path(__file__).resolve().parents[2]
workspace = read_map_workspace() workspace = read_map_workspace()
assert "function themeIdForDataset(" in workspace assert "function themeIdForDataset(" in workspace
assert "useState<DataThemeId>(() =>" in workspace assert "useState<DataThemeId>(() =>" in workspace
@@ -20,7 +20,7 @@ from app.services.storage_service import StorageService
from app.services.source_registry_service import SourceRegistryService from app.services.source_registry_service import SourceRegistryService
from app.services.temporal_analysis_service import TemporalAnalysisService from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature from tests.frontend_contract import assert_mentions, assert_wired, read_map_workspace, read_feature
class FakeSession: class FakeSession:
@@ -418,7 +418,6 @@ def test_frontend_fetches_canonical_workspace_outside_default_project_page() ->
def test_theme_failures_name_the_source_and_reason() -> None: def test_theme_failures_name_the_source_and_reason() -> None:
root = Path(__file__).resolve().parents[2]
hook = read_feature("map_workspace") hook = read_feature("map_workspace")
assert "queries[index]?.dataset?.name" in hook assert "queries[index]?.dataset?.name" in hook
@@ -428,7 +427,6 @@ def test_theme_failures_name_the_source_and_reason() -> None:
def test_workspace_navigation_resets_the_actual_scroll_container() -> None: def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
root = Path(__file__).resolve().parents[2]
app = read_feature("shell") app = read_feature("shell")
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
@@ -438,7 +436,6 @@ def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
def test_quality_scores_have_plain_language_interpretation() -> None: def test_quality_scores_have_plain_language_interpretation() -> None:
root = Path(__file__).resolve().parents[2]
quality = read_feature("quality") quality = read_feature("quality")
detection = read_feature("detection") detection = read_feature("detection")
@@ -471,7 +468,9 @@ def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
assert "department_omgeving_thematic_raster" in capability_source assert "department_omgeving_thematic_raster" in capability_source
assert "digitaal_vlaanderen_dhmv" in capability_source assert "digitaal_vlaanderen_dhmv" in capability_source
assert "vmm_flood_hazard" in capability_source assert "vmm_flood_hazard" in capability_source
assert "dataset.dataset_type !== 'raster' || dataset.status !== 'ready'" in capability_source assert "dataset.dataset_type !== 'raster'" in capability_source
assert "dataset.status !== 'ready'" in capability_source
assert "return datasetInferenceBlockReason(dataset) === null" in capability_source
assert "const detectionRasterDatasets = useMemo(" in app_source assert "const detectionRasterDatasets = useMemo(" in app_source
assert "rasterDatasets: detectionRasterDatasets" in app_source assert "rasterDatasets: detectionRasterDatasets" in app_source
assert "rasterDatasets={detectionRasterDatasets}" in app_source assert "rasterDatasets={detectionRasterDatasets}" in app_source
@@ -479,7 +478,6 @@ def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None: def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
root = Path(__file__).resolve().parents[2]
exports = read_feature("exports") exports = read_feature("exports")
assert "Gebiedsanalyse (JSON)" in exports assert "Gebiedsanalyse (JSON)" in exports
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
import json
from pathlib import Path from pathlib import Path
import ssl import ssl
import sys import sys
@@ -31,7 +30,7 @@ if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS))
import provision_flanders_geographic_scope as flanders_scope # noqa: E402 import provision_flanders_geographic_scope as flanders_scope # noqa: E402
from tests.frontend_contract import read_map_workspace, read_feature from tests.frontend_contract import read_map_workspace, read_feature # noqa: E402
class BinaryResponse: class BinaryResponse:
@@ -136,6 +135,32 @@ def test_mdk_probe_parses_capabilities_without_enabling_acquisition() -> None:
assert seen["timeout"] == 20 assert seen["timeout"] == 20
def test_mdk_probe_default_opener_uses_guarded_strict_tls_path(monkeypatch) -> None:
seen = {}
def guarded_factory(expected_url):
seen["expected_url"] = expected_url
def open_request(request, timeout):
seen["request_url"] = request.full_url
seen["timeout"] = timeout
return BinaryResponse(capabilities_xml())
return open_request
monkeypatch.setattr(
"app.services.mdk_bathymetry_probe_service.guarded_opener",
guarded_factory,
)
result = MdkBathymetryProbeService.probe(settings=Settings(_env_file=None))
assert result["status"] == "reachable"
assert seen["expected_url"] == seen["request_url"]
assert seen["expected_url"].startswith("https://")
assert seen["timeout"] == 20
def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None: def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None:
calls = 0 calls = 0
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature from tests.frontend_contract import assert_wired, read_map_workspace, read_feature
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -19,7 +19,7 @@ from app.models import Area, Dataset, Job, Project
from app.schemas.grb import GrbAcquireRequest from app.schemas.grb import GrbAcquireRequest
from app.services.dataset_service import DatasetService from app.services.dataset_service import DatasetService
from app.services.grb_acquisition_service import GrbAcquisitionService from app.services.grb_acquisition_service import GrbAcquisitionService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature from tests.frontend_contract import assert_wired, read_map_workspace, read_feature
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import io
from pathlib import Path from pathlib import Path
import sys import sys
import zipfile import zipfile

Some files were not shown because too many files have changed in this diff Show More