From 749cc0887cf133eee575369ea98235f7fc78c0e3 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Wed, 17 Sep 2025 16:18:40 -0700 Subject: [PATCH 01/15] Add new deploy route endpoints --- operator/controller/core-controller.yaml | 1 + operator/webapp/README.md | 22 +++ operator/webapp/app.py | 3 +- operator/webapp/core/k8s_client.py | 177 +++++++++++++++++++ operator/webapp/core/sync.py | 5 +- operator/webapp/core/test/test_k8s_client.py | 163 +++++++++++++++++ operator/webapp/models.py | 22 +++ operator/webapp/requirements-dev.txt | 1 + operator/webapp/requirements.txt | 2 + operator/webapp/routes/deploy.py | 84 +++++++++ operator/webapp/routes/test/test_deploy.py | 120 +++++++++++++ operator/webapp/routes/webhook.py | 8 +- 12 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 operator/webapp/core/k8s_client.py create mode 100644 operator/webapp/core/test/test_k8s_client.py create mode 100644 operator/webapp/models.py create mode 100644 operator/webapp/routes/deploy.py create mode 100644 operator/webapp/routes/test/test_deploy.py diff --git a/operator/controller/core-controller.yaml b/operator/controller/core-controller.yaml index 9b0a19f..af3ea26 100644 --- a/operator/controller/core-controller.yaml +++ b/operator/controller/core-controller.yaml @@ -46,6 +46,7 @@ metadata: namespace: keip spec: replicas: 1 + service-account: integrationroute-service selector: matchLabels: app: integrationroute-webhook diff --git a/operator/webapp/README.md b/operator/webapp/README.md index a142589..375c7f2 100644 --- a/operator/webapp/README.md +++ b/operator/webapp/README.md @@ -2,6 +2,8 @@ A Python web server that implements the following endpoints: - `/webhook`: A [lambda controller from the Metacontroller API](https://metacontroller.github.io/metacontroller/concepts.html#lambda-controller). +- `/deploy`: Deploys a route from an XML file. +- `/cluster-health`: Returns the health of the cluster. The webhook will be called as part of the Metacontroller control loop when `IntegrationRoute` parent resources are detected. @@ -15,6 +17,12 @@ resources are detected. The format for the request and response JSON payloads can be seen [here](https://metacontroller.github.io/metacontroller/api/compositecontroller.html#sync-hook) +## Deployment + +This web server is designed to be run as a service within a Kubernetes cluster. It is intended to be used with [Metacontroller](https://metacontroller.github.io/metacontroller/), which will call the `/webhook` endpoint to manage `IntegrationRoute` custom resources. + +The `/deploy` endpoint is provided for convenience to deploy routes from XML files. The `/cluster-health` endpoint can be used as a liveness or readiness probe. + ## Developer Guide Requirements: @@ -64,6 +72,20 @@ a [pre-commit git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks make precommit ``` +### Docker + +To build the Docker image, run: + +```shell +make build +``` + +To run the Docker container: + +```shell +make run-container +``` + ### Windows Development There are Windows-compatible equivalents for most of the `make` commands listed above, prefixed with `win-` ( diff --git a/operator/webapp/app.py b/operator/webapp/app.py index 982f745..361e3b8 100644 --- a/operator/webapp/app.py +++ b/operator/webapp/app.py @@ -4,7 +4,7 @@ from starlette.types import ASGIApp import config as cfg -from routes import webhook +from routes import webhook, deploy from logconf import LOG_CONF @@ -19,6 +19,7 @@ def create_app() -> ASGIApp: app = Starlette(debug=cfg.DEBUG) app.mount("/webhook", webhook.router) + app.mount("/deploy", deploy.router) return app diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py new file mode 100644 index 0000000..467acad --- /dev/null +++ b/operator/webapp/core/k8s_client.py @@ -0,0 +1,177 @@ +from typing import List +from kubernetes import config, client +from kubernetes.client.rest import ApiException +from urllib3.util.retry import Retry +import logging.config + +from models import RouteData, Resource, Status +from logconf import LOG_CONF + +ROUTE_API_GROUP = "keip.codice.org" +ROUTE_API_VERSION = "v1alpha1" +ROUTE_PLURAL = "integrationroutes" +WEBHOOK_CONTROLLER_PREFIX = "integrationroute-webhook" + + +logging.config.dictConfig(LOG_CONF) +logging.getLogger("urllib3").setLevel(logging.ERROR) +_LOGGER = logging.getLogger(__name__) + + +try: + # Try in-cluster config first + config.load_incluster_config() + _LOGGER.info("Using in-cluster Kubernetes config") +except config.ConfigException: + # Fall back to local kubeconfig + _LOGGER.info( + msg="Detected not running inside a cluster. Falling back to local kubeconfig." + ) + config.load_kube_config() + + +v1 = client.CoreV1Api() +routeApi = client.CustomObjectsApi() + + +def _check_cluster_reachable(): + try: + v1.list_namespace(limit=1, timeout_seconds=5) + return True + except Exception: + return False + + +def create_integration_route(route_data: RouteData, configmap_name: str) -> Resource: + """Create or update a new Integration Route with the provided configmap""" + if not _check_cluster_reachable(): + raise ApiException( + status=500, + reason="Kubernetes cluster not reachable. Verify the cluster is running" + ) + + existing_route = routeApi.list_namespaced_custom_object( + group=ROUTE_API_GROUP, + version=ROUTE_API_VERSION, + namespace=route_data.namespace, + plural=ROUTE_PLURAL, + field_selector=f"metadata.name={route_data.route_name}", + ) + + body = { + "apiVersion": "keip.codice.org/v1alpha1", + "kind": "IntegrationRoute", + "metadata": {"name": route_data.route_name}, + "spec": {"routeConfigMap": configmap_name}, + } + + status = Status.CREATED + + if existing_route["items"]: + # Recreate route + routeApi.delete_namespaced_custom_object( + group=ROUTE_API_GROUP, + version=ROUTE_API_VERSION, + namespace=route_data.namespace, + plural=ROUTE_PLURAL, + name=existing_route["items"][0]["metadata"]["name"], + ) + status = Status.RECREATED + + # Create new route + routeApi.create_namespaced_custom_object( + group=ROUTE_API_GROUP, + version=ROUTE_API_VERSION, + namespace=route_data.namespace, + plural=ROUTE_PLURAL, + body=body, + ) + + return Resource(status=status, name=route_data.route_name) + + +def create_route_configmap(route_data: RouteData) -> Resource: + """Create or update a ConfigMap with an XML route payload""" + if not _check_cluster_reachable(): + raise ApiException( + status=500, + reason="Kubernetes cluster not reachable. Verify the cluster is running", + ) + + configmap_name = f"{route_data.route_name}-cm" + configmap = client.V1ConfigMap( + metadata=client.V1ObjectMeta( + name=configmap_name, namespace=route_data.namespace + ), + data={"integrationRoute.xml": route_data.route_file}, + ) + + result = v1.list_namespaced_config_map( + namespace=route_data.namespace, field_selector=f"metadata.name={configmap_name}" + ) + + updated = False + + if len(result.items) > 0: + # Update if exists + _LOGGER.info( + "Route ConfigMap '%s' already exists and will be updated", configmap_name + ) + + v1.replace_namespaced_config_map( + name=configmap_name, namespace=route_data.namespace, body=configmap + ) + updated = True + else: + # Create if doesn't exist + _LOGGER.info( + "Route ConfigMap '%s' does not exist and will be created", configmap_name + ) + configmap = v1.create_namespaced_config_map( + namespace=route_data.namespace, body=configmap + ) + + status = Status.UPDATED if updated else Status.CREATED + return Resource(status=status, name=configmap_name) + + +def create_route_resources(route_data: RouteData) -> List[Resource]: + route_cm = create_route_configmap(route_data=route_data) + route = create_integration_route( + route_data=route_data, configmap_name=route_cm.name + ) + return [route_cm, route] + + +def is_cluster_ready() -> bool: + if not _check_cluster_reachable(): + _LOGGER.warning("Kubernetes client not reachable, cluster is not ready") + return False + try: + pods = v1.list_pod_for_all_namespaces() + matching_pods = [] + for pod in pods.items: + if pod.metadata.name.startswith(WEBHOOK_CONTROLLER_PREFIX): + ready = False + if pod.status and pod.status.conditions: + for condition in pod.status.conditions: + if condition.type == "Ready": + ready = condition.status + break + + matching_pods.append( + { + "name": pod.metadata.name, + "ready": ready, + "phase": pod.status.phase if pod.status else "Unknown", + } + ) + + if any(pod["ready"] for pod in matching_pods): + _LOGGER.debug( + msg=f"Integration Route Webhook pods ready to take requests: {matching_pods}" + ) + return True + except ApiException as e: + logging.error(f"Cluster is not ready: {e}") + return False \ No newline at end of file diff --git a/operator/webapp/core/sync.py b/operator/webapp/core/sync.py index ae8bf40..8071a74 100644 --- a/operator/webapp/core/sync.py +++ b/operator/webapp/core/sync.py @@ -256,7 +256,6 @@ def _spring_app_config_env_var(parent) -> Optional[Mapping]: def _get_keystore_password_env(tls) -> Mapping[str, Any]: - keystore = tls.get("keystore") if not keystore: @@ -276,7 +275,6 @@ def _get_keystore_password_env(tls) -> Mapping[str, Any]: def _get_java_jdk_options(tls) -> Optional[Mapping[str, str]]: - truststore = tls.get("truststore") if not truststore: @@ -312,7 +310,6 @@ def _generate_container_env_vars(parent) -> List[Mapping[str, str]]: def _create_pod_template(parent, labels, integration_image) -> Mapping[str, Any]: - vol_config = VolumeConfig(parent["spec"]) has_tls = _has_tls(parent) @@ -429,7 +426,7 @@ def _new_actuator_service(parent): "integration-route": parent_metadata["name"], "prometheus-metrics-enabled": "true", }, - "name": f'{parent_metadata["name"]}-actuator', + "name": f"{parent_metadata['name']}-actuator", }, "spec": { "ports": [ diff --git a/operator/webapp/core/test/test_k8s_client.py b/operator/webapp/core/test/test_k8s_client.py new file mode 100644 index 0000000..46bc42a --- /dev/null +++ b/operator/webapp/core/test/test_k8s_client.py @@ -0,0 +1,163 @@ +import pytest +from kubernetes import client +from core.k8s_client import ( + create_integration_route, + create_route_configmap, + is_cluster_ready, +) +from models import RouteData, Resource, Status +from kubernetes.client.rest import ApiException + + +@pytest.fixture +def route_data(): + return RouteData( + namespace="default", + route_name="my-route", + route_file="payload", + ) + + +@pytest.fixture(autouse=True) +def mock_api(mocker): + """Patch the global `v1` and `routeApi` objects used by k8s_client.""" + v1 = mocker.patch("core.k8s_client.v1") + route_api = mocker.patch("core.k8s_client.routeApi") + return {"v1": v1, "route_api": route_api} + + +def test_create_route_configmap_creates_new(route_data, mock_api): + """When no ConfigMap exists the function should call create_namespaced_config_map.""" + cm_list = client.V1ConfigMapList(items=[]) + mock_api["v1"].list_namespaced_config_map.return_value = cm_list + + res: Resource = create_route_configmap(route_data) + + # Verify that the correct name is returned + assert res.name == f"{route_data.route_name}-cm" + assert res.status == Status.CREATED + + +def test_create_route_configmap_updates_existing(mocker, route_data, mock_api): + """When a ConfigMap already exists the function should replace it.""" + cm_name = f"{route_data.route_name}-cm" + existing_cm = client.V1ConfigMap( + metadata=client.V1ObjectMeta(name=cm_name, namespace="default"), + data={"integrationRoute.xml": ""}, + ) + cm_list = client.V1ConfigMapList(items=[existing_cm]) + mock_api["v1"].list_namespaced_config_map.return_value = cm_list + + res: Resource = create_route_configmap(route_data) + + assert res.name == cm_name + assert res.status == Status.UPDATED + + +def test_create_integration_route_creates_new(route_data, mock_api): + """When no IntegrationRoute exists the function should call create.""" + mock_api["route_api"].list_namespaced_custom_object.return_value = {"items": []} + + res: Resource = create_integration_route(route_data, f"{route_data.route_name}-cm") + + assert res.name == route_data.route_name + assert res.status == Status.CREATED + + +def test_create_integration_route_updates_existing(route_data, mock_api): + """When an IntegrationRoute exists the function should recreate it.""" + existing_ir = {"metadata": {"name": "old-ir"}} + mock_api["route_api"].list_namespaced_custom_object.return_value = { + "items": [existing_ir] + } + + res: Resource = create_integration_route(route_data, f"{route_data.route_name}-cm") + + assert res.name == route_data.route_name + assert res.status == Status.RECREATED + + +@pytest.mark.parametrize("ready_flag", [True, False]) +def test_is_cluster_ready_various(mocker, ready_flag): + """Verify that the function returns True only when at least one pod is Ready.""" + mock_pod = client.V1Pod( + metadata=client.V1ObjectMeta( + name="integrationroute-webhook-pod", namespace="default" + ), + status=client.V1PodStatus( + phase="Running", + conditions=[client.V1PodCondition(type="Ready", status=ready_flag)], + ), + ) + pod_list = client.V1PodList(items=[mock_pod]) + + mock_v1 = mocker.patch("core.k8s_client.v1") + mock_v1.list_pod_for_all_namespaces.return_value = pod_list + + assert is_cluster_ready() == ready_flag + + +def test_is_cluster_ready_no_matching_pods(mocker): + """When there are no pods with the prefix, the function should return False.""" + pod_list = client.V1PodList(items=[]) + mock_v1 = mocker.patch("core.k8s_client.v1") + mock_v1.list_pod_for_all_namespaces.return_value = pod_list + + assert not is_cluster_ready() + + +def test_is_cluster_ready_pod_no_conditions(mocker): + """When a pod has no conditions, the function should return False.""" + mock_pod = client.V1Pod( + metadata=client.V1ObjectMeta( + name="integrationroute-webhook-pod", namespace="default" + ), + status=client.V1PodStatus( + phase="Running", + conditions=[], + ), + ) + pod_list = client.V1PodList(items=[mock_pod]) + + mock_v1 = mocker.patch("core.k8s_client.v1") + mock_v1.list_pod_for_all_namespaces.return_value = pod_list + + assert not is_cluster_ready() + + +def test_is_cluster_ready_api_exception(mocker): + """When an ApiException is raised, the function should return False.""" + mock_v1 = mocker.patch("core.k8s_client.v1") + mock_v1.list_pod_for_all_namespaces.side_effect = ApiException() + + assert not is_cluster_ready() + + +def test_create_integration_route_cluster_not_reachable(route_data, mocker): + """When the cluster is not reachable, create_integration_route should raise an ApiException.""" + mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) + with pytest.raises(ApiException): + create_integration_route(route_data, "configmap-name") + + +def test_create_route_configmap_cluster_not_reachable(route_data, mocker): + """When the cluster is not reachable, create_route_configmap should raise an ApiException.""" + mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) + with pytest.raises(ApiException): + create_route_configmap(route_data) + + +def test_is_cluster_ready_pod_no_status(mocker): + """When a pod has no status, the function should return False.""" + mock_pod = client.V1Pod( + metadata=client.V1ObjectMeta( + name="integrationroute-webhook-pod", namespace="default" + ), + status=None, + ) + pod_list = client.V1PodList(items=[mock_pod]) + + mock_v1 = mocker.patch("core.k8s_client.v1") + mock_v1.list_pod_for_all_namespaces.return_value = pod_list + + assert not is_cluster_ready() diff --git a/operator/webapp/models.py b/operator/webapp/models.py new file mode 100644 index 0000000..ea0d370 --- /dev/null +++ b/operator/webapp/models.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +class Status: + CREATED = "created" + DELETED = "deleted" + UPDATED = "updated" + RECREATED = "recreated" + + +@dataclass +class RouteData: + + route_name: str + route_file: str + namespace: str = "default" + + +@dataclass +class Resource: + name: str + status: Status diff --git a/operator/webapp/requirements-dev.txt b/operator/webapp/requirements-dev.txt index bb41ff9..4d0759e 100644 --- a/operator/webapp/requirements-dev.txt +++ b/operator/webapp/requirements-dev.txt @@ -3,4 +3,5 @@ coverage==7.10.6 httpx==0.28.1 mypy==1.18.1 pytest==8.4.2 +pytest-mock==3.14.1 ruff==0.13.0 \ No newline at end of file diff --git a/operator/webapp/requirements.txt b/operator/webapp/requirements.txt index e5cf58c..146f789 100644 --- a/operator/webapp/requirements.txt +++ b/operator/webapp/requirements.txt @@ -1,2 +1,4 @@ +kubernetes==33.1.0 +python-multipart starlette==0.47.3 uvicorn[standard]==0.35.0 diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py new file mode 100644 index 0000000..14488e9 --- /dev/null +++ b/operator/webapp/routes/deploy.py @@ -0,0 +1,84 @@ +import logging +import re +from pathlib import Path + +from dataclasses import asdict + +from starlette.exceptions import HTTPException +from starlette.responses import JSONResponse +from starlette.requests import Request +from starlette.routing import Route, Router + +from models import RouteData +from core import k8s_client +from logconf import LOG_CONF + + +logging.config.dictConfig(LOG_CONF) +_LOGGER = logging.getLogger(__name__) + + +async def deploy_route(request: Request): + _LOGGER.info("Received deployment request") + try: + async with request.form() as form: + if "upload_file" not in form: + raise HTTPException( + status_code=400, detail="Missing request parameter: upload_file" + ) + + filename = Path(form["upload_file"].filename).stem + route_file = await form["upload_file"].read() + content_type = form["upload_file"].content_type + + if content_type is None or content_type.lower() != "application/xml": + _LOGGER.warning("Invalid content type: %s", content_type) + raise HTTPException( + status_code=400, + detail="No Integration Route XML file found in form data", + ) + + route_data = RouteData( + route_name=_generate_route_name(filename), + route_file=route_file.decode("utf-8"), + ) + + _LOGGER.info("Creating resources for route: %s", route_data.route_name) + created_resources = k8s_client.create_route_resources(route_data) + + _LOGGER.debug("Created new resources: %s", created_resources) + return JSONResponse( + [asdict(resource) for resource in created_resources], status_code=201 + ) + + except HTTPException: + raise + except UnicodeDecodeError: + _LOGGER.warning("Invalid XML file encoding") + raise HTTPException(status_code=400, detail="Invalid XML file encoding") + except Exception as e: + _LOGGER.error("An unexpected error occurred: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +async def get_cluster_health(request: Request): + _LOGGER.info("Received cluster health request") + if k8s_client.is_cluster_ready(): + return JSONResponse(content={"status": "UP"}, status_code=200) + else: + return JSONResponse(content={"status": "DOWN"}, status_code=200) + + +def _generate_route_name(filename: str) -> str: + filename = filename.replace("_", "-") + filename = re.sub(r"[^a-z0-9-]", "", filename.lower()) + filename = filename.strip("-") + return filename + + +router = Router( + [ + Route("/", endpoint=deploy_route, methods=["POST"]), + Route("/cluster-health", endpoint=get_cluster_health, methods=["GET"]), + ] +) diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py new file mode 100644 index 0000000..daf4358 --- /dev/null +++ b/operator/webapp/routes/test/test_deploy.py @@ -0,0 +1,120 @@ +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from routes import deploy +from models import Resource, Status + + +@pytest.fixture +def mock_k8s_client(mocker): + return mocker.patch("routes.deploy.k8s_client") + + +@pytest.fixture(scope="module") +def client(): + app = Starlette() + app.mount("/deploy", deploy.router) + return TestClient(app) + + +resources = [Resource(name="my-route", status=Status.CREATED)] + + +def test_deploy_route(mock_k8s_client, client): + mock_k8s_client.create_route_resources.return_value = resources + + res = client.post( + "/deploy/", + files={ + "upload_file": ( + "my-route.xml", + b"...", + "application/xml", + ) + }, + ) + + assert res.status_code == 201 + result = res.json() + assert len(result) == 1 + assert result[0]["name"] == "my-route" + assert result[0]["status"] == Status.CREATED + + +@pytest.mark.parametrize("content_type", ["application/json", ""]) +def test_deploy_route_invalid_content_type(client, content_type): + res = client.post( + "/deploy/", + files={ + "upload_file": ( + "my-route.xml", + b"...", + content_type, + ) + }, + ) + + assert res.status_code == 400 + assert "No Integration Route XML file found in form data" in res.text + + +def test_deploy_route_missing_upload_file(mock_k8s_client, client): + res = client.post("/deploy/", files={}) + + assert res.status_code == 400 + assert "Missing request parameter: upload_file" in res.text + + +def test_deploy_route_unsupported_file_encoding(mock_k8s_client, client): + res = client.post( + "/deploy/", + files={ + "upload_file": ( + "my-route.xml", + "...".encode("utf-16"), + "application/xml", + ) + }, + ) + + assert res.status_code == 400 + assert "Invalid XML file encoding" in res.text + + +def test_cluster_health(mock_k8s_client, client): + mock_k8s_client.is_cluster_ready.return_value = True + + response = client.get("/deploy/cluster-health") + + assert response.status_code == 200 + assert response.json() == {"status": "UP"} + + +def test_cluster_health_cluster_down(mock_k8s_client, client): + mock_k8s_client.is_cluster_ready.return_value = False + + response = client.get("/deploy/cluster-health") + + assert response.status_code == 200 + assert response.json() == {"status": "DOWN"} + + +def test_deploy_route_generic_exception(mock_k8s_client, client): + mock_k8s_client.create_route_resources.side_effect = Exception( + "Something went wrong" + ) + + res = client.post( + "/deploy/", + files={ + "upload_file": ( + "my-route.xml", + b"...", + "application/xml", + ) + }, + ) + + assert res.status_code == 500 + assert "Internal server error" in res.text diff --git a/operator/webapp/routes/webhook.py b/operator/webapp/routes/webhook.py index 7adc1b9..5c4b5e2 100644 --- a/operator/webapp/routes/webhook.py +++ b/operator/webapp/routes/webhook.py @@ -12,7 +12,6 @@ from core.sync import sync from addons.certmanager.main import sync_certificate - _LOGGER = logging.getLogger(__name__) @@ -40,6 +39,13 @@ async def webhook(request: Request): async def status(request): + """ + responses: + 200: + description: The liveness status of the webhook + examples: + [{"status":"UP"}] + """ return JSONResponse({"status": "UP"}) From 33fd5f09d285f7ba65441e5a4186622c77398336 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 18 Sep 2025 16:36:40 -0700 Subject: [PATCH 02/15] Create cluster role needed for interacting with the cluster from within keip --- operator/controller/core-controller.yaml | 2 +- operator/controller/core-privileges.yaml | 35 ++++++++++++++++++ operator/webapp/core/k8s_client.py | 14 +++----- operator/webapp/routes/deploy.py | 2 +- operator/webapp/routes/test/test_deploy.py | 42 +++++++++++----------- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/operator/controller/core-controller.yaml b/operator/controller/core-controller.yaml index af3ea26..f0bf91a 100644 --- a/operator/controller/core-controller.yaml +++ b/operator/controller/core-controller.yaml @@ -46,7 +46,6 @@ metadata: namespace: keip spec: replicas: 1 - service-account: integrationroute-service selector: matchLabels: app: integrationroute-webhook @@ -55,6 +54,7 @@ spec: labels: app: integrationroute-webhook spec: + serviceAccountName: controller-service containers: - name: webhook image: ghcr.io/codice/keip/webapp:0.16.0 diff --git a/operator/controller/core-privileges.yaml b/operator/controller/core-privileges.yaml index 4d648bc..c7cf756 100644 --- a/operator/controller/core-privileges.yaml +++ b/operator/controller/core-privileges.yaml @@ -23,4 +23,39 @@ subjects: roleRef: kind: Role name: spring-cloud-kubernetes + apiGroup: "" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: controller-service + namespace: keip +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: controller-kubernetes-manager +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["keip.codice.org"] + resources: ["integrationroutes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: controller-kubernetes-binding +subjects: + - kind: ServiceAccount + name: controller-service + namespace: keip + apiGroup: "" +roleRef: + kind: ClusterRole + name: controller-kubernetes-manager apiGroup: "" \ No newline at end of file diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 467acad..45de1aa 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -1,11 +1,9 @@ from typing import List from kubernetes import config, client from kubernetes.client.rest import ApiException -from urllib3.util.retry import Retry -import logging.config +import logging from models import RouteData, Resource, Status -from logconf import LOG_CONF ROUTE_API_GROUP = "keip.codice.org" ROUTE_API_VERSION = "v1alpha1" @@ -13,8 +11,6 @@ WEBHOOK_CONTROLLER_PREFIX = "integrationroute-webhook" -logging.config.dictConfig(LOG_CONF) -logging.getLogger("urllib3").setLevel(logging.ERROR) _LOGGER = logging.getLogger(__name__) @@ -36,7 +32,7 @@ def _check_cluster_reachable(): try: - v1.list_namespace(limit=1, timeout_seconds=5) + v1.get_api_resources() return True except Exception: return False @@ -47,7 +43,7 @@ def create_integration_route(route_data: RouteData, configmap_name: str) -> Reso if not _check_cluster_reachable(): raise ApiException( status=500, - reason="Kubernetes cluster not reachable. Verify the cluster is running" + reason="Kubernetes cluster not reachable. Verify the cluster is running", ) existing_route = routeApi.list_namespaced_custom_object( @@ -148,7 +144,7 @@ def is_cluster_ready() -> bool: _LOGGER.warning("Kubernetes client not reachable, cluster is not ready") return False try: - pods = v1.list_pod_for_all_namespaces() + pods = v1.list_namespaced_pod(namespace="keip") matching_pods = [] for pod in pods.items: if pod.metadata.name.startswith(WEBHOOK_CONTROLLER_PREFIX): @@ -174,4 +170,4 @@ def is_cluster_ready() -> bool: return True except ApiException as e: logging.error(f"Cluster is not ready: {e}") - return False \ No newline at end of file + return False diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index 14488e9..b6fc884 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -78,7 +78,7 @@ def _generate_route_name(filename: str) -> str: router = Router( [ - Route("/", endpoint=deploy_route, methods=["POST"]), + Route("/route", endpoint=deploy_route, methods=["POST"]), Route("/cluster-health", endpoint=get_cluster_health, methods=["GET"]), ] ) diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index daf4358..b46de49 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -12,7 +12,7 @@ def mock_k8s_client(mocker): @pytest.fixture(scope="module") -def client(): +def test_client(): app = Starlette() app.mount("/deploy", deploy.router) return TestClient(app) @@ -21,11 +21,11 @@ def client(): resources = [Resource(name="my-route", status=Status.CREATED)] -def test_deploy_route(mock_k8s_client, client): +def test_deploy_route(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.return_value = resources - res = client.post( - "/deploy/", + res = test_client.post( + "/deploy/route", files={ "upload_file": ( "my-route.xml", @@ -43,9 +43,9 @@ def test_deploy_route(mock_k8s_client, client): @pytest.mark.parametrize("content_type", ["application/json", ""]) -def test_deploy_route_invalid_content_type(client, content_type): - res = client.post( - "/deploy/", +def test_deploy_route_invalid_content_type(test_client, content_type): + res = test_client.post( + "/deploy/route", files={ "upload_file": ( "my-route.xml", @@ -59,16 +59,16 @@ def test_deploy_route_invalid_content_type(client, content_type): assert "No Integration Route XML file found in form data" in res.text -def test_deploy_route_missing_upload_file(mock_k8s_client, client): - res = client.post("/deploy/", files={}) +def test_deploy_route_missing_upload_file(test_client): + res = test_client.post("/deploy/route", files={}) assert res.status_code == 400 assert "Missing request parameter: upload_file" in res.text -def test_deploy_route_unsupported_file_encoding(mock_k8s_client, client): - res = client.post( - "/deploy/", +def test_deploy_route_unsupported_file_encoding(test_client): + response = test_client.post( + "/deploy/route", files={ "upload_file": ( "my-route.xml", @@ -78,35 +78,35 @@ def test_deploy_route_unsupported_file_encoding(mock_k8s_client, client): }, ) - assert res.status_code == 400 - assert "Invalid XML file encoding" in res.text + assert response.status_code == 400 + assert "Invalid XML file encoding" in response.text -def test_cluster_health(mock_k8s_client, client): +def test_cluster_health(mock_k8s_client, test_client): mock_k8s_client.is_cluster_ready.return_value = True - response = client.get("/deploy/cluster-health") + response = test_client.get("/deploy/cluster-health") assert response.status_code == 200 assert response.json() == {"status": "UP"} -def test_cluster_health_cluster_down(mock_k8s_client, client): +def test_cluster_health_cluster_down(mock_k8s_client, test_client): mock_k8s_client.is_cluster_ready.return_value = False - response = client.get("/deploy/cluster-health") + response = test_client.get("/deploy/cluster-health") assert response.status_code == 200 assert response.json() == {"status": "DOWN"} -def test_deploy_route_generic_exception(mock_k8s_client, client): +def test_deploy_route_generic_exception(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.side_effect = Exception( "Something went wrong" ) - res = client.post( - "/deploy/", + res = test_client.post( + "/deploy/route", files={ "upload_file": ( "my-route.xml", From f1bafe09c7a95280c3dbe97082a73bf2478e67d2 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 18 Sep 2025 16:40:06 -0700 Subject: [PATCH 03/15] Remove health check endpoint --- operator/webapp/core/k8s_client.py | 34 --------- operator/webapp/core/test/test_k8s_client.py | 78 +------------------- operator/webapp/routes/deploy.py | 15 +--- operator/webapp/routes/test/test_deploy.py | 18 ----- 4 files changed, 2 insertions(+), 143 deletions(-) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 45de1aa..1ae0c1e 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -137,37 +137,3 @@ def create_route_resources(route_data: RouteData) -> List[Resource]: route_data=route_data, configmap_name=route_cm.name ) return [route_cm, route] - - -def is_cluster_ready() -> bool: - if not _check_cluster_reachable(): - _LOGGER.warning("Kubernetes client not reachable, cluster is not ready") - return False - try: - pods = v1.list_namespaced_pod(namespace="keip") - matching_pods = [] - for pod in pods.items: - if pod.metadata.name.startswith(WEBHOOK_CONTROLLER_PREFIX): - ready = False - if pod.status and pod.status.conditions: - for condition in pod.status.conditions: - if condition.type == "Ready": - ready = condition.status - break - - matching_pods.append( - { - "name": pod.metadata.name, - "ready": ready, - "phase": pod.status.phase if pod.status else "Unknown", - } - ) - - if any(pod["ready"] for pod in matching_pods): - _LOGGER.debug( - msg=f"Integration Route Webhook pods ready to take requests: {matching_pods}" - ) - return True - except ApiException as e: - logging.error(f"Cluster is not ready: {e}") - return False diff --git a/operator/webapp/core/test/test_k8s_client.py b/operator/webapp/core/test/test_k8s_client.py index 46bc42a..8894051 100644 --- a/operator/webapp/core/test/test_k8s_client.py +++ b/operator/webapp/core/test/test_k8s_client.py @@ -1,10 +1,6 @@ import pytest from kubernetes import client -from core.k8s_client import ( - create_integration_route, - create_route_configmap, - is_cluster_ready, -) +from core.k8s_client import create_integration_route, create_route_configmap from models import RouteData, Resource, Status from kubernetes.client.rest import ApiException @@ -77,62 +73,6 @@ def test_create_integration_route_updates_existing(route_data, mock_api): assert res.status == Status.RECREATED -@pytest.mark.parametrize("ready_flag", [True, False]) -def test_is_cluster_ready_various(mocker, ready_flag): - """Verify that the function returns True only when at least one pod is Ready.""" - mock_pod = client.V1Pod( - metadata=client.V1ObjectMeta( - name="integrationroute-webhook-pod", namespace="default" - ), - status=client.V1PodStatus( - phase="Running", - conditions=[client.V1PodCondition(type="Ready", status=ready_flag)], - ), - ) - pod_list = client.V1PodList(items=[mock_pod]) - - mock_v1 = mocker.patch("core.k8s_client.v1") - mock_v1.list_pod_for_all_namespaces.return_value = pod_list - - assert is_cluster_ready() == ready_flag - - -def test_is_cluster_ready_no_matching_pods(mocker): - """When there are no pods with the prefix, the function should return False.""" - pod_list = client.V1PodList(items=[]) - mock_v1 = mocker.patch("core.k8s_client.v1") - mock_v1.list_pod_for_all_namespaces.return_value = pod_list - - assert not is_cluster_ready() - - -def test_is_cluster_ready_pod_no_conditions(mocker): - """When a pod has no conditions, the function should return False.""" - mock_pod = client.V1Pod( - metadata=client.V1ObjectMeta( - name="integrationroute-webhook-pod", namespace="default" - ), - status=client.V1PodStatus( - phase="Running", - conditions=[], - ), - ) - pod_list = client.V1PodList(items=[mock_pod]) - - mock_v1 = mocker.patch("core.k8s_client.v1") - mock_v1.list_pod_for_all_namespaces.return_value = pod_list - - assert not is_cluster_ready() - - -def test_is_cluster_ready_api_exception(mocker): - """When an ApiException is raised, the function should return False.""" - mock_v1 = mocker.patch("core.k8s_client.v1") - mock_v1.list_pod_for_all_namespaces.side_effect = ApiException() - - assert not is_cluster_ready() - - def test_create_integration_route_cluster_not_reachable(route_data, mocker): """When the cluster is not reachable, create_integration_route should raise an ApiException.""" mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) @@ -145,19 +85,3 @@ def test_create_route_configmap_cluster_not_reachable(route_data, mocker): mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) with pytest.raises(ApiException): create_route_configmap(route_data) - - -def test_is_cluster_ready_pod_no_status(mocker): - """When a pod has no status, the function should return False.""" - mock_pod = client.V1Pod( - metadata=client.V1ObjectMeta( - name="integrationroute-webhook-pod", namespace="default" - ), - status=None, - ) - pod_list = client.V1PodList(items=[mock_pod]) - - mock_v1 = mocker.patch("core.k8s_client.v1") - mock_v1.list_pod_for_all_namespaces.return_value = pod_list - - assert not is_cluster_ready() diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index b6fc884..86cdf92 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -61,14 +61,6 @@ async def deploy_route(request: Request): raise HTTPException(status_code=500, detail="Internal server error") -async def get_cluster_health(request: Request): - _LOGGER.info("Received cluster health request") - if k8s_client.is_cluster_ready(): - return JSONResponse(content={"status": "UP"}, status_code=200) - else: - return JSONResponse(content={"status": "DOWN"}, status_code=200) - - def _generate_route_name(filename: str) -> str: filename = filename.replace("_", "-") filename = re.sub(r"[^a-z0-9-]", "", filename.lower()) @@ -76,9 +68,4 @@ def _generate_route_name(filename: str) -> str: return filename -router = Router( - [ - Route("/route", endpoint=deploy_route, methods=["POST"]), - Route("/cluster-health", endpoint=get_cluster_health, methods=["GET"]), - ] -) +router = Router([Route("/route", endpoint=deploy_route, methods=["POST"])]) diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index b46de49..98e1539 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -82,24 +82,6 @@ def test_deploy_route_unsupported_file_encoding(test_client): assert "Invalid XML file encoding" in response.text -def test_cluster_health(mock_k8s_client, test_client): - mock_k8s_client.is_cluster_ready.return_value = True - - response = test_client.get("/deploy/cluster-health") - - assert response.status_code == 200 - assert response.json() == {"status": "UP"} - - -def test_cluster_health_cluster_down(mock_k8s_client, test_client): - mock_k8s_client.is_cluster_ready.return_value = False - - response = test_client.get("/deploy/cluster-health") - - assert response.status_code == 200 - assert response.json() == {"status": "DOWN"} - - def test_deploy_route_generic_exception(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.side_effect = Exception( "Something went wrong" From b317e6d6d380b1d4cc6f3ecb1793a01b11c1b6a0 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 18 Sep 2025 17:22:53 -0700 Subject: [PATCH 04/15] Add 'created-by' labels to track to the resources created by the deploy endpoint --- operator/webapp/core/k8s_client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 1ae0c1e..3884fad 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -57,7 +57,10 @@ def create_integration_route(route_data: RouteData, configmap_name: str) -> Reso body = { "apiVersion": "keip.codice.org/v1alpha1", "kind": "IntegrationRoute", - "metadata": {"name": route_data.route_name}, + "metadata": { + "name": route_data.route_name, + "labels": {"app.kubernetes.io/created-by": "keip"} + }, "spec": {"routeConfigMap": configmap_name}, } @@ -97,7 +100,8 @@ def create_route_configmap(route_data: RouteData) -> Resource: configmap_name = f"{route_data.route_name}-cm" configmap = client.V1ConfigMap( metadata=client.V1ObjectMeta( - name=configmap_name, namespace=route_data.namespace + name=configmap_name, namespace=route_data.namespace, + labels={"app.kubernetes.io/created-by": "keip"} ), data={"integrationRoute.xml": route_data.route_file}, ) From ebbde85152b5feab2e0a06989dcc8216f77f3a45 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Fri, 19 Sep 2025 11:43:03 -0700 Subject: [PATCH 05/15] Update docstrings --- operator/webapp/core/k8s_client.py | 63 ++++++++++++++++++++++++++++-- operator/webapp/routes/deploy.py | 32 +++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 3884fad..0628ec3 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -31,6 +31,20 @@ def _check_cluster_reachable(): + """ + Checks if the Kubernetes cluster is reachable by attempting to retrieve API resources. + + This function attempts to call the Kubernetes API to list available API resources. + If the call succeeds, it confirms that the cluster is reachable and returns True. + If the call fails or raises an exception, it indicates that the cluster is unreachable + and returns False. + + Returns: + bool: True if the cluster is reachable, False otherwise. + + Raises: + Exception: If an unexpected error occurs during the API call. + """ try: v1.get_api_resources() return True @@ -59,7 +73,7 @@ def create_integration_route(route_data: RouteData, configmap_name: str) -> Reso "kind": "IntegrationRoute", "metadata": { "name": route_data.route_name, - "labels": {"app.kubernetes.io/created-by": "keip"} + "labels": {"app.kubernetes.io/created-by": "keip"}, }, "spec": {"routeConfigMap": configmap_name}, } @@ -90,7 +104,22 @@ def create_integration_route(route_data: RouteData, configmap_name: str) -> Reso def create_route_configmap(route_data: RouteData) -> Resource: - """Create or update a ConfigMap with an XML route payload""" + """ + Creates or updates a ConfigMap containing an XML route payload for an integration route. + + This function generates a ConfigMap with the provided route configuration and creates or updates + it in the specified namespace. + + Args: + route_data (RouteData): The route data containing the route name, namespace, and XML route file. + + Returns: + Resource: A Resource object indicating the status (CREATED or UPDATED) and name of the created/updated ConfigMap. + + Raises: + ApiException: If the Kubernetes cluster is unreachable or if there is an error during the API call. + Exception: If an unexpected error occurs during processing. + """ if not _check_cluster_reachable(): raise ApiException( status=500, @@ -100,8 +129,9 @@ def create_route_configmap(route_data: RouteData) -> Resource: configmap_name = f"{route_data.route_name}-cm" configmap = client.V1ConfigMap( metadata=client.V1ObjectMeta( - name=configmap_name, namespace=route_data.namespace, - labels={"app.kubernetes.io/created-by": "keip"} + name=configmap_name, + namespace=route_data.namespace, + labels={"app.kubernetes.io/created-by": "keip"}, ), data={"integrationRoute.xml": route_data.route_file}, ) @@ -136,6 +166,31 @@ def create_route_configmap(route_data: RouteData) -> Resource: def create_route_resources(route_data: RouteData) -> List[Resource]: + """ + Creates both a ConfigMap and an Integration Route resource for the specified route configuration. + + This function orchestrates the creation of two Kubernetes resources: + 1. A ConfigMap containing the XML route payload for the integration route + 2. An Integration Route resource that routes traffic based on the provided configuration + + The function first creates the ConfigMap using the provided route data, then creates the Integration Route + using the ConfigMap name as the routeConfigMap reference. If a ConfigMap with the same name already exists, + it is updated rather than recreated. The Integration Route is created or updated based on the existing state. + + Args: + route_data (RouteData): The route data containing the route name, namespace, and XML route file. + Must include all required fields to properly configure the integration route. + + Returns: + List[Resource]: A list containing two Resource objects: + - The created/updated ConfigMap resource + - The created/updated Integration Route resource + The resources are returned in the order: [ConfigMap, Integration Route] + + Raises: + ApiException: If the Kubernetes cluster is unreachable or if there is an error during API calls. + Exception: If an unexpected error occurs during processing or resource creation. + """ route_cm = create_route_configmap(route_data=route_data) route = create_integration_route( route_data=route_data, configmap_name=route_cm.name diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index 86cdf92..302662e 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -19,6 +19,24 @@ async def deploy_route(request: Request): + """ + Handles the deployment of an integration route via an XML file upload. + + The endpoint accepts a POST request with an XML file uploaded via form data. + It validates the file content type, extracts the route name from the filename, + and creates Kubernetes resources for the route using the provided XML configuration. + + Args: + request (Request): The incoming HTTP request containing the form data. + + Returns: + JSONResponse: A 201 status code response with the created resources in JSON format. + + Raises: + HTTPException: If the upload file is missing or has an invalid content type. + UnicodeDecodeError: If the XML file cannot be decoded properly. + HTTPException: If an unexpected error occurs during processing. + """ _LOGGER.info("Received deployment request") try: async with request.form() as form: @@ -62,6 +80,20 @@ async def deploy_route(request: Request): def _generate_route_name(filename: str) -> str: + """ + Generates a valid route name from a filename by replacing underscores with hyphens + and removing invalid characters, ensuring it adheres to Kubernetes naming conventions. + + Args: + filename (str): The original filename from which to generate the route name. + + Returns: + str: A cleaned, valid route name in lowercase with only alphanumeric characters and hyphens. + + Example: + Input: "my-route_with_invalid_chars.txt" + Output: "my-route-with-invalid-chars" + """ filename = filename.replace("_", "-") filename = re.sub(r"[^a-z0-9-]", "", filename.lower()) filename = filename.strip("-") From c145242cb28d933c2c5705d0b2c58310e1a37315 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Fri, 19 Sep 2025 11:49:47 -0700 Subject: [PATCH 06/15] rename deploy route to '/route' --- operator/webapp/README.md | 10 +++------- operator/webapp/app.py | 2 +- operator/webapp/routes/deploy.py | 2 +- operator/webapp/routes/test/test_deploy.py | 12 ++++++------ operator/webapp/routes/webhook.py | 8 +------- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/operator/webapp/README.md b/operator/webapp/README.md index 375c7f2..29926b7 100644 --- a/operator/webapp/README.md +++ b/operator/webapp/README.md @@ -2,13 +2,9 @@ A Python web server that implements the following endpoints: - `/webhook`: A [lambda controller from the Metacontroller API](https://metacontroller.github.io/metacontroller/concepts.html#lambda-controller). -- `/deploy`: Deploys a route from an XML file. -- `/cluster-health`: Returns the health of the cluster. -The webhook will be called as part of the Metacontroller control loop when `IntegrationRoute` parent -resources are detected. - - The webhook contains two endpoints, `/webhook/sync` and `/webhook/addons/certmanager/sync`. +- `/route`: Deploys a route from an XML file. +The webhook contains two endpoints, `/webhook/sync` and `/webhook/addons/certmanager/sync`. - `/webhook/sync`: The core logic that creates a `Deployment` from `IntegrationRoute` resources. - `/webhook/addons/certmanager/sync`: An add-on that creates a [cert-manager.io/v1.Certificate](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.Certificate) @@ -21,7 +17,7 @@ resources are detected. This web server is designed to be run as a service within a Kubernetes cluster. It is intended to be used with [Metacontroller](https://metacontroller.github.io/metacontroller/), which will call the `/webhook` endpoint to manage `IntegrationRoute` custom resources. -The `/deploy` endpoint is provided for convenience to deploy routes from XML files. The `/cluster-health` endpoint can be used as a liveness or readiness probe. +The `/route` endpoint is provided for convenience to deploy routes from XML files. The `/cluster-health` endpoint can be used as a liveness or readiness probe. ## Developer Guide diff --git a/operator/webapp/app.py b/operator/webapp/app.py index 361e3b8..3bc718c 100644 --- a/operator/webapp/app.py +++ b/operator/webapp/app.py @@ -19,7 +19,7 @@ def create_app() -> ASGIApp: app = Starlette(debug=cfg.DEBUG) app.mount("/webhook", webhook.router) - app.mount("/deploy", deploy.router) + app.mount("/route", deploy.router) return app diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index 302662e..5e67177 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -100,4 +100,4 @@ def _generate_route_name(filename: str) -> str: return filename -router = Router([Route("/route", endpoint=deploy_route, methods=["POST"])]) +router = Router([Route("/", endpoint=deploy_route, methods=["POST"])]) diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index 98e1539..587107d 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -14,7 +14,7 @@ def mock_k8s_client(mocker): @pytest.fixture(scope="module") def test_client(): app = Starlette() - app.mount("/deploy", deploy.router) + app.mount("/route", deploy.router) return TestClient(app) @@ -25,7 +25,7 @@ def test_deploy_route(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.return_value = resources res = test_client.post( - "/deploy/route", + "/route/", files={ "upload_file": ( "my-route.xml", @@ -45,7 +45,7 @@ def test_deploy_route(mock_k8s_client, test_client): @pytest.mark.parametrize("content_type", ["application/json", ""]) def test_deploy_route_invalid_content_type(test_client, content_type): res = test_client.post( - "/deploy/route", + "/route/", files={ "upload_file": ( "my-route.xml", @@ -60,7 +60,7 @@ def test_deploy_route_invalid_content_type(test_client, content_type): def test_deploy_route_missing_upload_file(test_client): - res = test_client.post("/deploy/route", files={}) + res = test_client.post("/route/", files={}) assert res.status_code == 400 assert "Missing request parameter: upload_file" in res.text @@ -68,7 +68,7 @@ def test_deploy_route_missing_upload_file(test_client): def test_deploy_route_unsupported_file_encoding(test_client): response = test_client.post( - "/deploy/route", + "/route/", files={ "upload_file": ( "my-route.xml", @@ -88,7 +88,7 @@ def test_deploy_route_generic_exception(mock_k8s_client, test_client): ) res = test_client.post( - "/deploy/route", + "/route/", files={ "upload_file": ( "my-route.xml", diff --git a/operator/webapp/routes/webhook.py b/operator/webapp/routes/webhook.py index 5c4b5e2..7adc1b9 100644 --- a/operator/webapp/routes/webhook.py +++ b/operator/webapp/routes/webhook.py @@ -12,6 +12,7 @@ from core.sync import sync from addons.certmanager.main import sync_certificate + _LOGGER = logging.getLogger(__name__) @@ -39,13 +40,6 @@ async def webhook(request: Request): async def status(request): - """ - responses: - 200: - description: The liveness status of the webhook - examples: - [{"status":"UP"}] - """ return JSONResponse({"status": "UP"}) From b20d910a0a094af41738db865fc6627cbddfc829 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Mon, 22 Sep 2025 13:01:57 -0700 Subject: [PATCH 07/15] Address comments --- operator/Makefile | 2 +- operator/controller/core-controller.yaml | 4 ++-- operator/controller/core-privileges.yaml | 4 ++-- operator/webapp/Makefile | 2 +- operator/webapp/core/k8s_client.py | 19 ++++++++----------- operator/webapp/core/test/test_k8s_client.py | 18 +++++++++--------- operator/webapp/models.py | 3 ++- operator/webapp/requirements.txt | 2 +- operator/webapp/routes/deploy.py | 4 ++-- operator/webapp/routes/test/test_deploy.py | 10 +++++----- 10 files changed, 33 insertions(+), 35 deletions(-) diff --git a/operator/Makefile b/operator/Makefile index fd90d38..0035085 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -1,4 +1,4 @@ -VERSION ?= 0.13.1 +VERSION ?= 0.14.0 GIT_TAG := operator_v$(VERSION) KEIP_INTEGRATION_IMAGE ?= ghcr.io/codice/keip/minimal-app:latest diff --git a/operator/controller/core-controller.yaml b/operator/controller/core-controller.yaml index f0bf91a..88fbad5 100644 --- a/operator/controller/core-controller.yaml +++ b/operator/controller/core-controller.yaml @@ -54,10 +54,10 @@ spec: labels: app: integrationroute-webhook spec: - serviceAccountName: controller-service + serviceAccountName: keip-controller-service containers: - name: webhook - image: ghcr.io/codice/keip/webapp:0.16.0 + image: ghcr.io/codice/keip/webapp:0.17.0 ports: - containerPort: 7080 name: webhook-http diff --git a/operator/controller/core-privileges.yaml b/operator/controller/core-privileges.yaml index c7cf756..2a458b2 100644 --- a/operator/controller/core-privileges.yaml +++ b/operator/controller/core-privileges.yaml @@ -28,7 +28,7 @@ roleRef: apiVersion: v1 kind: ServiceAccount metadata: - name: controller-service + name: keip-controller-service namespace: keip --- kind: ClusterRole @@ -52,7 +52,7 @@ metadata: name: controller-kubernetes-binding subjects: - kind: ServiceAccount - name: controller-service + name: keip-controller-service namespace: keip apiGroup: "" roleRef: diff --git a/operator/webapp/Makefile b/operator/webapp/Makefile index e79597a..b245f05 100644 --- a/operator/webapp/Makefile +++ b/operator/webapp/Makefile @@ -1,4 +1,4 @@ -VERSION ?= 0.16.0 +VERSION ?= 0.17.0 HOST_PORT ?= 7080 GIT_TAG := webapp_v$(VERSION) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 0628ec3..ca8d64d 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -1,4 +1,4 @@ -from typing import List +from typing import Tuple from kubernetes import config, client from kubernetes.client.rest import ApiException import logging @@ -41,9 +41,6 @@ def _check_cluster_reachable(): Returns: bool: True if the cluster is reachable, False otherwise. - - Raises: - Exception: If an unexpected error occurs during the API call. """ try: v1.get_api_resources() @@ -52,7 +49,7 @@ def _check_cluster_reachable(): return False -def create_integration_route(route_data: RouteData, configmap_name: str) -> Resource: +def _create_integration_route(route_data: RouteData, configmap_name: str) -> Resource: """Create or update a new Integration Route with the provided configmap""" if not _check_cluster_reachable(): raise ApiException( @@ -103,7 +100,7 @@ def create_integration_route(route_data: RouteData, configmap_name: str) -> Reso return Resource(status=status, name=route_data.route_name) -def create_route_configmap(route_data: RouteData) -> Resource: +def _create_route_configmap(route_data: RouteData) -> Resource: """ Creates or updates a ConfigMap containing an XML route payload for an integration route. @@ -165,7 +162,7 @@ def create_route_configmap(route_data: RouteData) -> Resource: return Resource(status=status, name=configmap_name) -def create_route_resources(route_data: RouteData) -> List[Resource]: +def create_route_resources(route_data: RouteData) -> Tuple[Resource]: """ Creates both a ConfigMap and an Integration Route resource for the specified route configuration. @@ -182,7 +179,7 @@ def create_route_resources(route_data: RouteData) -> List[Resource]: Must include all required fields to properly configure the integration route. Returns: - List[Resource]: A list containing two Resource objects: + Tuple[Resource]: A list containing two Resource objects: - The created/updated ConfigMap resource - The created/updated Integration Route resource The resources are returned in the order: [ConfigMap, Integration Route] @@ -191,8 +188,8 @@ def create_route_resources(route_data: RouteData) -> List[Resource]: ApiException: If the Kubernetes cluster is unreachable or if there is an error during API calls. Exception: If an unexpected error occurs during processing or resource creation. """ - route_cm = create_route_configmap(route_data=route_data) - route = create_integration_route( + route_cm = _create_route_configmap(route_data=route_data) + route = _create_integration_route( route_data=route_data, configmap_name=route_cm.name ) - return [route_cm, route] + return (route_cm, route) diff --git a/operator/webapp/core/test/test_k8s_client.py b/operator/webapp/core/test/test_k8s_client.py index 8894051..f1b7252 100644 --- a/operator/webapp/core/test/test_k8s_client.py +++ b/operator/webapp/core/test/test_k8s_client.py @@ -1,6 +1,6 @@ import pytest from kubernetes import client -from core.k8s_client import create_integration_route, create_route_configmap +from core.k8s_client import _create_integration_route, _create_route_configmap from models import RouteData, Resource, Status from kubernetes.client.rest import ApiException @@ -14,7 +14,7 @@ def route_data(): ) -@pytest.fixture(autouse=True) +@pytest.fixture def mock_api(mocker): """Patch the global `v1` and `routeApi` objects used by k8s_client.""" v1 = mocker.patch("core.k8s_client.v1") @@ -27,14 +27,14 @@ def test_create_route_configmap_creates_new(route_data, mock_api): cm_list = client.V1ConfigMapList(items=[]) mock_api["v1"].list_namespaced_config_map.return_value = cm_list - res: Resource = create_route_configmap(route_data) + res: Resource = _create_route_configmap(route_data) # Verify that the correct name is returned assert res.name == f"{route_data.route_name}-cm" assert res.status == Status.CREATED -def test_create_route_configmap_updates_existing(mocker, route_data, mock_api): +def test_create_route_configmap_updates_existing(route_data, mock_api): """When a ConfigMap already exists the function should replace it.""" cm_name = f"{route_data.route_name}-cm" existing_cm = client.V1ConfigMap( @@ -44,7 +44,7 @@ def test_create_route_configmap_updates_existing(mocker, route_data, mock_api): cm_list = client.V1ConfigMapList(items=[existing_cm]) mock_api["v1"].list_namespaced_config_map.return_value = cm_list - res: Resource = create_route_configmap(route_data) + res: Resource = _create_route_configmap(route_data) assert res.name == cm_name assert res.status == Status.UPDATED @@ -54,7 +54,7 @@ def test_create_integration_route_creates_new(route_data, mock_api): """When no IntegrationRoute exists the function should call create.""" mock_api["route_api"].list_namespaced_custom_object.return_value = {"items": []} - res: Resource = create_integration_route(route_data, f"{route_data.route_name}-cm") + res: Resource = _create_integration_route(route_data, f"{route_data.route_name}-cm") assert res.name == route_data.route_name assert res.status == Status.CREATED @@ -67,7 +67,7 @@ def test_create_integration_route_updates_existing(route_data, mock_api): "items": [existing_ir] } - res: Resource = create_integration_route(route_data, f"{route_data.route_name}-cm") + res: Resource = _create_integration_route(route_data, f"{route_data.route_name}-cm") assert res.name == route_data.route_name assert res.status == Status.RECREATED @@ -77,11 +77,11 @@ def test_create_integration_route_cluster_not_reachable(route_data, mocker): """When the cluster is not reachable, create_integration_route should raise an ApiException.""" mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) with pytest.raises(ApiException): - create_integration_route(route_data, "configmap-name") + _create_integration_route(route_data, "configmap-name") def test_create_route_configmap_cluster_not_reachable(route_data, mocker): """When the cluster is not reachable, create_route_configmap should raise an ApiException.""" mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) with pytest.raises(ApiException): - create_route_configmap(route_data) + _create_route_configmap(route_data) diff --git a/operator/webapp/models.py b/operator/webapp/models.py index ea0d370..1daaf37 100644 --- a/operator/webapp/models.py +++ b/operator/webapp/models.py @@ -1,7 +1,8 @@ from dataclasses import dataclass +from enum import Enum -class Status: +class Status(str, Enum): CREATED = "created" DELETED = "deleted" UPDATED = "updated" diff --git a/operator/webapp/requirements.txt b/operator/webapp/requirements.txt index 146f789..7292db5 100644 --- a/operator/webapp/requirements.txt +++ b/operator/webapp/requirements.txt @@ -1,4 +1,4 @@ kubernetes==33.1.0 -python-multipart +python_multipart==0.0.20 starlette==0.47.3 uvicorn[standard]==0.35.0 diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index 5e67177..9ebd793 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -22,7 +22,7 @@ async def deploy_route(request: Request): """ Handles the deployment of an integration route via an XML file upload. - The endpoint accepts a POST request with an XML file uploaded via form data. + The endpoint accepts a PUT request with an XML file uploaded via form data. It validates the file content type, extracts the route name from the filename, and creates Kubernetes resources for the route using the provided XML configuration. @@ -100,4 +100,4 @@ def _generate_route_name(filename: str) -> str: return filename -router = Router([Route("/", endpoint=deploy_route, methods=["POST"])]) +router = Router([Route("/", endpoint=deploy_route, methods=["PUT"])]) diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index 587107d..825c141 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -24,7 +24,7 @@ def test_client(): def test_deploy_route(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.return_value = resources - res = test_client.post( + res = test_client.put( "/route/", files={ "upload_file": ( @@ -44,7 +44,7 @@ def test_deploy_route(mock_k8s_client, test_client): @pytest.mark.parametrize("content_type", ["application/json", ""]) def test_deploy_route_invalid_content_type(test_client, content_type): - res = test_client.post( + res = test_client.put( "/route/", files={ "upload_file": ( @@ -60,14 +60,14 @@ def test_deploy_route_invalid_content_type(test_client, content_type): def test_deploy_route_missing_upload_file(test_client): - res = test_client.post("/route/", files={}) + res = test_client.put("/route/", files={}) assert res.status_code == 400 assert "Missing request parameter: upload_file" in res.text def test_deploy_route_unsupported_file_encoding(test_client): - response = test_client.post( + response = test_client.put( "/route/", files={ "upload_file": ( @@ -87,7 +87,7 @@ def test_deploy_route_generic_exception(mock_k8s_client, test_client): "Something went wrong" ) - res = test_client.post( + res = test_client.put( "/route/", files={ "upload_file": ( From c4295db9cff0d1f8f9bf908b51c4a039d6dd3a24 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Wed, 24 Sep 2025 10:58:57 -0700 Subject: [PATCH 08/15] Address comments and replace xml upload file with a json payload containing n number of xml integration routes. --- operator/webapp/app.py | 14 ++- operator/webapp/core/k8s_client.py | 2 +- operator/webapp/core/test/test_k8s_client.py | 2 +- operator/webapp/models.py | 14 ++- operator/webapp/requirements.txt | 5 +- operator/webapp/routes/deploy.py | 75 ++++++++------- operator/webapp/routes/test/test_deploy.py | 97 ++++++++++---------- 7 files changed, 117 insertions(+), 92 deletions(-) diff --git a/operator/webapp/app.py b/operator/webapp/app.py index 3bc718c..f030884 100644 --- a/operator/webapp/app.py +++ b/operator/webapp/app.py @@ -1,10 +1,12 @@ import logging.config from starlette.applications import Starlette +from starlette.routing import Route from starlette.types import ASGIApp import config as cfg -from routes import webhook, deploy +from routes import webhook +from routes.deploy import deploy_route from logconf import LOG_CONF @@ -17,11 +19,13 @@ def create_app() -> ASGIApp: if cfg.DEBUG: _LOGGER.warning("Running server with debug mode. NOT SUITABLE FOR PRODUCTION!") - app = Starlette(debug=cfg.DEBUG) - app.mount("/webhook", webhook.router) - app.mount("/route", deploy.router) + routes = [ + Route("/route", deploy_route, methods=["PUT"]) + ] + starlette_app = Starlette(debug=cfg.DEBUG, routes=routes) + starlette_app.mount("/webhook", webhook.router) - return app + return starlette_app app = create_app() diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index ca8d64d..0ce2559 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -130,7 +130,7 @@ def _create_route_configmap(route_data: RouteData) -> Resource: namespace=route_data.namespace, labels={"app.kubernetes.io/created-by": "keip"}, ), - data={"integrationRoute.xml": route_data.route_file}, + data={"integrationRoute.xml": route_data.route_xml}, ) result = v1.list_namespaced_config_map( diff --git a/operator/webapp/core/test/test_k8s_client.py b/operator/webapp/core/test/test_k8s_client.py index f1b7252..b831975 100644 --- a/operator/webapp/core/test/test_k8s_client.py +++ b/operator/webapp/core/test/test_k8s_client.py @@ -10,7 +10,7 @@ def route_data(): return RouteData( namespace="default", route_name="my-route", - route_file="payload", + route_xml="payload", ) diff --git a/operator/webapp/models.py b/operator/webapp/models.py index 1daaf37..5782f17 100644 --- a/operator/webapp/models.py +++ b/operator/webapp/models.py @@ -1,5 +1,7 @@ from dataclasses import dataclass from enum import Enum +from pydantic import BaseModel, Field +from typing import List class Status(str, Enum): @@ -8,13 +10,19 @@ class Status(str, Enum): UPDATED = "updated" RECREATED = "recreated" +class Route(BaseModel): + name: str + namespace: str = "default" + xml: str + +class RouteRequest(BaseModel): + routes: List[Route] = Field(min_length=1) @dataclass class RouteData: - route_name: str - route_file: str - namespace: str = "default" + route_xml: str + namespace: str @dataclass diff --git a/operator/webapp/requirements.txt b/operator/webapp/requirements.txt index 7292db5..25f766b 100644 --- a/operator/webapp/requirements.txt +++ b/operator/webapp/requirements.txt @@ -1,4 +1,5 @@ kubernetes==33.1.0 +pydantic==2.11.9 python_multipart==0.0.20 -starlette==0.47.3 -uvicorn[standard]==0.35.0 +starlette==0.48.0 +uvicorn[standard]==0.37.0 diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index 9ebd793..95a731c 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -1,15 +1,15 @@ -import logging +import logging.config import re -from pathlib import Path from dataclasses import asdict +from pydantic import ValidationError + from starlette.exceptions import HTTPException from starlette.responses import JSONResponse from starlette.requests import Request -from starlette.routing import Route, Router -from models import RouteData +from models import RouteData, RouteRequest from core import k8s_client from logconf import LOG_CONF @@ -22,12 +22,23 @@ async def deploy_route(request: Request): """ Handles the deployment of an integration route via an XML file upload. - The endpoint accepts a PUT request with an XML file uploaded via form data. + The endpoint accepts a PUT request with a JSON payload containing the XML of multiple Integration Routes. It validates the file content type, extracts the route name from the filename, and creates Kubernetes resources for the route using the provided XML configuration. Args: request (Request): The incoming HTTP request containing the form data. + The request body is a JSON payload containing a list of integration routes. + { + "routes": [ + { + "name": route-name + "namespace": "default" + "xml": "..." + } + ... + ] + } Returns: JSONResponse: A 201 status code response with the created resources in JSON format. @@ -39,44 +50,43 @@ async def deploy_route(request: Request): """ _LOGGER.info("Received deployment request") try: - async with request.form() as form: - if "upload_file" not in form: - raise HTTPException( - status_code=400, detail="Missing request parameter: upload_file" - ) - - filename = Path(form["upload_file"].filename).stem - route_file = await form["upload_file"].read() - content_type = form["upload_file"].content_type - - if content_type is None or content_type.lower() != "application/xml": - _LOGGER.warning("Invalid content type: %s", content_type) + body = await request.json() + route_request = RouteRequest(**body) + + content_type = request.headers['content-type'] + if content_type != "application/json": + _LOGGER.warning("Invalid content type: '%s'", content_type) raise HTTPException( status_code=400, detail="No Integration Route XML file found in form data", ) + created_resources = [] + for route in route_request.routes: + route_data = RouteData( + route_name=_generate_route_name(route.name), + route_xml=route.xml, + namespace=route.namespace + ) - route_data = RouteData( - route_name=_generate_route_name(filename), - route_file=route_file.decode("utf-8"), - ) - - _LOGGER.info("Creating resources for route: %s", route_data.route_name) - created_resources = k8s_client.create_route_resources(route_data) + _LOGGER.info("Creating resources for route: %s", route_data.route_name) + created_resources = k8s_client.create_route_resources(route_data) - _LOGGER.debug("Created new resources: %s", created_resources) + _LOGGER.debug("Created new resources: %s", created_resources) return JSONResponse( [asdict(resource) for resource in created_resources], status_code=201 ) except HTTPException: raise - except UnicodeDecodeError: - _LOGGER.warning("Invalid XML file encoding") - raise HTTPException(status_code=400, detail="Invalid XML file encoding") + except ValidationError as e: + return JSONResponse({ + "status": "error", + "message": "Validation failed", + "errors": e.errors() + }, status_code=422) except Exception as e: _LOGGER.error("An unexpected error occurred: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail="Internal server error") from e def _generate_route_name(filename: str) -> str: @@ -91,13 +101,10 @@ def _generate_route_name(filename: str) -> str: str: A cleaned, valid route name in lowercase with only alphanumeric characters and hyphens. Example: - Input: "my-route_with_invalid_chars.txt" + Input: "my-Route_With_Invalid_Chars.txt" Output: "my-route-with-invalid-chars" """ filename = filename.replace("_", "-") filename = re.sub(r"[^a-z0-9-]", "", filename.lower()) filename = filename.strip("-") - return filename - - -router = Router([Route("/", endpoint=deploy_route, methods=["PUT"])]) + return filename \ No newline at end of file diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index 825c141..7f7778e 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -1,8 +1,10 @@ import pytest +import copy from starlette.applications import Starlette +from starlette.routing import Route from starlette.testclient import TestClient -from routes import deploy +from routes.deploy import deploy_route from models import Resource, Status @@ -13,26 +15,27 @@ def mock_k8s_client(mocker): @pytest.fixture(scope="module") def test_client(): - app = Starlette() - app.mount("/route", deploy.router) + app = Starlette(routes=[Route("/route", deploy_route, methods=["PUT"])]) return TestClient(app) resources = [Resource(name="my-route", status=Status.CREATED)] +body = { + "routes": [ + { + "name": "my-route", + "xml": "..." + } + ] +} def test_deploy_route(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.return_value = resources res = test_client.put( - "/route/", - files={ - "upload_file": ( - "my-route.xml", - b"...", - "application/xml", - ) - }, + "/route", + json = body ) assert res.status_code == 201 @@ -42,44 +45,52 @@ def test_deploy_route(mock_k8s_client, test_client): assert result[0]["status"] == Status.CREATED -@pytest.mark.parametrize("content_type", ["application/json", ""]) +def test_deploy_malformed_json(test_client): + request_body = copy.deepcopy(body) + del(request_body["routes"][0]["name"]) + + res = test_client.put( + "/route", + json = request_body + ) + + assert res.status_code == 422 + result = res.json() + assert result["status"] == "error" + + +@pytest.mark.parametrize("content_type", ["application/xml", ""]) def test_deploy_route_invalid_content_type(test_client, content_type): res = test_client.put( - "/route/", - files={ - "upload_file": ( - "my-route.xml", - b"...", - content_type, - ) - }, + "/route", + headers={"content-type": content_type}, + json = body ) assert res.status_code == 400 assert "No Integration Route XML file found in form data" in res.text -def test_deploy_route_missing_upload_file(test_client): - res = test_client.put("/route/", files={}) +def test_deploy_route_missing_body(test_client): + res = test_client.put("/route", json={}) - assert res.status_code == 400 - assert "Missing request parameter: upload_file" in res.text - - -def test_deploy_route_unsupported_file_encoding(test_client): - response = test_client.put( - "/route/", - files={ - "upload_file": ( - "my-route.xml", - "...".encode("utf-16"), - "application/xml", - ) - }, + assert res.status_code == 422 + result = res.json() + assert result["status"] == "error" + + +def test_deploy_missing_route(test_client): + request_body = copy.deepcopy(body) + del(request_body["routes"][0]) + + res = test_client.put( + "/route", + json = request_body ) - assert response.status_code == 400 - assert "Invalid XML file encoding" in response.text + assert res.status_code == 422 + result = res.json() + assert result["status"] == "error" def test_deploy_route_generic_exception(mock_k8s_client, test_client): @@ -88,14 +99,8 @@ def test_deploy_route_generic_exception(mock_k8s_client, test_client): ) res = test_client.put( - "/route/", - files={ - "upload_file": ( - "my-route.xml", - b"...", - "application/xml", - ) - }, + "/route", + json = body ) assert res.status_code == 500 From 2ce6d4faff6a56475fb2b8ad738037ff563c296d Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Wed, 24 Sep 2025 12:27:32 -0700 Subject: [PATCH 09/15] Remove readme reference to /cluster-health endpoint --- operator/webapp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator/webapp/README.md b/operator/webapp/README.md index 29926b7..081dbcf 100644 --- a/operator/webapp/README.md +++ b/operator/webapp/README.md @@ -17,7 +17,7 @@ The webhook contains two endpoints, `/webhook/sync` and `/webhook/addons/certman This web server is designed to be run as a service within a Kubernetes cluster. It is intended to be used with [Metacontroller](https://metacontroller.github.io/metacontroller/), which will call the `/webhook` endpoint to manage `IntegrationRoute` custom resources. -The `/route` endpoint is provided for convenience to deploy routes from XML files. The `/cluster-health` endpoint can be used as a liveness or readiness probe. +The `/route` endpoint is provided for convenience to deploy routes from XML files. ## Developer Guide From 0f93e7de0839a637fbe277319bd5be616928a137 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Wed, 24 Sep 2025 13:23:06 -0700 Subject: [PATCH 10/15] Mock test kubeconfig and create sample deploy request body --- operator/webapp/app.py | 4 +- operator/webapp/core/k8s_client.py | 17 ++++--- operator/webapp/models.py | 3 ++ operator/webapp/routes/deploy.py | 15 +++--- .../routes/test/json/deploy_request_body.json | 9 ++++ operator/webapp/routes/test/test_deploy.py | 50 +++++++------------ 6 files changed, 48 insertions(+), 50 deletions(-) create mode 100644 operator/webapp/routes/test/json/deploy_request_body.json diff --git a/operator/webapp/app.py b/operator/webapp/app.py index f030884..35c01b4 100644 --- a/operator/webapp/app.py +++ b/operator/webapp/app.py @@ -19,9 +19,7 @@ def create_app() -> ASGIApp: if cfg.DEBUG: _LOGGER.warning("Running server with debug mode. NOT SUITABLE FOR PRODUCTION!") - routes = [ - Route("/route", deploy_route, methods=["PUT"]) - ] + routes = [Route("/route", deploy_route, methods=["PUT"])] starlette_app = Starlette(debug=cfg.DEBUG, routes=routes) starlette_app.mount("/webhook", webhook.router) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 0ce2559..0e0b32b 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -2,6 +2,7 @@ from kubernetes import config, client from kubernetes.client.rest import ApiException import logging +import os from models import RouteData, Resource, Status @@ -13,17 +14,17 @@ _LOGGER = logging.getLogger(__name__) - try: - # Try in-cluster config first - config.load_incluster_config() - _LOGGER.info("Using in-cluster Kubernetes config") + ( + config.load_kube_config(os.getenv("KUBECONFIG")) + if os.getenv("KUBECONFIG") + else config.load_incluster_config() + ) except config.ConfigException: # Fall back to local kubeconfig - _LOGGER.info( - msg="Detected not running inside a cluster. Falling back to local kubeconfig." + _LOGGER.error( + msg="Failed to configure the k8s_client. Keip will be unable to deploy integration routes.", ) - config.load_kube_config() v1 = client.CoreV1Api() @@ -45,7 +46,7 @@ def _check_cluster_reachable(): try: v1.get_api_resources() return True - except Exception: + except ApiException: return False diff --git a/operator/webapp/models.py b/operator/webapp/models.py index 5782f17..3952689 100644 --- a/operator/webapp/models.py +++ b/operator/webapp/models.py @@ -10,14 +10,17 @@ class Status(str, Enum): UPDATED = "updated" RECREATED = "recreated" + class Route(BaseModel): name: str namespace: str = "default" xml: str + class RouteRequest(BaseModel): routes: List[Route] = Field(min_length=1) + @dataclass class RouteData: route_name: str diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index 95a731c..a49dde1 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -53,7 +53,7 @@ async def deploy_route(request: Request): body = await request.json() route_request = RouteRequest(**body) - content_type = request.headers['content-type'] + content_type = request.headers["content-type"] if content_type != "application/json": _LOGGER.warning("Invalid content type: '%s'", content_type) raise HTTPException( @@ -65,7 +65,7 @@ async def deploy_route(request: Request): route_data = RouteData( route_name=_generate_route_name(route.name), route_xml=route.xml, - namespace=route.namespace + namespace=route.namespace, ) _LOGGER.info("Creating resources for route: %s", route_data.route_name) @@ -79,11 +79,10 @@ async def deploy_route(request: Request): except HTTPException: raise except ValidationError as e: - return JSONResponse({ - "status": "error", - "message": "Validation failed", - "errors": e.errors() - }, status_code=422) + return JSONResponse( + {"status": "error", "message": "Validation failed", "errors": e.errors()}, + status_code=422, + ) except Exception as e: _LOGGER.error("An unexpected error occurred: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") from e @@ -107,4 +106,4 @@ def _generate_route_name(filename: str) -> str: filename = filename.replace("_", "-") filename = re.sub(r"[^a-z0-9-]", "", filename.lower()) filename = filename.strip("-") - return filename \ No newline at end of file + return filename diff --git a/operator/webapp/routes/test/json/deploy_request_body.json b/operator/webapp/routes/test/json/deploy_request_body.json new file mode 100644 index 0000000..90d8053 --- /dev/null +++ b/operator/webapp/routes/test/json/deploy_request_body.json @@ -0,0 +1,9 @@ +{ + "routes": [ + { + "name": "my-route", + "namespace": "default", + "xml": "\n\n \n \n \n \n \n" + } + ] +} \ No newline at end of file diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index 7f7778e..4ef3d87 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -1,9 +1,12 @@ import pytest import copy +import json from starlette.applications import Starlette from starlette.routing import Route from starlette.testclient import TestClient +from unittest.mock import patch + from routes.deploy import deploy_route from models import Resource, Status @@ -13,6 +16,13 @@ def mock_k8s_client(mocker): return mocker.patch("routes.deploy.k8s_client") +@pytest.fixture +def mock_k8s_config(): + """We need to mock the kubeconfig to prevent the local dev's from being used.""" + with patch("kubernetes.config.load_kube_config"): + yield + + @pytest.fixture(scope="module") def test_client(): app = Starlette(routes=[Route("/route", deploy_route, methods=["PUT"])]) @@ -20,23 +30,14 @@ def test_client(): resources = [Resource(name="my-route", status=Status.CREATED)] -body = { - "routes": [ - { - "name": "my-route", - "xml": "..." - } - ] -} +with open("./routes/test/json/deploy_request_body.json", "r") as f: + body = json.load(f) def test_deploy_route(mock_k8s_client, test_client): mock_k8s_client.create_route_resources.return_value = resources - res = test_client.put( - "/route", - json = body - ) + res = test_client.put("/route", json=body) assert res.status_code == 201 result = res.json() @@ -47,12 +48,9 @@ def test_deploy_route(mock_k8s_client, test_client): def test_deploy_malformed_json(test_client): request_body = copy.deepcopy(body) - del(request_body["routes"][0]["name"]) + del request_body["routes"][0]["name"] - res = test_client.put( - "/route", - json = request_body - ) + res = test_client.put("/route", json=request_body) assert res.status_code == 422 result = res.json() @@ -61,11 +59,7 @@ def test_deploy_malformed_json(test_client): @pytest.mark.parametrize("content_type", ["application/xml", ""]) def test_deploy_route_invalid_content_type(test_client, content_type): - res = test_client.put( - "/route", - headers={"content-type": content_type}, - json = body - ) + res = test_client.put("/route", headers={"content-type": content_type}, json=body) assert res.status_code == 400 assert "No Integration Route XML file found in form data" in res.text @@ -81,12 +75,9 @@ def test_deploy_route_missing_body(test_client): def test_deploy_missing_route(test_client): request_body = copy.deepcopy(body) - del(request_body["routes"][0]) + del request_body["routes"][0] - res = test_client.put( - "/route", - json = request_body - ) + res = test_client.put("/route", json=request_body) assert res.status_code == 422 result = res.json() @@ -98,10 +89,7 @@ def test_deploy_route_generic_exception(mock_k8s_client, test_client): "Something went wrong" ) - res = test_client.put( - "/route", - json = body - ) + res = test_client.put("/route", json=body) assert res.status_code == 500 assert "Internal server error" in res.text From 12ef699584b1c8a75b367f91d42d0b05fa0bcc0e Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 25 Sep 2025 11:36:09 -0700 Subject: [PATCH 11/15] cleanup subroute mounting --- operator/webapp/app.py | 8 +++++--- operator/webapp/routes/webhook.py | 22 ++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/operator/webapp/app.py b/operator/webapp/app.py index 35c01b4..d953147 100644 --- a/operator/webapp/app.py +++ b/operator/webapp/app.py @@ -1,7 +1,7 @@ import logging.config from starlette.applications import Starlette -from starlette.routing import Route +from starlette.routing import Route, Mount from starlette.types import ASGIApp import config as cfg @@ -19,9 +19,11 @@ def create_app() -> ASGIApp: if cfg.DEBUG: _LOGGER.warning("Running server with debug mode. NOT SUITABLE FOR PRODUCTION!") - routes = [Route("/route", deploy_route, methods=["PUT"])] + routes = [ + Route("/route", deploy_route, methods=["PUT"]), + Mount(path="/webhook", routes=webhook.routes), + ] starlette_app = Starlette(debug=cfg.DEBUG, routes=routes) - starlette_app.mount("/webhook", webhook.router) return starlette_app diff --git a/operator/webapp/routes/webhook.py b/operator/webapp/routes/webhook.py index 7adc1b9..58adf8f 100644 --- a/operator/webapp/routes/webhook.py +++ b/operator/webapp/routes/webhook.py @@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.routing import Route, Router +from starlette.routing import Route from starlette.status import HTTP_400_BAD_REQUEST from core.sync import sync @@ -43,14 +43,12 @@ async def status(request): return JSONResponse({"status": "UP"}) -router = Router( - [ - Route("/sync", endpoint=build_webhook(sync), methods=["POST"]), - Route( - "/addons/certmanager/sync", - endpoint=build_webhook(sync_certificate), - methods=["POST"], - ), - Route("/status", endpoint=status, methods=["GET"]), - ] -) +routes = [ + Route("/sync", endpoint=build_webhook(sync), methods=["POST"]), + Route( + "/addons/certmanager/sync", + endpoint=build_webhook(sync_certificate), + methods=["POST"], + ), + Route("/status", endpoint=status, methods=["GET"]), +] From 74c3efcdefec8f7b70343164a883278539bf1be2 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 25 Sep 2025 14:45:03 -0700 Subject: [PATCH 12/15] validate route names instead of fixing them --- operator/webapp/models.py | 23 ++++++++++++++-- operator/webapp/routes/deploy.py | 31 +++++----------------- operator/webapp/routes/test/test_deploy.py | 27 ++++++++++++++++--- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/operator/webapp/models.py b/operator/webapp/models.py index 3952689..c092cc8 100644 --- a/operator/webapp/models.py +++ b/operator/webapp/models.py @@ -1,6 +1,9 @@ +import re + from dataclasses import dataclass from enum import Enum -from pydantic import BaseModel, Field + +from pydantic import BaseModel, Field, field_validator from typing import List @@ -12,10 +15,26 @@ class Status(str, Enum): class Route(BaseModel): - name: str + name: str = Field(min_length=1, max_length=253) namespace: str = "default" xml: str + @field_validator("name", mode="before") + @classmethod + def is_valid_name(cls, value: str) -> str: + if not value: + raise ValueError("Route name cannot be empty") + + if value != value.lower(): + raise ValueError("Route name must be lowercase") + + if not re.match(r"^[a-z0-9]([-.a-z0-9]*[a-z0-9])?$", value): + raise ValueError( + "Route name must start and end with alphanumeric characters and contain only lowercase letters, numbers, hyphens, and periods" + ) + + return value + class RouteRequest(BaseModel): routes: List[Route] = Field(min_length=1) diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py index a49dde1..b920649 100644 --- a/operator/webapp/routes/deploy.py +++ b/operator/webapp/routes/deploy.py @@ -1,5 +1,5 @@ import logging.config -import re +import json from dataclasses import asdict @@ -63,7 +63,7 @@ async def deploy_route(request: Request): created_resources = [] for route in route_request.routes: route_data = RouteData( - route_name=_generate_route_name(route.name), + route_name=route.name, route_xml=route.xml, namespace=route.namespace, ) @@ -80,30 +80,13 @@ async def deploy_route(request: Request): raise except ValidationError as e: return JSONResponse( - {"status": "error", "message": "Validation failed", "errors": e.errors()}, + { + "status": "error", + "message": "Validation failed", + "errors": json.loads(e.json()), + }, status_code=422, ) except Exception as e: _LOGGER.error("An unexpected error occurred: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") from e - - -def _generate_route_name(filename: str) -> str: - """ - Generates a valid route name from a filename by replacing underscores with hyphens - and removing invalid characters, ensuring it adheres to Kubernetes naming conventions. - - Args: - filename (str): The original filename from which to generate the route name. - - Returns: - str: A cleaned, valid route name in lowercase with only alphanumeric characters and hyphens. - - Example: - Input: "my-Route_With_Invalid_Chars.txt" - Output: "my-route-with-invalid-chars" - """ - filename = filename.replace("_", "-") - filename = re.sub(r"[^a-z0-9-]", "", filename.lower()) - filename = filename.strip("-") - return filename diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index 4ef3d87..546a2f7 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -46,7 +46,26 @@ def test_deploy_route(mock_k8s_client, test_client): assert result[0]["status"] == Status.CREATED -def test_deploy_malformed_json(test_client): +@pytest.mark.parametrize( + "name", + [ + "-starts-with-hyphen", + "contains_invalid_char#", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.commmm", + ], +) +def test_deploy_route_invalid_names(mock_k8s_client, test_client, name): + request_body = copy.deepcopy(body) + request_body["routes"][0]["name"] = name + + res = test_client.put("/route", json=request_body) + + assert res.status_code == 422 + result = res.json() + assert result["status"] == "error" + + +def test_deploy_malformed_json(mock_k8s_client, test_client): request_body = copy.deepcopy(body) del request_body["routes"][0]["name"] @@ -58,14 +77,14 @@ def test_deploy_malformed_json(test_client): @pytest.mark.parametrize("content_type", ["application/xml", ""]) -def test_deploy_route_invalid_content_type(test_client, content_type): +def test_deploy_route_invalid_content_type(mock_k8s_client, test_client, content_type): res = test_client.put("/route", headers={"content-type": content_type}, json=body) assert res.status_code == 400 assert "No Integration Route XML file found in form data" in res.text -def test_deploy_route_missing_body(test_client): +def test_deploy_route_missing_body(mock_k8s_client, test_client): res = test_client.put("/route", json={}) assert res.status_code == 422 @@ -73,7 +92,7 @@ def test_deploy_route_missing_body(test_client): assert result["status"] == "error" -def test_deploy_missing_route(test_client): +def test_deploy_missing_route(mock_k8s_client, test_client): request_body = copy.deepcopy(body) del request_body["routes"][0] From 8559734cc243b9f3225f8bb9ceff08110fef47b0 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 25 Sep 2025 14:50:27 -0700 Subject: [PATCH 13/15] remove unused dependency --- operator/webapp/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/operator/webapp/requirements.txt b/operator/webapp/requirements.txt index 25f766b..8d72002 100644 --- a/operator/webapp/requirements.txt +++ b/operator/webapp/requirements.txt @@ -1,5 +1,4 @@ kubernetes==33.1.0 pydantic==2.11.9 -python_multipart==0.0.20 starlette==0.48.0 uvicorn[standard]==0.37.0 From 54b142a633b502339ef09e83b008eacfc3daed42 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Thu, 25 Sep 2025 15:06:15 -0700 Subject: [PATCH 14/15] move status to root path --- operator/webapp/app.py | 6 ++++++ operator/webapp/core/k8s_client.py | 2 +- operator/webapp/routes/test/test_deploy.py | 2 ++ operator/webapp/routes/test/test_webapp.py | 2 +- operator/webapp/routes/webhook.py | 5 ----- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/operator/webapp/app.py b/operator/webapp/app.py index d953147..cc83f4b 100644 --- a/operator/webapp/app.py +++ b/operator/webapp/app.py @@ -2,6 +2,7 @@ from starlette.applications import Starlette from starlette.routing import Route, Mount +from starlette.responses import JSONResponse from starlette.types import ASGIApp import config as cfg @@ -13,6 +14,10 @@ _LOGGER = logging.getLogger(__name__) +async def status(request): + return JSONResponse({"status": "UP"}) + + def create_app() -> ASGIApp: logging.config.dictConfig(LOG_CONF) @@ -21,6 +26,7 @@ def create_app() -> ASGIApp: routes = [ Route("/route", deploy_route, methods=["PUT"]), + Route("/status", status, methods=["GET"]), Mount(path="/webhook", routes=webhook.routes), ] starlette_app = Starlette(debug=cfg.DEBUG, routes=routes) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index 0e0b32b..bce0127 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -79,7 +79,7 @@ def _create_integration_route(route_data: RouteData, configmap_name: str) -> Res status = Status.CREATED if existing_route["items"]: - # Recreate route + # Delete existing route routeApi.delete_namespaced_custom_object( group=ROUTE_API_GROUP, version=ROUTE_API_VERSION, diff --git a/operator/webapp/routes/test/test_deploy.py b/operator/webapp/routes/test/test_deploy.py index 546a2f7..dd6f9c5 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/operator/webapp/routes/test/test_deploy.py @@ -49,6 +49,8 @@ def test_deploy_route(mock_k8s_client, test_client): @pytest.mark.parametrize( "name", [ + "", + " ", "-starts-with-hyphen", "contains_invalid_char#", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.commmm", diff --git a/operator/webapp/routes/test/test_webapp.py b/operator/webapp/routes/test/test_webapp.py index 119dc5f..f2c8d4a 100644 --- a/operator/webapp/routes/test/test_webapp.py +++ b/operator/webapp/routes/test/test_webapp.py @@ -9,7 +9,7 @@ def test_status_endpoint(test_client): - response = test_client.get("/webhook/status") + response = test_client.get("/status") assert response.status_code == 200 assert response.json() == {"status": "UP"} diff --git a/operator/webapp/routes/webhook.py b/operator/webapp/routes/webhook.py index 58adf8f..3be5781 100644 --- a/operator/webapp/routes/webhook.py +++ b/operator/webapp/routes/webhook.py @@ -39,10 +39,6 @@ async def webhook(request: Request): return webhook -async def status(request): - return JSONResponse({"status": "UP"}) - - routes = [ Route("/sync", endpoint=build_webhook(sync), methods=["POST"]), Route( @@ -50,5 +46,4 @@ async def status(request): endpoint=build_webhook(sync_certificate), methods=["POST"], ), - Route("/status", endpoint=status, methods=["GET"]), ] From e3594e7b8e07d6478c5a0c1a3a0da2e784976c89 Mon Sep 17 00:00:00 2001 From: Josh Hunziker Date: Mon, 29 Sep 2025 08:41:09 -0700 Subject: [PATCH 15/15] k8s client cleanup --- operator/webapp/core/k8s_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py index bce0127..c1749ba 100644 --- a/operator/webapp/core/k8s_client.py +++ b/operator/webapp/core/k8s_client.py @@ -31,7 +31,7 @@ routeApi = client.CustomObjectsApi() -def _check_cluster_reachable(): +def _check_cluster_reachable() -> bool: """ Checks if the Kubernetes cluster is reachable by attempting to retrieve API resources. @@ -155,7 +155,7 @@ def _create_route_configmap(route_data: RouteData) -> Resource: _LOGGER.info( "Route ConfigMap '%s' does not exist and will be created", configmap_name ) - configmap = v1.create_namespaced_config_map( + v1.create_namespaced_config_map( namespace=route_data.namespace, body=configmap ) @@ -163,7 +163,7 @@ def _create_route_configmap(route_data: RouteData) -> Resource: return Resource(status=status, name=configmap_name) -def create_route_resources(route_data: RouteData) -> Tuple[Resource]: +def create_route_resources(route_data: RouteData) -> Tuple[Resource, Resource]: """ Creates both a ConfigMap and an Integration Route resource for the specified route configuration. @@ -193,4 +193,4 @@ def create_route_resources(route_data: RouteData) -> Tuple[Resource]: route = _create_integration_route( route_data=route_data, configmap_name=route_cm.name ) - return (route_cm, route) + return route_cm, route