From 233102629a7000b2f4daec4e006e7ae35a0c3cd7 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 01/10] Restructure repo and simplify operator deployment - Move webapp from operator/webapp/ to top-level webapp/ - Delete keip-container-archetype (use minimal-app instead) - Split core-controller.yaml into composite-controller.yaml, namespace.yaml, and webhook-deployment.yaml - Add declarative ConfigMap (keip-controller-props.yaml), replacing imperative kubectl create cm in Makefile - Add top-level operator/kustomization.yaml enabling single-command install via kubectl apply -k - Simplify operator Makefile - Update CI workflows for new directory structure --- .github/workflows/operator.yml | 2 - .github/workflows/webapp.yml | 6 +- Makefile | 29 +++++ keip-container-archetype/README.md | 30 ----- keip-container-archetype/pom.xml | 54 -------- .../META-INF/maven/archetype-metadata.xml | 13 -- .../resources/archetype-resources/pom.xml | 116 ------------------ .../src/main/java/KeipApplication.java | 18 --- .../projects/basic/archetype.properties | 5 - .../test/resources/projects/basic/goal.txt | 0 operator/Makefile | 12 +- operator/controller/composite-controller.yaml | 35 ++++++ .../controller/keip-controller-props.yaml | 7 ++ operator/controller/kustomization.yaml | 7 +- operator/controller/namespace.yaml | 5 + ...ontroller.yaml => webhook-deployment.yaml} | 40 ------ operator/kustomization.yaml | 6 + {operator/webapp => webapp}/.dockerignore | 0 {operator/webapp => webapp}/Dockerfile | 0 {operator/webapp => webapp}/Makefile | 0 {operator/webapp => webapp}/README.md | 0 {operator/webapp => webapp}/__init__.py | 0 .../webapp => webapp}/addons/__init__.py | 0 .../addons/certmanager/README.md | 0 .../addons/certmanager/__init__.py | 0 .../addons/certmanager/main.py | 0 .../addons/certmanager/test/__init__.py | 0 .../json/full-integration-route-request.json | 0 .../certmanager/test/json/full-response.json | 0 .../certmanager/test/test_sync_certificate.py | 8 +- {operator/webapp => webapp}/app.py | 12 +- {operator/webapp => webapp}/config.py | 0 webapp/conftest.py | 7 ++ {operator/webapp => webapp}/core/__init__.py | 0 .../webapp => webapp}/core/k8s_client.py | 0 {operator/webapp => webapp}/core/sync.py | 0 .../webapp => webapp}/core/test/__init__.py | 0 .../webapp => webapp}/core/test/conftest.py | 10 +- .../json/full-integration-route-request.json | 0 .../core/test/json/full-response.json | 0 .../core/test/test_k8s_client.py | 0 .../core/test/test_status.py | 0 .../webapp => webapp}/core/test/test_sync.py | 0 {operator/webapp => webapp}/logconf.py | 0 {operator/webapp => webapp}/models.py | 0 .../webapp => webapp}/requirements-dev.txt | 0 {operator/webapp => webapp}/requirements.txt | 0 .../webapp => webapp}/routes/__init__.py | 0 {operator/webapp => webapp}/routes/deploy.py | 0 .../webapp => webapp}/routes/test/__init__.py | 0 .../routes/test/json/deploy-request-body.json | 0 .../routes/test/json/full-cert-request.json | 0 .../routes/test/json/full-cert-response.json | 0 .../routes/test/json/full-route-request.json | 0 .../routes/test/json/full-route-response.json | 0 .../routes/test/load_test/benchmark.md | 0 .../routes/test/test_cors.py | 0 .../routes/test/test_deploy.py | 2 +- .../routes/test/test_webapp.py | 8 +- {operator/webapp => webapp}/routes/webhook.py | 6 - 60 files changed, 121 insertions(+), 317 deletions(-) create mode 100644 Makefile delete mode 100644 keip-container-archetype/README.md delete mode 100644 keip-container-archetype/pom.xml delete mode 100644 keip-container-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml delete mode 100644 keip-container-archetype/src/main/resources/archetype-resources/pom.xml delete mode 100644 keip-container-archetype/src/main/resources/archetype-resources/src/main/java/KeipApplication.java delete mode 100644 keip-container-archetype/src/test/resources/projects/basic/archetype.properties delete mode 100644 keip-container-archetype/src/test/resources/projects/basic/goal.txt create mode 100644 operator/controller/composite-controller.yaml create mode 100644 operator/controller/keip-controller-props.yaml create mode 100644 operator/controller/namespace.yaml rename operator/controller/{core-controller.yaml => webhook-deployment.yaml} (54%) create mode 100644 operator/kustomization.yaml rename {operator/webapp => webapp}/.dockerignore (100%) rename {operator/webapp => webapp}/Dockerfile (100%) rename {operator/webapp => webapp}/Makefile (100%) rename {operator/webapp => webapp}/README.md (100%) rename {operator/webapp => webapp}/__init__.py (100%) rename {operator/webapp => webapp}/addons/__init__.py (100%) rename {operator/webapp => webapp}/addons/certmanager/README.md (100%) rename {operator/webapp => webapp}/addons/certmanager/__init__.py (100%) rename {operator/webapp => webapp}/addons/certmanager/main.py (100%) rename {operator/webapp => webapp}/addons/certmanager/test/__init__.py (100%) rename operator/webapp/addons/certmanager/test/json/full-iroute-request.json => webapp/addons/certmanager/test/json/full-integration-route-request.json (100%) rename {operator/webapp => webapp}/addons/certmanager/test/json/full-response.json (100%) rename {operator/webapp => webapp}/addons/certmanager/test/test_sync_certificate.py (98%) rename {operator/webapp => webapp}/app.py (81%) rename {operator/webapp => webapp}/config.py (100%) create mode 100644 webapp/conftest.py rename {operator/webapp => webapp}/core/__init__.py (100%) rename {operator/webapp => webapp}/core/k8s_client.py (100%) rename {operator/webapp => webapp}/core/sync.py (100%) rename {operator/webapp => webapp}/core/test/__init__.py (100%) rename {operator/webapp => webapp}/core/test/conftest.py (59%) rename operator/webapp/core/test/json/full-iroute-request.json => webapp/core/test/json/full-integration-route-request.json (100%) rename {operator/webapp => webapp}/core/test/json/full-response.json (100%) rename {operator/webapp => webapp}/core/test/test_k8s_client.py (100%) rename {operator/webapp => webapp}/core/test/test_status.py (100%) rename {operator/webapp => webapp}/core/test/test_sync.py (100%) rename {operator/webapp => webapp}/logconf.py (100%) rename {operator/webapp => webapp}/models.py (100%) rename {operator/webapp => webapp}/requirements-dev.txt (100%) rename {operator/webapp => webapp}/requirements.txt (100%) rename {operator/webapp => webapp}/routes/__init__.py (100%) rename {operator/webapp => webapp}/routes/deploy.py (100%) rename {operator/webapp => webapp}/routes/test/__init__.py (100%) rename operator/webapp/routes/test/json/deploy_request_body.json => webapp/routes/test/json/deploy-request-body.json (100%) rename {operator/webapp => webapp}/routes/test/json/full-cert-request.json (100%) rename {operator/webapp => webapp}/routes/test/json/full-cert-response.json (100%) rename {operator/webapp => webapp}/routes/test/json/full-route-request.json (100%) rename {operator/webapp => webapp}/routes/test/json/full-route-response.json (100%) rename {operator/webapp => webapp}/routes/test/load_test/benchmark.md (100%) rename {operator/webapp => webapp}/routes/test/test_cors.py (100%) rename {operator/webapp => webapp}/routes/test/test_deploy.py (98%) rename {operator/webapp => webapp}/routes/test/test_webapp.py (92%) rename {operator/webapp => webapp}/routes/webhook.py (87%) diff --git a/.github/workflows/operator.yml b/.github/workflows/operator.yml index 09326091..46e46dcd 100644 --- a/.github/workflows/operator.yml +++ b/.github/workflows/operator.yml @@ -6,7 +6,6 @@ on: - operator/** - .github/workflows/operator.yml - .github/workflows/scripts - - "!operator/webapp/**" - '!**.md' - '!operator/examples/**' push: @@ -14,7 +13,6 @@ on: - main paths: - operator/** - - "!operator/webapp/**" - '!**.md' - '!operator/examples/**' diff --git a/.github/workflows/webapp.yml b/.github/workflows/webapp.yml index 3b4161f6..c6b43b48 100644 --- a/.github/workflows/webapp.yml +++ b/.github/workflows/webapp.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: pull_request: paths: - - operator/webapp/** + - webapp/** - .github/workflows/webapp.yml - .github/workflows/scripts - '!**.md' @@ -11,12 +11,12 @@ on: branches: - main paths: - - operator/webapp/** + - webapp/** - '!**.md' env: PYTHON_VERSION: 3.11 - WORKING_DIR: ./operator/webapp + WORKING_DIR: ./webapp jobs: verify-versions: diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9e91d137 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +# Root Makefile — delegates to sub-projects + +.PHONY: test-webapp lint-webapp format-webapp precommit-webapp +.PHONY: deploy-operator undeploy-operator +.PHONY: build-minimal-app + +# Webapp targets +test-webapp: + $(MAKE) -C webapp test + +lint-webapp: + $(MAKE) -C webapp lint + +format-webapp: + $(MAKE) -C webapp format + +precommit-webapp: + $(MAKE) -C webapp precommit + +# Operator targets +deploy-operator: + $(MAKE) -C operator deploy + +undeploy-operator: + $(MAKE) -C operator undeploy + +# Minimal app targets +build-minimal-app: + cd minimal-app && mvn clean install diff --git a/keip-container-archetype/README.md b/keip-container-archetype/README.md deleted file mode 100644 index ca78fdd1..00000000 --- a/keip-container-archetype/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Keip Container Archetype - -Because Spring Integration is able to connect to so many different protocols, the default keip container is necessarily -basic. This archetype will simplify the process of creating a custom container with the Spring Integration dependencies -needed for your specific project. - -## Creating a custom Keip container - -The process for creating a custom keip container is outlined here: - -1. build this archtetype: `mvn clean install` -2. change to a directory where you want to create a new project: `cd ..` -3. issue the following command (change the values of groupId, artifactId and version to suit your needs): - -```shell -mvn archetype:generate -DinteractiveMode=false \ - -DarchetypeCatalog=local \ - -DarchetypeGroupId=org.codice.keip \ - -DarchetypeArtifactId=keip-container-archetype \ - -DarchetypeVersion=0.0.1-SNAPSHOT \ - -DgroupId=com.acme \ - -DartifactId=rocket-skis \ - -Dversion=0.1.0-SNAPSHOT -``` - -4. change to the new directory: `cd rocket-skis` -5. edit the `pom.xml` file to include any dependencies required for your project or remove those that aren't required. -6. build the container: `mvn clean install` - -This example will create a new Docker container called `rocket-skis:0.1.0-SNAPSHOT` \ No newline at end of file diff --git a/keip-container-archetype/pom.xml b/keip-container-archetype/pom.xml deleted file mode 100644 index 8f153787..00000000 --- a/keip-container-archetype/pom.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - 4.0.0 - - org.codice.keip - keip-container-archetype - 0.0.1-SNAPSHOT - maven-archetype - - keip-integration-archetype - - - - - org.apache.maven.archetype - archetype-packaging - 3.2.1 - - - - - - - maven-archetype-plugin - 3.2.1 - - - - - - Parent pom providing dependency and plugin management for applications built with Maven - - https://spring.io/projects/spring-boot/keip-integration - - - - Spring - ask@spring.io - VMware, Inc. - https://www.spring.io - - - - - - Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - - - - - https://github.com/spring-projects/spring-boot/keip-integration - - diff --git a/keip-container-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/keip-container-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml deleted file mode 100644 index f843e121..00000000 --- a/keip-container-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - src/main/java - - **/*.java - - - - diff --git a/keip-container-archetype/src/main/resources/archetype-resources/pom.xml b/keip-container-archetype/src/main/resources/archetype-resources/pom.xml deleted file mode 100644 index d5ad1ed8..00000000 --- a/keip-container-archetype/src/main/resources/archetype-resources/pom.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.1.2 - - - ${groupId} - ${artifactId} - ${version} - - - 17 - UTF-8 - 3.0.9 - 1.1.3 - 2022.0.4 - - - - - - io.micrometer - micrometer-tracing-bom - ${micrometer-tracing.version} - pom - import - - - org.springframework.cloud - spring-cloud-dependencies - ${spring-cloud.version} - pom - import - - - - - - - io.micrometer - micrometer-tracing - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-actuator - - - org.springframework.cloud - spring-cloud-starter-kubernetes-client-config - - - org.springframework.boot - spring-boot-starter-integration - - - org.springframework.integration - spring-integration-file - - - org.springframework.integration - spring-integration-jms - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - repackage - - - - - - - com.google.cloud.tools - jib-maven-plugin - 3.3.2 - - - ${maven.build.timestamp} - - -Dspring.config.location=/var/spring/config/ - - - - /var/spring/xml - /var/spring/config - - - - - - build-image - package - - dockerBuild - - - - - - - diff --git a/keip-container-archetype/src/main/resources/archetype-resources/src/main/java/KeipApplication.java b/keip-container-archetype/src/main/resources/archetype-resources/src/main/java/KeipApplication.java deleted file mode 100644 index 6e5e5baa..00000000 --- a/keip-container-archetype/src/main/resources/archetype-resources/src/main/java/KeipApplication.java +++ /dev/null @@ -1,18 +0,0 @@ -#set( $symbol_pound = '#' ) -#set( $symbol_dollar = '$' ) -#set( $symbol_escape = '\' ) -package ${package}; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.ImportResource; -import org.springframework.integration.config.EnableIntegration; - -@SpringBootApplication -@EnableIntegration -@ImportResource(locations = "${symbol_dollar}{keip.integration.filepath:file:/var/spring/xml/integrationRoute.xml}") -public class KeipApplication { - public static void main(String[] args) { - SpringApplication.run(KeipApplication.class); - } -} diff --git a/keip-container-archetype/src/test/resources/projects/basic/archetype.properties b/keip-container-archetype/src/test/resources/projects/basic/archetype.properties deleted file mode 100644 index 60f573f1..00000000 --- a/keip-container-archetype/src/test/resources/projects/basic/archetype.properties +++ /dev/null @@ -1,5 +0,0 @@ -#Thu Aug 10 16:38:28 MDT 2023 -package=it.pkg -groupId=archetype.it -artifactId=basic -version=0.1-SNAPSHOT diff --git a/keip-container-archetype/src/test/resources/projects/basic/goal.txt b/keip-container-archetype/src/test/resources/projects/basic/goal.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/operator/Makefile b/operator/Makefile index 60d621c4..463a31ee 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -1,16 +1,16 @@ VERSION ?= 0.15.0 GIT_TAG := operator_v$(VERSION) -KEIP_INTEGRATION_IMAGE ?= ghcr.io/codice/keip/minimal-app:latest KUBECTL := kubectl KUBECTL_DELETE := $(KUBECTL) delete --ignore-not-found -CONTROLLER_NAMESPACE := keip .PHONY: deploy -deploy: metacontroller/deploy controller/deploy +deploy: + $(KUBECTL) apply -k . .PHONY: undeploy -undeploy: controller/undeploy metacontroller/undeploy +undeploy: + -$(KUBECTL_DELETE) -k . .PHONY: get-tag get-tag: @@ -23,6 +23,8 @@ clean: prep-release: @rm -rf output @mkdir -p output/addons + @echo "Running kustomize build on ./ (full install)" + kustomize build . > ./output/install.yaml @echo "Running kustomize build on ./controller/" kustomize build ./controller/ > ./output/keip-core-controller.yaml @for dir in ./controller/addons/*/ ; do \ @@ -46,10 +48,8 @@ metacontroller/undeploy: controller/deploy: crd/deploy $(KUBECTL) apply -k controller - $(KUBECTL) -n $(CONTROLLER_NAMESPACE) create cm keip-controller-props --from-literal=integration-image=$(KEIP_INTEGRATION_IMAGE) -o yaml --dry-run=client | $(KUBECTL) apply -f - controller/undeploy: crd/undeploy - -$(KUBECTL_DELETE) -n $(CONTROLLER_NAMESPACE) cm keip-controller-props -$(KUBECTL_DELETE) -k controller addons/certmanager/deploy: diff --git a/operator/controller/composite-controller.yaml b/operator/controller/composite-controller.yaml new file mode 100644 index 00000000..a9d0e22e --- /dev/null +++ b/operator/controller/composite-controller.yaml @@ -0,0 +1,35 @@ +--- +apiVersion: metacontroller.k8s.io/v1alpha1 +kind: CompositeController +metadata: + name: keip-integrationroute-controller +spec: + generateSelector: true + parentResource: + apiVersion: keip.codice.org/v1alpha1 + resource: integrationroutes + revisionHistory: + fieldPaths: + - spec.routeConfigMap + childResources: + - apiVersion: apps/v1 + resource: deployments + updateStrategy: + method: RollingRecreate + statusChecks: + conditions: + - type: Ready + status: "True" + - apiVersion: v1 + resource: services + updateStrategy: + method: RollingRecreate + statusChecks: + conditions: + - type: Ready + status: "True" + hooks: + sync: + webhook: + url: http://integrationroute-webhook.keip/webhook/sync + timeout: 10s diff --git a/operator/controller/keip-controller-props.yaml b/operator/controller/keip-controller-props.yaml new file mode 100644 index 00000000..784e34f5 --- /dev/null +++ b/operator/controller/keip-controller-props.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: keip-controller-props + namespace: keip +data: + integration-image: "ghcr.io/codice/keip/minimal-app:latest" diff --git a/operator/controller/kustomization.yaml b/operator/controller/kustomization.yaml index 67695800..2c32e4df 100644 --- a/operator/controller/kustomization.yaml +++ b/operator/controller/kustomization.yaml @@ -1,3 +1,6 @@ resources: - - core-controller.yaml - - core-privileges.yaml \ No newline at end of file + - namespace.yaml + - composite-controller.yaml + - webhook-deployment.yaml + - core-privileges.yaml + - keip-controller-props.yaml diff --git a/operator/controller/namespace.yaml b/operator/controller/namespace.yaml new file mode 100644 index 00000000..c632f84d --- /dev/null +++ b/operator/controller/namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: keip diff --git a/operator/controller/core-controller.yaml b/operator/controller/webhook-deployment.yaml similarity index 54% rename from operator/controller/core-controller.yaml rename to operator/controller/webhook-deployment.yaml index 2d20983c..e96e291d 100644 --- a/operator/controller/core-controller.yaml +++ b/operator/controller/webhook-deployment.yaml @@ -1,44 +1,4 @@ --- -apiVersion: metacontroller.k8s.io/v1alpha1 -kind: CompositeController -metadata: - name: keip-integrationroute-controller -spec: - generateSelector: true - parentResource: - apiVersion: keip.codice.org/v1alpha1 - resource: integrationroutes - revisionHistory: - fieldPaths: - - spec.routeConfigMap - childResources: - - apiVersion: apps/v1 - resource: deployments - updateStrategy: - method: RollingRecreate - statusChecks: - conditions: - - type: Ready - status: "True" - - apiVersion: v1 - resource: services - updateStrategy: - method: RollingRecreate - statusChecks: - conditions: - - type: Ready - status: "True" - hooks: - sync: - webhook: - url: http://integrationroute-webhook.keip/webhook/sync - timeout: 10s ---- -apiVersion: v1 -kind: Namespace -metadata: - name: keip ---- apiVersion: apps/v1 kind: Deployment metadata: diff --git a/operator/kustomization.yaml b/operator/kustomization.yaml new file mode 100644 index 00000000..3047fb81 --- /dev/null +++ b/operator/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - metacontroller + - crd + - controller diff --git a/operator/webapp/.dockerignore b/webapp/.dockerignore similarity index 100% rename from operator/webapp/.dockerignore rename to webapp/.dockerignore diff --git a/operator/webapp/Dockerfile b/webapp/Dockerfile similarity index 100% rename from operator/webapp/Dockerfile rename to webapp/Dockerfile diff --git a/operator/webapp/Makefile b/webapp/Makefile similarity index 100% rename from operator/webapp/Makefile rename to webapp/Makefile diff --git a/operator/webapp/README.md b/webapp/README.md similarity index 100% rename from operator/webapp/README.md rename to webapp/README.md diff --git a/operator/webapp/__init__.py b/webapp/__init__.py similarity index 100% rename from operator/webapp/__init__.py rename to webapp/__init__.py diff --git a/operator/webapp/addons/__init__.py b/webapp/addons/__init__.py similarity index 100% rename from operator/webapp/addons/__init__.py rename to webapp/addons/__init__.py diff --git a/operator/webapp/addons/certmanager/README.md b/webapp/addons/certmanager/README.md similarity index 100% rename from operator/webapp/addons/certmanager/README.md rename to webapp/addons/certmanager/README.md diff --git a/operator/webapp/addons/certmanager/__init__.py b/webapp/addons/certmanager/__init__.py similarity index 100% rename from operator/webapp/addons/certmanager/__init__.py rename to webapp/addons/certmanager/__init__.py diff --git a/operator/webapp/addons/certmanager/main.py b/webapp/addons/certmanager/main.py similarity index 100% rename from operator/webapp/addons/certmanager/main.py rename to webapp/addons/certmanager/main.py diff --git a/operator/webapp/addons/certmanager/test/__init__.py b/webapp/addons/certmanager/test/__init__.py similarity index 100% rename from operator/webapp/addons/certmanager/test/__init__.py rename to webapp/addons/certmanager/test/__init__.py diff --git a/operator/webapp/addons/certmanager/test/json/full-iroute-request.json b/webapp/addons/certmanager/test/json/full-integration-route-request.json similarity index 100% rename from operator/webapp/addons/certmanager/test/json/full-iroute-request.json rename to webapp/addons/certmanager/test/json/full-integration-route-request.json diff --git a/operator/webapp/addons/certmanager/test/json/full-response.json b/webapp/addons/certmanager/test/json/full-response.json similarity index 100% rename from operator/webapp/addons/certmanager/test/json/full-response.json rename to webapp/addons/certmanager/test/json/full-response.json diff --git a/operator/webapp/addons/certmanager/test/test_sync_certificate.py b/webapp/addons/certmanager/test/test_sync_certificate.py similarity index 98% rename from operator/webapp/addons/certmanager/test/test_sync_certificate.py rename to webapp/addons/certmanager/test/test_sync_certificate.py index 06497f4d..fe45cf2b 100644 --- a/operator/webapp/addons/certmanager/test/test_sync_certificate.py +++ b/webapp/addons/certmanager/test/test_sync_certificate.py @@ -5,6 +5,7 @@ import pytest +from conftest import load_json_as_dict from webapp.addons.certmanager.main import ( sync_certificate, _get_annotation_vals_as_list, @@ -250,9 +251,4 @@ def full_route(full_route_load: dict): @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) + return load_json_as_dict(f"{cwd}/json/full-integration-route-request.json") diff --git a/operator/webapp/app.py b/webapp/app.py similarity index 81% rename from operator/webapp/app.py rename to webapp/app.py index 7e8ddcf9..351a773e 100644 --- a/operator/webapp/app.py +++ b/webapp/app.py @@ -9,7 +9,9 @@ import config as cfg from logconf import LOG_CONF from routes import webhook +from routes.webhook import build_webhook from routes.deploy import deploy_route +from addons.certmanager.main import sync_certificate _LOGGER = logging.getLogger(__name__) @@ -41,10 +43,18 @@ def create_app() -> ASGIApp: if cfg.DEBUG: _LOGGER.warning("Running server with debug mode. NOT SUITABLE FOR PRODUCTION!") + addon_routes = [ + Route( + "/addons/certmanager/sync", + endpoint=build_webhook(sync_certificate), + methods=["POST"], + ), + ] + routes = [ Route("/route", deploy_route, methods=["PUT"]), Route("/status", status, methods=["GET"]), - Mount(path="/webhook", routes=webhook.routes), + Mount(path="/webhook", routes=webhook.routes + addon_routes), ] starlette_app = Starlette(debug=cfg.DEBUG, routes=routes) diff --git a/operator/webapp/config.py b/webapp/config.py similarity index 100% rename from operator/webapp/config.py rename to webapp/config.py diff --git a/webapp/conftest.py b/webapp/conftest.py new file mode 100644 index 00000000..e25f56d3 --- /dev/null +++ b/webapp/conftest.py @@ -0,0 +1,7 @@ +import json +from typing import Mapping + + +def load_json_as_dict(filepath: str) -> Mapping: + with open(filepath, "r") as f: + return json.load(f) diff --git a/operator/webapp/core/__init__.py b/webapp/core/__init__.py similarity index 100% rename from operator/webapp/core/__init__.py rename to webapp/core/__init__.py diff --git a/operator/webapp/core/k8s_client.py b/webapp/core/k8s_client.py similarity index 100% rename from operator/webapp/core/k8s_client.py rename to webapp/core/k8s_client.py diff --git a/operator/webapp/core/sync.py b/webapp/core/sync.py similarity index 100% rename from operator/webapp/core/sync.py rename to webapp/core/sync.py diff --git a/operator/webapp/core/test/__init__.py b/webapp/core/test/__init__.py similarity index 100% rename from operator/webapp/core/test/__init__.py rename to webapp/core/test/__init__.py diff --git a/operator/webapp/core/test/conftest.py b/webapp/core/test/conftest.py similarity index 59% rename from operator/webapp/core/test/conftest.py rename to webapp/core/test/conftest.py index 6d2829cf..1ed01e36 100644 --- a/operator/webapp/core/test/conftest.py +++ b/webapp/core/test/conftest.py @@ -1,10 +1,11 @@ import copy -import json import os from typing import Mapping import pytest +from conftest import load_json_as_dict + @pytest.fixture() def full_route(full_route_load: dict): @@ -14,9 +15,4 @@ def full_route(full_route_load: dict): @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) + return load_json_as_dict(f"{cwd}/json/full-integration-route-request.json") diff --git a/operator/webapp/core/test/json/full-iroute-request.json b/webapp/core/test/json/full-integration-route-request.json similarity index 100% rename from operator/webapp/core/test/json/full-iroute-request.json rename to webapp/core/test/json/full-integration-route-request.json diff --git a/operator/webapp/core/test/json/full-response.json b/webapp/core/test/json/full-response.json similarity index 100% rename from operator/webapp/core/test/json/full-response.json rename to webapp/core/test/json/full-response.json diff --git a/operator/webapp/core/test/test_k8s_client.py b/webapp/core/test/test_k8s_client.py similarity index 100% rename from operator/webapp/core/test/test_k8s_client.py rename to webapp/core/test/test_k8s_client.py diff --git a/operator/webapp/core/test/test_status.py b/webapp/core/test/test_status.py similarity index 100% rename from operator/webapp/core/test/test_status.py rename to webapp/core/test/test_status.py diff --git a/operator/webapp/core/test/test_sync.py b/webapp/core/test/test_sync.py similarity index 100% rename from operator/webapp/core/test/test_sync.py rename to webapp/core/test/test_sync.py diff --git a/operator/webapp/logconf.py b/webapp/logconf.py similarity index 100% rename from operator/webapp/logconf.py rename to webapp/logconf.py diff --git a/operator/webapp/models.py b/webapp/models.py similarity index 100% rename from operator/webapp/models.py rename to webapp/models.py diff --git a/operator/webapp/requirements-dev.txt b/webapp/requirements-dev.txt similarity index 100% rename from operator/webapp/requirements-dev.txt rename to webapp/requirements-dev.txt diff --git a/operator/webapp/requirements.txt b/webapp/requirements.txt similarity index 100% rename from operator/webapp/requirements.txt rename to webapp/requirements.txt diff --git a/operator/webapp/routes/__init__.py b/webapp/routes/__init__.py similarity index 100% rename from operator/webapp/routes/__init__.py rename to webapp/routes/__init__.py diff --git a/operator/webapp/routes/deploy.py b/webapp/routes/deploy.py similarity index 100% rename from operator/webapp/routes/deploy.py rename to webapp/routes/deploy.py diff --git a/operator/webapp/routes/test/__init__.py b/webapp/routes/test/__init__.py similarity index 100% rename from operator/webapp/routes/test/__init__.py rename to webapp/routes/test/__init__.py diff --git a/operator/webapp/routes/test/json/deploy_request_body.json b/webapp/routes/test/json/deploy-request-body.json similarity index 100% rename from operator/webapp/routes/test/json/deploy_request_body.json rename to webapp/routes/test/json/deploy-request-body.json diff --git a/operator/webapp/routes/test/json/full-cert-request.json b/webapp/routes/test/json/full-cert-request.json similarity index 100% rename from operator/webapp/routes/test/json/full-cert-request.json rename to webapp/routes/test/json/full-cert-request.json diff --git a/operator/webapp/routes/test/json/full-cert-response.json b/webapp/routes/test/json/full-cert-response.json similarity index 100% rename from operator/webapp/routes/test/json/full-cert-response.json rename to webapp/routes/test/json/full-cert-response.json diff --git a/operator/webapp/routes/test/json/full-route-request.json b/webapp/routes/test/json/full-route-request.json similarity index 100% rename from operator/webapp/routes/test/json/full-route-request.json rename to webapp/routes/test/json/full-route-request.json diff --git a/operator/webapp/routes/test/json/full-route-response.json b/webapp/routes/test/json/full-route-response.json similarity index 100% rename from operator/webapp/routes/test/json/full-route-response.json rename to webapp/routes/test/json/full-route-response.json diff --git a/operator/webapp/routes/test/load_test/benchmark.md b/webapp/routes/test/load_test/benchmark.md similarity index 100% rename from operator/webapp/routes/test/load_test/benchmark.md rename to webapp/routes/test/load_test/benchmark.md diff --git a/operator/webapp/routes/test/test_cors.py b/webapp/routes/test/test_cors.py similarity index 100% rename from operator/webapp/routes/test/test_cors.py rename to webapp/routes/test/test_cors.py diff --git a/operator/webapp/routes/test/test_deploy.py b/webapp/routes/test/test_deploy.py similarity index 98% rename from operator/webapp/routes/test/test_deploy.py rename to webapp/routes/test/test_deploy.py index dd6f9c5b..47de195e 100644 --- a/operator/webapp/routes/test/test_deploy.py +++ b/webapp/routes/test/test_deploy.py @@ -30,7 +30,7 @@ def test_client(): resources = [Resource(name="my-route", status=Status.CREATED)] -with open("./routes/test/json/deploy_request_body.json", "r") as f: +with open("./routes/test/json/deploy-request-body.json", "r") as f: body = json.load(f) diff --git a/operator/webapp/routes/test/test_webapp.py b/webapp/routes/test/test_webapp.py similarity index 92% rename from operator/webapp/routes/test/test_webapp.py rename to webapp/routes/test/test_webapp.py index f2c8d4ad..0ca4e7cb 100644 --- a/operator/webapp/routes/test/test_webapp.py +++ b/webapp/routes/test/test_webapp.py @@ -1,11 +1,10 @@ -import json import os -from typing import Mapping import pytest from starlette.testclient import TestClient from app import app +from conftest import load_json_as_dict def test_status_endpoint(test_client): @@ -69,11 +68,6 @@ def test_sync_endpoint_empty_body(test_client, endpoint, status_code): assert response.status_code == status_code -def load_json_as_dict(filepath: str) -> Mapping: - with open(filepath, "r") as f: - return json.load(f) - - @pytest.fixture(scope="module") def test_client(): return TestClient(app) diff --git a/operator/webapp/routes/webhook.py b/webapp/routes/webhook.py similarity index 87% rename from operator/webapp/routes/webhook.py rename to webapp/routes/webhook.py index 3be57819..5b4d46f8 100644 --- a/operator/webapp/routes/webhook.py +++ b/webapp/routes/webhook.py @@ -10,7 +10,6 @@ from starlette.status import HTTP_400_BAD_REQUEST from core.sync import sync -from addons.certmanager.main import sync_certificate _LOGGER = logging.getLogger(__name__) @@ -41,9 +40,4 @@ async def webhook(request: Request): routes = [ Route("/sync", endpoint=build_webhook(sync), methods=["POST"]), - Route( - "/addons/certmanager/sync", - endpoint=build_webhook(sync_certificate), - methods=["POST"], - ), ] From 9a8aba9e3e0ea733612fd4efbf6a6d4b3ba1f344 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 02/10] Update documentation and developer reference --- CLAUDE.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 104 ++++++++++++++++++------------------- 3 files changed, 184 insertions(+), 56 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..65931a18 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +keip (Kubernetes Enterprise Integration Patterns) is a Kubernetes operator that deploys Spring Integration routes as Kubernetes resources. It uses Metacontroller to watch `IntegrationRoute` custom resources, calls a Python webhook to generate Deployment/Service manifests, and manages the lifecycle of Spring Boot integration containers. + +## Repository Structure + +Three independently versioned artifacts in a monorepo: + +- **`webapp/`** — Python (3.11) webhook server (Starlette/Uvicorn) that handles Metacontroller sync requests and generates Kubernetes child resources. This is where most active development happens. +- **`operator/`** — Kubernetes manifests (CRD, CompositeController, RBAC) deployed via kustomize. +- **`minimal-app/`** — Default Java 21 Spring Boot container that runs the integration routes. + +A root `Makefile` provides aggregate targets that delegate to sub-projects. + +**Python 3.11 required** — the webapp uses `logging.getLevelNamesMapping()` (3.11+) and `datetime.fromisoformat()` with `"Z"` suffix (3.11+). On systems without `python3.11` as default, set `HOST_PYTHON=python3.11 make venv`. + +## Build & Development Commands + +### Webapp (Python) — `webapp/` + +```bash +make venv # Create virtualenv and install deps (requires python3.11) +make test # Run pytest with coverage +make lint # Run ruff linter +make format # Run black formatter +make precommit # Run test + format + lint (use before committing) +make start-dev-server # Start uvicorn on :7080 with --reload +``` + +Run a single test or test file: +```bash +EXTRA_PYTEST_ARGS="-k test_function_name" make test +EXTRA_PYTEST_ARGS="core/test/test_sync.py" make test +EXTRA_PYTEST_ARGS="-vv --log-cli-level=DEBUG" make test # verbose with debug logging +``` + +### Operator (K8s Manifests) — `operator/` + +```bash +make deploy # Deploy all operator components via top-level kustomization +make undeploy # Remove all operator components +make prep-release # Generate install.yaml and per-component manifests to ./output/ +``` + +Users can also install without cloning: `kubectl apply -f /install.yaml` or `kubectl apply -k 'https://github.com/codice/keip/operator?ref='`. + +### Minimal App (Java) — `minimal-app/` + +```bash +mvn clean install # Build JAR and Docker image (uses Jib) +mvn verify # Run tests without install +``` + +### Root Makefile (delegates to sub-projects) + +```bash +make test-webapp # Run webapp tests +make lint-webapp # Lint webapp +make precommit-webapp # Full precommit check +make deploy-operator # Deploy operator to cluster +make build-minimal-app # Build Java app +``` + +## Architecture + +``` +IntegrationRoute CR → Metacontroller → Webhook (Python) → Deployment + Service + (user) (watches CRDs) (sync.py logic) (child resources) +``` + +1. User creates an `IntegrationRoute` resource (`keip.codice.org/v1alpha1`, shortname: `ir`) +2. Metacontroller (v4.11.6) detects it via CompositeController +3. Metacontroller POSTs to the webhook at `/webhook/sync` +4. `core/sync.py` generates a Deployment spec (mounting the route XML from a ConfigMap) and an actuator Service +5. Metacontroller applies the generated child resources + +### Webapp Code Layout + +- **`app.py`** — Starlette app entrypoint, CORS config, route and addon registration +- **`config.py`** — Env var config (`DEBUG`, `CORS_ALLOWED_ORIGINS`, `INTEGRATION_IMAGE`) +- **`models.py`** — Pydantic models for request/response validation +- **`core/sync.py`** — Core sync logic. `VolumeConfig` class handles volume/mount generation. Key functions: `_new_deployment()`, `_new_actuator_service()`, `_compute_status()`, `_gen_children()` +- **`logconf.py`** — Logging config; reads `LOG_LEVEL` env var via `get_log_level_from_env()` (uses `logging.getLevelNamesMapping()`, Python 3.11+) +- **`core/k8s_client.py`** — Kubernetes API client wrappers (used by deploy route) +- **`routes/webhook.py`** — Core Metacontroller webhook endpoint (`build_webhook()` factory) +- **`routes/deploy.py`** — `/route` endpoint for direct deployment via K8s API +- **`addons/certmanager/`** — Optional cert-manager TLS integration addon (registered in `app.py`) + +### Operator Manifest Layout + +- **`kustomization.yaml`** — Top-level kustomization (references metacontroller, crd, controller) +- **`controller/composite-controller.yaml`** — Metacontroller CompositeController definition +- **`controller/namespace.yaml`** — keip namespace +- **`controller/webhook-deployment.yaml`** — Webhook Deployment + Service +- **`controller/core-privileges.yaml`** — RBAC (ServiceAccounts, Roles, ClusterRoles) +- **`controller/keip-controller-props.yaml`** — ConfigMap with default integration image +- **`crd/crd.yaml`** — IntegrationRoute CRD definition + +## Key Environment Variables (Webapp) + +| Variable | Default | Source | Purpose | +|---|---|---|---| +| `INTEGRATION_IMAGE` | `keip-integration` | `config.py` | Container image for integration routes | +| `CORS_ALLOWED_ORIGINS` | `""` | `config.py` | Comma-separated allowed origins | +| `DEBUG` | `false` | `config.py` | Starlette debug mode | +| `LOG_LEVEL` | `INFO` | `logconf.py` | Python logging level (requires Python 3.11+) | + +## Testing + +Tests live adjacent to source in `test/` subdirectories with shared utilities in `webapp/conftest.py`: +- `webapp/core/test/` — sync, status, k8s_client tests +- `webapp/routes/test/` — deploy, webhook, CORS, webapp integration tests +- `webapp/addons/certmanager/test/` — cert-manager addon tests + +Uses pytest, httpx (for async HTTP testing), pytest-mock. Coverage is tracked via `coverage` to `.test_coverage/`. + +## CI/CD + +GitHub Actions workflows in `.github/workflows/`: +- **`webapp.yml`** — verify-versions → test → lint → build (PR) / release (main). Builds linux/amd64 + arm64. +- **`operator.yml`** — verify-versions → kustomize build (PR) / release (main) +- **`minimal-app.yml`** — verify-versions → Maven verify (PR) / release (main). Java 21. + +## Conventions + +- Python code formatted with **black**, linted with **ruff** +- Webapp uses **Starlette** (not Flask/FastAPI) as the ASGI framework with **Pydantic** for validation +- Kubernetes manifests managed with **kustomize** (no Helm) +- Container images published to `ghcr.io/codice` +- Operator namespace: `keip`, metacontroller namespace: `metacontroller` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e8b1de1..411018da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,4 +45,4 @@ applicable. ## See Also -- [Keip Webhook Developer Guide](operator%2Fwebhook%2FREADME.md) \ No newline at end of file +- [keip Webapp Developer Guide](webapp/README.md) diff --git a/README.md b/README.md index 1ab9bdcd..65c42d83 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ ## What is keip? -keip (Kubernetes Enterprise Integration Patterns) is a Kubernetes operator that serves as the communication backbone for modern distributed systems. Whether you're orchestrating microservices, building AI toolchains, or handling traditional enterprise integration, keip transforms complex data flow challenges into simple, declarative configurations. +keip (Kubernetes Enterprise Integration Patterns) is a Kubernetes operator that serves as the communication backbone for modern distributed systems. Whether you're orchestrating microservices, building AI toolchains, or handling traditional enterprise integration, keip transforms complex data flow challenges into simple, declarative configurations. -Instead of writing, compiling, and deploying Java applications for service communication and data integration, you can now define Spring Integration routes as Kubernetes resources and let keip handle the rest. +Instead of writing, compiling, and deploying Java applications for service communication and data integration, define Spring Integration routes as Kubernetes resources and let keip handle the rest. ### The Problems It Solves Modern distributed systems often need to: - **Microservices Communication**: Orchestrate complex service-to-service interactions, event streaming, and API workflows -- **AI Toolchain Coordination**: Connect AI models, data pipelines, feature stores, and inference engines in sophisticated workflows +- **AI Toolchain Coordination**: Connect AI models, data pipelines, feature stores, and inference engines in sophisticated workflows - **Enterprise Integration**: Move data between different systems (databases, message queues, APIs, files) - **Data Transformation**: Convert formats (JSON to XML, CSV to database records, etc.) and enrich data in transit - **Smart Routing**: Route messages based on content, rules, or dynamic conditions @@ -37,13 +37,13 @@ With keip, you define your integration logic in XML configuration and deploy it ## Key Features -- **🔧 Kubernetes Native**: Fully integrates with Kubernetes using custom resources and scales with cluster capabilities -- **🌱 Spring Ecosystem Powered**: Built on Spring Boot and Spring Integration with access to 300+ connectors and enterprise patterns -- **⚡ No Code Compilation**: Define integration routes in XML and deploy instantly -- **📈 Auto-Scaling**: Leverages Kubernetes scaling capabilities for integration workloads -- **🔄 Runtime Flexibility**: Update integration routes without rebuilding applications -- **🏢 Enterprise Ready**: Battle-tested Spring components with comprehensive error handling and monitoring -- **📊 Cloud-Native Observability**: Native Kubernetes monitoring and logging support +- **Kubernetes Native**: Fully integrates with Kubernetes using custom resources and scales with cluster capabilities +- **Spring Ecosystem Powered**: Built on Spring Boot and Spring Integration with access to 300+ connectors and enterprise patterns +- **No Code Compilation**: Define integration routes in XML and deploy instantly +- **Auto-Scaling**: Leverages Kubernetes scaling capabilities for integration workloads +- **Runtime Flexibility**: Update integration routes without rebuilding applications +- **Enterprise Ready**: Battle-tested Spring components with comprehensive error handling and monitoring +- **Cloud-Native Observability**: Native Kubernetes monitoring and logging support ## Quick Start @@ -51,38 +51,45 @@ With keip, you define your integration logic in XML configuration and deploy it - Kubernetes cluster (v1.24+ recommended) - `kubectl` installed and configured to interact with your cluster -- The `Make` utility for deploying the operator ### Installation -1. **Clone the repository:** - ```bash - git clone https://github.com/codice/keip.git && cd keip - ``` +Choose one of the following methods: -2. **Deploy the keip operator:** - ```bash - cd operator && make deploy - ``` +**Option A — Static manifest (recommended):** +```bash +kubectl apply -f https://github.com/codice/keip/releases/latest/download/install.yaml +``` - This creates the `keip` and `metacontroller` namespaces and deploys the necessary components. +**Option B — Remote kustomize:** +```bash +kubectl apply -k 'https://github.com/codice/keip/operator?ref=main' +``` -3. **Verify installation:** - ```bash - # Check metacontroller pod - kubectl -n metacontroller get po - - # Check keip webhook pod - kubectl -n keip get po - ``` +**Option C — From source (development):** +```bash +git clone https://github.com/codice/keip.git && cd keip/operator +make deploy +``` + +All methods create the `keip` and `metacontroller` namespaces and deploy the necessary components. + +**Verify installation:** +```bash +# Check metacontroller pod +kubectl -n metacontroller get po + +# Check keip webhook pod +kubectl -n keip get po +``` ### Your First Integration Route -Let's create a simple integration that prints a message every 5 seconds: +Create a simple integration that prints a message every 5 seconds: 1. **Create the integration configuration:** ```bash - cat <<'EOF' | kubectl create -f - + cat < - + - - + + - + - EOF + YAMEOF ``` 2. **Deploy the integration route:** ```bash - cat <<'EOF' | kubectl create -f - + cat < Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 03/10] Upgrade to Java 21 --- .github/workflows/minimal-app.yml | 2 +- minimal-app/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/minimal-app.yml b/.github/workflows/minimal-app.yml index a8eeeaba..2724eed7 100644 --- a/.github/workflows/minimal-app.yml +++ b/.github/workflows/minimal-app.yml @@ -15,7 +15,7 @@ on: env: WORKING_DIR: ./minimal-app - JAVA_VERSION: 17 + JAVA_VERSION: 21 GIT_TAG_PREFIX: minimal-app_v jobs: diff --git a/minimal-app/pom.xml b/minimal-app/pom.xml index 26a72d1f..a93b1968 100644 --- a/minimal-app/pom.xml +++ b/minimal-app/pom.xml @@ -22,7 +22,7 @@ true - 17 + 21 UTF-8 1.1.3 2022.0.4 From 5594a971111ffec50eea7e6a0acb9278bc2b9800 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 04/10] Upgrade to Spring Boot 4.0.2 and Spring Framework 7 --- minimal-app/pom.xml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/minimal-app/pom.xml b/minimal-app/pom.xml index a93b1968..3419d357 100644 --- a/minimal-app/pom.xml +++ b/minimal-app/pom.xml @@ -6,13 +6,13 @@ org.springframework.boot spring-boot-starter-parent - 3.1.2 + 4.0.2 org.codice.keip minimal-app - 0.3.0 + 0.4.0 ghcr.io/codice @@ -24,19 +24,11 @@ 21 UTF-8 - 1.1.3 - 2022.0.4 + 2025.1.0 - - io.micrometer - micrometer-tracing-bom - ${micrometer-tracing.version} - pom - import - org.springframework.cloud spring-cloud-dependencies @@ -103,7 +95,7 @@ com.google.cloud.tools jib-maven-plugin - 3.4.0 + 3.5.1 ${maven.build.timestamp} From f7fb467684ed02a65b6d21eb6496e7db96493970 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 05/10] Upgrade CRD to v1alpha2 --- .../keip-certmanager-controller.yaml | 2 +- operator/controller/composite-controller.yaml | 2 +- operator/crd/crd.yaml | 367 +++++++++++++++++- operator/examples/basic/testroute.yaml | 4 +- .../examples/certmanager-addon/testroute.yaml | 2 +- 5 files changed, 354 insertions(+), 23 deletions(-) diff --git a/operator/controller/addons/certmanager/keip-certmanager-controller.yaml b/operator/controller/addons/certmanager/keip-certmanager-controller.yaml index f738370e..c9c2633d 100644 --- a/operator/controller/addons/certmanager/keip-certmanager-controller.yaml +++ b/operator/controller/addons/certmanager/keip-certmanager-controller.yaml @@ -5,7 +5,7 @@ metadata: name: keip-certmanager-controller spec: resources: - - apiVersion: keip.codice.org/v1alpha1 + - apiVersion: keip.codice.org/v1alpha2 resource: integrationroutes attachments: - apiVersion: cert-manager.io/v1 diff --git a/operator/controller/composite-controller.yaml b/operator/controller/composite-controller.yaml index a9d0e22e..6d1a9c2b 100644 --- a/operator/controller/composite-controller.yaml +++ b/operator/controller/composite-controller.yaml @@ -6,7 +6,7 @@ metadata: spec: generateSelector: true parentResource: - apiVersion: keip.codice.org/v1alpha1 + apiVersion: keip.codice.org/v1alpha2 resource: integrationroutes revisionHistory: fieldPaths: diff --git a/operator/crd/crd.yaml b/operator/crd/crd.yaml index 16452637..510ea9e6 100644 --- a/operator/crd/crd.yaml +++ b/operator/crd/crd.yaml @@ -10,9 +10,14 @@ spec: singular: integrationroute shortNames: - ir + categories: + - keip + - all scope: Namespaced + conversion: + strategy: None versions: - - name: v1alpha1 + - name: v1alpha2 served: true storage: true additionalPrinterColumns: @@ -22,9 +27,9 @@ spec: - name: Replicas type: integer jsonPath: .status.expectedReplicas - - name: Deployment + - name: Route ConfigMap type: string - jsonPath: .metadata.name + jsonPath: .spec.routeConfigMap - name: Age type: date jsonPath: .metadata.creationTimestamp @@ -33,6 +38,7 @@ spec: type: object properties: spec: + description: "Desired state of the IntegrationRoute deployment" type: object properties: annotations: @@ -55,20 +61,337 @@ spec: type: object properties: name: + description: "Name of the ConfigMap to use as a PropertySource" type: string labels: + description: "Label selector to match ConfigMaps to use as PropertySources" type: object additionalProperties: type: string oneOf: - - properties: + - required: + - name + - required: + - labels + secretSources: + description: "List of Secrets that will be mounted into the integration route container and included as Spring PropertySources. The Secrets should be in the same namespace as the IntegrationRoute resource" + type: array + items: + type: object + properties: + name: + description: "Name of the Secret" + type: string + required: + - name + configMaps: + description: "List of configMaps that will be mounted into the integration route container" + type: array + items: + type: object + properties: + name: + description: "Name of the ConfigMap to mount" + type: string + mountPath: + description: "Filesystem path where the ConfigMap will be mounted in the container" + type: string + required: + - name + - mountPath + env: + description: "List of environment variables to set in the container (cannot be updated)" + type: array + items: + type: object + properties: + name: + description: "Name of the environment variable" + type: string + value: + description: "Value of the environment variable" + type: string + required: + - name + - value + envFrom: + description: "List of sources to populate environment variables in the container (cannot be updated)" + type: array + items: + type: object + properties: + configMapRef: + description: "Reference to a ConfigMap to populate environment variables from" + type: object + properties: + name: + description: "Name of the ConfigMap" + type: string + optional: + description: "Whether the ConfigMap must exist" + type: boolean required: - name - - properties: + secretRef: + description: "Reference to a Secret to populate environment variables from" + type: object + properties: + name: + description: "Name of the Secret" + type: string + optional: + description: "Whether the Secret must exist" + type: boolean required: + - name + oneOf: + - required: + - configMapRef + - required: + - secretRef + resources: + description: "Compute resources given to the route containers. Follows https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#resource-requests-and-limits-of-pod-and-container" + type: object + properties: + limits: + description: "Limits describe the maximum amount of compute and memory resources allowed." + type: object + properties: + cpu: + type: string + memory: + type: string + requests: + description: "Requests describe the minimum amount of compute and memory resources required." + type: object + properties: + cpu: + type: string + memory: + type: string + replicas: + description: "Number of pods running the integration route" + type: integer + minimum: 1 + maximum: 20 # TODO: Figure out a reasonable maximum + default: 1 + persistentVolumeClaims: + description: "List with the names of PersistentVolumeClaims and their mountPaths on the pod container" + type: array + items: + type: object + properties: + claimName: + type: string + mountPath: + type: string + required: + - claimName + - mountPath + tls: + description: "Configure server and client TLS connections." + type: object + properties: + truststore: + description: "Configures client TLS connections using a JKS or PKCS12 truststore. A JKS truststore should have its password set to 'changeit', while a PKCS12 truststore should have an empty password." + type: object + properties: + jks: + type: object + properties: + configMapName: + description: "The name of the ConfigMap resource containing the truststore (truststore.jks)." + type: string + key: + description: "The name of the key containing the truststore in the ConfigMap resource (configMapName)." + type: string + required: + - configMapName + - key + pkcs12: + type: object + properties: + configMapName: + description: "The name of the ConfigMap resource containing the truststore (truststore.p12)." + type: string + key: + description: "The name of the key containing the truststore in the ConfigMap resource (configMapName)." + type: string + required: + - configMapName + - key + oneOf: + - required: + - jks + - required: + - pkcs12 + keystore: + description: "Configures HTTP server TLS connections using a JKS or PKCS12 keystore. The keystore password should be stored in a Secret resource and referenced in the route's Custom Resource. The format of the Secret is `password=`." + type: object + properties: + jks: + type: object + properties: + alias: + description: "Specifies the unique identifier for the key in the JKS keystore. If not provided, the default alias of `certificate` is used." + type: string + secretName: + description: "The name of the Secret resource containing the keystore (keystore.jks)." + type: string + key: + description: "The name of the key containing the keystore in the Secret resource (secretName)." + type: string + passwordSecretRef: + description: "The reference to the Secret resource containing the password used to encrypt the JKS keystore." + type: string + required: + - secretName + - key + - passwordSecretRef + pkcs12: + type: object + properties: + secretName: + description: "The name of the Secret resource containing the keystore (keystore.p12)." + type: string + key: + description: "The name of the key, containing the keystore, in the Secret resource (secretName)." + type: string + passwordSecretRef: + description: "The reference to the Secret resource containing the password used to encrypt the PKCS12 keystore." + type: string + required: + - secretName + - key + - passwordSecretRef + oneOf: + - required: + - jks + - required: + - pkcs12 + required: + - routeConfigMap + status: + description: "Observed state of the IntegrationRoute deployment" + type: object + properties: + conditions: + description: |- + List of status conditions to indicate the status of IntegrationRoute Deployments. + Known condition types are `Ready` and `Available`. + type: array + items: + type: object + required: + - status + - type + properties: + lastTransitionTime: + description: |- + LastTransitionTime is the timestamp corresponding to the last status + change of this condition. + type: string + format: date-time + message: + description: |- + A human readable message indicating details about the transition. + type: string + observedGeneration: + description: |- + The generation observed by the IntegrationRoute controller. + type: integer + format: int64 + reason: + description: |- + A programmatic identifier indicating the reason for the condition's last transition + type: string + status: + description: Status of the condition, one of (`True`, `False`, `Unknown`). + type: string + enum: + - "True" + - "False" + - Unknown + type: + description: Type of the condition, known values are (`Ready`, `Available`). + type: string + expectedReplicas: + description: "Target number of replica pods requested by IntegrationRoute" + type: integer + readyReplicas: + description: "number of pods targeted by this IntegrationRoute with a Ready Condition" + type: integer + runningReplicas: + description: "Total number of non-terminated pods targeted by this IntegrationRoute" + type: integer + required: + - spec + subresources: + # Enables adding status information to CustomResource + status: { } + scale: + specReplicasPath: .spec.replicas + statusReplicasPath: .status.runningReplicas + - name: v1alpha1 + served: true + storage: false + deprecated: true + deprecationWarning: "keip.codice.org/v1alpha1 IntegrationRoute is deprecated; use keip.codice.org/v1alpha2 instead" + additionalPrinterColumns: + - name: Ready + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].status + - name: Replicas + type: integer + jsonPath: .status.expectedReplicas + - name: Route ConfigMap + type: string + jsonPath: .spec.routeConfigMap + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + properties: + spec: + description: "Desired state of the IntegrationRoute deployment" + type: object + properties: + annotations: + description: "Annotations to add to the IntegrationRoute pod and deployment templates" + type: object + additionalProperties: + type: string + labels: + description: "Labels to add to the IntegrationRoute pod and deployment templates" + type: object + additionalProperties: + type: string + routeConfigMap: + description: "Name of a ConfigMap containing integration route definitions. The ConfigMap should be in the same namespace as the IntegrationRoute resource" + type: string + propSources: + description: "List of names or labels referencing ConfigMap sources that will be included as Spring PropertySources. The ConfigMaps should be in the same namespace as the IntegrationRoute resource" + type: array + items: + type: object + properties: + name: + description: "Name of the ConfigMap to use as a PropertySource" + type: string + labels: + description: "Label selector to match ConfigMaps to use as PropertySources" + type: object + additionalProperties: + type: string + oneOf: + - required: + - name + - required: - labels secretSources: - description: "List of names referencing Secrets that will be mounted into the integration route container and included as Spring PropertySources. The Secrets should be in the same namespace as the IntegrationRoute resource" + description: "List of Secret names that will be mounted into the integration route container and included as Spring PropertySources. The Secrets should be in the same namespace as the IntegrationRoute resource" type: array items: type: string @@ -79,8 +402,10 @@ spec: type: object properties: name: + description: "Name of the ConfigMap to mount" type: string mountPath: + description: "Filesystem path where the ConfigMap will be mounted in the container" type: string env: description: "List of environment variables to set in the container (cannot be updated)" @@ -89,8 +414,10 @@ spec: type: object properties: name: + description: "Name of the environment variable" type: string value: + description: "Value of the environment variable" type: string required: - name @@ -102,29 +429,33 @@ spec: type: object properties: configMapRef: + description: "Reference to a ConfigMap to populate environment variables from" type: object properties: name: + description: "Name of the ConfigMap" type: string optional: + description: "Whether the ConfigMap must exist" type: boolean required: - name secretRef: + description: "Reference to a Secret to populate environment variables from" type: object properties: name: + description: "Name of the Secret" type: string optional: + description: "Whether the Secret must exist" type: boolean required: - name oneOf: - - properties: - required: + - required: - configMapRef - - properties: - required: + - required: - secretRef resources: description: "Compute resources given to the route containers. Follows https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#resource-requests-and-limits-of-pod-and-container" @@ -198,11 +529,9 @@ spec: - configMapName - key oneOf: - - properties: - required: + - required: - jks - - properties: - required: + - required: - pkcs12 keystore: description: "Configures HTTP server TLS connections using a JKS or PKCS12 keystore. The keystore password should be stored in a Secret resource and referenced in the route's Custom Resource. The format of the Secret is `password=`." @@ -244,15 +573,14 @@ spec: - key - passwordSecretRef oneOf: - - properties: - required: + - required: - jks - - properties: - required: + - required: - pkcs12 required: - routeConfigMap status: + description: "Observed state of the IntegrationRoute deployment" type: object properties: conditions: @@ -309,3 +637,6 @@ spec: subresources: # Enables adding status information to CustomResource status: { } + scale: + specReplicasPath: .spec.replicas + statusReplicasPath: .status.runningReplicas diff --git a/operator/examples/basic/testroute.yaml b/operator/examples/basic/testroute.yaml index 0c919b43..d5c2187e 100644 --- a/operator/examples/basic/testroute.yaml +++ b/operator/examples/basic/testroute.yaml @@ -1,4 +1,4 @@ -apiVersion: keip.codice.org/v1alpha1 +apiVersion: keip.codice.org/v1alpha2 kind: IntegrationRoute metadata: name: testroute @@ -11,4 +11,4 @@ spec: # - labels: # group: abc secretSources: - - testroute-secret \ No newline at end of file + - name: testroute-secret diff --git a/operator/examples/certmanager-addon/testroute.yaml b/operator/examples/certmanager-addon/testroute.yaml index c9e54e23..a17adc0f 100644 --- a/operator/examples/certmanager-addon/testroute.yaml +++ b/operator/examples/certmanager-addon/testroute.yaml @@ -1,4 +1,4 @@ -apiVersion: keip.codice.org/v1alpha1 +apiVersion: keip.codice.org/v1alpha2 kind: IntegrationRoute metadata: name: testroute From 11e6a3a4233a9ca2a334e5bcefc5fd24520f70af Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 06/10] Enhance webapp with improved k8s client and test coverage --- .../json/full-integration-route-request.json | 4 +- webapp/core/k8s_client.py | 38 +++++++++++-------- webapp/core/sync.py | 38 +++++++++++++++---- .../json/full-integration-route-request.json | 5 ++- webapp/core/test/json/full-response.json | 1 + webapp/core/test/test_k8s_client.py | 1 + webapp/core/test/test_status.py | 38 ++++++++++++++++--- .../routes/test/json/full-cert-request.json | 6 +-- .../routes/test/json/full-route-request.json | 6 +-- 9 files changed, 99 insertions(+), 38 deletions(-) diff --git a/webapp/addons/certmanager/test/json/full-integration-route-request.json b/webapp/addons/certmanager/test/json/full-integration-route-request.json index 18458df9..f6346d2f 100644 --- a/webapp/addons/certmanager/test/json/full-integration-route-request.json +++ b/webapp/addons/certmanager/test/json/full-integration-route-request.json @@ -1,6 +1,6 @@ { "object": { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "kind": "IntegrationRoute", "metadata": { "annotations": { @@ -28,7 +28,7 @@ "replicas": 1, "routeConfigMap": "testroute-xml", "secretSources": [ - "testroute-secret" + {"name": "testroute-secret"} ], "tls": { "keystore": { diff --git a/webapp/core/k8s_client.py b/webapp/core/k8s_client.py index c1749baf..139a80d3 100644 --- a/webapp/core/k8s_client.py +++ b/webapp/core/k8s_client.py @@ -7,28 +7,35 @@ from models import RouteData, Resource, Status ROUTE_API_GROUP = "keip.codice.org" -ROUTE_API_VERSION = "v1alpha1" +ROUTE_API_VERSION = "v1alpha2" 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.", - ) +_configured = False +v1 = None +routeApi = None -v1 = client.CoreV1Api() -routeApi = client.CustomObjectsApi() +def _ensure_configured(): + global _configured, v1, routeApi + if _configured: + return + try: + ( + config.load_kube_config(os.getenv("KUBECONFIG")) + if os.getenv("KUBECONFIG") + else config.load_incluster_config() + ) + except config.ConfigException: + _LOGGER.error( + msg="Failed to configure the k8s_client. keip will be unable to deploy integration routes.", + ) + v1 = client.CoreV1Api() + routeApi = client.CustomObjectsApi() + _configured = True def _check_cluster_reachable() -> bool: @@ -43,6 +50,7 @@ def _check_cluster_reachable() -> bool: Returns: bool: True if the cluster is reachable, False otherwise. """ + _ensure_configured() try: v1.get_api_resources() return True @@ -67,7 +75,7 @@ def _create_integration_route(route_data: RouteData, configmap_name: str) -> Res ) body = { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "kind": "IntegrationRoute", "metadata": { "name": route_data.route_name, diff --git a/webapp/core/sync.py b/webapp/core/sync.py index 8071a74d..4b4b3fad 100644 --- a/webapp/core/sync.py +++ b/webapp/core/sync.py @@ -23,6 +23,15 @@ } +def _normalize_secret_sources(secret_sources: list) -> list: + """Normalize secretSources to object format. + + v1alpha1 uses string arrays (e.g. ["my-secret"]), + v1alpha2 uses object arrays (e.g. [{"name": "my-secret"}]). + """ + return [{"name": s} if isinstance(s, str) else s for s in secret_sources] + + class VolumeConfig: """ Handles creating a pod's volumes and volumeMounts based on the following IntegrationRoute inputs: @@ -38,7 +47,6 @@ class VolumeConfig: - replicas - persistentVolumeClaims - tls - - services """ _route_vol_name = "integration-route-config" @@ -47,7 +55,9 @@ class VolumeConfig: def __init__(self, parent_spec) -> None: self._route_config = parent_spec["routeConfigMap"] - self._secret_srcs = parent_spec.get("secretSources", []) + self._secret_srcs = _normalize_secret_sources( + parent_spec.get("secretSources", []) + ) self._pvcs = parent_spec.get("persistentVolumeClaims", []) self._config_maps = parent_spec.get("configMaps", []) self._tls_config = parent_spec.get("tls") @@ -63,7 +73,8 @@ def get_volumes(self) -> List[Mapping]: ] for secret in self._secret_srcs: - volumes.append({"name": secret, "secret": {"secretName": secret}}) + secret_name = secret["name"] + volumes.append({"name": secret_name, "secret": {"secretName": secret_name}}) for pvc_spec in self._pvcs: volumes.append( @@ -126,11 +137,12 @@ def get_mounts(self) -> List[dict]: ] for secret in self._secret_srcs: + secret_name = secret["name"] volume_mounts.append( { - "name": secret, + "name": secret_name, "readOnly": True, - "mountPath": str(PurePosixPath(SECRETS_ROOT, secret)), + "mountPath": str(PurePosixPath(SECRETS_ROOT, secret_name)), } ) @@ -446,6 +458,7 @@ def _new_actuator_service(parent): def _compute_status(parent: Mapping, children: Mapping) -> Mapping: route_name = parent["metadata"]["name"] + generation = parent["metadata"]["generation"] expected_replicas = parent["spec"]["replicas"] init_status = { @@ -472,7 +485,9 @@ def _compute_status(parent: Mapping, children: Mapping) -> Mapping: ready_conditions = [ _get_status_ready_condition( - parent.get("status", {}), expected_replicas == ready_replicas + parent.get("status", {}), + expected_replicas == ready_replicas, + generation, ) ] @@ -484,7 +499,9 @@ def _compute_status(parent: Mapping, children: Mapping) -> Mapping: } -def _get_status_ready_condition(parent_status: Mapping, is_ready: bool) -> Mapping: +def _get_status_ready_condition( + parent_status: Mapping, is_ready: bool, observed_generation: int +) -> Mapping: condition_type = "Ready" ready_condition_list = ( @@ -494,11 +511,16 @@ def _get_status_ready_condition(parent_status: Mapping, is_ready: bool) -> Mappi ) ready_condition = ready_condition_list[0] if ready_condition_list else None - if ready_condition and ready_condition.get("status") == str(is_ready): + if ( + ready_condition + and ready_condition.get("status") == str(is_ready) + and ready_condition.get("observedGeneration") == observed_generation + ): return ready_condition updated_condition = { "lastTransitionTime": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "observedGeneration": observed_generation, "status": str(is_ready), "type": condition_type, } diff --git a/webapp/core/test/json/full-integration-route-request.json b/webapp/core/test/json/full-integration-route-request.json index 672f8cbd..93366ae6 100644 --- a/webapp/core/test/json/full-integration-route-request.json +++ b/webapp/core/test/json/full-integration-route-request.json @@ -1,6 +1,6 @@ { "parent": { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "kind": "IntegrationRoute", "metadata": { "creationTimestamp": "2023-09-06T01:16:27Z", @@ -57,7 +57,7 @@ "replicas": 2, "routeConfigMap": "testroute-xml", "secretSources": [ - "testroute-secret" + {"name": "testroute-secret"} ], "tls": { "keystore": { @@ -106,6 +106,7 @@ { "lastTransitionTime": "2023-09-06T01:25:45Z", "message": "All IntegrationRoute pod replicas are ready", + "observedGeneration": 1, "reason": "ReplicasReady", "status": "True", "type": "Ready" diff --git a/webapp/core/test/json/full-response.json b/webapp/core/test/json/full-response.json index 62dcd095..1c84f63f 100644 --- a/webapp/core/test/json/full-response.json +++ b/webapp/core/test/json/full-response.json @@ -255,6 +255,7 @@ { "lastTransitionTime": "2023-09-06T01:25:45Z", "message": "All IntegrationRoute pod replicas are ready", + "observedGeneration": 1, "reason": "ReplicasReady", "status": "True", "type": "Ready" diff --git a/webapp/core/test/test_k8s_client.py b/webapp/core/test/test_k8s_client.py index b8319754..e5c29553 100644 --- a/webapp/core/test/test_k8s_client.py +++ b/webapp/core/test/test_k8s_client.py @@ -17,6 +17,7 @@ def route_data(): @pytest.fixture def mock_api(mocker): """Patch the global `v1` and `routeApi` objects used by k8s_client.""" + mocker.patch("core.k8s_client._ensure_configured") v1 = mocker.patch("core.k8s_client.v1") route_api = mocker.patch("core.k8s_client.routeApi") return {"v1": v1, "route_api": route_api} diff --git a/webapp/core/test/test_status.py b/webapp/core/test/test_status.py index d588b395..82548726 100644 --- a/webapp/core/test/test_status.py +++ b/webapp/core/test/test_status.py @@ -11,9 +11,12 @@ FIXED_ISO_TIMESTAMP = "2023-09-06T12:34:56Z" +PARENT_GENERATION = 1 + STATUS_NOT_READY_CONDITION = { "lastTransitionTime": FIXED_ISO_TIMESTAMP, "message": "Some IntegrationRoute pod replicas are not ready", + "observedGeneration": PARENT_GENERATION, "reason": "ReplicasNotReady", "status": "False", "type": "Ready", @@ -22,6 +25,7 @@ STATUS_READY_CONDITION = { "lastTransitionTime": FIXED_ISO_TIMESTAMP, "message": "All IntegrationRoute pod replicas are ready", + "observedGeneration": PARENT_GENERATION, "reason": "ReplicasReady", "status": "True", "type": "Ready", @@ -122,7 +126,7 @@ def test_status_conditions_with_parent_missing_status_field_generate_new_status( def test_ready_status_with_parent_missing_status_field_generate_new_status( patch_datetime, ): - ready_condition = _get_status_ready_condition({}, False) + ready_condition = _get_status_ready_condition({}, False, PARENT_GENERATION) assert ready_condition == STATUS_NOT_READY_CONDITION @@ -136,7 +140,9 @@ def test_ready_status_with_parent_missing_ready_condition_generate_new_status( c for c in full_route["parent"]["status"]["conditions"] if c["type"] != "Ready" ] - ready_condition = _get_status_ready_condition(parent_status, True) + ready_condition = _get_status_ready_condition( + parent_status, True, PARENT_GENERATION + ) assert ready_condition == STATUS_READY_CONDITION @@ -150,8 +156,11 @@ def test_ready_status_with_parent_matching_ready_condition_reuse_parent_status( parent_status = full_route["parent"]["status"] parent_condition = get_ready_condition(parent_status["conditions"]) parent_condition["status"] = parent_ready + parent_condition["observedGeneration"] = PARENT_GENERATION - ready_condition = _get_status_ready_condition(parent_status, computed_ready) + ready_condition = _get_status_ready_condition( + parent_status, computed_ready, PARENT_GENERATION + ) assert ready_condition == parent_condition @@ -166,7 +175,9 @@ def test_ready_status_with_parent_different_ready_condition_generate_new_status( parent_condition = get_ready_condition(parent_status["conditions"]) parent_condition["status"] = parent_ready - ready_condition = _get_status_ready_condition(parent_status, computed_ready) + ready_condition = _get_status_ready_condition( + parent_status, computed_ready, PARENT_GENERATION + ) if computed_ready: assert ready_condition == STATUS_READY_CONDITION @@ -174,6 +185,23 @@ def test_ready_status_with_parent_different_ready_condition_generate_new_status( assert ready_condition == STATUS_NOT_READY_CONDITION +def test_ready_status_with_changed_generation_generates_new_condition( + patch_datetime, full_route +): + parent_status = full_route["parent"]["status"] + parent_condition = get_ready_condition(parent_status["conditions"]) + parent_condition["status"] = "True" + parent_condition["observedGeneration"] = PARENT_GENERATION + + new_generation = PARENT_GENERATION + 1 + ready_condition = _get_status_ready_condition( + parent_status, True, new_generation + ) + + assert ready_condition["observedGeneration"] == new_generation + assert ready_condition["lastTransitionTime"] == FIXED_ISO_TIMESTAMP + + @pytest.fixture() def patch_datetime(monkeypatch): monkeypatch.setattr(core.sync, "datetime", MockDateTime) @@ -182,7 +210,7 @@ def patch_datetime(monkeypatch): class MockDateTime(datetime): @classmethod def now(cls, tz=None): - return datetime.fromisoformat(FIXED_ISO_TIMESTAMP) + return datetime.fromisoformat(FIXED_ISO_TIMESTAMP.replace("Z", "+00:00")) def get_child_deployment(route_request: Mapping): diff --git a/webapp/routes/test/json/full-cert-request.json b/webapp/routes/test/json/full-cert-request.json index 9efdaaaf..aa9da4f2 100644 --- a/webapp/routes/test/json/full-cert-request.json +++ b/webapp/routes/test/json/full-cert-request.json @@ -12,7 +12,7 @@ "spec": { "resources": [ { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "resource": "integrationroutes" } ], @@ -37,7 +37,7 @@ "status": {} }, "object": { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "kind": "IntegrationRoute", "metadata": { "annotations": { @@ -65,7 +65,7 @@ "replicas": 1, "routeConfigMap": "testroute-xml", "secretSources": [ - "testroute-secret" + {"name": "testroute-secret"} ], "tls": { "keystore": { diff --git a/webapp/routes/test/json/full-route-request.json b/webapp/routes/test/json/full-route-request.json index 9239bbbb..7e1f3861 100644 --- a/webapp/routes/test/json/full-route-request.json +++ b/webapp/routes/test/json/full-route-request.json @@ -11,7 +11,7 @@ }, "spec": { "parentResource": { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "resource": "integrationroutes", "revisionHistory": { "fieldPaths": [ @@ -65,7 +65,7 @@ "status": {} }, "parent": { - "apiVersion": "keip.codice.org/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "kind": "IntegrationRoute", "metadata": { "annotations": { @@ -93,7 +93,7 @@ "replicas": 1, "routeConfigMap": "testroute-xml", "secretSources": [ - "testroute-secret" + {"name": "testroute-secret"} ], "tls": { "keystore": { From e92435259a8f401281bdedfc5177bb42c32edb86 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Wed, 18 Feb 2026 07:19:06 -0700 Subject: [PATCH 07/10] Add Dockerfile for minimal-app container image --- minimal-app/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 minimal-app/Dockerfile diff --git a/minimal-app/Dockerfile b/minimal-app/Dockerfile new file mode 100644 index 00000000..6e2a2534 --- /dev/null +++ b/minimal-app/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre +COPY target/minimal-app-0.4.0.jar /app/app.jar +VOLUME /var/spring/xml +VOLUME /var/spring/config +ENTRYPOINT ["java", "-Djdk.httpclient.HttpClient.log=errors,requests", "-Dspring.config.location=/var/spring/config/", "-jar", "/app/app.jar"] From 2f13fbc26ee65dccecc6fe510caf23a238b0d20c Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Thu, 19 Feb 2026 13:48:36 -0700 Subject: [PATCH 08/10] Address code review findings across all severity levels CI/CD: - Pin all third-party GitHub Actions to full commit SHAs - Add restrictive default permissions (contents: read) to all workflows - Fix subshell bug in verify_current_webapp_img.sh where exit 1 only exited the subshell, silently passing version mismatches - Quote and validate $GITHUB_BASE_REF in verify_changes_update_version.sh Operator: - Fix RBAC: replace unused delete verb with required patch for integrationroutes; fix roleRef.apiGroup to rbac.authorization.k8s.io - Add topologySpreadConstraints to webhook Deployment for HA - Add startupProbe to webhook container - Increase webhook memory limit to 256Mi for burst headroom - Add app.kubernetes.io/part-of label to webhook pod template - Set v1alpha1 served: false to prevent silently ignored resources - Add x-kubernetes-list-type/list-map-keys to CRD conditions arrays Webapp: - Sanitize webhook debug logging to emit only resource metadata - Add max_length (1MB) to Route.xml and namespace validation - Add _config_failed flag to k8s_client to prevent retry storms - Move cluster reachability check to create_route_resources to eliminate redundant API calls - Parallelize route deployment with asyncio.gather - Add catch-all exception handler to webhook endpoint - Extract shared get_cert_store_type(), removing duplicate in certmanager - Filter user labels to prevent overriding app.kubernetes.io/* selectors - Add securityContext to generated integration route pod templates - Fix .dockerignore to exclude venv/, .test_coverage/, and Makefile - Add return type to _with_cors(), remove unnecessary .keys() call - Fix relative path in test_deploy.py fixture loading keip-integration: - Pin eclipse-temurin base image to digest in Dockerfile and Jib - Add application.properties with secure actuator defaults - Remove VOLUME declarations; rely on Kubernetes volumeMounts - Add basic Spring context load test Co-Authored-By: Claude Opus 4.6 --- .../{minimal-app.yml => keip-integration.yml} | 31 ++++--- .github/workflows/operator.yml | 15 ++-- .../shared/verify_changes_update_version.sh | 10 ++- .../shared/verify_current_webapp_img.sh | 14 +-- ... => verify_keip_integration_releasable.sh} | 0 .github/workflows/webapp.yml | 23 ++--- keip-integration/Dockerfile | 7 ++ {minimal-app => keip-integration}/pom.xml | 14 ++- .../java/org/codice/keip/KeipApplication.java | 0 .../src/main/resources/application.properties | 7 ++ .../org/codice/keip/KeipApplicationTests.java | 11 +++ minimal-app/Dockerfile | 5 -- operator/controller/core-privileges.yaml | 11 +-- operator/controller/webhook-deployment.yaml | 60 +++++++++++-- operator/crd/crd.yaml | 15 +++- webapp/.dockerignore | 5 +- webapp/addons/certmanager/main.py | 8 +- webapp/app.py | 2 +- webapp/core/k8s_client.py | 85 ++++++++++--------- webapp/core/sync.py | 61 ++++++++----- webapp/core/test/json/full-response.json | 22 +++-- webapp/core/test/test_k8s_client.py | 22 ++--- webapp/logconf.py | 2 +- webapp/models.py | 4 +- webapp/routes/deploy.py | 34 +++----- .../routes/test/json/full-route-response.json | 10 ++- webapp/routes/test/test_deploy.py | 11 +-- webapp/routes/webhook.py | 16 +++- 28 files changed, 313 insertions(+), 192 deletions(-) rename .github/workflows/{minimal-app.yml => keip-integration.yml} (78%) rename .github/workflows/scripts/{verify_minimal_app_releasable.sh => verify_keip_integration_releasable.sh} (100%) create mode 100644 keip-integration/Dockerfile rename {minimal-app => keip-integration}/pom.xml (93%) rename {minimal-app => keip-integration}/src/main/java/org/codice/keip/KeipApplication.java (100%) create mode 100644 keip-integration/src/main/resources/application.properties create mode 100644 keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java delete mode 100644 minimal-app/Dockerfile diff --git a/.github/workflows/minimal-app.yml b/.github/workflows/keip-integration.yml similarity index 78% rename from .github/workflows/minimal-app.yml rename to .github/workflows/keip-integration.yml index 2724eed7..a0ffe93e 100644 --- a/.github/workflows/minimal-app.yml +++ b/.github/workflows/keip-integration.yml @@ -1,31 +1,34 @@ -name: minimal-app +name: keip-integration on: workflow_dispatch: pull_request: paths: - - minimal-app/** - - .github/workflows/minimal-app.yml + - keip-integration/** + - .github/workflows/keip-integration.yml - .github/workflows/scripts push: branches: - main paths: - - minimal-app/** + - keip-integration/** env: - WORKING_DIR: ./minimal-app + WORKING_DIR: ./keip-integration JAVA_VERSION: 21 - GIT_TAG_PREFIX: minimal-app_v + GIT_TAG_PREFIX: keip-integration_v + +permissions: + contents: read jobs: verify-versions: if: github.ref != 'refs/heads/main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - run: sh .github/workflows/scripts/verify_minimal_app_releasable.sh - name: Verify minimal app is in a state to be released on merge + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - run: sh .github/workflows/scripts/verify_keip_integration_releasable.sh + name: Verify keip-integration is in a state to be released on merge build: name: build image if: github.ref != 'refs/heads/main' @@ -35,8 +38,8 @@ jobs: shell: bash working-directory: ${{ env.WORKING_DIR }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: "${{ env.JAVA_VERSION }}" distribution: "temurin" @@ -54,7 +57,7 @@ jobs: contents: write # create git tags packages: write # push docker images steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: | VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) REGISTRY=$(mvn help:evaluate -Dexpression=docker.registry -q -DforceStdout) @@ -77,7 +80,7 @@ jobs: fi name: check if release is needed - - uses: actions/setup-java@v3 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 if: env.needs_release == 'true' with: java-version: "${{ env.JAVA_VERSION }}" @@ -99,7 +102,7 @@ jobs: docker push ${{ steps.naming-selector.outputs.LATEST_FULL_IMAGE_NAME }} if: env.needs_release == 'true' - - uses: mathieudutour/github-tag-action@v6.1 + - uses: mathieudutour/github-tag-action@fcfbdceb3093f6d85a3b194740f8c6cec632f4e2 # v6.1 if: env.needs_release == 'true' id: tag_version with: diff --git a/.github/workflows/operator.yml b/.github/workflows/operator.yml index 46e46dcd..20a76125 100644 --- a/.github/workflows/operator.yml +++ b/.github/workflows/operator.yml @@ -19,12 +19,15 @@ on: env: WORKING_DIR: ./operator +permissions: + contents: read + jobs: verify-versions: if: github.ref != 'refs/heads/main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: sh .github/workflows/scripts/verify_operator_releasable.sh name: Verify operator is in a state to be released on merge @@ -36,19 +39,21 @@ jobs: shell: bash working-directory: ${{ env.WORKING_DIR }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: make prep-release name: generate release files release: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: write defaults: run: shell: bash working-directory: ${{ env.WORKING_DIR }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # set variables used by multiple steps in the job - run: | @@ -69,7 +74,7 @@ jobs: if: env.needs_release == 'true' name: generate release files - - uses: mathieudutour/github-tag-action@v6.1 + - uses: mathieudutour/github-tag-action@fcfbdceb3093f6d85a3b194740f8c6cec632f4e2 # v6.1 if: env.needs_release == 'true' id: tag_version with: @@ -78,7 +83,7 @@ jobs: # avoid v prefix before tag tag_prefix: "" - - uses: ncipollo/release-action@v1 + - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1 if: env.needs_release == 'true' with: tag: ${{ steps.tag_version.outputs.new_tag }} diff --git a/.github/workflows/scripts/shared/verify_changes_update_version.sh b/.github/workflows/scripts/shared/verify_changes_update_version.sh index 8b2ea530..b646f693 100644 --- a/.github/workflows/scripts/shared/verify_changes_update_version.sh +++ b/.github/workflows/scripts/shared/verify_changes_update_version.sh @@ -15,7 +15,11 @@ GREP_FILTER_STDERR_OUTPUT="/tmp/diff_grep_filter_stderr" main() { # github actions job does not fetch other git objects by default - git fetch origin $GITHUB_BASE_REF + if [ -z "$GITHUB_BASE_REF" ]; then + echo "ERROR: GITHUB_BASE_REF is not set" + exit 1 + fi + git fetch origin "$GITHUB_BASE_REF" git fetch --tags # if 'grep -v' (inverted-match) matches all the file-paths in the list, an error code is returned, which immediately @@ -23,7 +27,7 @@ main() { # checking if a release is required. A '|| true' is added at the end of the command to force a non-error return code. # To still be able to catch any unexpected errors with the 'grep' command, stderr is piped to a file that is later # checked for errors. - filtered_changes=$(git diff --name-only origin/$GITHUB_BASE_REF -- $DIRECTORY | grep -E -v \ + filtered_changes=$(git diff --name-only "origin/$GITHUB_BASE_REF" -- "$DIRECTORY" | grep -E -v \ -e 'test/' \ -e 'requirements-dev\.txt$' \ -e '\.md$' \ @@ -37,7 +41,7 @@ main() { exit 1 fi - echo "Comparing current branch and $GITHUB_BASE_REF at directory: ${DIRECTORY}" + echo "Comparing current branch and $GITHUB_BASE_REF at directory: $DIRECTORY" if [ -n "$filtered_changes" ]; then echo "$filtered_changes" diff --git a/.github/workflows/scripts/shared/verify_current_webapp_img.sh b/.github/workflows/scripts/shared/verify_current_webapp_img.sh index bab42d84..51f6c1e5 100644 --- a/.github/workflows/scripts/shared/verify_current_webapp_img.sh +++ b/.github/workflows/scripts/shared/verify_current_webapp_img.sh @@ -1,15 +1,19 @@ OPERATOR_DIR=operator -OPERATOR_CONTROLLER_YAML=$OPERATOR_DIR/controller/core-controller.yaml +OPERATOR_CONTROLLER_YAML=$OPERATOR_DIR/controller/webhook-deployment.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) + set -eu + + current_webapp_img=$(make --no-print-directory -C 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) + if [ "$webapp_image_used" != "$current_webapp_img" ]; then + echo "Operator is using $webapp_image_used but should be using the most recent $current_webapp_img." + exit 1 + fi } verify_current_webapp_img diff --git a/.github/workflows/scripts/verify_minimal_app_releasable.sh b/.github/workflows/scripts/verify_keip_integration_releasable.sh similarity index 100% rename from .github/workflows/scripts/verify_minimal_app_releasable.sh rename to .github/workflows/scripts/verify_keip_integration_releasable.sh diff --git a/.github/workflows/webapp.yml b/.github/workflows/webapp.yml index c6b43b48..9d2fbc9e 100644 --- a/.github/workflows/webapp.yml +++ b/.github/workflows/webapp.yml @@ -18,12 +18,15 @@ env: PYTHON_VERSION: 3.11 WORKING_DIR: ./webapp +permissions: + contents: read + jobs: verify-versions: if: github.ref != 'refs/heads/main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: sh .github/workflows/scripts/verify_webapp_releasable.sh name: Verify webapp is in a state to be released on merge test: @@ -34,8 +37,8 @@ jobs: shell: bash working-directory: ${{ env.WORKING_DIR }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ env.PYTHON_VERSION }} - run: make test @@ -48,8 +51,8 @@ jobs: shell: bash working-directory: ${{ env.WORKING_DIR }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ env.PYTHON_VERSION }} - run: make lint @@ -64,9 +67,9 @@ jobs: shell: bash working-directory: ${{ env.WORKING_DIR }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 with: context: ${{ env.WORKING_DIR }} @@ -85,7 +88,7 @@ jobs: contents: write # create git tags packages: write # push docker images steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: | REGISTRY=$(make get-registry) IMAGE_NAME=$(make get-image-name) @@ -116,7 +119,7 @@ jobs: - name: Set up Docker Buildx if: env.needs_release == 'true' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 if: env.needs_release == 'true' @@ -126,7 +129,7 @@ jobs: push: true tags: ${{ steps.naming-selector.outputs.FULL_IMAGE_NAME }} - - uses: mathieudutour/github-tag-action@v6.1 + - uses: mathieudutour/github-tag-action@fcfbdceb3093f6d85a3b194740f8c6cec632f4e2 # v6.1 if: env.needs_release == 'true' id: tag_version with: diff --git a/keip-integration/Dockerfile b/keip-integration/Dockerfile new file mode 100644 index 00000000..afaad595 --- /dev/null +++ b/keip-integration/Dockerfile @@ -0,0 +1,7 @@ +# Local development Dockerfile. CI releases use the Jib Maven plugin (see pom.xml). +FROM eclipse-temurin:21-jre@sha256:34a58218d838035428163eb35abb629944c5906d1bedcfef8bc8864cce11dfe5 +RUN groupadd --system appgroup && useradd --system --gid appgroup appuser +COPY target/app.jar /app/app.jar +RUN chown -R appuser:appgroup /app +USER appuser +ENTRYPOINT ["java", "-Djdk.httpclient.HttpClient.log=errors,requests", "-Dspring.config.location=/var/spring/config/", "-jar", "/app/app.jar"] diff --git a/minimal-app/pom.xml b/keip-integration/pom.xml similarity index 93% rename from minimal-app/pom.xml rename to keip-integration/pom.xml index 3419d357..fa375d3b 100644 --- a/minimal-app/pom.xml +++ b/keip-integration/pom.xml @@ -11,7 +11,7 @@ org.codice.keip - minimal-app + keip-integration 0.4.0 @@ -79,6 +79,7 @@ + app org.springframework.boot @@ -97,19 +98,14 @@ jib-maven-plugin 3.5.1 + + eclipse-temurin:21-jre@sha256:34a58218d838035428163eb35abb629944c5906d1bedcfef8bc8864cce11dfe5 + ${maven.build.timestamp} -Dspring.config.location=/var/spring/config/ - - - /var/spring/xml - - - /var/spring/config - - ${container.source.label} diff --git a/minimal-app/src/main/java/org/codice/keip/KeipApplication.java b/keip-integration/src/main/java/org/codice/keip/KeipApplication.java similarity index 100% rename from minimal-app/src/main/java/org/codice/keip/KeipApplication.java rename to keip-integration/src/main/java/org/codice/keip/KeipApplication.java diff --git a/keip-integration/src/main/resources/application.properties b/keip-integration/src/main/resources/application.properties new file mode 100644 index 00000000..6a16e20d --- /dev/null +++ b/keip-integration/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# Secure defaults for actuator endpoints. The webhook's SPRING_APPLICATION_JSON +# env var may override these at runtime, but these ensure safe behavior when +# the container is run outside of the operator (e.g. local development). +management.endpoints.web.exposure.include=health,prometheus +management.endpoint.env.enabled=false +management.endpoint.beans.enabled=false +management.endpoint.configprops.enabled=false diff --git a/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java b/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java new file mode 100644 index 00000000..3846fbf6 --- /dev/null +++ b/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java @@ -0,0 +1,11 @@ +package org.codice.keip; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class KeipApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/minimal-app/Dockerfile b/minimal-app/Dockerfile deleted file mode 100644 index 6e2a2534..00000000 --- a/minimal-app/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM eclipse-temurin:21-jre -COPY target/minimal-app-0.4.0.jar /app/app.jar -VOLUME /var/spring/xml -VOLUME /var/spring/config -ENTRYPOINT ["java", "-Djdk.httpclient.HttpClient.log=errors,requests", "-Dspring.config.location=/var/spring/config/", "-jar", "/app/app.jar"] diff --git a/operator/controller/core-privileges.yaml b/operator/controller/core-privileges.yaml index 2a458b2c..6a382ae9 100644 --- a/operator/controller/core-privileges.yaml +++ b/operator/controller/core-privileges.yaml @@ -23,7 +23,7 @@ subjects: roleRef: kind: Role name: spring-cloud-kubernetes - apiGroup: "" + apiGroup: rbac.authorization.k8s.io --- apiVersion: v1 kind: ServiceAccount @@ -36,15 +36,12 @@ 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"] + verbs: ["list", "create", "update"] - apiGroups: ["keip.codice.org"] resources: ["integrationroutes"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + verbs: ["list", "create", "patch"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 @@ -58,4 +55,4 @@ subjects: roleRef: kind: ClusterRole name: controller-kubernetes-manager - apiGroup: "" \ No newline at end of file + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/operator/controller/webhook-deployment.yaml b/operator/controller/webhook-deployment.yaml index e96e291d..2e45ae9f 100644 --- a/operator/controller/webhook-deployment.yaml +++ b/operator/controller/webhook-deployment.yaml @@ -5,22 +5,60 @@ metadata: name: integrationroute-webhook namespace: keip spec: - replicas: 1 + replicas: 2 selector: matchLabels: - app: integrationroute-webhook + app.kubernetes.io/name: integrationroute-webhook template: metadata: labels: - app: integrationroute-webhook + app.kubernetes.io/name: integrationroute-webhook + app.kubernetes.io/component: webhook + app.kubernetes.io/managed-by: keip + app.kubernetes.io/part-of: keip spec: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: integrationroute-webhook serviceAccountName: keip-controller-service + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault containers: - name: webhook image: ghcr.io/codice/keip/webapp:0.18.0 ports: - containerPort: 7080 name: webhook-http + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /status + port: webhook-http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /status + port: webhook-http + initialDelaySeconds: 3 + periodSeconds: 10 + startupProbe: + httpGet: + path: /status + port: webhook-http + failureThreshold: 10 + periodSeconds: 3 env: - name: INTEGRATION_IMAGE valueFrom: @@ -32,8 +70,20 @@ spec: resources: requests: cpu: "100m" - limits: memory: "128Mi" + limits: + memory: "256Mi" +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: integrationroute-webhook + namespace: keip +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: integrationroute-webhook --- apiVersion: v1 kind: Service @@ -42,7 +92,7 @@ metadata: namespace: keip spec: selector: - app: integrationroute-webhook + app.kubernetes.io/name: integrationroute-webhook ports: - port: 80 targetPort: webhook-http diff --git a/operator/crd/crd.yaml b/operator/crd/crd.yaml index 510ea9e6..507ec461 100644 --- a/operator/crd/crd.yaml +++ b/operator/crd/crd.yaml @@ -173,7 +173,7 @@ spec: description: "Number of pods running the integration route" type: integer minimum: 1 - maximum: 20 # TODO: Figure out a reasonable maximum + maximum: 20 default: 1 persistentVolumeClaims: description: "List with the names of PersistentVolumeClaims and their mountPaths on the pod container" @@ -280,6 +280,9 @@ spec: List of status conditions to indicate the status of IntegrationRoute Deployments. Known condition types are `Ready` and `Available`. type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type items: type: object required: @@ -333,7 +336,7 @@ spec: specReplicasPath: .spec.replicas statusReplicasPath: .status.runningReplicas - name: v1alpha1 - served: true + served: false storage: false deprecated: true deprecationWarning: "keip.codice.org/v1alpha1 IntegrationRoute is deprecated; use keip.codice.org/v1alpha2 instead" @@ -407,6 +410,9 @@ spec: mountPath: description: "Filesystem path where the ConfigMap will be mounted in the container" type: string + required: + - name + - mountPath env: description: "List of environment variables to set in the container (cannot be updated)" type: array @@ -481,7 +487,7 @@ spec: description: "Number of pods running the integration route" type: integer minimum: 1 - maximum: 20 # TODO: Figure out a reasonable maximum + maximum: 20 default: 1 persistentVolumeClaims: description: "List with the names of PersistentVolumeClaims and their mountPaths on the pod container" @@ -588,6 +594,9 @@ spec: List of status conditions to indicate the status of IntegrationRoute Deployments. Known condition types are `Ready` and `Available`. type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type items: type: object required: diff --git a/webapp/.dockerignore b/webapp/.dockerignore index 4416c4b3..09ac3d92 100644 --- a/webapp/.dockerignore +++ b/webapp/.dockerignore @@ -12,5 +12,8 @@ pip-log.txt # Local directives .venv +venv test -requirements-dev.txt \ No newline at end of file +requirements-dev.txt +.test_coverage +Makefile \ No newline at end of file diff --git a/webapp/addons/certmanager/main.py b/webapp/addons/certmanager/main.py index aea74a18..6707126f 100644 --- a/webapp/addons/certmanager/main.py +++ b/webapp/addons/certmanager/main.py @@ -1,6 +1,8 @@ import logging from typing import Mapping, List, Any +from core.sync import get_cert_store_type + _LOGGER = logging.getLogger(__name__) @@ -127,7 +129,7 @@ def _get_subject(annotations, name, common_name, namespace) -> Mapping[str, List def _get_keystores(keystore) -> Mapping[str, Mapping[str, Any]]: - keystore_type = _get_keystore_type(keystore) + keystore_type = get_cert_store_type(keystore) password_secret_ref_name = keystore[keystore_type]["passwordSecretRef"] return { @@ -149,10 +151,6 @@ def _get_annotation_vals_as_list(annotation_val) -> List[str]: ) -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"] diff --git a/webapp/app.py b/webapp/app.py index 351a773e..c1e9b8dc 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -def _with_cors(app: Starlette, origins_env: str): +def _with_cors(app: Starlette, origins_env: str) -> ASGIApp: origins = [s for part in origins_env.split(",") if (s := part.strip())] if not origins: _LOGGER.warning( diff --git a/webapp/core/k8s_client.py b/webapp/core/k8s_client.py index 139a80d3..9d6a2064 100644 --- a/webapp/core/k8s_client.py +++ b/webapp/core/k8s_client.py @@ -3,6 +3,7 @@ from kubernetes.client.rest import ApiException import logging import os +import threading from models import RouteData, Resource, Status @@ -14,28 +15,37 @@ _LOGGER = logging.getLogger(__name__) +_lock = threading.Lock() _configured = False +_config_failed = False v1 = None routeApi = None def _ensure_configured(): - global _configured, v1, routeApi + global _configured, _config_failed, v1, routeApi if _configured: return - try: - ( - config.load_kube_config(os.getenv("KUBECONFIG")) - if os.getenv("KUBECONFIG") - else config.load_incluster_config() - ) - except config.ConfigException: - _LOGGER.error( - msg="Failed to configure the k8s_client. keip will be unable to deploy integration routes.", - ) - v1 = client.CoreV1Api() - routeApi = client.CustomObjectsApi() - _configured = True + if _config_failed: + return + with _lock: + if _configured or _config_failed: + return + try: + ( + config.load_kube_config(os.getenv("KUBECONFIG")) + if os.getenv("KUBECONFIG") + else config.load_incluster_config() + ) + except config.ConfigException: + _LOGGER.error( + msg="Failed to configure the k8s_client. keip will be unable to deploy integration routes.", + ) + _config_failed = True + return + v1 = client.CoreV1Api() + routeApi = client.CustomObjectsApi() + _configured = True def _check_cluster_reachable() -> bool: @@ -51,6 +61,8 @@ def _check_cluster_reachable() -> bool: bool: True if the cluster is reachable, False otherwise. """ _ensure_configured() + if v1 is None: + return False try: v1.get_api_resources() return True @@ -60,19 +72,6 @@ def _check_cluster_reachable() -> bool: 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/v1alpha2", @@ -84,20 +83,25 @@ def _create_integration_route(route_data: RouteData, configmap_name: str) -> Res "spec": {"routeConfigMap": configmap_name}, } - status = Status.CREATED + 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}", + ) if existing_route["items"]: - # Delete existing route - routeApi.delete_namespaced_custom_object( + routeApi.patch_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"], + name=route_data.route_name, + body=body, ) - status = Status.RECREATED + return Resource(status=Status.UPDATED, name=route_data.route_name) - # Create new route routeApi.create_namespaced_custom_object( group=ROUTE_API_GROUP, version=ROUTE_API_VERSION, @@ -105,8 +109,7 @@ def _create_integration_route(route_data: RouteData, configmap_name: str) -> Res plural=ROUTE_PLURAL, body=body, ) - - return Resource(status=status, name=route_data.route_name) + return Resource(status=Status.CREATED, name=route_data.route_name) def _create_route_configmap(route_data: RouteData) -> Resource: @@ -126,12 +129,6 @@ def _create_route_configmap(route_data: RouteData) -> Resource: 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( @@ -197,6 +194,12 @@ def create_route_resources(route_data: RouteData) -> Tuple[Resource, Resource]: ApiException: If the Kubernetes cluster is unreachable or if there is an error during API calls. Exception: If an unexpected error occurs during processing or resource creation. """ + if not _check_cluster_reachable(): + raise ApiException( + status=500, + reason="Kubernetes cluster not reachable. Verify the cluster is running", + ) + route_cm = _create_route_configmap(route_data=route_data) route = _create_integration_route( route_data=route_data, configmap_name=route_cm.name diff --git a/webapp/core/sync.py b/webapp/core/sync.py index 4b4b3fad..ae916c5e 100644 --- a/webapp/core/sync.py +++ b/webapp/core/sync.py @@ -74,25 +74,27 @@ def get_volumes(self) -> List[Mapping]: for secret in self._secret_srcs: secret_name = secret["name"] - volumes.append({"name": secret_name, "secret": {"secretName": secret_name}}) + volumes.append( + {"name": f"secret-{secret_name}", "secret": {"secretName": secret_name}} + ) for pvc_spec in self._pvcs: volumes.append( { - "name": pvc_spec["claimName"], + "name": f"pvc-{pvc_spec['claimName']}", "persistentVolumeClaim": {"claimName": pvc_spec["claimName"]}, } ) for cm_spec in self._config_maps: volumes.append( - {"name": cm_spec["name"], "configMap": {"name": cm_spec["name"]}} + {"name": f"cm-{cm_spec['name']}", "configMap": {"name": cm_spec["name"]}} ) if self._tls_config: truststore = self._tls_config.get("truststore") if truststore: - truststore_type = _get_tls_cert_store_type(truststore) + truststore_type = get_cert_store_type(truststore) volumes.append( { "name": self._tls_truststore_name, @@ -110,7 +112,7 @@ def get_volumes(self) -> List[Mapping]: keystore = self._tls_config.get("keystore") if keystore: - keystore_type = _get_tls_cert_store_type(keystore) + keystore_type = get_cert_store_type(keystore) volumes.append( { "name": self._tls_keystore_name, @@ -140,7 +142,7 @@ def get_mounts(self) -> List[dict]: secret_name = secret["name"] volume_mounts.append( { - "name": secret_name, + "name": f"secret-{secret_name}", "readOnly": True, "mountPath": str(PurePosixPath(SECRETS_ROOT, secret_name)), } @@ -149,7 +151,7 @@ def get_mounts(self) -> List[dict]: for pvc_spec in self._pvcs: volume_mounts.append( { - "name": pvc_spec["claimName"], + "name": f"pvc-{pvc_spec['claimName']}", "mountPath": pvc_spec["mountPath"], } ) @@ -157,7 +159,7 @@ def get_mounts(self) -> List[dict]: for cm_spec in self._config_maps: volume_mounts.append( { - "name": cm_spec["name"], + "name": f"cm-{cm_spec['name']}", "mountPath": cm_spec["mountPath"], } ) @@ -191,17 +193,21 @@ def _spring_cloud_k8s_config(parent) -> Optional[Mapping]: if not props_srcs and not secret_srcs: return None - return { + k8s_config = { "kubernetes": { "config": { "fail-fast": True, "namespace": metadata["namespace"], - "sources": props_srcs, }, "secrets": {"paths": SECRETS_ROOT}, } } + if props_srcs: + k8s_config["kubernetes"]["config"]["sources"] = props_srcs + + return k8s_config + def _get_server_ssl_config(parent) -> Optional[Mapping]: tls_config = parent["spec"].get("tls") @@ -240,11 +246,12 @@ def _service_name_env_var(parent) -> Mapping[str, str]: return {"name": "SERVICE_NAME", "value": parent["metadata"]["name"]} -def _get_tls_cert_store_type(tls_cert_store) -> str: - return "jks" if "jks" in tls_cert_store else "pkcs12" +def get_cert_store_type(cert_store) -> str: + """Determine if a cert store config is JKS or PKCS12.""" + return "jks" if "jks" in cert_store else "pkcs12" -def _spring_app_config_env_var(parent) -> Optional[Mapping]: +def _spring_app_config_env_var(parent) -> Mapping: metadata = parent["metadata"] app_config = { "spring": { @@ -273,7 +280,7 @@ def _get_keystore_password_env(tls) -> Mapping[str, Any]: if not keystore: return {} - keystore_type = _get_tls_cert_store_type(keystore) + keystore_type = get_cert_store_type(keystore) return { "name": "SERVER_SSL_KEYSTOREPASSWORD", @@ -292,7 +299,7 @@ def _get_java_jdk_options(tls) -> Optional[Mapping[str, str]]: if not truststore: return None - truststore_type = _get_tls_cert_store_type(truststore) + truststore_type = get_cert_store_type(truststore) truststore_password = "changeit" if truststore_type == "jks" else "" return { @@ -304,8 +311,7 @@ def _get_java_jdk_options(tls) -> Optional[Mapping[str, str]]: def _generate_container_env_vars(parent) -> List[Mapping[str, str]]: env_vars = [] - if spring_app_config := _spring_app_config_env_var(parent): - env_vars.append(spring_app_config) + env_vars.append(_spring_app_config_env_var(parent)) if tls := parent["spec"].get("tls"): if jdk_options := _get_java_jdk_options(tls): @@ -333,6 +339,10 @@ def _create_pod_template(parent, labels, integration_image) -> Mapping[str, Any] "metadata": {"labels": labels}, "spec": { "serviceAccountName": "integrationroute-service", + "securityContext": { + "runAsNonRoot": True, + "seccompProfile": {"type": "RuntimeDefault"}, + }, "containers": [ { "name": "integration-app", @@ -397,7 +407,12 @@ def _new_deployment(parent): "app.kubernetes.io/name": parent_metadata["name"], } - labels = autogenerated_labels | parent["spec"].get("labels", {}) + user_labels = { + k: v + for k, v in parent["spec"].get("labels", {}).items() + if not k.startswith("app.kubernetes.io/") + } + labels = autogenerated_labels | user_labels deployment = { "apiVersion": "apps/v1", @@ -480,7 +495,9 @@ def _compute_status(parent: Mapping, children: Mapping) -> Mapping: ready_replicas = deployment_status.get("readyReplicas", 0) available_conditions = [ - c for c in deployment_status["conditions"] if c["type"] == "Available" + c + for c in deployment_status.get("conditions", []) + if c["type"] == "Available" ] ready_conditions = [ @@ -505,7 +522,11 @@ def _get_status_ready_condition( condition_type = "Ready" ready_condition_list = ( - [c for c in parent_status["conditions"] if c["type"] == condition_type] + [ + c + for c in parent_status.get("conditions", []) + if c["type"] == condition_type + ] if parent_status else [] ) diff --git a/webapp/core/test/json/full-response.json b/webapp/core/test/json/full-response.json index 1c84f63f..ce3df872 100644 --- a/webapp/core/test/json/full-response.json +++ b/webapp/core/test/json/full-response.json @@ -36,6 +36,12 @@ }, "spec": { "serviceAccountName": "integrationroute-service", + "securityContext": { + "runAsNonRoot": true, + "seccompProfile": { + "type": "RuntimeDefault" + } + }, "containers": [ { "name": "integration-app", @@ -46,20 +52,20 @@ "mountPath": "/var/spring/xml" }, { - "name": "testroute-secret", + "name": "secret-testroute-secret", "readOnly": true, "mountPath": "/etc/secrets/testroute-secret" }, { - "name": "testroute-pvc", + "name": "pvc-testroute-pvc", "mountPath": "/tmp/testdir" }, { - "name": "test-cm-1", + "name": "cm-test-cm-1", "mountPath": "/path/to/cm1" }, { - "name": "test-cm-2", + "name": "cm-test-cm-2", "mountPath": "/path/to/cm2" }, { @@ -162,25 +168,25 @@ } }, { - "name": "testroute-secret", + "name": "secret-testroute-secret", "secret": { "secretName": "testroute-secret" } }, { - "name": "testroute-pvc", + "name": "pvc-testroute-pvc", "persistentVolumeClaim": { "claimName": "testroute-pvc" } }, { - "name": "test-cm-1", + "name": "cm-test-cm-1", "configMap": { "name": "test-cm-1" } }, { - "name": "test-cm-2", + "name": "cm-test-cm-2", "configMap": { "name": "test-cm-2" } diff --git a/webapp/core/test/test_k8s_client.py b/webapp/core/test/test_k8s_client.py index e5c29553..f9763bac 100644 --- a/webapp/core/test/test_k8s_client.py +++ b/webapp/core/test/test_k8s_client.py @@ -1,6 +1,6 @@ import pytest from kubernetes import client -from core.k8s_client import _create_integration_route, _create_route_configmap +from core.k8s_client import _create_integration_route, _create_route_configmap, create_route_resources from models import RouteData, Resource, Status from kubernetes.client.rest import ApiException @@ -62,8 +62,8 @@ def test_create_integration_route_creates_new(route_data, mock_api): 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"}} + """When an IntegrationRoute exists the function should patch it.""" + existing_ir = {"metadata": {"name": route_data.route_name}} mock_api["route_api"].list_namespaced_custom_object.return_value = { "items": [existing_ir] } @@ -71,18 +71,12 @@ def test_create_integration_route_updates_existing(route_data, mock_api): 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") + assert res.status == Status.UPDATED + mock_api["route_api"].patch_namespaced_custom_object.assert_called_once() -def test_create_route_configmap_cluster_not_reachable(route_data, mocker): - """When the cluster is not reachable, create_route_configmap should raise an ApiException.""" +def test_create_route_resources_cluster_not_reachable(route_data, mocker): + """When the cluster is not reachable, create_route_resources should raise an ApiException.""" mocker.patch("core.k8s_client._check_cluster_reachable", return_value=False) with pytest.raises(ApiException): - _create_route_configmap(route_data) + create_route_resources(route_data) diff --git a/webapp/logconf.py b/webapp/logconf.py index a1402d79..e8ac9a6d 100644 --- a/webapp/logconf.py +++ b/webapp/logconf.py @@ -4,7 +4,7 @@ def get_log_level_from_env(): level = os.getenv("LOG_LEVEL", "").upper() - if level in logging.getLevelNamesMapping().keys(): + if level in logging.getLevelNamesMapping(): return level return "INFO" diff --git a/webapp/models.py b/webapp/models.py index c092cc8d..37a92786 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -16,8 +16,8 @@ class Status(str, Enum): class Route(BaseModel): name: str = Field(min_length=1, max_length=253) - namespace: str = "default" - xml: str + namespace: str = Field(default="default", min_length=1, max_length=63, pattern=r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") + xml: str = Field(min_length=1, max_length=1_048_576) @field_validator("name", mode="before") @classmethod diff --git a/webapp/routes/deploy.py b/webapp/routes/deploy.py index b9206494..ae133d3c 100644 --- a/webapp/routes/deploy.py +++ b/webapp/routes/deploy.py @@ -1,4 +1,5 @@ -import logging.config +import asyncio +import logging import json from dataclasses import asdict @@ -11,23 +12,20 @@ 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. + Handles the deployment of an integration route. 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. + It creates Kubernetes resources for the route using the provided XML configuration. Args: - request (Request): The incoming HTTP request containing the form data. + request (Request): The incoming HTTP request. The request body is a JSON payload containing a list of integration routes. { "routes": [ @@ -44,8 +42,6 @@ async def deploy_route(request: Request): 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") @@ -53,25 +49,21 @@ async def deploy_route(request: Request): 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: + async def _deploy_single_route(route): 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) + return await asyncio.to_thread( + k8s_client.create_route_resources, route_data + ) - _LOGGER.debug("Created new resources: %s", created_resources) + results = await asyncio.gather( + *[_deploy_single_route(route) for route in route_request.routes] + ) + created_resources = [r for result in results for r in result] return JSONResponse( [asdict(resource) for resource in created_resources], status_code=201 ) diff --git a/webapp/routes/test/json/full-route-response.json b/webapp/routes/test/json/full-route-response.json index 129f77cb..00797e92 100644 --- a/webapp/routes/test/json/full-route-response.json +++ b/webapp/routes/test/json/full-route-response.json @@ -29,6 +29,12 @@ }, "spec": { "serviceAccountName": "integrationroute-service", + "securityContext": { + "runAsNonRoot": true, + "seccompProfile": { + "type": "RuntimeDefault" + } + }, "containers": [ { "name": "integration-app", @@ -39,7 +45,7 @@ "mountPath": "/var/spring/xml" }, { - "name": "testroute-secret", + "name": "secret-testroute-secret", "readOnly": true, "mountPath": "/etc/secrets/testroute-secret" }, @@ -105,7 +111,7 @@ } }, { - "name": "testroute-secret", + "name": "secret-testroute-secret", "secret": { "secretName": "testroute-secret" } diff --git a/webapp/routes/test/test_deploy.py b/webapp/routes/test/test_deploy.py index 47de195e..d67c11ed 100644 --- a/webapp/routes/test/test_deploy.py +++ b/webapp/routes/test/test_deploy.py @@ -1,6 +1,7 @@ import pytest import copy import json +import os from starlette.applications import Starlette from starlette.routing import Route from starlette.testclient import TestClient @@ -30,7 +31,8 @@ def test_client(): resources = [Resource(name="my-route", status=Status.CREATED)] -with open("./routes/test/json/deploy-request-body.json", "r") as f: +_TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +with open(os.path.join(_TEST_DIR, "json/deploy-request-body.json"), "r") as f: body = json.load(f) @@ -78,13 +80,6 @@ def test_deploy_malformed_json(mock_k8s_client, test_client): 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={}) diff --git a/webapp/routes/webhook.py b/webapp/routes/webhook.py index 5b4d46f8..3e32c6e3 100644 --- a/webapp/routes/webhook.py +++ b/webapp/routes/webhook.py @@ -15,11 +15,17 @@ _LOGGER = logging.getLogger(__name__) +def _summarize_request(body: dict) -> str: + """Return a short summary of the sync request without sensitive fields.""" + metadata = body.get("parent", {}).get("metadata", {}) + return f"name={metadata.get('name')}, namespace={metadata.get('namespace')}, generation={metadata.get('generation')}" + + def build_webhook(sync_func: Callable[[Mapping], Mapping]): async def webhook(request: Request): try: body = await request.json() - _LOGGER.debug(f"Webhook request:\n {json.dumps(body)}") + _LOGGER.debug("Webhook request: %s", _summarize_request(body)) response = sync_func(body) except JSONDecodeError as e: raise HTTPException( @@ -31,8 +37,14 @@ async def webhook(request: Request): status_code=HTTP_400_BAD_REQUEST, detail=f"Missing field from request: {repr(e)}", ) + except Exception as e: + _LOGGER.error("Unexpected error processing webhook: %s", e, exc_info=True) + raise HTTPException( + status_code=500, + detail="Internal server error", + ) - _LOGGER.debug(f"Webhook response:\n {json.dumps(response)}") + _LOGGER.debug("Webhook response: status=%s", response.get("status", {})) return JSONResponse(response) return webhook From 940a220e8c45754035f275b148dd94f4432f4071 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Thu, 19 Feb 2026 14:30:20 -0700 Subject: [PATCH 09/10] Fix CI failures: lint, test deps, and version bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `import json` from webhook.py (ruff F401) - Add spring-boot-starter-test dependency to keip-integration pom.xml - Add test route XML and TestPropertySource for KeipApplicationTests - Bump versions: operator 0.15.0→0.16.0, webapp 0.18.0→0.19.0, keip-integration 0.4.0→0.5.0 - Update operator image refs to match new versions Co-Authored-By: Claude Opus 4.6 --- keip-integration/pom.xml | 8 +++++++- .../java/org/codice/keip/KeipApplicationTests.java | 2 ++ keip-integration/src/test/resources/test-route.xml | 13 +++++++++++++ operator/Makefile | 2 +- operator/controller/keip-controller-props.yaml | 2 +- operator/controller/webhook-deployment.yaml | 2 +- webapp/Makefile | 2 +- webapp/routes/webhook.py | 1 - 8 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 keip-integration/src/test/resources/test-route.xml diff --git a/keip-integration/pom.xml b/keip-integration/pom.xml index fa375d3b..5d484bbe 100644 --- a/keip-integration/pom.xml +++ b/keip-integration/pom.xml @@ -12,7 +12,7 @@ org.codice.keip keip-integration - 0.4.0 + 0.5.0 ghcr.io/codice @@ -76,6 +76,12 @@ org.springframework.integration spring-integration-http + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java b/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java index 3846fbf6..f9931871 100644 --- a/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java +++ b/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@TestPropertySource(properties = "keip.integration.filepath=classpath:test-route.xml") class KeipApplicationTests { @Test diff --git a/keip-integration/src/test/resources/test-route.xml b/keip-integration/src/test/resources/test-route.xml new file mode 100644 index 00000000..2535b2ee --- /dev/null +++ b/keip-integration/src/test/resources/test-route.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/operator/Makefile b/operator/Makefile index 463a31ee..82633de6 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -1,4 +1,4 @@ -VERSION ?= 0.15.0 +VERSION ?= 0.16.0 GIT_TAG := operator_v$(VERSION) KUBECTL := kubectl diff --git a/operator/controller/keip-controller-props.yaml b/operator/controller/keip-controller-props.yaml index 784e34f5..f9830a7e 100644 --- a/operator/controller/keip-controller-props.yaml +++ b/operator/controller/keip-controller-props.yaml @@ -4,4 +4,4 @@ metadata: name: keip-controller-props namespace: keip data: - integration-image: "ghcr.io/codice/keip/minimal-app:latest" + integration-image: "ghcr.io/codice/keip/keip-integration:0.5.0" diff --git a/operator/controller/webhook-deployment.yaml b/operator/controller/webhook-deployment.yaml index 2e45ae9f..0199343e 100644 --- a/operator/controller/webhook-deployment.yaml +++ b/operator/controller/webhook-deployment.yaml @@ -31,7 +31,7 @@ spec: type: RuntimeDefault containers: - name: webhook - image: ghcr.io/codice/keip/webapp:0.18.0 + image: ghcr.io/codice/keip/webapp:0.19.0 ports: - containerPort: 7080 name: webhook-http diff --git a/webapp/Makefile b/webapp/Makefile index 700da2dd..d2426a52 100644 --- a/webapp/Makefile +++ b/webapp/Makefile @@ -1,4 +1,4 @@ -VERSION ?= 0.18.0 +VERSION ?= 0.19.0 HOST_PORT ?= 7080 GIT_TAG := webapp_v$(VERSION) diff --git a/webapp/routes/webhook.py b/webapp/routes/webhook.py index 3e32c6e3..38d5edc7 100644 --- a/webapp/routes/webhook.py +++ b/webapp/routes/webhook.py @@ -1,4 +1,3 @@ -import json import logging from json import JSONDecodeError from typing import Callable, Mapping From 6ad4428094e708b982909ef6de7fff337adbe451 Mon Sep 17 00:00:00 2001 From: Don Cruver Date: Thu, 19 Feb 2026 14:42:35 -0700 Subject: [PATCH 10/10] Complete minimal-app to keip-integration rename and upgrade fixups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update verify scripts for renamed directories (minimal-app → keip-integration, operator/webapp → webapp) - Update CLAUDE.md, README.md, Makefile, and CI README for renames - Pass args to SpringApplication.run() in KeipApplication - Simplify composite-controller (remove statusChecks, add HTTPS TODO) - Harden webapp Dockerfile (non-root user, PYTHONDONTWRITEBYTECODE) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/README.md | 4 ++-- .../scripts/verify_keip_integration_releasable.sh | 6 +++--- .../workflows/scripts/verify_operator_releasable.sh | 2 +- .github/workflows/scripts/verify_webapp_releasable.sh | 2 +- CLAUDE.md | 10 +++++----- Makefile | 8 ++++---- README.md | 4 ++-- .../src/main/java/org/codice/keip/KeipApplication.java | 2 +- operator/controller/composite-controller.yaml | 5 +---- webapp/Dockerfile | 8 +++++++- 10 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 03353fec..5213bdbe 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,8 +1,8 @@ # Github Actions This monorepo consists of 3 artifacts that are versioned, built, and released separately. -- minimal-app +- keip-integration - operator -- operator/webapp +- 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/scripts/verify_keip_integration_releasable.sh b/.github/workflows/scripts/verify_keip_integration_releasable.sh index 8fd36e4c..20564275 100644 --- a/.github/workflows/scripts/verify_keip_integration_releasable.sh +++ b/.github/workflows/scripts/verify_keip_integration_releasable.sh @@ -1,11 +1,11 @@ set -eux -MINIMAL_APP_DIR=minimal-app +KEIP_INTEGRATION_DIR=keip-integration verify_version_bump() { - version=$(mvn -f minimal-app/pom.xml help:evaluate -Dexpression=project.version -q -DforceStdout) + version=$(mvn -f keip-integration/pom.xml help:evaluate -Dexpression=project.version -q -DforceStdout) potential_tag="${GIT_TAG_PREFIX}${version}" - sh .github/workflows/scripts/shared/verify_changes_update_version.sh $potential_tag $MINIMAL_APP_DIR + sh .github/workflows/scripts/shared/verify_changes_update_version.sh $potential_tag $KEIP_INTEGRATION_DIR } verify_version_bump diff --git a/.github/workflows/scripts/verify_operator_releasable.sh b/.github/workflows/scripts/verify_operator_releasable.sh index d417852a..e8f01cb6 100644 --- a/.github/workflows/scripts/verify_operator_releasable.sh +++ b/.github/workflows/scripts/verify_operator_releasable.sh @@ -7,7 +7,7 @@ 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/webapp/' + -e ^webapp/' } sh .github/workflows/scripts/shared/verify_current_webapp_img.sh diff --git a/.github/workflows/scripts/verify_webapp_releasable.sh b/.github/workflows/scripts/verify_webapp_releasable.sh index 849d55bf..91206460 100644 --- a/.github/workflows/scripts/verify_webapp_releasable.sh +++ b/.github/workflows/scripts/verify_webapp_releasable.sh @@ -1,6 +1,6 @@ set -eux -WEBAPP_DIR=operator/webapp +WEBAPP_DIR=webapp verify_version_bump() { potential_tag=$(make --no-print-directory -C $WEBAPP_DIR get-tag) diff --git a/CLAUDE.md b/CLAUDE.md index 65931a18..df6b12df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Three independently versioned artifacts in a monorepo: - **`webapp/`** — Python (3.11) webhook server (Starlette/Uvicorn) that handles Metacontroller sync requests and generates Kubernetes child resources. This is where most active development happens. - **`operator/`** — Kubernetes manifests (CRD, CompositeController, RBAC) deployed via kustomize. -- **`minimal-app/`** — Default Java 21 Spring Boot container that runs the integration routes. +- **`keip-integration/`** — Default Java 21 Spring Boot container that runs the integration routes. A root `Makefile` provides aggregate targets that delegate to sub-projects. @@ -48,7 +48,7 @@ make prep-release # Generate install.yaml and per-component manifests t Users can also install without cloning: `kubectl apply -f /install.yaml` or `kubectl apply -k 'https://github.com/codice/keip/operator?ref='`. -### Minimal App (Java) — `minimal-app/` +### Integration App (Java) — `keip-integration/` ```bash mvn clean install # Build JAR and Docker image (uses Jib) @@ -62,7 +62,7 @@ make test-webapp # Run webapp tests make lint-webapp # Lint webapp make precommit-webapp # Full precommit check make deploy-operator # Deploy operator to cluster -make build-minimal-app # Build Java app +make build-keip-integration # Build Java app ``` ## Architecture @@ -72,7 +72,7 @@ IntegrationRoute CR → Metacontroller → Webhook (Python) → Deployment (user) (watches CRDs) (sync.py logic) (child resources) ``` -1. User creates an `IntegrationRoute` resource (`keip.codice.org/v1alpha1`, shortname: `ir`) +1. User creates an `IntegrationRoute` resource (`keip.codice.org/v1alpha2`, shortname: `ir`) 2. Metacontroller (v4.11.6) detects it via CompositeController 3. Metacontroller POSTs to the webhook at `/webhook/sync` 4. `core/sync.py` generates a Deployment spec (mounting the route XML from a ConfigMap) and an actuator Service @@ -123,7 +123,7 @@ Uses pytest, httpx (for async HTTP testing), pytest-mock. Coverage is tracked vi GitHub Actions workflows in `.github/workflows/`: - **`webapp.yml`** — verify-versions → test → lint → build (PR) / release (main). Builds linux/amd64 + arm64. - **`operator.yml`** — verify-versions → kustomize build (PR) / release (main) -- **`minimal-app.yml`** — verify-versions → Maven verify (PR) / release (main). Java 21. +- **`keip-integration.yml`** — verify-versions → Maven verify (PR) / release (main). Java 21. ## Conventions diff --git a/Makefile b/Makefile index 9e91d137..ea0963ba 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: test-webapp lint-webapp format-webapp precommit-webapp .PHONY: deploy-operator undeploy-operator -.PHONY: build-minimal-app +.PHONY: build-keip-integration # Webapp targets test-webapp: @@ -24,6 +24,6 @@ deploy-operator: undeploy-operator: $(MAKE) -C operator undeploy -# Minimal app targets -build-minimal-app: - cd minimal-app && mvn clean install +# Integration app targets +build-keip-integration: + cd keip-integration && mvn clean install diff --git a/README.md b/README.md index 65c42d83..0e023624 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Create a simple integration that prints a message every 5 seconds: 2. **Deploy the integration route:** ```bash cat <