diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e0d7433d..03353fec 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -2,7 +2,7 @@ This monorepo consists of 3 artifacts that are versioned, built, and released separately. - minimal-app - operator -- operator/webhook +- operator/webapp ## PR builds When a PR is opened or updated, it will determine if any files changed in each of the sub-project directories. diff --git a/.github/workflows/operator.yml b/.github/workflows/operator.yml index a18ad901..09326091 100644 --- a/.github/workflows/operator.yml +++ b/.github/workflows/operator.yml @@ -6,7 +6,7 @@ on: - operator/** - .github/workflows/operator.yml - .github/workflows/scripts - - "!operator/webhook/**" + - "!operator/webapp/**" - '!**.md' - '!operator/examples/**' push: @@ -14,7 +14,7 @@ on: - main paths: - operator/** - - "!operator/webhook/**" + - "!operator/webapp/**" - '!**.md' - '!operator/examples/**' diff --git a/.github/workflows/scripts/shared/verify_current_webapp_img.sh b/.github/workflows/scripts/shared/verify_current_webapp_img.sh new file mode 100644 index 00000000..bab42d84 --- /dev/null +++ b/.github/workflows/scripts/shared/verify_current_webapp_img.sh @@ -0,0 +1,15 @@ +OPERATOR_DIR=operator +OPERATOR_CONTROLLER_YAML=$OPERATOR_DIR/controller/core-controller.yaml + +verify_current_webapp_img() { + current_webapp_img=$(make --no-print-directory -C operator/webapp get-image-name) + webapp_image_used=$(yq eval '.spec.template.spec.containers[].image' $OPERATOR_CONTROLLER_YAML) + + test -n "$current_webapp_img" + test -n "$webapp_image_used" + + error_message="Operator is using $webapp_image_used but should be using the most recent $current_webapp_img." + test "$webapp_image_used" = "$current_webapp_img" || (echo $error_message && exit 1) +} + +verify_current_webapp_img diff --git a/.github/workflows/scripts/shared/verify_current_webhook_img.sh b/.github/workflows/scripts/shared/verify_current_webhook_img.sh deleted file mode 100644 index 673baf83..00000000 --- a/.github/workflows/scripts/shared/verify_current_webhook_img.sh +++ /dev/null @@ -1,15 +0,0 @@ -OPERATOR_DIR=operator -OPERATOR_CONTROLLER_YAML=$OPERATOR_DIR/controller/core-controller.yaml - -verify_current_webhook_img() { - current_webhook_img=$(make --no-print-directory -C operator/webhook get-image-name) - webhook_image_used=$(yq eval '.spec.template.spec.containers[].image' $OPERATOR_CONTROLLER_YAML) - - test -n "$current_webhook_img" - test -n "$webhook_image_used" - - error_message="Operator is using $webhook_image_used but should be using the most recent $current_webhook_img." - test "$webhook_image_used" = "$current_webhook_img" || (echo $error_message && exit 1) -} - -verify_current_webhook_img diff --git a/.github/workflows/scripts/verify_operator_releasable.sh b/.github/workflows/scripts/verify_operator_releasable.sh index ba9a8f11..d417852a 100644 --- a/.github/workflows/scripts/verify_operator_releasable.sh +++ b/.github/workflows/scripts/verify_operator_releasable.sh @@ -7,8 +7,8 @@ verify_version_bump() { sh .github/workflows/scripts/shared/verify_changes_update_version.sh $potential_tag $OPERATOR_DIR \ '-e ^operator/examples/ -e ^operator/example/ - -e ^operator/webhook/' + -e ^operator/webapp/' } -sh .github/workflows/scripts/shared/verify_current_webhook_img.sh +sh .github/workflows/scripts/shared/verify_current_webapp_img.sh verify_version_bump diff --git a/.github/workflows/scripts/verify_webapp_releasable.sh b/.github/workflows/scripts/verify_webapp_releasable.sh new file mode 100644 index 00000000..849d55bf --- /dev/null +++ b/.github/workflows/scripts/verify_webapp_releasable.sh @@ -0,0 +1,13 @@ +set -eux + +WEBAPP_DIR=operator/webapp + +verify_version_bump() { + potential_tag=$(make --no-print-directory -C $WEBAPP_DIR get-tag) + + # Changes to the Makefile are excluded since they will not change the webhook image + sh .github/workflows/scripts/shared/verify_changes_update_version.sh $potential_tag $WEBAPP_DIR '-e Makefile$' +} + +sh .github/workflows/scripts/shared/verify_current_webapp_img.sh +verify_version_bump diff --git a/.github/workflows/scripts/verify_webhook_releasable.sh b/.github/workflows/scripts/verify_webhook_releasable.sh deleted file mode 100644 index 6c16e16e..00000000 --- a/.github/workflows/scripts/verify_webhook_releasable.sh +++ /dev/null @@ -1,13 +0,0 @@ -set -eux - -WEBHOOK_DIR=operator/webhook - -verify_version_bump() { - potential_tag=$(make --no-print-directory -C $WEBHOOK_DIR get-tag) - - # Changes to the Makefile are excluded since they will not change the webhook image - sh .github/workflows/scripts/shared/verify_changes_update_version.sh $potential_tag $WEBHOOK_DIR '-e Makefile$' -} - -sh .github/workflows/scripts/shared/verify_current_webhook_img.sh -verify_version_bump diff --git a/.github/workflows/webhook.yml b/.github/workflows/webapp.yml similarity index 92% rename from .github/workflows/webhook.yml rename to .github/workflows/webapp.yml index fdfe2681..3b4161f6 100644 --- a/.github/workflows/webhook.yml +++ b/.github/workflows/webapp.yml @@ -1,22 +1,22 @@ -name: webhook +name: webapp on: workflow_dispatch: pull_request: paths: - - operator/webhook/** - - .github/workflows/webhook.yml + - operator/webapp/** + - .github/workflows/webapp.yml - .github/workflows/scripts - '!**.md' push: branches: - main paths: - - operator/webhook/** + - operator/webapp/** - '!**.md' env: PYTHON_VERSION: 3.11 - WORKING_DIR: ./operator/webhook + WORKING_DIR: ./operator/webapp jobs: verify-versions: @@ -24,8 +24,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: sh .github/workflows/scripts/verify_webhook_releasable.sh - name: Verify webhook is in a state to be released on merge + - run: sh .github/workflows/scripts/verify_webapp_releasable.sh + name: Verify webapp is in a state to be released on merge test: name: unit test runs-on: ubuntu-latest diff --git a/operator/Makefile b/operator/Makefile index cb9662f9..fd90d38c 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -1,4 +1,4 @@ -VERSION ?= 0.13.0 +VERSION ?= 0.13.1 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 2fdf970e..9b0a19fd 100644 --- a/operator/controller/core-controller.yaml +++ b/operator/controller/core-controller.yaml @@ -31,7 +31,7 @@ spec: hooks: sync: webhook: - url: http://integrationroute-webhook.keip/sync + url: http://integrationroute-webhook.keip/webhook/sync timeout: 10s --- apiVersion: v1 @@ -56,7 +56,7 @@ spec: spec: containers: - name: webhook - image: ghcr.io/codice/keip/route-webhook:0.15.0 + image: ghcr.io/codice/keip/webapp:0.16.0 ports: - containerPort: 7080 name: webhook-http diff --git a/operator/webhook/.dockerignore b/operator/webapp/.dockerignore similarity index 100% rename from operator/webhook/.dockerignore rename to operator/webapp/.dockerignore diff --git a/operator/webhook/Dockerfile b/operator/webapp/Dockerfile similarity index 56% rename from operator/webhook/Dockerfile rename to operator/webapp/Dockerfile index 9611390a..26b8bb29 100644 --- a/operator/webhook/Dockerfile +++ b/operator/webapp/Dockerfile @@ -2,11 +2,11 @@ FROM python:3.11.5-slim LABEL org.opencontainers.image.source=https://github.com/codice/keip -WORKDIR /code/webhook +WORKDIR /code/webapp COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY . . -ENTRYPOINT ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7080", "--app-dir", "/code"] +ENTRYPOINT ["python", "-m", "uvicorn", "webapp.app:app", "--host", "0.0.0.0", "--port", "7080", "--app-dir", "/code"] diff --git a/operator/webhook/Makefile b/operator/webapp/Makefile similarity index 92% rename from operator/webhook/Makefile rename to operator/webapp/Makefile index 54ab87b6..e79597ae 100644 --- a/operator/webhook/Makefile +++ b/operator/webapp/Makefile @@ -1,11 +1,11 @@ -VERSION ?= 0.15.0 +VERSION ?= 0.16.0 HOST_PORT ?= 7080 -GIT_TAG := webhook_v$(VERSION) +GIT_TAG := webapp_v$(VERSION) IMG_REGISTRY := ghcr.io/codice -IMG_NAME := keip/route-webhook +IMG_NAME := keip/webapp FULL_IMAGE_NAME := $(IMG_REGISTRY)/$(IMG_NAME):$(VERSION) -CONTAINER_NAME := integration-route-webhook +CONTAINER_NAME := integration-route-webapp TEST_COVERAGE_DIR := .test_coverage TEST_COVERAGE_FILE := $(TEST_COVERAGE_DIR)/.coverage EXTRA_PYTEST_ARGS ?= @@ -114,11 +114,11 @@ win-precommit: win-test win-format win-lint .PHONY: start-dev-server start-dev-server: - $(PYTHON) -m uvicorn --port 7080 --reload --app-dir .. webhook.app:app + $(PYTHON) -m uvicorn --port 7080 --reload --app-dir .. app:app .PHONY: win-start-dev-server win-start-dev-server: - $(WIN_PYTHON) -m uvicorn --port 7080 --reload --app-dir .. webhook.app:app + $(WIN_PYTHON) -m uvicorn --port 7080 --reload --app-dir .. app:app .PHONY: deploy deploy: build diff --git a/operator/webhook/README.md b/operator/webapp/README.md similarity index 64% rename from operator/webhook/README.md rename to operator/webapp/README.md index b92cecb9..a1425895 100644 --- a/operator/webhook/README.md +++ b/operator/webapp/README.md @@ -1,19 +1,19 @@ -# Keip Integration Route Webhook +# Keip Integration Route App -A Python web server that implements -a [lambda controller from the Metacontroller API](https://metacontroller.github.io/metacontroller/concepts.html#lambda-controller). +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, `/sync` and `/addons/certmanager/sync`. + The webhook contains two endpoints, `/webhook/sync` and `/webhook/addons/certmanager/sync`. -- `/sync`: The core logic that creates a `Deployment` from `IntegrationRoute` resources. -- `/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) - based on annotations in an `IntegrationRoute`. + - `/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) + based on annotations in an `IntegrationRoute`. -The format for the request and response JSON payloads can be -seen [here](https://metacontroller.github.io/metacontroller/api/compositecontroller.html#sync-hook) + The format for the request and response JSON payloads can be + seen [here](https://metacontroller.github.io/metacontroller/api/compositecontroller.html#sync-hook) ## Developer Guide diff --git a/operator/webhook/__init__.py b/operator/webapp/__init__.py similarity index 100% rename from operator/webhook/__init__.py rename to operator/webapp/__init__.py diff --git a/operator/webhook/addons/__init__.py b/operator/webapp/addons/__init__.py similarity index 100% rename from operator/webhook/addons/__init__.py rename to operator/webapp/addons/__init__.py diff --git a/operator/webhook/addons/certmanager/README.md b/operator/webapp/addons/certmanager/README.md similarity index 99% rename from operator/webhook/addons/certmanager/README.md rename to operator/webapp/addons/certmanager/README.md index ee68ce15..83370c73 100644 --- a/operator/webhook/addons/certmanager/README.md +++ b/operator/webapp/addons/certmanager/README.md @@ -1,20 +1,20 @@ -# Certmanager Addon - -The Certmanager Addon creates certificates for `IntegrationRoute`s based off of annotations in the `IntegrationRoute`. - -See the example `IntegrationRoute` in the [README](../../../example/README.md#example-integrationroute-using-tls-and-the-certmanager-addon). - -## Supported Annotations -- **cert-manager.io/issuer:** The name of an `Issuer` to acquire the `Certificate` required for this `IntegrationRoute`. The `Issuer` must be in the same namespace as the `IntegrationRoute` resource. -- **cert-manager.io/cluster-issuer:** The name of a `ClusterIssuer` to acquire the `Certificate` required for this `IntegrationRoute`. It does not matter which namespace your `IntegrationRoute` resides, as `ClusterIssuer`s are non-namespaced resources. -- **cert-manager.io/common-name:** (optional) This annotation allows you to configure `spec.commonName` for the `Certificate` to be generated. -- **cert-manager.io/subject-countries:** (optional) This annotation allows you to configure the `spec.subject.countries` field for the `Certificate` to be generated. Supports comma-separated values e.g. "Country 1,Country 2". -- **cert-manager.io/subject-localities:** (optional) This annotation allows you to configure the `spec.subject.localities` field for the `Certificate` to be generated. Supports comma-separated values e.g. "City 1,City 2". -- **cert-manager.io/subject-provinces:** (optional) This annotation allows you to configure the `spec.subject.provinces` field for the `Certificate` to be generated. Supports comma-separated values e.g. "Province 1,Province 2". -- **cert-manager.io/subject-organizationalunits:** (optional) This annotation allows you to configure the `spec.subject.organizationalUnits` field for the `Certificate` to be generated. Supports comma-separated values e.g. "IT Services,Cloud Services". -- **cert-manager.io/alt-names:** (optional) This annotation allows you to configure subject alternative names (SANs). Supports comma-separated values e.g. "san1,san2". - -> **_NOTE:_** `IntegrationRoute`s cannot have both `cert-manager.io/issuer` and `cert-manager.io/cluster-issuer` annotations. - -## See Also -- [Cert-manager.io Supported Annotations](https://cert-manager.io/docs/usage/ingress/#supported-annotations) +# Certmanager Addon + +The Certmanager Addon creates certificates for `IntegrationRoute`s based off of annotations in the `IntegrationRoute`. + +See the example `IntegrationRoute` in the [README](../../../example/README.md#example-integrationroute-using-tls-and-the-certmanager-addon). + +## Supported Annotations +- **cert-manager.io/issuer:** The name of an `Issuer` to acquire the `Certificate` required for this `IntegrationRoute`. The `Issuer` must be in the same namespace as the `IntegrationRoute` resource. +- **cert-manager.io/cluster-issuer:** The name of a `ClusterIssuer` to acquire the `Certificate` required for this `IntegrationRoute`. It does not matter which namespace your `IntegrationRoute` resides, as `ClusterIssuer`s are non-namespaced resources. +- **cert-manager.io/common-name:** (optional) This annotation allows you to configure `spec.commonName` for the `Certificate` to be generated. +- **cert-manager.io/subject-countries:** (optional) This annotation allows you to configure the `spec.subject.countries` field for the `Certificate` to be generated. Supports comma-separated values e.g. "Country 1,Country 2". +- **cert-manager.io/subject-localities:** (optional) This annotation allows you to configure the `spec.subject.localities` field for the `Certificate` to be generated. Supports comma-separated values e.g. "City 1,City 2". +- **cert-manager.io/subject-provinces:** (optional) This annotation allows you to configure the `spec.subject.provinces` field for the `Certificate` to be generated. Supports comma-separated values e.g. "Province 1,Province 2". +- **cert-manager.io/subject-organizationalunits:** (optional) This annotation allows you to configure the `spec.subject.organizationalUnits` field for the `Certificate` to be generated. Supports comma-separated values e.g. "IT Services,Cloud Services". +- **cert-manager.io/alt-names:** (optional) This annotation allows you to configure subject alternative names (SANs). Supports comma-separated values e.g. "san1,san2". + +> **_NOTE:_** `IntegrationRoute`s cannot have both `cert-manager.io/issuer` and `cert-manager.io/cluster-issuer` annotations. + +## See Also +- [Cert-manager.io Supported Annotations](https://cert-manager.io/docs/usage/ingress/#supported-annotations) diff --git a/operator/webhook/addons/certmanager/__init__.py b/operator/webapp/addons/certmanager/__init__.py similarity index 100% rename from operator/webhook/addons/certmanager/__init__.py rename to operator/webapp/addons/certmanager/__init__.py diff --git a/operator/webhook/addons/certmanager/main.py b/operator/webapp/addons/certmanager/main.py similarity index 96% rename from operator/webhook/addons/certmanager/main.py rename to operator/webapp/addons/certmanager/main.py index ced8ff5f..aea74a18 100644 --- a/operator/webhook/addons/certmanager/main.py +++ b/operator/webapp/addons/certmanager/main.py @@ -1,162 +1,162 @@ -import logging -from typing import Mapping, List, Any - -_LOGGER = logging.getLogger(__name__) - - -def _new_certificate(obj) -> Mapping[str, Any]: - metadata = obj["metadata"] - - name = metadata["name"] - - namespace = metadata["namespace"] - - annotations = metadata.get("annotations") - if annotations is None: - _LOGGER.debug( - "IntegrationRoute does not contain metadata.annotations. No certificate will be generated." - ) - return {} - - cert_manager_io_annotations = [ - annotation for annotation in annotations if "cert-manager.io" in annotation - ] - - if not cert_manager_io_annotations: - return {} - - common_name = annotations.get("cert-manager.io/common-name", name) - - issuer = _get_issuer_ref(annotations) - if not issuer: - return {} - - cert = { - "apiVersion": "cert-manager.io/v1", - "kind": "Certificate", - "metadata": { - "name": f"{name}-certs", - "namespace": namespace, - }, - "spec": { - "commonName": f"{common_name}.{namespace}", - "dnsNames": _get_dns_names(annotations, name, common_name, namespace), - "issuerRef": issuer, - "keystores": _get_keystores(obj["spec"]["tls"]["keystore"]), - "secretName": f"{name}-certstore", - "subject": _get_subject(annotations, name, common_name, namespace), - }, - } - - return cert - - -def _get_dns_names(annotations, name, common_name, namespace) -> List[str]: - alt_names = _get_annotation_vals_as_list( - annotations.get("cert-manager.io/alt-names") - ) - - dns_names = [ - f"{common_name}.{namespace}.svc.cluster.local", - f"{common_name}.{namespace}.svc", - f"{common_name}.{namespace}", - common_name, - f"{name}-actuator.{namespace}.svc.cluster.local", - ] - dns_names = dns_names + alt_names - return dns_names - - -def _get_issuer_ref(annotations) -> Mapping[str, str]: - issuer = annotations.get("cert-manager.io/issuer") - cluster_issuer = annotations.get("cert-manager.io/cluster-issuer") - - if issuer is not None and cluster_issuer is not None: - _LOGGER.error( - "IntegrationRoute cannot have metadata.annotations.cert-manager.io/issuer and metadata.annotations.cert-manager.io/cluster-issuer" - ) - return {} - - if issuer is None and cluster_issuer is None: - _LOGGER.error( - "IntegrationRoute must have metadata.annotations.cert-manager.io/issuer or metadata.annotations.cert-manager.io/cluster-issuer" - ) - return {} - - if issuer is not None: - issuer_kind = "Issuer" - else: - issuer_kind = "ClusterIssuer" - issuer = cluster_issuer - - return { - "group": "cert-manager.io", - "kind": issuer_kind, - "name": issuer, - } - - -def _get_subject(annotations, name, common_name, namespace) -> Mapping[str, List[str]]: - organizational_units = _get_annotation_vals_as_list( - annotations.get("cert-manager.io/subject-organizationalunits") - ) - countries = _get_annotation_vals_as_list( - annotations.get("cert-manager.io/subject-countries") - ) - provinces = _get_annotation_vals_as_list( - annotations.get("cert-manager.io/subject-provinces") - ) - localities = _get_annotation_vals_as_list( - annotations.get("cert-manager.io/subject-localities") - ) - - subject = {} - if organizational_units: - subject["organizationalUnits"] = organizational_units - - if countries: - subject["countries"] = countries - - if provinces: - subject["provinces"] = provinces - - if localities: - subject["localities"] = localities - - return subject - - -def _get_keystores(keystore) -> Mapping[str, Mapping[str, Any]]: - keystore_type = _get_keystore_type(keystore) - password_secret_ref_name = keystore[keystore_type]["passwordSecretRef"] - - return { - keystore_type: { - "create": True, - "passwordSecretRef": { - "key": "password", - "name": password_secret_ref_name, - }, - }, - } - - -def _get_annotation_vals_as_list(annotation_val) -> List[str]: - return ( - [val for i in annotation_val.split(",") if (val := i.strip())] - if annotation_val - else [] - ) - - -def _get_keystore_type(keystore) -> str: - return "jks" if "jks" in keystore else "pkcs12" - - -def sync_certificate(body) -> Mapping[str, List[Mapping[str, Any]]]: - # Request API at for DecoratorController at https://metacontroller.github.io/metacontroller/api/decoratorcontroller.html#sync-hook-request - obj = body["object"] - certificate = _new_certificate(obj) - attachments = [certificate] if certificate else [] - desired_state = {"attachments": attachments} - return desired_state +import logging +from typing import Mapping, List, Any + +_LOGGER = logging.getLogger(__name__) + + +def _new_certificate(obj) -> Mapping[str, Any]: + metadata = obj["metadata"] + + name = metadata["name"] + + namespace = metadata["namespace"] + + annotations = metadata.get("annotations") + if annotations is None: + _LOGGER.debug( + "IntegrationRoute does not contain metadata.annotations. No certificate will be generated." + ) + return {} + + cert_manager_io_annotations = [ + annotation for annotation in annotations if "cert-manager.io" in annotation + ] + + if not cert_manager_io_annotations: + return {} + + common_name = annotations.get("cert-manager.io/common-name", name) + + issuer = _get_issuer_ref(annotations) + if not issuer: + return {} + + cert = { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": { + "name": f"{name}-certs", + "namespace": namespace, + }, + "spec": { + "commonName": f"{common_name}.{namespace}", + "dnsNames": _get_dns_names(annotations, name, common_name, namespace), + "issuerRef": issuer, + "keystores": _get_keystores(obj["spec"]["tls"]["keystore"]), + "secretName": f"{name}-certstore", + "subject": _get_subject(annotations, name, common_name, namespace), + }, + } + + return cert + + +def _get_dns_names(annotations, name, common_name, namespace) -> List[str]: + alt_names = _get_annotation_vals_as_list( + annotations.get("cert-manager.io/alt-names") + ) + + dns_names = [ + f"{common_name}.{namespace}.svc.cluster.local", + f"{common_name}.{namespace}.svc", + f"{common_name}.{namespace}", + common_name, + f"{name}-actuator.{namespace}.svc.cluster.local", + ] + dns_names = dns_names + alt_names + return dns_names + + +def _get_issuer_ref(annotations) -> Mapping[str, str]: + issuer = annotations.get("cert-manager.io/issuer") + cluster_issuer = annotations.get("cert-manager.io/cluster-issuer") + + if issuer is not None and cluster_issuer is not None: + _LOGGER.error( + "IntegrationRoute cannot have metadata.annotations.cert-manager.io/issuer and metadata.annotations.cert-manager.io/cluster-issuer" + ) + return {} + + if issuer is None and cluster_issuer is None: + _LOGGER.error( + "IntegrationRoute must have metadata.annotations.cert-manager.io/issuer or metadata.annotations.cert-manager.io/cluster-issuer" + ) + return {} + + if issuer is not None: + issuer_kind = "Issuer" + else: + issuer_kind = "ClusterIssuer" + issuer = cluster_issuer + + return { + "group": "cert-manager.io", + "kind": issuer_kind, + "name": issuer, + } + + +def _get_subject(annotations, name, common_name, namespace) -> Mapping[str, List[str]]: + organizational_units = _get_annotation_vals_as_list( + annotations.get("cert-manager.io/subject-organizationalunits") + ) + countries = _get_annotation_vals_as_list( + annotations.get("cert-manager.io/subject-countries") + ) + provinces = _get_annotation_vals_as_list( + annotations.get("cert-manager.io/subject-provinces") + ) + localities = _get_annotation_vals_as_list( + annotations.get("cert-manager.io/subject-localities") + ) + + subject = {} + if organizational_units: + subject["organizationalUnits"] = organizational_units + + if countries: + subject["countries"] = countries + + if provinces: + subject["provinces"] = provinces + + if localities: + subject["localities"] = localities + + return subject + + +def _get_keystores(keystore) -> Mapping[str, Mapping[str, Any]]: + keystore_type = _get_keystore_type(keystore) + password_secret_ref_name = keystore[keystore_type]["passwordSecretRef"] + + return { + keystore_type: { + "create": True, + "passwordSecretRef": { + "key": "password", + "name": password_secret_ref_name, + }, + }, + } + + +def _get_annotation_vals_as_list(annotation_val) -> List[str]: + return ( + [val for i in annotation_val.split(",") if (val := i.strip())] + if annotation_val + else [] + ) + + +def _get_keystore_type(keystore) -> str: + return "jks" if "jks" in keystore else "pkcs12" + + +def sync_certificate(body) -> Mapping[str, List[Mapping[str, Any]]]: + # Request API at for DecoratorController at https://metacontroller.github.io/metacontroller/api/decoratorcontroller.html#sync-hook-request + obj = body["object"] + certificate = _new_certificate(obj) + attachments = [certificate] if certificate else [] + desired_state = {"attachments": attachments} + return desired_state diff --git a/operator/webhook/addons/certmanager/test/__init__.py b/operator/webapp/addons/certmanager/test/__init__.py similarity index 100% rename from operator/webhook/addons/certmanager/test/__init__.py rename to operator/webapp/addons/certmanager/test/__init__.py diff --git a/operator/webhook/addons/certmanager/test/json/full-iroute-request.json b/operator/webapp/addons/certmanager/test/json/full-iroute-request.json similarity index 96% rename from operator/webhook/addons/certmanager/test/json/full-iroute-request.json rename to operator/webapp/addons/certmanager/test/json/full-iroute-request.json index 944c069b..18458df9 100644 --- a/operator/webhook/addons/certmanager/test/json/full-iroute-request.json +++ b/operator/webapp/addons/certmanager/test/json/full-iroute-request.json @@ -1,49 +1,49 @@ -{ - "object": { - "apiVersion": "keip.codice.org/v1alpha1", - "kind": "IntegrationRoute", - "metadata": { - "annotations": { - "cert-manager.io/alt-names": "cloud-integration-route-actuator.testnamespace.svc.cluster.local", - "cert-manager.io/cluster-issuer": "test-selfsigned", - "cert-manager.io/common-name": "testroute", - "cert-manager.io/subject-countries": "US", - "cert-manager.io/subject-localities": "A Park", - "cert-manager.io/subject-organizationalunits": "Parks and Recreation", - "cert-manager.io/subject-provinces": "FL" - }, - "creationTimestamp": "2025-03-18T15:55:04Z", - "generation": 1, - "name": "testroute", - "namespace": "testnamespace", - "resourceVersion": "48692499", - "uid": "c4d501ce-0196-42f1-bd8c-8dd82759d890" - }, - "spec": { - "propSources": [ - { - "name": "testroute-props" - } - ], - "replicas": 1, - "routeConfigMap": "testroute-xml", - "secretSources": [ - "testroute-secret" - ], - "tls": { - "keystore": { - "jks": { - "key": "keystore.jks", - "passwordSecretRef": "jks-password", - "secretName": "testroute-certstore" - } - } - } - } - }, - "attachments": { - "Certificate.cert-manager.io/v1": {} - }, - "related": {}, - "finalizing": false +{ + "object": { + "apiVersion": "keip.codice.org/v1alpha1", + "kind": "IntegrationRoute", + "metadata": { + "annotations": { + "cert-manager.io/alt-names": "cloud-integration-route-actuator.testnamespace.svc.cluster.local", + "cert-manager.io/cluster-issuer": "test-selfsigned", + "cert-manager.io/common-name": "testroute", + "cert-manager.io/subject-countries": "US", + "cert-manager.io/subject-localities": "A Park", + "cert-manager.io/subject-organizationalunits": "Parks and Recreation", + "cert-manager.io/subject-provinces": "FL" + }, + "creationTimestamp": "2025-03-18T15:55:04Z", + "generation": 1, + "name": "testroute", + "namespace": "testnamespace", + "resourceVersion": "48692499", + "uid": "c4d501ce-0196-42f1-bd8c-8dd82759d890" + }, + "spec": { + "propSources": [ + { + "name": "testroute-props" + } + ], + "replicas": 1, + "routeConfigMap": "testroute-xml", + "secretSources": [ + "testroute-secret" + ], + "tls": { + "keystore": { + "jks": { + "key": "keystore.jks", + "passwordSecretRef": "jks-password", + "secretName": "testroute-certstore" + } + } + } + } + }, + "attachments": { + "Certificate.cert-manager.io/v1": {} + }, + "related": {}, + "finalizing": false } \ No newline at end of file diff --git a/operator/webhook/addons/certmanager/test/json/full-response.json b/operator/webapp/addons/certmanager/test/json/full-response.json similarity index 96% rename from operator/webhook/addons/certmanager/test/json/full-response.json rename to operator/webapp/addons/certmanager/test/json/full-response.json index 78e12ffa..4b959f6a 100644 --- a/operator/webhook/addons/certmanager/test/json/full-response.json +++ b/operator/webapp/addons/certmanager/test/json/full-response.json @@ -1,38 +1,38 @@ -{ - "attachments": [ - { - "apiVersion": "cert-manager.io/v1", - "kind": "Certificate", - "metadata": { "name": "testroute-certs", "namespace": "testnamespace" }, - "spec": { - "commonName": "testroute.testnamespace", - "dnsNames": [ - "testroute.testnamespace.svc.cluster.local", - "testroute.testnamespace.svc", - "testroute.testnamespace", - "testroute", - "testroute-actuator.testnamespace.svc.cluster.local", - "cloud-integration-route-actuator.testnamespace.svc.cluster.local" - ], - "issuerRef": { - "group": "cert-manager.io", - "kind": "ClusterIssuer", - "name": "test-selfsigned" - }, - "keystores": { - "jks": { - "create": true, - "passwordSecretRef": { "key": "password", "name": "jks-password" } - } - }, - "secretName": "testroute-certstore", - "subject": { - "organizationalUnits": ["Parks and Recreation"], - "countries": ["US"], - "provinces": ["FL"], - "localities": ["A Park"] - } - } - } - ] -} +{ + "attachments": [ + { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": { "name": "testroute-certs", "namespace": "testnamespace" }, + "spec": { + "commonName": "testroute.testnamespace", + "dnsNames": [ + "testroute.testnamespace.svc.cluster.local", + "testroute.testnamespace.svc", + "testroute.testnamespace", + "testroute", + "testroute-actuator.testnamespace.svc.cluster.local", + "cloud-integration-route-actuator.testnamespace.svc.cluster.local" + ], + "issuerRef": { + "group": "cert-manager.io", + "kind": "ClusterIssuer", + "name": "test-selfsigned" + }, + "keystores": { + "jks": { + "create": true, + "passwordSecretRef": { "key": "password", "name": "jks-password" } + } + }, + "secretName": "testroute-certstore", + "subject": { + "organizationalUnits": ["Parks and Recreation"], + "countries": ["US"], + "provinces": ["FL"], + "localities": ["A Park"] + } + } + } + ] +} diff --git a/operator/webhook/addons/certmanager/test/test_sync_certificate.py b/operator/webapp/addons/certmanager/test/test_sync_certificate.py similarity index 96% rename from operator/webhook/addons/certmanager/test/test_sync_certificate.py rename to operator/webapp/addons/certmanager/test/test_sync_certificate.py index 906f9560..06497f4d 100644 --- a/operator/webhook/addons/certmanager/test/test_sync_certificate.py +++ b/operator/webapp/addons/certmanager/test/test_sync_certificate.py @@ -1,258 +1,258 @@ -import copy -import json -import os -from typing import Mapping - -import pytest - -from webhook.addons.certmanager.main import ( - sync_certificate, - _get_annotation_vals_as_list, -) - - -def test_sync_certificate(full_route): - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_route(full_route): - full_route = {} - with pytest.raises(KeyError): - sync_certificate(full_route) - - -def test_sync_certificate_no_cert_manager_annotations(full_route): - del full_route["object"]["metadata"]["annotations"] - expected_desired_state_json = json.dumps({"attachments": []}) - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_alt_names(full_route): - del full_route["object"]["metadata"]["annotations"]["cert-manager.io/alt-names"] - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - expected_desired_state_dict["attachments"][0]["spec"]["dnsNames"].remove( - "cloud-integration-route-actuator.testnamespace.svc.cluster.local" - ) - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_get_annotation_vals_as_list(): - annotation_vals = "annotation1, annotation2, annotation3" - actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) - expected_annotation_list = ["annotation1", "annotation2", "annotation3"] - assert actual_annotation_list == expected_annotation_list - - -def test_get_annotation_vals_as_list_empty_string(): - annotation_vals = "" - actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) - expected_annotation_list = [] - assert actual_annotation_list == expected_annotation_list - - -def test_get_annotation_vals_as_list_only_separator(): - annotation_vals = "," - actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) - expected_annotation_list = [] - assert actual_annotation_list == expected_annotation_list - - -def test_get_annotation_vals_as_list_extra_separators(): - annotation_vals = ",annotation1,,annotation2,,, annotation3," - actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) - expected_annotation_list = ["annotation1", "annotation2", "annotation3"] - assert actual_annotation_list == expected_annotation_list - - -def test_sync_certificate_iroute_has_issuer(full_route): - del full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/cluster-issuer" - ] - - full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/issuer" - ] = "test-issuer" - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - expected_desired_state_dict["attachments"][0]["spec"]["issuerRef"][ - "kind" - ] = "Issuer" - expected_desired_state_dict["attachments"][0]["spec"]["issuerRef"][ - "name" - ] = "test-issuer" - expected_desired_state_json = json.dumps(expected_desired_state_dict) - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_iroute_has_cluster_issuer(full_route): - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - expected_desired_state_json = json.dumps(expected_desired_state_dict) - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_iroute_has_neither_issuer_or_cluster_issuer( - full_route, -): - del full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/cluster-issuer" - ] - expected_desired_state_json = json.dumps({"attachments": []}) - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_iroute_has_issuer_and_cluster_issuer(full_route): - full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/issuer" - ] = "test-issuer" - expected_desired_state_json = json.dumps({"attachments": []}) - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_common_name(full_route): - del full_route["object"]["metadata"]["annotations"]["cert-manager.io/common-name"] - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - expected_desired_state_json = json.dumps(expected_desired_state_dict) - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_organizational_units(full_route): - del full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/subject-organizationalunits" - ] - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - del expected_desired_state_dict["attachments"][0]["spec"]["subject"][ - "organizationalUnits" - ] - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_countries(full_route): - del full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/subject-countries" - ] - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - del expected_desired_state_dict["attachments"][0]["spec"]["subject"]["countries"] - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_provinces(full_route): - del full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/subject-provinces" - ] - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - del expected_desired_state_dict["attachments"][0]["spec"]["subject"]["provinces"] - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_no_localities(full_route): - del full_route["object"]["metadata"]["annotations"][ - "cert-manager.io/subject-localities" - ] - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - del expected_desired_state_dict["attachments"][0]["spec"]["subject"]["localities"] - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_jks_keystore(full_route): - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -def test_sync_certificate_pkcs12_keystore(full_route): - del full_route["object"]["spec"]["tls"]["keystore"]["jks"] - full_route["object"]["spec"]["tls"]["keystore"]["pkcs12"] = { - "key": "keystore.p12", - "passwordSecretRef": "pkcs12-password", - "secretName": "testroute-certstore", - } - expected_desired_state_dict = load_json_as_dict( - f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" - ) - del expected_desired_state_dict["attachments"][0]["spec"]["keystores"]["jks"] - expected_desired_state_dict["attachments"][0]["spec"]["keystores"] = { - "pkcs12": { - "create": True, - "passwordSecretRef": {"key": "password", "name": "pkcs12-password"}, - } - } - expected_desired_state_json = json.dumps(expected_desired_state_dict) - - actual_desired_state_json = json.dumps(sync_certificate(full_route)) - - assert actual_desired_state_json == expected_desired_state_json - - -@pytest.fixture() -def full_route(full_route_load: dict): - return copy.deepcopy(full_route_load) - - -@pytest.fixture(scope="module") -def full_route_load() -> Mapping: - cwd = os.path.dirname(os.path.abspath(__file__)) - return load_json_as_dict(f"{cwd}/json/full-iroute-request.json") - - -def load_json_as_dict(filepath: str) -> Mapping: - with open(filepath, "r") as f: - return json.load(f) +import copy +import json +import os +from typing import Mapping + +import pytest + +from webapp.addons.certmanager.main import ( + sync_certificate, + _get_annotation_vals_as_list, +) + + +def test_sync_certificate(full_route): + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_route(full_route): + full_route = {} + with pytest.raises(KeyError): + sync_certificate(full_route) + + +def test_sync_certificate_no_cert_manager_annotations(full_route): + del full_route["object"]["metadata"]["annotations"] + expected_desired_state_json = json.dumps({"attachments": []}) + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_alt_names(full_route): + del full_route["object"]["metadata"]["annotations"]["cert-manager.io/alt-names"] + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + expected_desired_state_dict["attachments"][0]["spec"]["dnsNames"].remove( + "cloud-integration-route-actuator.testnamespace.svc.cluster.local" + ) + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_get_annotation_vals_as_list(): + annotation_vals = "annotation1, annotation2, annotation3" + actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) + expected_annotation_list = ["annotation1", "annotation2", "annotation3"] + assert actual_annotation_list == expected_annotation_list + + +def test_get_annotation_vals_as_list_empty_string(): + annotation_vals = "" + actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) + expected_annotation_list = [] + assert actual_annotation_list == expected_annotation_list + + +def test_get_annotation_vals_as_list_only_separator(): + annotation_vals = "," + actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) + expected_annotation_list = [] + assert actual_annotation_list == expected_annotation_list + + +def test_get_annotation_vals_as_list_extra_separators(): + annotation_vals = ",annotation1,,annotation2,,, annotation3," + actual_annotation_list = _get_annotation_vals_as_list(annotation_vals) + expected_annotation_list = ["annotation1", "annotation2", "annotation3"] + assert actual_annotation_list == expected_annotation_list + + +def test_sync_certificate_iroute_has_issuer(full_route): + del full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/cluster-issuer" + ] + + full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/issuer" + ] = "test-issuer" + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + expected_desired_state_dict["attachments"][0]["spec"]["issuerRef"][ + "kind" + ] = "Issuer" + expected_desired_state_dict["attachments"][0]["spec"]["issuerRef"][ + "name" + ] = "test-issuer" + expected_desired_state_json = json.dumps(expected_desired_state_dict) + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_iroute_has_cluster_issuer(full_route): + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + expected_desired_state_json = json.dumps(expected_desired_state_dict) + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_iroute_has_neither_issuer_or_cluster_issuer( + full_route, +): + del full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/cluster-issuer" + ] + expected_desired_state_json = json.dumps({"attachments": []}) + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_iroute_has_issuer_and_cluster_issuer(full_route): + full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/issuer" + ] = "test-issuer" + expected_desired_state_json = json.dumps({"attachments": []}) + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_common_name(full_route): + del full_route["object"]["metadata"]["annotations"]["cert-manager.io/common-name"] + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + expected_desired_state_json = json.dumps(expected_desired_state_dict) + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_organizational_units(full_route): + del full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/subject-organizationalunits" + ] + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + del expected_desired_state_dict["attachments"][0]["spec"]["subject"][ + "organizationalUnits" + ] + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_countries(full_route): + del full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/subject-countries" + ] + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + del expected_desired_state_dict["attachments"][0]["spec"]["subject"]["countries"] + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_provinces(full_route): + del full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/subject-provinces" + ] + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + del expected_desired_state_dict["attachments"][0]["spec"]["subject"]["provinces"] + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_no_localities(full_route): + del full_route["object"]["metadata"]["annotations"][ + "cert-manager.io/subject-localities" + ] + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + del expected_desired_state_dict["attachments"][0]["spec"]["subject"]["localities"] + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_jks_keystore(full_route): + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +def test_sync_certificate_pkcs12_keystore(full_route): + del full_route["object"]["spec"]["tls"]["keystore"]["jks"] + full_route["object"]["spec"]["tls"]["keystore"]["pkcs12"] = { + "key": "keystore.p12", + "passwordSecretRef": "pkcs12-password", + "secretName": "testroute-certstore", + } + expected_desired_state_dict = load_json_as_dict( + f"{os.path.dirname(os.path.abspath(__file__))}/json/full-response.json" + ) + del expected_desired_state_dict["attachments"][0]["spec"]["keystores"]["jks"] + expected_desired_state_dict["attachments"][0]["spec"]["keystores"] = { + "pkcs12": { + "create": True, + "passwordSecretRef": {"key": "password", "name": "pkcs12-password"}, + } + } + expected_desired_state_json = json.dumps(expected_desired_state_dict) + + actual_desired_state_json = json.dumps(sync_certificate(full_route)) + + assert actual_desired_state_json == expected_desired_state_json + + +@pytest.fixture() +def full_route(full_route_load: dict): + return copy.deepcopy(full_route_load) + + +@pytest.fixture(scope="module") +def full_route_load() -> Mapping: + cwd = os.path.dirname(os.path.abspath(__file__)) + return load_json_as_dict(f"{cwd}/json/full-iroute-request.json") + + +def load_json_as_dict(filepath: str) -> Mapping: + with open(filepath, "r") as f: + return json.load(f) diff --git a/operator/webapp/app.py b/operator/webapp/app.py new file mode 100644 index 00000000..982f7456 --- /dev/null +++ b/operator/webapp/app.py @@ -0,0 +1,26 @@ +import logging.config + +from starlette.applications import Starlette +from starlette.types import ASGIApp + +import config as cfg +from routes import webhook +from logconf import LOG_CONF + + +_LOGGER = logging.getLogger(__name__) + + +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) + + return app + + +app = create_app() diff --git a/operator/webhook/config.py b/operator/webapp/config.py similarity index 100% rename from operator/webhook/config.py rename to operator/webapp/config.py diff --git a/operator/webhook/core/__init__.py b/operator/webapp/core/__init__.py similarity index 100% rename from operator/webhook/core/__init__.py rename to operator/webapp/core/__init__.py diff --git a/operator/webhook/core/sync.py b/operator/webapp/core/sync.py similarity index 99% rename from operator/webhook/core/sync.py rename to operator/webapp/core/sync.py index d72d5dbb..ae8bf40e 100644 --- a/operator/webhook/core/sync.py +++ b/operator/webapp/core/sync.py @@ -3,7 +3,7 @@ from pathlib import PurePosixPath from typing import List, Mapping, Optional, Any -from webhook import config as cfg +import config as cfg SECRETS_ROOT = "/etc/secrets" diff --git a/operator/webhook/core/test/__init__.py b/operator/webapp/core/test/__init__.py similarity index 100% rename from operator/webhook/core/test/__init__.py rename to operator/webapp/core/test/__init__.py diff --git a/operator/webhook/core/test/conftest.py b/operator/webapp/core/test/conftest.py similarity index 100% rename from operator/webhook/core/test/conftest.py rename to operator/webapp/core/test/conftest.py diff --git a/operator/webhook/core/test/json/full-iroute-request.json b/operator/webapp/core/test/json/full-iroute-request.json similarity index 100% rename from operator/webhook/core/test/json/full-iroute-request.json rename to operator/webapp/core/test/json/full-iroute-request.json diff --git a/operator/webhook/core/test/json/full-response.json b/operator/webapp/core/test/json/full-response.json similarity index 100% rename from operator/webhook/core/test/json/full-response.json rename to operator/webapp/core/test/json/full-response.json diff --git a/operator/webhook/core/test/test_status.py b/operator/webapp/core/test/test_status.py similarity index 97% rename from operator/webhook/core/test/test_status.py rename to operator/webapp/core/test/test_status.py index ed19ca6b..d588b395 100644 --- a/operator/webhook/core/test/test_status.py +++ b/operator/webapp/core/test/test_status.py @@ -3,8 +3,8 @@ import pytest -import webhook.core.sync -from webhook.core.sync import ( +import core.sync +from core.sync import ( _compute_status, _get_status_ready_condition, ) @@ -176,7 +176,7 @@ def test_ready_status_with_parent_different_ready_condition_generate_new_status( @pytest.fixture() def patch_datetime(monkeypatch): - monkeypatch.setattr(webhook.core.sync, "datetime", MockDateTime) + monkeypatch.setattr(core.sync, "datetime", MockDateTime) class MockDateTime(datetime): diff --git a/operator/webhook/core/test/test_sync.py b/operator/webapp/core/test/test_sync.py similarity index 99% rename from operator/webhook/core/test/test_sync.py rename to operator/webapp/core/test/test_sync.py index d92b1ff5..a208b5ca 100644 --- a/operator/webhook/core/test/test_sync.py +++ b/operator/webapp/core/test/test_sync.py @@ -5,7 +5,7 @@ import pytest -from webhook.core.sync import ( +from core.sync import ( sync, VolumeConfig, _spring_cloud_k8s_config, @@ -17,7 +17,7 @@ _generate_container_env_vars, _get_server_ssl_config, ) -from webhook.test.test_webapp import load_json_as_dict +from webapp.routes.test.test_webapp import load_json_as_dict JDK_OPTIONS_ENV_NAME = "JDK_JAVA_OPTIONS" diff --git a/operator/webhook/logconf.py b/operator/webapp/logconf.py similarity index 100% rename from operator/webhook/logconf.py rename to operator/webapp/logconf.py diff --git a/operator/webapp/requirements-dev.txt b/operator/webapp/requirements-dev.txt new file mode 100644 index 00000000..bb41ff9e --- /dev/null +++ b/operator/webapp/requirements-dev.txt @@ -0,0 +1,6 @@ +black==25.1.0 +coverage==7.10.6 +httpx==0.28.1 +mypy==1.18.1 +pytest==8.4.2 +ruff==0.13.0 \ No newline at end of file diff --git a/operator/webapp/requirements.txt b/operator/webapp/requirements.txt new file mode 100644 index 00000000..e5cf58c1 --- /dev/null +++ b/operator/webapp/requirements.txt @@ -0,0 +1,2 @@ +starlette==0.47.3 +uvicorn[standard]==0.35.0 diff --git a/operator/webhook/test/__init__.py b/operator/webapp/routes/__init__.py similarity index 100% rename from operator/webhook/test/__init__.py rename to operator/webapp/routes/__init__.py diff --git a/operator/webapp/routes/test/__init__.py b/operator/webapp/routes/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/operator/webhook/test/json/full-cert-request.json b/operator/webapp/routes/test/json/full-cert-request.json similarity index 96% rename from operator/webhook/test/json/full-cert-request.json rename to operator/webapp/routes/test/json/full-cert-request.json index e5de38a6..9efdaaaf 100644 --- a/operator/webhook/test/json/full-cert-request.json +++ b/operator/webapp/routes/test/json/full-cert-request.json @@ -1,86 +1,86 @@ -{ - "controller": { - "kind": "DecoratorController", - "apiVersion": "metacontroller.k8s.io/v1alpha1", - "metadata": { - "name": "keip-certmanager-controller", - "uid": "79dfff16-d192-439f-8af2-aaa75409514e", - "resourceVersion": "48689094", - "generation": 1, - "creationTimestamp": "2025-03-18T15:19:49Z" - }, - "spec": { - "resources": [ - { - "apiVersion": "keip.codice.org/v1alpha1", - "resource": "integrationroutes" - } - ], - "attachments": [ - { - "apiVersion": "cert-manager.io/v1", - "resource": "certificates", - "updateStrategy": { - "method": "RollingRecreate" - } - } - ], - "hooks": { - "sync": { - "version": "v1", - "webhook": { - "url": "http://integrationroute-webhook.keip/addons/certmanager/sync" - } - } - } - }, - "status": {} - }, - "object": { - "apiVersion": "keip.codice.org/v1alpha1", - "kind": "IntegrationRoute", - "metadata": { - "annotations": { - "cert-manager.io/alt-names": "cloud-integration-route-actuator.testnamespace.svc.cluster.local", - "cert-manager.io/cluster-issuer": "test-selfsigned", - "cert-manager.io/common-name": "testroute", - "cert-manager.io/subject-countries": "US", - "cert-manager.io/subject-localities": "A Park", - "cert-manager.io/subject-organizationalunits": "Parks and Recreation", - "cert-manager.io/subject-provinces": "FL" - }, - "creationTimestamp": "2025-03-18T15:55:04Z", - "generation": 1, - "name": "testroute", - "namespace": "testnamespace", - "resourceVersion": "48692499", - "uid": "c4d501ce-0196-42f1-bd8c-8dd82759d890" - }, - "spec": { - "propSources": [ - { - "name": "testroute-props" - } - ], - "replicas": 1, - "routeConfigMap": "testroute-xml", - "secretSources": [ - "testroute-secret" - ], - "tls": { - "keystore": { - "jks": { - "key": "keystore.jks", - "passwordSecretRef": "jks-password", - "secretName": "testroute-certstore" - } - } - } - } - }, - "attachments": { - "Certificate.cert-manager.io/v1": {} - }, - "related": {}, - "finalizing": false -} +{ + "controller": { + "kind": "DecoratorController", + "apiVersion": "metacontroller.k8s.io/v1alpha1", + "metadata": { + "name": "keip-certmanager-controller", + "uid": "79dfff16-d192-439f-8af2-aaa75409514e", + "resourceVersion": "48689094", + "generation": 1, + "creationTimestamp": "2025-03-18T15:19:49Z" + }, + "spec": { + "resources": [ + { + "apiVersion": "keip.codice.org/v1alpha1", + "resource": "integrationroutes" + } + ], + "attachments": [ + { + "apiVersion": "cert-manager.io/v1", + "resource": "certificates", + "updateStrategy": { + "method": "RollingRecreate" + } + } + ], + "hooks": { + "sync": { + "version": "v1", + "webhook": { + "url": "http://integrationroute-webhook.keip/addons/certmanager/sync" + } + } + } + }, + "status": {} + }, + "object": { + "apiVersion": "keip.codice.org/v1alpha1", + "kind": "IntegrationRoute", + "metadata": { + "annotations": { + "cert-manager.io/alt-names": "cloud-integration-route-actuator.testnamespace.svc.cluster.local", + "cert-manager.io/cluster-issuer": "test-selfsigned", + "cert-manager.io/common-name": "testroute", + "cert-manager.io/subject-countries": "US", + "cert-manager.io/subject-localities": "A Park", + "cert-manager.io/subject-organizationalunits": "Parks and Recreation", + "cert-manager.io/subject-provinces": "FL" + }, + "creationTimestamp": "2025-03-18T15:55:04Z", + "generation": 1, + "name": "testroute", + "namespace": "testnamespace", + "resourceVersion": "48692499", + "uid": "c4d501ce-0196-42f1-bd8c-8dd82759d890" + }, + "spec": { + "propSources": [ + { + "name": "testroute-props" + } + ], + "replicas": 1, + "routeConfigMap": "testroute-xml", + "secretSources": [ + "testroute-secret" + ], + "tls": { + "keystore": { + "jks": { + "key": "keystore.jks", + "passwordSecretRef": "jks-password", + "secretName": "testroute-certstore" + } + } + } + } + }, + "attachments": { + "Certificate.cert-manager.io/v1": {} + }, + "related": {}, + "finalizing": false +} diff --git a/operator/webhook/test/json/full-cert-response.json b/operator/webapp/routes/test/json/full-cert-response.json similarity index 96% rename from operator/webhook/test/json/full-cert-response.json rename to operator/webapp/routes/test/json/full-cert-response.json index e689bc8a..6122b75a 100644 --- a/operator/webhook/test/json/full-cert-response.json +++ b/operator/webapp/routes/test/json/full-cert-response.json @@ -1,38 +1,38 @@ -{ - "attachments": [ - { - "apiVersion": "cert-manager.io/v1", - "kind": "Certificate", - "metadata": { "name": "testroute-certs", "namespace": "testnamespace" }, - "spec": { - "commonName": "testroute.testnamespace", - "dnsNames": [ - "testroute.testnamespace.svc.cluster.local", - "testroute.testnamespace.svc", - "testroute.testnamespace", - "testroute", - "testroute-actuator.testnamespace.svc.cluster.local", - "cloud-integration-route-actuator.testnamespace.svc.cluster.local" - ], - "issuerRef": { - "group": "cert-manager.io", - "kind": "ClusterIssuer", - "name": "test-selfsigned" - }, - "keystores": { - "jks": { - "create": true, - "passwordSecretRef": { "key": "password", "name": "jks-password" } - } - }, - "secretName": "testroute-certstore", - "subject": { - "organizationalUnits": ["Parks and Recreation"], - "countries": ["US"], - "provinces": ["FL"], - "localities": ["A Park"] - } - } - } - ] +{ + "attachments": [ + { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": { "name": "testroute-certs", "namespace": "testnamespace" }, + "spec": { + "commonName": "testroute.testnamespace", + "dnsNames": [ + "testroute.testnamespace.svc.cluster.local", + "testroute.testnamespace.svc", + "testroute.testnamespace", + "testroute", + "testroute-actuator.testnamespace.svc.cluster.local", + "cloud-integration-route-actuator.testnamespace.svc.cluster.local" + ], + "issuerRef": { + "group": "cert-manager.io", + "kind": "ClusterIssuer", + "name": "test-selfsigned" + }, + "keystores": { + "jks": { + "create": true, + "passwordSecretRef": { "key": "password", "name": "jks-password" } + } + }, + "secretName": "testroute-certstore", + "subject": { + "organizationalUnits": ["Parks and Recreation"], + "countries": ["US"], + "provinces": ["FL"], + "localities": ["A Park"] + } + } + } + ] } \ No newline at end of file diff --git a/operator/webhook/test/json/full-route-request.json b/operator/webapp/routes/test/json/full-route-request.json similarity index 100% rename from operator/webhook/test/json/full-route-request.json rename to operator/webapp/routes/test/json/full-route-request.json diff --git a/operator/webhook/test/json/full-route-response.json b/operator/webapp/routes/test/json/full-route-response.json similarity index 100% rename from operator/webhook/test/json/full-route-response.json rename to operator/webapp/routes/test/json/full-route-response.json diff --git a/operator/webhook/test/load_test/benchmark.md b/operator/webapp/routes/test/load_test/benchmark.md similarity index 100% rename from operator/webhook/test/load_test/benchmark.md rename to operator/webapp/routes/test/load_test/benchmark.md diff --git a/operator/webhook/test/test_webapp.py b/operator/webapp/routes/test/test_webapp.py similarity index 81% rename from operator/webhook/test/test_webapp.py rename to operator/webapp/routes/test/test_webapp.py index ad30ba20..119dc5ff 100644 --- a/operator/webhook/test/test_webapp.py +++ b/operator/webapp/routes/test/test_webapp.py @@ -5,11 +5,11 @@ import pytest from starlette.testclient import TestClient -from webhook.app import app +from app import app def test_status_endpoint(test_client): - response = test_client.get("/status") + response = test_client.get("/webhook/status") assert response.status_code == 200 assert response.json() == {"status": "UP"} @@ -20,7 +20,7 @@ def test_sync_endpoint_success(test_client): f"{os.path.dirname(os.path.abspath(__file__))}/json/full-route-request.json" ) - response = test_client.post("/sync", json=request) + response = test_client.post("/webhook/sync", json=request) expected = load_json_as_dict( f"{os.path.dirname(os.path.abspath(__file__))}/json/full-route-response.json" ) @@ -34,7 +34,7 @@ def test_sync_certificate_endpoint_success(test_client): f"{os.path.dirname(os.path.abspath(__file__))}/json/full-cert-request.json" ) - response = test_client.post("/addons/certmanager/sync", json=request) + response = test_client.post("/webhook/addons/certmanager/sync", json=request) expected = load_json_as_dict( f"{os.path.dirname(os.path.abspath(__file__))}/json/full-cert-response.json" ) @@ -46,8 +46,8 @@ def test_sync_certificate_endpoint_success(test_client): @pytest.mark.parametrize( "endpoint, status_code", [ - ("/sync", 400), - ("/addons/certmanager/sync", 400), + ("/webhook/sync", 400), + ("/webhook/addons/certmanager/sync", 400), ], ) def test_sync_endpoint_invalid_json(test_client, endpoint, status_code): @@ -59,8 +59,8 @@ def test_sync_endpoint_invalid_json(test_client, endpoint, status_code): @pytest.mark.parametrize( "endpoint, status_code", [ - ("/sync", 400), - ("/addons/certmanager/sync", 400), + ("/webhook/sync", 400), + ("/webhook/addons/certmanager/sync", 400), ], ) def test_sync_endpoint_empty_body(test_client, endpoint, status_code): diff --git a/operator/webhook/app.py b/operator/webapp/routes/webhook.py similarity index 61% rename from operator/webhook/app.py rename to operator/webapp/routes/webhook.py index f620496b..7adc1b9d 100644 --- a/operator/webhook/app.py +++ b/operator/webapp/routes/webhook.py @@ -1,19 +1,17 @@ import json -import logging.config +import logging from json import JSONDecodeError from typing import Callable, Mapping -from starlette.applications import Starlette from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.routing import Route +from starlette.routing import Route, Router from starlette.status import HTTP_400_BAD_REQUEST -from webhook import config as cfg -from webhook.core.sync import sync -from webhook.addons.certmanager.main import sync_certificate -from webhook.logconf import LOG_CONF +from core.sync import sync +from addons.certmanager.main import sync_certificate + _LOGGER = logging.getLogger(__name__) @@ -45,20 +43,14 @@ async def status(request): return JSONResponse({"status": "UP"}) -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"]), -] - - -logging.config.dictConfig(LOG_CONF) - -if cfg.DEBUG: - _LOGGER.warning("Running server with debug mode. NOT SUITABLE FOR PRODUCTION!") - -app = Starlette(debug=cfg.DEBUG, routes=routes) +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"]), + ] +) diff --git a/operator/webhook/requirements-dev.txt b/operator/webhook/requirements-dev.txt deleted file mode 100644 index 06853243..00000000 --- a/operator/webhook/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -black==25.1.0 -coverage==7.7.1 -httpx==0.28.1 -mypy==1.15.0 -pytest==8.3.5 -ruff==0.11.2 \ No newline at end of file diff --git a/operator/webhook/requirements.txt b/operator/webhook/requirements.txt deleted file mode 100644 index caf8a391..00000000 --- a/operator/webhook/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -starlette==0.41.3 -uvicorn[standard]==0.29.0 \ No newline at end of file