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 9b0a19f..88fbad5 100644
--- a/operator/controller/core-controller.yaml
+++ b/operator/controller/core-controller.yaml
@@ -54,9 +54,10 @@ spec:
labels:
app: integrationroute-webhook
spec:
+ 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 4d648bc..2a458b2 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: keip-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: keip-controller-service
+ namespace: keip
+ apiGroup: ""
+roleRef:
+ kind: ClusterRole
+ name: controller-kubernetes-manager
apiGroup: ""
\ No newline at end of file
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/README.md b/operator/webapp/README.md
index a142589..081dbcf 100644
--- a/operator/webapp/README.md
+++ b/operator/webapp/README.md
@@ -2,11 +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).
-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)
@@ -15,6 +13,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 `/route` endpoint is provided for convenience to deploy routes from XML files.
+
## Developer Guide
Requirements:
@@ -64,6 +68,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..cc83f4b 100644
--- a/operator/webapp/app.py
+++ b/operator/webapp/app.py
@@ -1,26 +1,37 @@
import logging.config
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
from routes import webhook
+from routes.deploy import deploy_route
from logconf import LOG_CONF
_LOGGER = logging.getLogger(__name__)
+async def status(request):
+ return JSONResponse({"status": "UP"})
+
+
def create_app() -> ASGIApp:
logging.config.dictConfig(LOG_CONF)
if cfg.DEBUG:
_LOGGER.warning("Running server with debug mode. NOT SUITABLE FOR PRODUCTION!")
- app = Starlette(debug=cfg.DEBUG)
- app.mount("/webhook", webhook.router)
+ 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)
- return app
+ return starlette_app
app = create_app()
diff --git a/operator/webapp/core/k8s_client.py b/operator/webapp/core/k8s_client.py
new file mode 100644
index 0000000..c1749ba
--- /dev/null
+++ b/operator/webapp/core/k8s_client.py
@@ -0,0 +1,196 @@
+from typing import Tuple
+from kubernetes import config, client
+from kubernetes.client.rest import ApiException
+import logging
+import os
+
+from models import RouteData, Resource, Status
+
+ROUTE_API_GROUP = "keip.codice.org"
+ROUTE_API_VERSION = "v1alpha1"
+ROUTE_PLURAL = "integrationroutes"
+WEBHOOK_CONTROLLER_PREFIX = "integrationroute-webhook"
+
+
+_LOGGER = logging.getLogger(__name__)
+
+try:
+ (
+ 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.error(
+ msg="Failed to configure the k8s_client. Keip will be unable to deploy integration routes.",
+ )
+
+
+v1 = client.CoreV1Api()
+routeApi = client.CustomObjectsApi()
+
+
+def _check_cluster_reachable() -> bool:
+ """
+ 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.
+ """
+ try:
+ v1.get_api_resources()
+ return True
+ except ApiException:
+ 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,
+ "labels": {"app.kubernetes.io/created-by": "keip"},
+ },
+ "spec": {"routeConfigMap": configmap_name},
+ }
+
+ status = Status.CREATED
+
+ if existing_route["items"]:
+ # Delete existing 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:
+ """
+ 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,
+ 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,
+ labels={"app.kubernetes.io/created-by": "keip"},
+ ),
+ data={"integrationRoute.xml": route_data.route_xml},
+ )
+
+ 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
+ )
+ 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) -> Tuple[Resource, 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:
+ 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]
+
+ 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
+ )
+ return route_cm, route
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..b831975
--- /dev/null
+++ b/operator/webapp/core/test/test_k8s_client.py
@@ -0,0 +1,87 @@
+import pytest
+from kubernetes import client
+from core.k8s_client import _create_integration_route, _create_route_configmap
+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_xml="payload",
+ )
+
+
+@pytest.fixture
+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(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
+
+
+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)
diff --git a/operator/webapp/models.py b/operator/webapp/models.py
new file mode 100644
index 0000000..c092cc8
--- /dev/null
+++ b/operator/webapp/models.py
@@ -0,0 +1,53 @@
+import re
+
+from dataclasses import dataclass
+from enum import Enum
+
+from pydantic import BaseModel, Field, field_validator
+from typing import List
+
+
+class Status(str, Enum):
+ CREATED = "created"
+ DELETED = "deleted"
+ UPDATED = "updated"
+ RECREATED = "recreated"
+
+
+class Route(BaseModel):
+ 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)
+
+
+@dataclass
+class RouteData:
+ route_name: str
+ route_xml: str
+ namespace: str
+
+
+@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..8d72002 100644
--- a/operator/webapp/requirements.txt
+++ b/operator/webapp/requirements.txt
@@ -1,2 +1,4 @@
-starlette==0.47.3
-uvicorn[standard]==0.35.0
+kubernetes==33.1.0
+pydantic==2.11.9
+starlette==0.48.0
+uvicorn[standard]==0.37.0
diff --git a/operator/webapp/routes/deploy.py b/operator/webapp/routes/deploy.py
new file mode 100644
index 0000000..b920649
--- /dev/null
+++ b/operator/webapp/routes/deploy.py
@@ -0,0 +1,92 @@
+import logging.config
+import json
+
+from dataclasses import asdict
+
+from pydantic import ValidationError
+
+from starlette.exceptions import HTTPException
+from starlette.responses import JSONResponse
+from starlette.requests import Request
+
+from models import RouteData, RouteRequest
+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):
+ """
+ Handles the deployment of an integration route via an XML file upload.
+
+ 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.
+
+ 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:
+ 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=route.name,
+ route_xml=route.xml,
+ namespace=route.namespace,
+ )
+
+ _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 ValidationError as e:
+ return JSONResponse(
+ {
+ "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
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
new file mode 100644
index 0000000..dd6f9c5
--- /dev/null
+++ b/operator/webapp/routes/test/test_deploy.py
@@ -0,0 +1,116 @@
+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
+
+
+@pytest.fixture
+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"])])
+ return TestClient(app)
+
+
+resources = [Resource(name="my-route", status=Status.CREATED)]
+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)
+
+ 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(
+ "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"]
+
+ 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(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(mock_k8s_client, test_client):
+ res = test_client.put("/route", json={})
+
+ assert res.status_code == 422
+ result = res.json()
+ assert result["status"] == "error"
+
+
+def test_deploy_missing_route(mock_k8s_client, test_client):
+ request_body = copy.deepcopy(body)
+ del request_body["routes"][0]
+
+ res = test_client.put("/route", json=request_body)
+
+ assert res.status_code == 422
+ result = res.json()
+ assert result["status"] == "error"
+
+
+def test_deploy_route_generic_exception(mock_k8s_client, test_client):
+ mock_k8s_client.create_route_resources.side_effect = Exception(
+ "Something went wrong"
+ )
+
+ res = test_client.put("/route", json=body)
+
+ assert res.status_code == 500
+ assert "Internal server error" in res.text
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 7adc1b9..3be5781 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
@@ -39,18 +39,11 @@ async def webhook(request: Request):
return webhook
-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"],
+ ),
+]