From a73e72019b619d643ca1952ec260550f65ba4cad Mon Sep 17 00:00:00 2001 From: GO-MOO Date: Mon, 13 Apr 2026 13:52:41 +0200 Subject: [PATCH 1/3] patch AWS image --- Dockerfile.lambda | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Dockerfile.lambda b/Dockerfile.lambda index b50936d..6b0a68c 100644 --- a/Dockerfile.lambda +++ b/Dockerfile.lambda @@ -28,6 +28,15 @@ RUN --mount=from=uv,source=/uv,target=/bin/uv \ FROM public.ecr.aws/lambda/python:3.14 +# Patch OS-level vulnerabilities (openssl, aws-lambda-rie). +RUN dnf upgrade -y openssl-libs openssl-fips-provider-latest && \ + dnf clean all && \ + rm -rf /var/cache/dnf + +# Update aws-lambda-rie to latest release to fix CVE-2026-2673. +ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-x86_64 /usr/local/bin/aws-lambda-rie +RUN chmod 755 /usr/local/bin/aws-lambda-rie + # Copy the runtime dependencies from the builder stage. COPY --from=builder ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT} diff --git a/pyproject.toml b/pyproject.toml index 0d1e9fc..7ee2d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "drillapi" -version = "0.1.2" +version = "0.1.12" description = "drillapi" authors = [{name = "SFOE", email = "geoinformation@bfe.admin.ch"}] readme = "README.md" From da3d423b85af5967ebefe3164915cc82c0ab59f8 Mon Sep 17 00:00:00 2001 From: GO-MOO Date: Thu, 23 Apr 2026 14:14:05 +0200 Subject: [PATCH 2/3] update images, fix geoservices failures --- Dockerfile | 4 +- Dockerfile.lambda | 12 +- src/drillapi/models/models.py | 1 + src/drillapi/routes/drill_category.py | 14 ++- src/drillapi/services/processing.py | 34 ++++-- tests/test_geoservice_unavailable.py | 167 ++++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 tests/test_geoservice_unavailable.py diff --git a/Dockerfile b/Dockerfile index d6e9cb0..7558aa1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:python3.14-alpine +FROM ghcr.io/astral-sh/uv:0.11.7-python3.14-alpine AS base WORKDIR /app ENV PYTHONPATH=/app @@ -10,4 +10,4 @@ RUN uv sync --no-install-project EXPOSE 8000 -CMD ["uv", "run", "uvicorn", "drillapi.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uv", "run", "uvicorn", "drillapi.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.lambda b/Dockerfile.lambda index 370ccf6..49042d8 100644 --- a/Dockerfile.lambda +++ b/Dockerfile.lambda @@ -1,6 +1,6 @@ # from https://docs.astral.sh/uv/guides/integration/aws-lambda/#deploying-a-docker-image -FROM ghcr.io/astral-sh/uv:0.11.6 AS uv +FROM ghcr.io/astral-sh/uv:0.11.7 AS uv # First, bundle the dependencies into the task root. FROM public.ecr.aws/lambda/python:3.14 AS builder @@ -28,16 +28,6 @@ RUN --mount=from=uv,source=/uv,target=/bin/uv \ FROM public.ecr.aws/lambda/python:3.14 -# Patch OS-level vulnerabilities (openssl, aws-lambda-rie). -# fix CVE-2026-2673. -RUN dnf upgrade -y openssl-libs openssl-fips-provider-latest && \ - dnf clean all && \ - rm -rf /var/cache/dnf - -# Update aws-lambda-rie to latest release to fix CVE-2026-32280. -ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-x86_64 /usr/local/bin/aws-lambda-rie -RUN chmod 755 /usr/local/bin/aws-lambda-rie - # Copy the runtime dependencies from the builder stage. COPY --from=builder ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT} diff --git a/src/drillapi/models/models.py b/src/drillapi/models/models.py index 31de8ad..531d61e 100644 --- a/src/drillapi/models/models.py +++ b/src/drillapi/models/models.py @@ -16,6 +16,7 @@ class GroundSuitability(IntEnum): UNKNOWN = 4 NOT_AVAILABLE = 5 NOT_IN_SWITZERLAND = 6 + GEOSERVICE_UNAVAILABLE = 98 PROBLEM = 99 diff --git a/src/drillapi/routes/drill_category.py b/src/drillapi/routes/drill_category.py index 1b2f9e4..e8d3344 100644 --- a/src/drillapi/routes/drill_category.py +++ b/src/drillapi/routes/drill_category.py @@ -3,7 +3,7 @@ from ..services import processing, security from ..services.error_handler import handle_errors from ..config import settings -from ..models.models import SuitabilityFeature, GroundCategory, ResultDetail +from ..models.models import SuitabilityFeature, GroundCategory, GroundSuitability, ResultDetail import logging router = APIRouter() @@ -75,6 +75,18 @@ async def get_drill_category( # Fetch features (WMS or ESRI REST) from external geoservices and process into feature result = await processing.fetch_features_for_point(coord_x, coord_y, canton_config) + # Handle external geoservice unavailability + if result.get("geoservice_unavailable"): + suitability_feature.ground_category.harmonized_value = GroundSuitability.GEOSERVICE_UNAVAILABLE + suitability_feature.ground_category.source_values = "geoservice unavailable" + # Keep canton_config so the frontend can access cantonal_energy_service_url + suitability_feature.result_detail = ResultDetail( + message="External geoservice unavailable", + full_url=result.get("full_url", ""), + detail=result.get("error"), + ) + return suitability_feature + # Feature(s) found, process to reclassification processed_ground_category = processing.process_ground_category( result["features"], diff --git a/src/drillapi/services/processing.py b/src/drillapi/services/processing.py index f74c22c..efe024a 100644 --- a/src/drillapi/services/processing.py +++ b/src/drillapi/services/processing.py @@ -117,10 +117,13 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict) features = data.get("features") or [] except Exception as e: error_message = f"WMS request failed: {e}" - logger.error("%s — URL: %s", error_message, full_url) - raise HTTPException( - status_code=502, detail=f"{error_message} — URL: {full_url}" - ) + logger.error("%s — URL: %s", error_message, full_url or esri_url) + return { + "features": [], + "full_url": full_url or esri_url, + "error": error_message, + "geoservice_unavailable": True, + } # WMS GetFeatureInfo else: @@ -161,10 +164,13 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict) resp.raise_for_status() except Exception as e: error_message = f"WMS request failed: {e}" - logger.error("%s — URL: %s", error_message, full_url) - raise HTTPException( - status_code=502, detail=f"{error_message} — URL: {full_url}" - ) + logger.error("%s — URL: %s", error_message, full_url or query_url) + return { + "features": [], + "full_url": full_url or query_url, + "error": error_message, + "geoservice_unavailable": True, + } try: features = parse_wms_getfeatureinfo( @@ -177,12 +183,18 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict) "error": error_message, } + except HTTPException: + # Re-raise genuine internal errors (e.g., invalid JSON/XML from parse_wms_getfeatureinfo) + raise except Exception as e: error_message = f"Failed to parse WMS or ESRI REST response: {e}" logger.error("%s — URL: %s", error_message, full_url) - raise HTTPException( - status_code=502, detail=f"{error_message} — URL: {full_url}" - ) + return { + "features": [], + "full_url": full_url, + "error": error_message, + "geoservice_unavailable": True, + } # PARSE WMS or REST responses diff --git a/tests/test_geoservice_unavailable.py b/tests/test_geoservice_unavailable.py new file mode 100644 index 0000000..193dcb2 --- /dev/null +++ b/tests/test_geoservice_unavailable.py @@ -0,0 +1,167 @@ +"""Tests for geoservice unavailability handling. + +Verifies that when external cantonal geoservices fail (timeout, connection error, +invalid response), the backend returns a structured HTTP 200 response with +harmonized_value=98 and the canton identifier, instead of raising HTTPException(502). +Canton config is preserved so the frontend can access cantonal_energy_service_url. +""" + +import pytest +import respx +import httpx +from fastapi.testclient import TestClient +from drillapi.app import app + + +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + + +def _mock_canton_identify(canton_code: str): + """Mock the geo.admin.ch canton identification endpoint.""" + canton_response = { + "results": [ + { + "featureId": 1, + "layerBodId": "ch.swisstopo.swissboundaries3d-kanton-flaeche.fill", + "layerName": "Cantonal boundaries", + "id": 1, + "attributes": {"ak": canton_code}, + } + ] + } + respx.get( + "https://api3.geo.admin.ch/rest/services/ech/MapServer/identify", params=None + ).mock( + return_value=httpx.Response( + 200, + json=canton_response, + headers={"Content-Type": "application/json"}, + ) + ) + + +@respx.mock +def test_wms_timeout_returns_structured_response(client): + """When a WMS geoservice times out, return HTTP 200 with harmonized_value=98.""" + coord_x = 2574738 + coord_y = 1249285 + + _mock_canton_identify("JU") + + respx.get("https://geoservices.jura.ch/wms", params=None).mock( + side_effect=httpx.ConnectTimeout("Connection timed out") + ) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["canton"] == "JU" + assert payload["ground_category"]["harmonized_value"] == 98 + assert payload["ground_category"]["source_values"] == "geoservice unavailable" + assert payload["result_detail"]["message"] == "External geoservice unavailable" + assert payload["canton_config"] is not None + assert payload["canton_config"]["name"] == "JU" + + +@respx.mock +def test_esri_connection_error_returns_structured_response(client): + """When an ESRI REST geoservice has a connection error, return HTTP 200 with harmonized_value=98.""" + coord_x = 2582124 + coord_y = 1164966 + + _mock_canton_identify("FR") + + respx.get( + "https://map.geo.fr.ch/arcgis/rest/services/PortailCarto/Theme_environnement/MapServer/17/query", + params=None, + ).mock(side_effect=httpx.ConnectError("Connection refused")) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["canton"] == "FR" + assert payload["ground_category"]["harmonized_value"] == 98 + assert payload["ground_category"]["source_values"] == "geoservice unavailable" + assert payload["result_detail"]["message"] == "External geoservice unavailable" + assert payload["canton_config"] is not None + assert payload["canton_config"]["name"] == "FR" + + +@respx.mock +def test_wms_http_500_returns_structured_response(client): + """When a WMS geoservice returns HTTP 500, return HTTP 200 with harmonized_value=98.""" + coord_x = 2574738 + coord_y = 1249285 + + _mock_canton_identify("JU") + + respx.get("https://geoservices.jura.ch/wms", params=None).mock( + return_value=httpx.Response(500, text="Internal Server Error") + ) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["canton"] == "JU" + assert payload["ground_category"]["harmonized_value"] == 98 + assert payload["ground_category"]["source_values"] == "geoservice unavailable" + assert payload["canton_config"] is not None + assert payload["canton_config"]["name"] == "JU" + + +@respx.mock +def test_successful_wms_still_works(client): + """Successful WMS responses should still return normal suitability values (preservation).""" + coord_x = 2574738 + coord_y = 1249285 + + _mock_canton_identify("JU") + + with open("tests/data/wms/getfeatureinfo_ju.gml", "rb") as f: + gml = f.read() + + respx.get("https://geoservices.jura.ch/wms", params=None).mock( + return_value=httpx.Response( + 200, + content=gml, + headers={"Content-Type": "application/vnd.ogc.gml"}, + ) + ) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["ground_category"]["harmonized_value"] == 1 + assert payload["result_detail"]["message"] == "Success" + + +@respx.mock +def test_canton_preserved_in_geoservice_failure(client): + """Canton identifier and config must be preserved in the response when geoservice fails.""" + coord_x = 2574738 + coord_y = 1249285 + + _mock_canton_identify("JU") + + respx.get("https://geoservices.jura.ch/wms", params=None).mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + # Canton must be preserved + assert payload["canton"] == "JU" + # Canton config preserved so frontend can access cantonal_energy_service_url + assert payload["canton_config"] is not None + assert payload["canton_config"]["name"] == "JU" + assert payload["canton_config"]["cantonal_energy_service_url"] is not None + assert payload["ground_category"]["harmonized_value"] == 98 From bb1146fcf67d6bc8421d68ddd0cb8148b9e11779 Mon Sep 17 00:00:00 2001 From: GO-MOO Date: Thu, 23 Apr 2026 14:20:41 +0200 Subject: [PATCH 3/3] lint --- src/drillapi/routes/drill_category.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/drillapi/routes/drill_category.py b/src/drillapi/routes/drill_category.py index e8d3344..568a605 100644 --- a/src/drillapi/routes/drill_category.py +++ b/src/drillapi/routes/drill_category.py @@ -3,7 +3,12 @@ from ..services import processing, security from ..services.error_handler import handle_errors from ..config import settings -from ..models.models import SuitabilityFeature, GroundCategory, GroundSuitability, ResultDetail +from ..models.models import ( + SuitabilityFeature, + GroundCategory, + GroundSuitability, + ResultDetail, +) import logging router = APIRouter() @@ -77,7 +82,9 @@ async def get_drill_category( # Handle external geoservice unavailability if result.get("geoservice_unavailable"): - suitability_feature.ground_category.harmonized_value = GroundSuitability.GEOSERVICE_UNAVAILABLE + suitability_feature.ground_category.harmonized_value = ( + GroundSuitability.GEOSERVICE_UNAVAILABLE + ) suitability_feature.ground_category.source_values = "geoservice unavailable" # Keep canton_config so the frontend can access cantonal_energy_service_url suitability_feature.result_detail = ResultDetail(