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"], + ), +]