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/minimal-app.yml b/.github/workflows/keip-integration.yml similarity index 77% rename from .github/workflows/minimal-app.yml rename to .github/workflows/keip-integration.yml index a8eeeaba..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 - JAVA_VERSION: 17 - GIT_TAG_PREFIX: minimal-app_v + WORKING_DIR: ./keip-integration + JAVA_VERSION: 21 + 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 09326091..20a76125 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,19 +13,21 @@ on: - main paths: - operator/** - - "!operator/webapp/**" - '!**.md' - '!operator/examples/**' 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 @@ -38,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: | @@ -71,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: @@ -80,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_keip_integration_releasable.sh b/.github/workflows/scripts/verify_keip_integration_releasable.sh new file mode 100644 index 00000000..20564275 --- /dev/null +++ b/.github/workflows/scripts/verify_keip_integration_releasable.sh @@ -0,0 +1,11 @@ +set -eux + +KEIP_INTEGRATION_DIR=keip-integration + +verify_version_bump() { + 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 $KEIP_INTEGRATION_DIR +} + +verify_version_bump diff --git a/.github/workflows/scripts/verify_minimal_app_releasable.sh b/.github/workflows/scripts/verify_minimal_app_releasable.sh deleted file mode 100644 index 8fd36e4c..00000000 --- a/.github/workflows/scripts/verify_minimal_app_releasable.sh +++ /dev/null @@ -1,11 +0,0 @@ -set -eux - -MINIMAL_APP_DIR=minimal-app - -verify_version_bump() { - version=$(mvn -f minimal-app/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 -} - -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/.github/workflows/webapp.yml b/.github/workflows/webapp.yml index 3b4161f6..9d2fbc9e 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,19 +11,22 @@ on: branches: - main paths: - - operator/webapp/** + - webapp/** - '!**.md' env: PYTHON_VERSION: 3.11 - WORKING_DIR: ./operator/webapp + 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..df6b12df --- /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. +- **`keip-integration/`** — 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='`. + +### Integration App (Java) — `keip-integration/` + +```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-keip-integration # 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/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 +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) +- **`keip-integration.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/Makefile b/Makefile new file mode 100644 index 00000000..ea0963ba --- /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-keip-integration + +# 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 + +# Integration app targets +build-keip-integration: + cd keip-integration && mvn clean install diff --git a/README.md b/README.md index 1ab9bdcd..0e023624 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 - - apiVersion: keip.codice.org/v1alpha1 + cat < - - 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/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 84% rename from minimal-app/pom.xml rename to keip-integration/pom.xml index 26a72d1f..5d484bbe 100644 --- a/minimal-app/pom.xml +++ b/keip-integration/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 + keip-integration + 0.5.0 ghcr.io/codice @@ -22,21 +22,13 @@ true - 17 + 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 @@ -84,9 +76,16 @@ org.springframework.integration spring-integration-http + + + org.springframework.boot + spring-boot-starter-test + test + + app org.springframework.boot @@ -103,21 +102,16 @@ com.google.cloud.tools jib-maven-plugin - 3.4.0 + 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 89% rename from minimal-app/src/main/java/org/codice/keip/KeipApplication.java rename to keip-integration/src/main/java/org/codice/keip/KeipApplication.java index e6efa78d..8b9a5e2a 100644 --- a/minimal-app/src/main/java/org/codice/keip/KeipApplication.java +++ b/keip-integration/src/main/java/org/codice/keip/KeipApplication.java @@ -10,6 +10,6 @@ @ImportResource(locations = "${keip.integration.filepath:file:/var/spring/xml/integrationRoute.xml}") public class KeipApplication { public static void main(String[] args) { - SpringApplication.run(KeipApplication.class); + SpringApplication.run(KeipApplication.class, args); } } 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..f9931871 --- /dev/null +++ b/keip-integration/src/test/java/org/codice/keip/KeipApplicationTests.java @@ -0,0 +1,13 @@ +package org.codice.keip; + +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 + void contextLoads() {} +} 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 60d621c4..82633de6 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -1,16 +1,16 @@ -VERSION ?= 0.15.0 +VERSION ?= 0.16.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/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 new file mode 100644 index 00000000..c075f0ca --- /dev/null +++ b/operator/controller/composite-controller.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: metacontroller.k8s.io/v1alpha1 +kind: CompositeController +metadata: + name: keip-integrationroute-controller +spec: + generateSelector: true + parentResource: + apiVersion: keip.codice.org/v1alpha2 + 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 + hooks: + sync: + webhook: + # TODO: Migrate to HTTPS. Sync requests contain CR specs with secret references. + url: http://integrationroute-webhook.keip/webhook/sync + timeout: 10s diff --git a/operator/controller/core-controller.yaml b/operator/controller/core-controller.yaml deleted file mode 100644 index 2d20983c..00000000 --- a/operator/controller/core-controller.yaml +++ /dev/null @@ -1,88 +0,0 @@ ---- -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: - name: integrationroute-webhook - namespace: keip -spec: - replicas: 1 - selector: - matchLabels: - app: integrationroute-webhook - template: - metadata: - labels: - app: integrationroute-webhook - spec: - serviceAccountName: keip-controller-service - containers: - - name: webhook - image: ghcr.io/codice/keip/webapp:0.18.0 - ports: - - containerPort: 7080 - name: webhook-http - env: - - name: INTEGRATION_IMAGE - valueFrom: - configMapKeyRef: - name: keip-controller-props - key: integration-image - - name: LOG_LEVEL - value: INFO - resources: - requests: - cpu: "100m" - limits: - memory: "128Mi" ---- -apiVersion: v1 -kind: Service -metadata: - name: integrationroute-webhook - namespace: keip -spec: - selector: - app: integrationroute-webhook - ports: - - port: 80 - targetPort: webhook-http 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/keip-controller-props.yaml b/operator/controller/keip-controller-props.yaml new file mode 100644 index 00000000..f9830a7e --- /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/keip-integration:0.5.0" 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/webhook-deployment.yaml b/operator/controller/webhook-deployment.yaml new file mode 100644 index 00000000..0199343e --- /dev/null +++ b/operator/controller/webhook-deployment.yaml @@ -0,0 +1,98 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: integrationroute-webhook + namespace: keip +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: integrationroute-webhook + template: + metadata: + labels: + 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.19.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: + configMapKeyRef: + name: keip-controller-props + key: integration-image + - name: LOG_LEVEL + value: INFO + resources: + requests: + cpu: "100m" + 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 +metadata: + name: integrationroute-webhook + namespace: keip +spec: + selector: + 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 16452637..507ec461 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,340 @@ 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 + 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 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + 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: false + 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,9 +405,14 @@ 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 + required: + - name + - mountPath env: description: "List of environment variables to set in the container (cannot be updated)" type: array @@ -89,8 +420,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 +435,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" @@ -150,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" @@ -198,11 +535,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 +579,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: @@ -260,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: @@ -309,3 +646,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 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 75% rename from operator/webapp/.dockerignore rename to webapp/.dockerignore index 4416c4b3..09ac3d92 100644 --- a/operator/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/operator/webapp/Dockerfile b/webapp/Dockerfile similarity index 62% rename from operator/webapp/Dockerfile rename to webapp/Dockerfile index 26b8bb29..2c730221 100644 --- a/operator/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,7 +1,9 @@ -FROM python:3.11.5-slim +FROM python:3.11-slim LABEL org.opencontainers.image.source=https://github.com/codice/keip +RUN groupadd --system appgroup && useradd --system --gid appgroup appuser + WORKDIR /code/webapp COPY requirements.txt ./ @@ -9,4 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN chown -R appuser:appgroup /code +ENV PYTHONDONTWRITEBYTECODE=1 +USER appuser + ENTRYPOINT ["python", "-m", "uvicorn", "webapp.app:app", "--host", "0.0.0.0", "--port", "7080", "--app-dir", "/code"] diff --git a/operator/webapp/Makefile b/webapp/Makefile similarity index 99% rename from operator/webapp/Makefile rename to webapp/Makefile index 700da2dd..d2426a52 100644 --- a/operator/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/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 97% rename from operator/webapp/addons/certmanager/main.py rename to webapp/addons/certmanager/main.py index aea74a18..6707126f 100644 --- a/operator/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/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 94% rename from operator/webapp/addons/certmanager/test/json/full-iroute-request.json rename to webapp/addons/certmanager/test/json/full-integration-route-request.json index 18458df9..f6346d2f 100644 --- a/operator/webapp/addons/certmanager/test/json/full-iroute-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/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 78% rename from operator/webapp/app.py rename to webapp/app.py index 7e8ddcf9..c1e9b8dc 100644 --- a/operator/webapp/app.py +++ b/webapp/app.py @@ -9,12 +9,14 @@ 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__) -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( @@ -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 82% rename from operator/webapp/core/k8s_client.py rename to webapp/core/k8s_client.py index c1749baf..9d6a2064 100644 --- a/operator/webapp/core/k8s_client.py +++ b/webapp/core/k8s_client.py @@ -3,32 +3,49 @@ from kubernetes.client.rest import ApiException import logging import os +import threading 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.", - ) - - -v1 = client.CoreV1Api() -routeApi = client.CustomObjectsApi() +_lock = threading.Lock() +_configured = False +_config_failed = False +v1 = None +routeApi = None + + +def _ensure_configured(): + global _configured, _config_failed, v1, routeApi + if _configured: + return + 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: @@ -43,6 +60,9 @@ def _check_cluster_reachable() -> bool: Returns: bool: True if the cluster is reachable, False otherwise. """ + _ensure_configured() + if v1 is None: + return False try: v1.get_api_resources() return True @@ -52,22 +72,9 @@ 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/v1alpha1", + "apiVersion": "keip.codice.org/v1alpha2", "kind": "IntegrationRoute", "metadata": { "name": route_data.route_name, @@ -76,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, @@ -97,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: @@ -118,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( @@ -189,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/operator/webapp/core/sync.py b/webapp/core/sync.py similarity index 84% rename from operator/webapp/core/sync.py rename to webapp/core/sync.py index 8071a74d..ae916c5e 100644 --- a/operator/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,25 +73,28 @@ 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": 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, @@ -99,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, @@ -126,18 +139,19 @@ def get_mounts(self) -> List[dict]: ] for secret in self._secret_srcs: + secret_name = secret["name"] volume_mounts.append( { - "name": secret, + "name": f"secret-{secret_name}", "readOnly": True, - "mountPath": str(PurePosixPath(SECRETS_ROOT, secret)), + "mountPath": str(PurePosixPath(SECRETS_ROOT, secret_name)), } ) for pvc_spec in self._pvcs: volume_mounts.append( { - "name": pvc_spec["claimName"], + "name": f"pvc-{pvc_spec['claimName']}", "mountPath": pvc_spec["mountPath"], } ) @@ -145,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"], } ) @@ -179,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") @@ -228,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": { @@ -261,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", @@ -280,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 { @@ -292,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): @@ -321,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", @@ -385,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", @@ -446,6 +473,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 = { @@ -467,12 +495,16 @@ 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 = [ _get_status_ready_condition( - parent.get("status", {}), expected_replicas == ready_replicas + parent.get("status", {}), + expected_replicas == ready_replicas, + generation, ) ] @@ -484,21 +516,32 @@ 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 = ( - [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 [] ) 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/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 97% rename from operator/webapp/core/test/json/full-iroute-request.json rename to webapp/core/test/json/full-integration-route-request.json index 672f8cbd..93366ae6 100644 --- a/operator/webapp/core/test/json/full-iroute-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/operator/webapp/core/test/json/full-response.json b/webapp/core/test/json/full-response.json similarity index 93% rename from operator/webapp/core/test/json/full-response.json rename to webapp/core/test/json/full-response.json index 62dcd095..ce3df872 100644 --- a/operator/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" } @@ -255,6 +261,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/operator/webapp/core/test/test_k8s_client.py b/webapp/core/test/test_k8s_client.py similarity index 78% rename from operator/webapp/core/test/test_k8s_client.py rename to webapp/core/test/test_k8s_client.py index b8319754..f9763bac 100644 --- a/operator/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 @@ -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} @@ -61,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] } @@ -70,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/operator/webapp/core/test/test_status.py b/webapp/core/test/test_status.py similarity index 81% rename from operator/webapp/core/test/test_status.py rename to webapp/core/test/test_status.py index d588b395..82548726 100644 --- a/operator/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/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 91% rename from operator/webapp/logconf.py rename to webapp/logconf.py index a1402d79..e8ac9a6d 100644 --- a/operator/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/operator/webapp/models.py b/webapp/models.py similarity index 86% rename from operator/webapp/models.py rename to webapp/models.py index c092cc8d..37a92786 100644 --- a/operator/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/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 64% rename from operator/webapp/routes/deploy.py rename to webapp/routes/deploy.py index b9206494..ae133d3c 100644 --- a/operator/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/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 94% rename from operator/webapp/routes/test/json/full-cert-request.json rename to webapp/routes/test/json/full-cert-request.json index 9efdaaaf..aa9da4f2 100644 --- a/operator/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/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 95% rename from operator/webapp/routes/test/json/full-route-request.json rename to webapp/routes/test/json/full-route-request.json index 9239bbbb..7e1f3861 100644 --- a/operator/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": { diff --git a/operator/webapp/routes/test/json/full-route-response.json b/webapp/routes/test/json/full-route-response.json similarity index 94% rename from operator/webapp/routes/test/json/full-route-response.json rename to webapp/routes/test/json/full-route-response.json index 129f77cb..00797e92 100644 --- a/operator/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/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 87% rename from operator/webapp/routes/test/test_deploy.py rename to webapp/routes/test/test_deploy.py index dd6f9c5b..d67c11ed 100644 --- a/operator/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/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 60% rename from operator/webapp/routes/webhook.py rename to webapp/routes/webhook.py index 3be57819..38d5edc7 100644 --- a/operator/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 @@ -10,17 +9,22 @@ from starlette.status import HTTP_400_BAD_REQUEST from core.sync import sync -from addons.certmanager.main import sync_certificate _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( @@ -32,8 +36,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 @@ -41,9 +51,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"], - ), ]