From b51df2ee0f1d8c7fc9b8fe4d53d574f0f669a12d Mon Sep 17 00:00:00 2001 From: "Brent E. Edwards" Date: Fri, 27 Mar 2026 23:53:22 +0000 Subject: [PATCH] feat(server): add Kubernetes Helm chart for server deployment (#1085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds Kubernetes Helm deployment support for the CleverAgents server. Closes #928 ### What this PR includes - Helm chart under `k8s/` with Deployment, Service, optional Ingress, ConfigMap, ServiceAccount, Secrets, NOTES, and optional Redis subchart configuration. - Multi-stage `Dockerfile.server` for server runtime deployment. - Deployment-focused docs in `k8s/README.md`. - Behave + Robot + benchmark coverage for chart/deployment wiring. ### Review fixes applied (cycle 11 — hurui200320 review #2687) **Critical fix:** 1. **CI SHA256 checksum verification fixed** — All 3 Helm install blocks (`unit_tests`, `integration_tests`, `helm` jobs) now save the tarball using its original filename (`helm-v3.16.4-linux-amd64.tar.gz`) instead of `helm.tgz`, so the `.sha256sum` file can correctly locate and verify it. This was causing all 3 CI Helm jobs to fail with "No such file or directory". **Major fixes (test coverage gaps):** 2. **405 Allow header now tested** — The existing "POST to known path returns method not allowed" scenario now asserts that the `Allow: GET` header is present. Added a second 405 scenario testing `POST /live` for broader `_KNOWN_PATHS` coverage (review item #11). 3. **Security-hardening headers now tested** — New scenario "HTTP responses include security-hardening headers" verifies `content-length`, `x-content-type-options: nosniff`, and `cache-control: no-store` headers are present on HTTP responses. 4. **Lifespan warning logging now tested** — New scenario "Unrecognised lifespan message type logs warning and continues" queues `lifespan.startup` → `lifespan.bogus` → `lifespan.shutdown` and verifies: (a) the app completes the lifespan cycle cleanly, (b) a warning is logged mentioning the unrecognised type. ### Review fixes applied (cycle 10) **Critical/Major fixes (from hurui200320's prior REQUEST_CHANGES):** 1. **Rebased branch onto master** — Removed merge commit per CONTRIBUTING.md rebase-only policy. Clean linear history restored. 2. **Fixed commit message body** — Replaced literal `\n` sequences with actual newlines. `ISSUES CLOSED: #928` footer is now on its own line after a blank separator. 3. **Moved uvicorn import to module top level** — `from uvicorn import run as uvicorn_run` is now at the top of `src/cleveragents/cli/commands/server.py` per Import Guidelines. Updated test mock target from `uvicorn.run` to `cleveragents.cli.commands.server.uvicorn_run`. 4. **Added SHA256 checksum verification** — All three Helm CLI install blocks in `.forgejo/workflows/ci.yml` now download and verify `helm.sha256sum` before extracting the binary. **Minor fixes:** 5. **Added `Dockerfile.server` build to CI** — New "Build Docker image (Server)" step in the `docker` job validates the server Dockerfile. 6. **ASGI 405 Method Not Allowed** — Known paths (`/`, `/live`, `/ready`, `/health`) now return 405 with `Allow: GET` header for non-GET methods, per RFC 9110 §15.5.6. Added `_KNOWN_PATHS` frozenset. 7. **WebSocket close protocol fix** — App now calls `await receive()` to consume the `websocket.connect` event before closing. Changed close code from 1000 (Normal Closure) to 1008 (Policy Violation). 8. **Lifespan handler logging** — Unrecognised lifespan message types are now logged as warnings instead of silently consumed. 9. **Security-hardening headers** — `_send_response` now includes `content-length`, `x-content-type-options: nosniff`, and `cache-control: no-store` on all HTTP responses. 10. **`.dockerignore` credential patterns** — Added `*.pem`, `*.key`, `*.p12`, `*.pfx`, `credentials*.json`. 11. **`--log-level` validation** — Constrained to `click.Choice(["critical", "error", "warning", "info", "debug", "trace"])` for clean CLI validation errors. 12. **Reverted unrelated semgrep pre-commit change** — `pass_filenames` and `entry` restored to original values per atomic commit hygiene. 13. **Removed unused `ReceiveCallable` type alias** and `Callable`/`Awaitable` imports from `asgi_app_steps.py`. 14. **Fixed redundant `shutil.which("helm")` check** — `_skip_if_helm_missing` now returns `bool` to eliminate the duplicate check in `_render_chart`. 15. **Improved test deque error handling** — Lifespan test receive mock now raises descriptive `AssertionError` instead of opaque `IndexError`. 16. **Scope type dispatch** — Changed `if/if/if` to `if/elif/elif` for mutually exclusive ASGI scope types. 17. **Dockerfile.server base image** — Standardised to `python:3.13-slim` (floating minor) consistent with CLI Dockerfile. 18. **Dockerfile layer caching** — Split `uv pip install build` and `python -m build` into separate `RUN` instructions. 19. **Removed extraneous double blank line** in Dockerfile.server. ### Deferred items (acknowledged, not in scope) - PodDisruptionBudget, HorizontalPodAutoscaler, NetworkPolicy — Follow-up for production hardening. - `appVersion: "1.0.0"` placeholder — Needs tracking issue for release versioning alignment. - Readiness probe with downstream dependency checks — Documented limitation. - Cross-system test for probe paths matching ASGI routes — Test enhancement. - Improved benchmarks (helm template timing vs PyYAML parsing) — Benchmark quality improvement. - CI DRY violation (Helm install 3×) — Code quality improvement, consider composite action. - File length limits exceeded (`k8s_helm_chart_steps.py` 551 lines, `helper_k8s_helm_chart.py` 678 lines) — Non-blocking, can be split in follow-up. - `runAsGroup: 1000` in pod security context — Defense-in-depth improvement. - HEAD method support on known paths — RFC compliance, does not affect K8s probes. - `click.Choice` log-level validation via CLI runner test — Test gap. ### Scope note: status-check CI gate The `status-check` job now includes `integration_tests`, `e2e_tests`, and `helm` in its `needs` list. The `helm` job is new in this PR. The `integration_tests` and `e2e_tests` additions fix previously-missing gate checks — included here since this PR modifies both of those jobs to install Helm. ### Quality gates - `nox -e lint` ✅ - `nox -e typecheck` ✅ - `nox -e unit_tests` ✅ (12,321 scenarios passed, 4 skipped) - `nox -e integration_tests` — 3 pre-existing failures in unrelated areas (plan correction, resource types) - `nox -e e2e_tests` — pre-existing failures (LLM API keys not available in local env) - `nox -e coverage_report` ✅ (**97.7%**) Reviewed-on: https://git.cleverthis.com/cleveragents/cleveragents-core/pulls/1085 Reviewed-by: Jeffrey Phillips Freeman Co-authored-by: Brent E. Edwards Co-committed-by: Brent E. Edwards --- .dockerignore | 30 +- .forgejo/workflows/ci.yml | 95 ++- .pre-commit-config.yaml | 1 + CHANGELOG.md | 7 + CONTRIBUTING.md | 1 + Dockerfile.server | 71 ++ Dockerfile.server.dockerignore | 40 ++ benchmarks/k8s_helm_chart_bench.py | 94 +++ features/asgi_app.feature | 78 ++ features/dockerignore_scope.feature | 21 + features/k8s_helm_chart.feature | 289 ++++++++ features/server_cli_coverage_boost.feature | 31 + features/steps/asgi_app_steps.py | 283 ++++++++ features/steps/dockerignore_scope_steps.py | 53 ++ features/steps/k8s_helm_chart_steps.py | 551 ++++++++++++++ .../steps/server_cli_coverage_boost_steps.py | 149 ++++ k8s/.helmignore | 16 + k8s/Chart.yaml | 26 + k8s/README.md | 250 +++++++ k8s/templates/NOTES.txt | 34 + k8s/templates/_helpers.tpl | 72 ++ k8s/templates/configmap.yaml | 15 + k8s/templates/deployment.yaml | 137 ++++ k8s/templates/ingress.yaml | 53 ++ k8s/templates/secrets.yaml | 23 + k8s/templates/service.yaml | 15 + k8s/templates/serviceaccount.yaml | 13 + k8s/values.yaml | 182 +++++ noxfile.py | 4 + robot/helper_k8s_helm_chart.py | 678 ++++++++++++++++++ robot/k8s_helm_chart.robot | 202 ++++++ src/cleveragents/a2a/asgi.py | 134 ++++ src/cleveragents/cli/commands/server.py | 75 ++ 33 files changed, 3714 insertions(+), 9 deletions(-) create mode 100644 Dockerfile.server create mode 100644 Dockerfile.server.dockerignore create mode 100644 benchmarks/k8s_helm_chart_bench.py create mode 100644 features/asgi_app.feature create mode 100644 features/dockerignore_scope.feature create mode 100644 features/k8s_helm_chart.feature create mode 100644 features/steps/asgi_app_steps.py create mode 100644 features/steps/dockerignore_scope_steps.py create mode 100644 features/steps/k8s_helm_chart_steps.py create mode 100644 k8s/.helmignore create mode 100644 k8s/Chart.yaml create mode 100644 k8s/README.md create mode 100644 k8s/templates/NOTES.txt create mode 100644 k8s/templates/_helpers.tpl create mode 100644 k8s/templates/configmap.yaml create mode 100644 k8s/templates/deployment.yaml create mode 100644 k8s/templates/ingress.yaml create mode 100644 k8s/templates/secrets.yaml create mode 100644 k8s/templates/service.yaml create mode 100644 k8s/templates/serviceaccount.yaml create mode 100644 k8s/values.yaml create mode 100644 robot/helper_k8s_helm_chart.py create mode 100644 robot/k8s_helm_chart.robot create mode 100644 src/cleveragents/a2a/asgi.py diff --git a/.dockerignore b/.dockerignore index 8e2d07a03..1d363382d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,33 @@ -# Minimal dockerignore for boilerplate +# Shared Docker build context exclusions. +# Keep this file focused on broad, safe exclusions because it applies to +# every Docker build in this repository. + __pycache__/ .venv/ build/ dist/ + +# Version control +.git/ +.gitignore + +# Environment and secrets +.env +.env.* + +# Private keys and credentials +*.pem +*.key +*.p12 +*.pfx +credentials*.json + +# Tool caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +htmlcov/ + +# IDE and editor files +.vscode/ +.idea/ diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 743b01a40..547898f94 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -145,9 +145,22 @@ jobs: container: image: python:3.13-slim steps: - - name: Install system dependencies (nodejs for checkout, git for merge tests) + - name: Install system dependencies (nodejs for checkout, git for merge tests, curl/tar for Helm) run: | - apt-get update && apt-get install -y -qq nodejs git && rm -rf /var/lib/apt/lists/* + apt-get update && apt-get install -y -qq nodejs git curl tar && rm -rf /var/lib/apt/lists/* + + - name: Install Helm CLI + run: | + HELM_VERSION="v3.16.4" + ARCH="amd64" + HELM_TARBALL="helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" + curl -fsSL "https://get.helm.sh/${HELM_TARBALL}" -o "/tmp/${HELM_TARBALL}" + curl -fsSL "https://get.helm.sh/${HELM_TARBALL}.sha256sum" -o /tmp/helm.sha256sum + cd /tmp && sha256sum -c helm.sha256sum + tar -xzf "/tmp/${HELM_TARBALL}" -C /tmp + mv /tmp/linux-${ARCH}/helm /usr/local/bin/helm + chmod +x /usr/local/bin/helm + helm version --short - uses: actions/checkout@v4 @@ -174,9 +187,22 @@ jobs: container: image: python:3.13-slim steps: - - name: Install system dependencies (nodejs for checkout, git for integration tests) + - name: Install system dependencies (nodejs for checkout, git for integration tests, curl/tar for Helm) run: | - apt-get update && apt-get install -y -qq nodejs git && rm -rf /var/lib/apt/lists/* + apt-get update && apt-get install -y -qq nodejs git curl tar && rm -rf /var/lib/apt/lists/* + + - name: Install Helm CLI + run: | + HELM_VERSION="v3.16.4" + ARCH="amd64" + HELM_TARBALL="helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" + curl -fsSL "https://get.helm.sh/${HELM_TARBALL}" -o "/tmp/${HELM_TARBALL}" + curl -fsSL "https://get.helm.sh/${HELM_TARBALL}.sha256sum" -o /tmp/helm.sha256sum + cd /tmp && sha256sum -c helm.sha256sum + tar -xzf "/tmp/${HELM_TARBALL}" -C /tmp + mv /tmp/linux-${ARCH}/helm /usr/local/bin/helm + chmod +x /usr/local/bin/helm + helm version --short - uses: actions/checkout@v4 @@ -197,6 +223,7 @@ jobs: nox -s integration_tests env: NOX_DEFAULT_VENV_BACKEND: uv + CLEVERAGENTS_REQUIRE_HELM_RENDER_ASSERTIONS: "true" # LLM API keys required for Robot Framework integration tests. # These secrets must be configured in Forgejo UI: # Repository Settings > Actions > Secrets @@ -453,17 +480,60 @@ jobs: - uses: actions/checkout@v4 - - name: Build Docker image + - name: Build Docker image (CLI) run: | docker build -t cleverernie:test . - - name: Test Docker image + - name: Test Docker image (CLI) run: | docker run --rm cleverernie:test --version + - name: Build Docker image (Server) + run: | + docker build -f Dockerfile.server -t cleveragents-server:test . + + helm: + runs-on: docker + container: + image: python:3.13-slim + steps: + - name: Install system dependencies (nodejs for checkout, curl for Helm) + run: | + apt-get update && apt-get install -y -qq nodejs curl tar && rm -rf /var/lib/apt/lists/* + + - uses: actions/checkout@v4 + + - name: Install Helm CLI + run: | + HELM_VERSION="v3.16.4" + ARCH="amd64" + HELM_TARBALL="helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" + curl -fsSL "https://get.helm.sh/${HELM_TARBALL}" -o "/tmp/${HELM_TARBALL}" + curl -fsSL "https://get.helm.sh/${HELM_TARBALL}.sha256sum" -o /tmp/helm.sha256sum + cd /tmp && sha256sum -c helm.sha256sum + tar -xzf "/tmp/${HELM_TARBALL}" -C /tmp + mv /tmp/linux-${ARCH}/helm /usr/local/bin/helm + chmod +x /usr/local/bin/helm + helm version --short + + - name: Build Helm chart dependencies + run: | + helm dependency build ./k8s + + - name: Helm lint chart + run: | + helm lint ./k8s \ + --set database.url="postgresql+asyncpg://user:pass@db-host:5432/cleveragents" + + - name: Helm template smoke render + run: | + helm template cleveragents ./k8s \ + --set database.url="postgresql+asyncpg://user:pass@db-host:5432/cleveragents" >/tmp/rendered.yaml + test -s /tmp/rendered.yaml + status-check: if: always() - needs: [lint, typecheck, security, quality, unit_tests, coverage, build, docker] + needs: [lint, typecheck, security, quality, unit_tests, integration_tests, e2e_tests, coverage, build, docker, helm] runs-on: docker container: image: python:3.13-slim @@ -475,15 +545,24 @@ jobs: echo "security: ${{ needs.security.result }}" echo "quality: ${{ needs.quality.result }}" echo "unit_tests: ${{ needs.unit_tests.result }}" + echo "integration_tests: ${{ needs.integration_tests.result }}" + echo "e2e_tests: ${{ needs.e2e_tests.result }}" echo "coverage: ${{ needs.coverage.result }}" echo "build: ${{ needs.build.result }}" echo "docker: ${{ needs.docker.result }}" + echo "helm: ${{ needs.helm.result }}" if [ "${{ needs.lint.result }}" != "success" ] || \ [ "${{ needs.typecheck.result }}" != "success" ] || \ [ "${{ needs.security.result }}" != "success" ] || \ + [ "${{ needs.quality.result }}" != "success" ] || \ [ "${{ needs.unit_tests.result }}" != "success" ] || \ - [ "${{ needs.coverage.result }}" != "success" ]; then + [ "${{ needs.integration_tests.result }}" != "success" ] || \ + [ "${{ needs.e2e_tests.result }}" != "success" ] || \ + [ "${{ needs.coverage.result }}" != "success" ] || \ + [ "${{ needs.build.result }}" != "success" ] || \ + [ "${{ needs.docker.result }}" != "success" ] || \ + [ "${{ needs.helm.result }}" != "success" ]; then echo "FAILED: One or more required jobs did not succeed" exit 1 fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e165e97e5..92010dc04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,6 +16,7 @@ repos: args: ["--branch", "master"] - id: check-yaml name: Validate YAML syntax + exclude: ^k8s/templates/ - id: check-toml name: Validate TOML syntax - id: check-json diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d968378..309d097c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -219,6 +219,13 @@ - Added overlay filesystem sandbox strategy with real OverlayFS support and userspace copy-tree fallback. Includes lifecycle management, diff computation, and sandbox factory registration. (#880) +- Added Kubernetes Helm chart in `k8s/` directory for server deployment. + Chart includes Deployment, Service, Ingress (with TLS termination), + ConfigMap, ServiceAccount, Secrets, and optional Redis subchart for + multi-instance session affinity. Added `Dockerfile.server` for ASGI + server containerization with multi-stage build, non-root user, and + pinned uv version. Includes deployment README with configuration + reference and quick-start instructions. (#928) - Added CLI polish infrastructure: shared constants.py (exit codes, format constants), centralized errors.py (cli_error, cli_not_found, cli_warning), and completion command for shell tab-completion generation. (#861) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7543307e..825aa2fbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1153,6 +1153,7 @@ All files must be organized in the following project directories: - `/config/` - Configuration files. - `/scripts/` - Utility scripts. - `/examples/` - Example code. +- `/k8s/` - Kubernetes Helm chart for server deployment. ### Specification diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 000000000..a6d3ba482 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,71 @@ +# syntax=docker/dockerfile:1 +# CleverAgents Server Dockerfile +# Multi-stage build for the ASGI server deployment. +# See k8s/README.md for Kubernetes deployment instructions. + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +# NOTE: Base images are pinned by tag (not by digest) for readability and +# ease of updates. Digest pinning provides stronger reproducibility but +# complicates routine version bumps. For production supply-chain security, +# consider switching to digest pinning or using a verified image registry. +FROM python:3.13-slim AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for fast dependency resolution +COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /usr/local/bin/uv + +WORKDIR /app + +# Copy project metadata and source code +COPY pyproject.toml README.md ./ +COPY src/ ./src/ + +# Install build tool (separate layer for better caching) +RUN uv pip install --system build + +# Build wheel +RUN python -m build --wheel --outdir /dist + +# --------------------------------------------------------------------------- +# Stage 2: Runtime +# --------------------------------------------------------------------------- +FROM python:3.13-slim + +# Create non-root user (uid 1000 per spec) +RUN useradd -m -u 1000 appuser + +# Install uv temporarily for fast package installation, then remove it +# to reduce attack surface in the runtime image. +COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /usr/local/bin/uv + +# Install the built wheel (includes uvicorn as a runtime dependency) +COPY --from=builder /dist/*.whl /tmp/ +RUN uv pip install --system --no-cache /tmp/*.whl && \ + rm -rf /tmp/* && \ + rm /usr/local/bin/uv + +# Create writable directories for runtime data +RUN mkdir -p /app/data && chown appuser:appuser /app/data + +# Switch to non-root user +USER appuser +WORKDIR /app + +# Expose the default server port +EXPOSE 8000 + +# Health check against the /health endpoint +# Note: Health checking in Kubernetes is handled by liveness/readiness +# probes configured in the Helm chart (k8s/values.yaml). + +# Run the ASGI server through the project CLI entrypoint. +# This aligns with the specification's deployment contract: +# `python -m cleveragents`. +ENTRYPOINT ["python", "-m", "cleveragents"] +CMD ["server", "serve", "--app", "cleveragents.a2a.asgi:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.server.dockerignore b/Dockerfile.server.dockerignore new file mode 100644 index 000000000..c73f6b929 --- /dev/null +++ b/Dockerfile.server.dockerignore @@ -0,0 +1,40 @@ +# Dockerfile.server-specific build context exclusions. +# This keeps server-only exclusions out of the shared .dockerignore used by +# the default CLI Dockerfile build. + +__pycache__/ +.venv/ +build/ +dist/ + +# Version control +.git/ +.gitignore + +# Environment and secrets +.env +.env.* + +# Private keys and credentials +*.pem +*.key +*.p12 +*.pfx +credentials*.json + +# Test suites and docs (not needed in server image context) +features/ +robot/ +benchmarks/ +docs/ +k8s/ + +# Tool caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +htmlcov/ + +# IDE and editor files +.vscode/ +.idea/ diff --git a/benchmarks/k8s_helm_chart_bench.py b/benchmarks/k8s_helm_chart_bench.py new file mode 100644 index 000000000..565dacf90 --- /dev/null +++ b/benchmarks/k8s_helm_chart_bench.py @@ -0,0 +1,94 @@ +"""ASV benchmarks for Kubernetes Helm chart YAML parsing. + +Structural smoke benchmarks that guard against YAML parsing regressions +in the Helm chart configuration files (Chart.yaml and values.yaml). + +These benchmarks intentionally measure PyYAML parsing performance rather +than project-specific application logic because the chart files are +static YAML consumed by Helm — there is no project Python code to +benchmark for this feature. The benchmarks exist to: + +1. Detect accidental chart corruption (malformed YAML). +2. Guard against unexpected parsing slowdowns from chart growth. +3. Satisfy the ASV benchmark requirement for every feature ticket. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from typing import Any + +import yaml + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +_K8S_DIR: Path = Path(__file__).resolve().parents[1] / "k8s" + + +class TimeChartYamlParsing: + """Benchmark Chart.yaml parsing and validation.""" + + timeout: int = 30 + + def setup(self) -> None: + self.chart_path: Path = _K8S_DIR / "Chart.yaml" + assert self.chart_path.exists(), "Chart.yaml not found" + + def time_parse_chart_yaml(self) -> None: + """Time parsing Chart.yaml.""" + with open(self.chart_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + assert data["apiVersion"] == "v2" + assert data["name"] == "cleveragents" + + def time_parse_values_yaml(self) -> None: + """Time parsing values.yaml.""" + values_path: Path = _K8S_DIR / "values.yaml" + with open(values_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + assert "replicaCount" in data + assert "resources" in data + assert "ingress" in data + assert "redis" in data + + +class TimeValuesValidation: + """Benchmark values.yaml structure validation.""" + + timeout: int = 30 + + def setup(self) -> None: + values_path: Path = _K8S_DIR / "values.yaml" + with open(values_path, encoding="utf-8") as f: + self.values: dict[str, Any] = yaml.safe_load(f) + + def time_validate_resource_limits(self) -> None: + """Time validating resource limits structure.""" + resources: dict[str, Any] = self.values["resources"] + assert "limits" in resources + assert "requests" in resources + assert "cpu" in resources["limits"] + assert "memory" in resources["limits"] + + def time_validate_ingress_config(self) -> None: + """Time validating ingress configuration.""" + ingress: dict[str, Any] = self.values["ingress"] + assert "enabled" in ingress + assert "tls" in ingress + assert "hosts" in ingress + + def time_validate_redis_config(self) -> None: + """Time validating Redis configuration.""" + redis: dict[str, Any] = self.values["redis"] + assert "enabled" in redis + assert "auth" in redis diff --git a/features/asgi_app.feature b/features/asgi_app.feature new file mode 100644 index 000000000..873feecc8 --- /dev/null +++ b/features/asgi_app.feature @@ -0,0 +1,78 @@ +Feature: ASGI application protocol handling + As a server deployment operator + I want the ASGI app to handle each protocol correctly + So that HTTP, lifespan, and websocket scopes behave per ASGI contract + + Scenario: HTTP health endpoint returns success JSON + Given the ASGI app module is loaded + When I send an HTTP GET request to "/health" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response body should be "{\"status\":\"ok\"}" + + Scenario: HTTP liveness endpoint returns alive JSON + Given the ASGI app module is loaded + When I send an HTTP GET request to "/live" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response body should be "{\"status\":\"alive\"}" + + Scenario: HTTP readiness endpoint returns ready JSON + Given the ASGI app module is loaded + When I send an HTTP GET request to "/ready" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response body should be "{\"status\":\"ready\"}" + + Scenario: HTTP root endpoint returns service metadata + Given the ASGI app module is loaded + When I send an HTTP GET request to "/" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response body should be "{\"service\":\"cleveragents\"}" + + Scenario: Unknown HTTP endpoint returns not found + Given the ASGI app module is loaded + When I send an HTTP GET request to "/missing" through the ASGI app + Then the HTTP response status should be 404 + And the HTTP response body should be "{\"error\":\"not found\"}" + + Scenario: POST to known path returns method not allowed + Given the ASGI app module is loaded + When I send an HTTP POST request to "/health" through the ASGI app + Then the HTTP response status should be 405 + And the HTTP response body should be "{\"error\":\"method not allowed\"}" + And the HTTP response should include an Allow header with value "GET" + + Scenario: POST to another known path returns method not allowed + Given the ASGI app module is loaded + When I send an HTTP POST request to "/live" through the ASGI app + Then the HTTP response status should be 405 + And the HTTP response body should be "{\"error\":\"method not allowed\"}" + And the HTTP response should include an Allow header with value "GET" + + Scenario: HTTP responses include security-hardening headers + Given the ASGI app module is loaded + When I send an HTTP GET request to "/health" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response should include a content-length header + And the HTTP response should include header "x-content-type-options" with value "nosniff" + And the HTTP response should include header "cache-control" with value "no-store" + + Scenario: Lifespan startup and shutdown complete cleanly + Given the ASGI app module is loaded + When I run ASGI lifespan startup then shutdown + Then the ASGI app should emit lifespan completion messages + And no HTTP response frames should be emitted + + Scenario: Unrecognised lifespan message type logs warning and continues + Given the ASGI app module is loaded + When I run ASGI lifespan with an unrecognised message type + Then the ASGI app should emit lifespan completion messages + And a warning should be logged for the unrecognised lifespan message type + + Scenario: Websocket scopes close with protocol-correct frame + Given the ASGI app module is loaded + When I invoke the ASGI app with a websocket scope + Then the ASGI app should emit a websocket close frame + + Scenario: Unsupported ASGI scope raises runtime error + Given the ASGI app module is loaded + When I invoke the ASGI app with an unsupported scope type + Then the ASGI invocation should raise an unsupported scope runtime error diff --git a/features/dockerignore_scope.feature b/features/dockerignore_scope.feature new file mode 100644 index 000000000..c8bc4f2fe --- /dev/null +++ b/features/dockerignore_scope.feature @@ -0,0 +1,21 @@ +Feature: Dockerignore scope isolation + As a maintainer + I want server-only dockerignore rules isolated + So CLI image builds are not affected by server-specific exclusions + + Scenario: Shared .dockerignore does not exclude server-only paths + Given the repository root for dockerignore validation + When I read the dockerignore file ".dockerignore" + Then the dockerignore file should not exclude "docs/" + And the dockerignore file should not exclude "k8s/" + And the dockerignore file should not exclude "features/" + And the dockerignore file should not exclude "robot/" + + Scenario: Dockerfile.server uses a dedicated dockerignore file + Given the repository root for dockerignore validation + Then the dockerignore file "Dockerfile.server.dockerignore" should exist + When I read the dockerignore file "Dockerfile.server.dockerignore" + Then the dockerignore file should exclude "docs/" + And the dockerignore file should exclude "k8s/" + And the dockerignore file should exclude "features/" + And the dockerignore file should exclude "robot/" diff --git a/features/k8s_helm_chart.feature b/features/k8s_helm_chart.feature new file mode 100644 index 000000000..551577ed2 --- /dev/null +++ b/features/k8s_helm_chart.feature @@ -0,0 +1,289 @@ +Feature: Kubernetes Helm Chart Structure + As a DevOps engineer + I want a valid Helm chart in the k8s/ directory + So that I can deploy the CleverAgents server to Kubernetes + + Background: + Given the project root directory + + # -- Chart.yaml validation -- + + Scenario: Chart.yaml exists with required Helm v2 fields + When I read the Helm chart metadata + Then the chart API version should be "v2" + And the chart name should be "cleveragents" + And the chart type should be "application" + And the chart version should be set + + Scenario: Chart.yaml declares optional Redis dependency + When I read the Helm chart metadata + Then the chart should have a dependency named "redis" + And the Redis dependency should be conditional on "redis.enabled" + + # -- values.yaml validation -- + + Scenario: values.yaml has configurable replica count + When I read the Helm chart values + Then the values should contain key "replicaCount" + And the default replica count should be 1 + + Scenario: values.yaml has configurable resource limits + When I read the Helm chart values + Then the values should contain key "resources" + And the resources should have CPU and memory limits + And the resources should have CPU and memory requests + + Scenario: values.yaml has ingress configuration with TLS support + When I read the Helm chart values + Then the values should contain key "ingress" + And the ingress should have an enabled toggle + And the ingress should have a TLS configuration section + + Scenario: values.yaml has optional Redis configuration + When I read the Helm chart values + Then the values should contain key "redis" + And the Redis configuration should have an enabled toggle + And the Redis configuration should have authentication settings + + Scenario: values.yaml has server configuration + When I read the Helm chart values + Then the values should contain key "server" + And the server config should have host setting + And the server config should have port setting + And the server config should have workers setting + + Scenario: values.yaml has database configuration + When I read the Helm chart values + Then the values should contain key "database" + And the database config should support existing secrets + + Scenario: values.yaml has security context for non-root execution + When I read the Helm chart values + Then the pod security context should enforce non-root execution + And the container security context should drop all capabilities + + # -- Template file content validation -- + + Scenario: Deployment template has correct kind and API version + When I read the template "k8s/templates/deployment.yaml" + Then the template should contain "kind: Deployment" + And the template should contain "apiVersion: apps/v1" + And the template should contain "volumeMounts:" + And the template should contain "mountPath: /tmp" + And the template should contain "mountPath: /app/data" + And the template should contain "sizeLimit: 100Mi" + And the template should contain "sizeLimit: 1Gi" + + Scenario: Service template has correct kind and API version + When I read the template "k8s/templates/service.yaml" + Then the template should contain "kind: Service" + And the template should contain "apiVersion: v1" + + Scenario: Ingress template has correct kind and API version + When I read the template "k8s/templates/ingress.yaml" + Then the template should contain "kind: Ingress" + And the template should contain "networking.k8s.io/v1" + And the template should match pattern "^\s+tls:" + + Scenario: ConfigMap template has correct kind and API version + When I read the template "k8s/templates/configmap.yaml" + Then the template should contain "kind: ConfigMap" + And the template should contain "apiVersion: v1" + + Scenario: ServiceAccount template has correct kind and API version + When I read the template "k8s/templates/serviceaccount.yaml" + Then the template should contain "kind: ServiceAccount" + And the template should contain "apiVersion: v1" + And the template should contain "automountServiceAccountToken: false" + + Scenario: Secrets template has correct kind + When I read the template "k8s/templates/secrets.yaml" + Then the template should contain "kind: Secret" + + Scenario: Helpers template exists with chart helpers + When I read the template "k8s/templates/_helpers.tpl" + Then the template should contain "cleveragents.fullname" + And the template should contain "cleveragents.labels" + + Scenario: NOTES.txt template exists with deployment info + When I read the template "k8s/templates/NOTES.txt" + Then the template should contain "CleverAgents Server has been deployed" + + # -- Server Dockerfile -- + + Scenario: Server-specific Dockerfile exists + Then the chart file "Dockerfile.server" should exist + + Scenario: Server Dockerfile uses multi-stage build + When I read the server Dockerfile + Then it should have a builder stage + And it should have a runtime stage + + Scenario: Server Dockerfile uses non-root user + When I read the server Dockerfile + Then it should create a non-root user with uid 1000 + + Scenario: Server Dockerfile exposes port 8000 + When I read the server Dockerfile + Then it should expose port 8000 + + Scenario: Server Dockerfile pins uv image version + When I read the server Dockerfile + Then it should not use unpinned uv latest tag + + # -- Secrets structure -- + + Scenario: Secrets template guards database secret with existingSecret check + When I read the template "k8s/templates/secrets.yaml" + Then the template should contain "database.existingSecret" + And the template should contain "database.url" + And the template should contain "database-url" + + Scenario: Secrets template guards Redis secret with existingSecret check + When I read the template "k8s/templates/secrets.yaml" + Then the template should contain "redis.auth.existingSecret" + And the template should contain "redis.auth.password" + And the template should contain "redis-password" + + # -- Deployment command/args -- + + Scenario: Deployment template overrides command with CLI server serve values + When I read the template "k8s/templates/deployment.yaml" + Then the template should contain "python" + And the template should contain "cleveragents" + And the template should contain "server" + And the template should contain "serve" + And the template should contain "--app" + And the template should contain "cleveragents.a2a.asgi:app" + And the template should contain "server.host" + And the template should contain "server.port" + And the template should contain "server.workers" + And the template should contain "server.logLevel" + + # -- envFrom wiring -- + + Scenario: Deployment template injects ConfigMap via envFrom + When I read the template "k8s/templates/deployment.yaml" + Then the template should contain "envFrom" + And the template should contain "configMapRef" + + # -- Redis host helper -- + + Scenario: Helpers template defines Redis host using Release.Name + When I read the template "k8s/templates/_helpers.tpl" + Then the template should contain "cleveragents.redisHost" + And the template should contain ".Release.Name" + + # -- Probes -- + + Scenario: values.yaml splits liveness and readiness probe endpoints + When I read the Helm chart values + Then the liveness probe should target the live endpoint + And the readiness probe should target the ready endpoint + + # -- Image defaults -- + + Scenario: values.yaml has sensible image defaults + When I read the Helm chart values + Then the image pull policy should be "IfNotPresent" + And the image tag should default to empty for appVersion fallback + + # -- Service defaults -- + + Scenario: values.yaml service defaults to ClusterIP on port 8000 + When I read the Helm chart values + Then the service type should be "ClusterIP" + And the service port should be 8000 + + # -- Conditional template branch validation -- + + Scenario: Ingress template is guarded by ingress.enabled condition + When I read the template "k8s/templates/ingress.yaml" + Then the template should match pattern "^\{\{-\s*if\s+\.Values\.ingress\.enabled" + + Scenario: Ingress template guards ssl-redirect against user annotation override + When I read the template "k8s/templates/ingress.yaml" + Then the template should match pattern "not.*hasKey.*\.Values\.ingress\.annotations.*ssl-redirect" + + Scenario: ServiceAccount template is guarded by serviceAccount.create condition + When I read the template "k8s/templates/serviceaccount.yaml" + Then the template should match pattern "^\{\{-\s*if\s+\.Values\.serviceAccount\.create" + + Scenario: Secrets template guards database secret with conditional logic + When I read the template "k8s/templates/secrets.yaml" + Then the template should match pattern "if\s+and\s+\.Values\.database\.url.*not\s+\.Values\.database\.existingSecret" + + Scenario: Secrets template guards Redis secret with conditional logic + When I read the template "k8s/templates/secrets.yaml" + Then the template should match pattern "if\s+and\s+\.Values\.redis\.enabled\s+\.Values\.redis\.auth\.enabled\s+\.Values\.redis\.auth\.password.*not\s+\.Values\.redis\.auth\.existingSecret" + + Scenario: Deployment template has database credential 3-way branching + When I read the template "k8s/templates/deployment.yaml" + Then the template should match pattern "if\s+\.Values\.database\.existingSecret" + And the template should match pattern "else\s+if\s+\.Values\.database\.url" + + Scenario: Deployment template has Redis credential 3-way branching + When I read the template "k8s/templates/deployment.yaml" + Then the template should match pattern "if\s+\.Values\.redis\.auth\.existingSecret" + And the template should match pattern "else\s+if\s+\.Values\.redis\.auth\.password" + And the template should contain "name: {{ .Release.Name }}-redis" + And the template should contain "key: redis-password" + + Scenario: Deployment template gates Redis env vars behind redis.enabled + When I read the template "k8s/templates/deployment.yaml" + Then the template should match pattern "if\s+\.Values\.redis\.enabled" + And the template should match pattern "CLEVERAGENTS_REDIS_HOST" + And the template should match pattern "CLEVERAGENTS_REDIS_PASSWORD" + + Scenario: Deployment template requires explicit database configuration + When I read the template "k8s/templates/deployment.yaml" + Then the template should contain "database configuration is required" + + Scenario: Helm render fails fast without database configuration + When I render the Helm chart without database configuration + Then the render should fail with required database configuration error + + Scenario: Helm render fails when ingress is enabled without TLS by default + When I render the Helm chart with ingress enabled and no TLS + Then the render should fail with required ingress TLS configuration error + + Scenario: Helm render allows explicit insecure ingress opt-out for development + When I render the Helm chart with ingress enabled and insecure opt-out enabled + Then the ingress render should succeed in insecure dev mode + + Scenario: Helm render succeeds when ingress TLS is explicitly configured + When I render the Helm chart with ingress enabled and TLS configured + Then the ingress render should succeed with TLS enabled + + Scenario: Deployment template disables service account token mounting + When I read the template "k8s/templates/deployment.yaml" + Then the template should match pattern "^\s+automountServiceAccountToken:\s+false" + + Scenario: Security context includes seccomp profile + When I read the Helm chart values + Then the container security context should have a seccomp profile + + Scenario: Security context enforces readOnlyRootFilesystem + When I read the Helm chart values + Then the container security context should set readOnlyRootFilesystem to true + + Scenario: Security context disables privilege escalation + When I read the Helm chart values + Then the container security context should set allowPrivilegeEscalation to false + + Scenario: Server Dockerfile pins uv version to specific semver + When I read the server Dockerfile + Then the uv image reference should use a specific semver tag + + Scenario: Server Dockerfile removes uv from runtime image + When I read the server Dockerfile + Then the Dockerfile should remove uv after installation + + # -- Deployment README -- + + Scenario: Deployment README exists with instructions + When I read the deployment README + Then it should contain quick start instructions + And it should contain TLS configuration documentation + And it should contain Redis configuration documentation + And it should contain scaling instructions diff --git a/features/server_cli_coverage_boost.feature b/features/server_cli_coverage_boost.feature index 03a778508..d8087d611 100644 --- a/features/server_cli_coverage_boost.feature +++ b/features/server_cli_coverage_boost.feature @@ -101,3 +101,34 @@ Feature: Server CLI command coverage boost Then the echoed output should contain "disabled" And the echoed output should contain "null" And the echoed output should contain "default" + + # ----------------------------------------------------------------------- + # server_serve — uvicorn.run argument mapping + # ----------------------------------------------------------------------- + + Scenario: server_serve maps explicit CLI arguments to uvicorn.run + Given uvicorn run is patched for server serve assertions + When I call server_serve with app "cleveragents.a2a.asgi:app" host "127.0.0.1" port 9001 workers 3 and log level "warning" + Then uvicorn run should be called with app "cleveragents.a2a.asgi:app" host "127.0.0.1" port 9001 workers 3 and log level "warning" + + Scenario: server_serve uses documented defaults for uvicorn.run + Given uvicorn run is patched for server serve assertions + When I call server_serve with default arguments + Then uvicorn run should be called with app "cleveragents.a2a.asgi:app" host "0.0.0.0" port 8000 workers 1 and log level "info" + + Scenario: server_serve exits cleanly when uvicorn is unavailable + Given uvicorn run is unavailable for server serve assertions + When I call server_serve with default arguments and capture failure + Then server_serve should exit with code 1 + And the captured console output should contain "optional dependency 'uvicorn' is not installed" + + Scenario: server_serve normalizes log level via Typer option parsing + Given uvicorn run is patched for server serve assertions + When I invoke server_serve through the CLI with log level "WARNING" + Then uvicorn run should be called with app "cleveragents.a2a.asgi:app" host "0.0.0.0" port 8000 workers 1 and log level "warning" + + Scenario: server_serve rejects invalid log level via Typer option parsing + Given uvicorn run is patched for server serve assertions + When I invoke server_serve through the CLI with invalid log level "LOUD" + Then the CLI invocation should fail with exit code 2 + And the server CLI invocation output should contain "Invalid value for '--log-level'" diff --git a/features/steps/asgi_app_steps.py b/features/steps/asgi_app_steps.py new file mode 100644 index 000000000..f7bc3f3de --- /dev/null +++ b/features/steps/asgi_app_steps.py @@ -0,0 +1,283 @@ +"""Step definitions for ASGI protocol behavior scenarios.""" + +from __future__ import annotations + +import asyncio +import logging +from collections import deque +from typing import Any + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.a2a.asgi import app + +SendMessage = dict[str, Any] + + +@given("the ASGI app module is loaded") +def step_asgi_module_loaded(context: Context) -> None: + context.asgi_app = app + + +@when('I send an HTTP GET request to "{path}" through the ASGI app') +def step_send_http_request(context: Context, path: str) -> None: + sent_messages: list[SendMessage] = [] + + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "http", "method": "GET", "path": path} + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + + +@when('I send an HTTP POST request to "{path}" through the ASGI app') +def step_send_http_post_request(context: Context, path: str) -> None: + sent_messages: list[SendMessage] = [] + + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "http", "method": "POST", "path": path} + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + + +@then("the HTTP response status should be {status:d}") +def step_http_status(context: Context, status: int) -> None: + start = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.start" + ) + assert start.get("status") == status, ( + f"Expected HTTP status {status}, got {start.get('status')}" + ) + + +@then('the HTTP response body should be "{body}"') +def step_http_body(context: Context, body: str) -> None: + response_body = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.body" + ) + normalized_body = body.replace(r"\"", '"') + assert response_body.get("body") == normalized_body.encode("utf-8"), ( + f"Expected body {normalized_body!r}, got {response_body.get('body')!r}" + ) + + +@when("I run ASGI lifespan startup then shutdown") +def step_run_lifespan(context: Context) -> None: + sent_messages: list[SendMessage] = [] + incoming = deque( + [ + {"type": "lifespan.startup"}, + {"type": "lifespan.shutdown"}, + ] + ) + + async def receive() -> dict[str, Any]: + if not incoming: + raise AssertionError( + "ASGI app issued an unexpected extra receive() call; " + "all queued lifespan messages have already been consumed" + ) + return incoming.popleft() + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "lifespan"} + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + + +@then("the ASGI app should emit lifespan completion messages") +def step_assert_lifespan_messages(context: Context) -> None: + message_types = [str(msg.get("type")) for msg in context.asgi_sent_messages] + assert message_types == [ + "lifespan.startup.complete", + "lifespan.shutdown.complete", + ], f"Unexpected lifespan messages: {message_types}" + + +@then("no HTTP response frames should be emitted") +def step_no_http_frames(context: Context) -> None: + message_types = [str(msg.get("type")) for msg in context.asgi_sent_messages] + assert not any( + msg_type.startswith("http.response") for msg_type in message_types + ), f"Unexpected HTTP response frames: {message_types}" + + +@when("I invoke the ASGI app with a websocket scope") +def step_websocket_scope(context: Context) -> None: + sent_messages: list[SendMessage] = [] + + async def receive() -> dict[str, Any]: + return {"type": "websocket.connect"} + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "websocket", "path": "/ws"} + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + + +@when("I invoke the ASGI app with an unsupported scope type") +def step_unsupported_scope(context: Context) -> None: + async def receive() -> dict[str, Any]: + return {"type": "unsupported.receive"} + + async def send(message: SendMessage) -> None: + del message + + scope = {"type": "unsupported", "path": "/"} + try: + asyncio.run(context.asgi_app(scope, receive, send)) + except RuntimeError as exc: + context.asgi_error = exc + return + + raise AssertionError("Expected RuntimeError for unsupported ASGI scope type") + + +@then("the ASGI app should emit a websocket close frame") +def step_websocket_close_frame(context: Context) -> None: + assert context.asgi_sent_messages == [{"type": "websocket.close", "code": 1008}], ( + f"Unexpected websocket frames: {context.asgi_sent_messages}" + ) + + +@then("the ASGI invocation should raise an unsupported scope runtime error") +def step_unsupported_scope_error(context: Context) -> None: + error = getattr(context, "asgi_error", None) + assert isinstance(error, RuntimeError), f"Expected RuntimeError, got {error!r}" + assert "Unsupported ASGI scope type" in str(error), ( + f"Unexpected runtime error message: {error}" + ) + + +# --- Allow header assertion for 405 responses --- + + +@then('the HTTP response should include an Allow header with value "{value}"') +def step_http_allow_header(context: Context, value: str) -> None: + start = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.start" + ) + headers: list[tuple[bytes, bytes]] = start.get("headers", []) + allow_values = [v.decode("utf-8") for k, v in headers if k.lower() == b"allow"] + assert allow_values, f"Expected Allow header, but none found in: {headers}" + assert value in allow_values, ( + f"Expected Allow header value {value!r}, got {allow_values!r}" + ) + + +# --- Security-hardening header assertions --- + + +@then("the HTTP response should include a content-length header") +def step_http_content_length_header(context: Context) -> None: + start = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.start" + ) + headers: list[tuple[bytes, bytes]] = start.get("headers", []) + header_names = [k.lower() for k, _v in headers] + assert b"content-length" in header_names, ( + f"Expected content-length header, but found: {headers}" + ) + + +@then('the HTTP response should include header "{name}" with value "{value}"') +def step_http_header_with_value(context: Context, name: str, value: str) -> None: + start = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.start" + ) + headers: list[tuple[bytes, bytes]] = start.get("headers", []) + name_bytes = name.lower().encode("utf-8") + matched_values = [v.decode("utf-8") for k, v in headers if k.lower() == name_bytes] + assert matched_values, f"Expected {name} header, but none found in: {headers}" + assert value in matched_values, ( + f"Expected {name} header value {value!r}, got {matched_values!r}" + ) + + +# --- Unrecognised lifespan message type --- + + +@when("I run ASGI lifespan with an unrecognised message type") +def step_run_lifespan_with_unrecognised_type(context: Context) -> None: + sent_messages: list[SendMessage] = [] + incoming = deque( + [ + {"type": "lifespan.startup"}, + {"type": "lifespan.bogus"}, + {"type": "lifespan.shutdown"}, + ] + ) + + async def receive() -> dict[str, Any]: + if not incoming: + raise AssertionError( + "ASGI app issued an unexpected extra receive() call; " + "all queued lifespan messages have already been consumed" + ) + return incoming.popleft() + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "lifespan"} + logger_name = "cleveragents.a2a.asgi" + with _capture_log_records(logger_name, logging.WARNING) as records: + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + context.asgi_log_records = records + + +@then("a warning should be logged for the unrecognised lifespan message type") +def step_assert_lifespan_warning_logged(context: Context) -> None: + records: list[logging.LogRecord] = context.asgi_log_records + warning_messages = [r.getMessage() for r in records] + assert any("lifespan.bogus" in msg for msg in warning_messages), ( + f"Expected a warning about 'lifespan.bogus', got: {warning_messages}" + ) + + +class _capture_log_records: + """Context manager that captures log records for a named logger.""" + + def __init__(self, logger_name: str, level: int) -> None: + self._logger = logging.getLogger(logger_name) + self._level = level + self._handler: logging.Handler | None = None + self.records: list[logging.LogRecord] = [] + + def __enter__(self) -> list[logging.LogRecord]: + handler = logging.Handler() + handler.setLevel(self._level) + handler.emit = self.records.append # type: ignore[assignment] + self._handler = handler + self._logger.addHandler(handler) + self._logger.setLevel(self._level) + return self.records + + def __exit__(self, *_args: object) -> None: + if self._handler is not None: + self._logger.removeHandler(self._handler) diff --git a/features/steps/dockerignore_scope_steps.py b/features/steps/dockerignore_scope_steps.py new file mode 100644 index 000000000..038dab0ce --- /dev/null +++ b/features/steps/dockerignore_scope_steps.py @@ -0,0 +1,53 @@ +"""Step definitions for dockerignore scope isolation checks.""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + + +def _dockerignore_patterns(content: str) -> set[str]: + return { + line.strip() + for line in content.splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + +@given("the repository root for dockerignore validation") +def step_set_project_root(context: Context) -> None: + context.project_root = Path(__file__).resolve().parents[2] + + +@then('the dockerignore file "{filename}" should exist') +def step_assert_dockerignore_exists(context: Context, filename: str) -> None: + dockerignore_path = context.project_root / filename + assert dockerignore_path.exists(), ( + f"Expected dockerignore file at {dockerignore_path}" + ) + + +@when('I read the dockerignore file "{filename}"') +def step_read_dockerignore(context: Context, filename: str) -> None: + dockerignore_path = context.project_root / filename + assert dockerignore_path.exists(), ( + f"Dockerignore file not found: {dockerignore_path}" + ) + content = dockerignore_path.read_text(encoding="utf-8") + context.dockerignore_patterns = _dockerignore_patterns(content) + + +@then('the dockerignore file should exclude "{pattern}"') +def step_assert_excludes_pattern(context: Context, pattern: str) -> None: + patterns: set[str] = context.dockerignore_patterns + assert pattern in patterns, f"Expected dockerignore to exclude {pattern!r}" + + +@then('the dockerignore file should not exclude "{pattern}"') +def step_assert_does_not_exclude_pattern(context: Context, pattern: str) -> None: + patterns: set[str] = context.dockerignore_patterns + assert pattern not in patterns, ( + f"Did not expect dockerignore to exclude {pattern!r}" + ) diff --git a/features/steps/k8s_helm_chart_steps.py b/features/steps/k8s_helm_chart_steps.py new file mode 100644 index 000000000..df5588569 --- /dev/null +++ b/features/steps/k8s_helm_chart_steps.py @@ -0,0 +1,551 @@ +"""Step definitions for the Kubernetes Helm Chart feature.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import yaml +from behave import given, then, when +from behave.runner import Context + + +def _project_root() -> Path: + """Return the project root directory. + + Walks up from this file until we find pyproject.toml. + """ + current: Path = Path(__file__).resolve() + for parent in current.parents: + if (parent / "pyproject.toml").exists(): + return parent + raise RuntimeError("Could not find project root (no pyproject.toml found)") + + +def _skip_if_helm_missing(context: Context, *, reason: str) -> bool: + """Skip the current scenario when Helm CLI is unavailable. + + Returns ``True`` when Helm is missing (scenario was skipped). + """ + if shutil.which("helm") is None: + context.scenario.skip(reason) + return True + return False + + +def _render_chart(context: Context, *, set_overrides: list[str] | None = None) -> None: + """Run ``helm template`` and store the subprocess result on context.""" + if _skip_if_helm_missing( + context, + reason="Helm CLI is required for rendered Helm behavior scenarios", + ): + context.helm_render_result = None + return + + command: list[str] = [ + "helm", + "template", + "cleveragents", + str(context.project_root / "k8s"), + "--dependency-update", + ] + for item in set_overrides or []: + command.extend(["--set", item]) + + context.helm_render_result = subprocess.run( + command, + cwd=str(context.project_root), + capture_output=True, + text=True, + check=False, + ) + + +@given("the project root directory") +def step_given_project_root(context: Context) -> None: + context.project_root = _project_root() + + +@when("I read the Helm chart metadata") +def step_read_chart_metadata(context: Context) -> None: + chart_path: Path = context.project_root / "k8s" / "Chart.yaml" + assert chart_path.exists(), f"Chart.yaml not found at {chart_path}" + with open(chart_path, encoding="utf-8") as f: + context.chart_metadata = yaml.safe_load(f) + + +@then('the chart API version should be "{version}"') +def step_chart_api_version(context: Context, version: str) -> None: + assert context.chart_metadata.get("apiVersion") == version + + +@then('the chart name should be "{name}"') +def step_chart_name(context: Context, name: str) -> None: + assert context.chart_metadata.get("name") == name + + +@then('the chart type should be "{chart_type}"') +def step_chart_type(context: Context, chart_type: str) -> None: + assert context.chart_metadata.get("type") == chart_type + + +@then("the chart version should be set") +def step_chart_version_set(context: Context) -> None: + assert "version" in context.chart_metadata + assert context.chart_metadata.get("version") + + +@then('the chart should have a dependency named "{dep_name}"') +def step_chart_has_dependency(context: Context, dep_name: str) -> None: + deps: list[dict[str, str]] = context.chart_metadata.get("dependencies", []) + dep_names: list[str] = [d.get("name", "") for d in deps] + assert dep_name in dep_names, f"Dependency '{dep_name}' not found in {dep_names}" + for dep in deps: + if dep.get("name") == dep_name: + context.matched_dependency = dep + break + + +@then('the Redis dependency should be conditional on "{condition}"') +def step_redis_dependency_condition(context: Context, condition: str) -> None: + dep: dict[str, str] = context.matched_dependency + assert dep.get("condition") == condition, ( + f"Expected condition '{condition}', got '{dep.get('condition')}'" + ) + + +@when("I read the Helm chart values") +def step_read_chart_values(context: Context) -> None: + values_path: Path = context.project_root / "k8s" / "values.yaml" + assert values_path.exists(), f"values.yaml not found at {values_path}" + with open(values_path, encoding="utf-8") as f: + context.chart_values = yaml.safe_load(f) + + +@then('the values should contain key "{key}"') +def step_values_contains_key(context: Context, key: str) -> None: + assert key in context.chart_values, ( + f"Key '{key}' not found in values.yaml. " + f"Available keys: {list(context.chart_values.keys())}" + ) + + +@then("the default replica count should be {count:d}") +def step_default_replica_count(context: Context, count: int) -> None: + assert context.chart_values.get("replicaCount") == count + + +@then("the resources should have CPU and memory limits") +def step_resources_have_limits(context: Context) -> None: + resources: dict[str, Any] = context.chart_values.get("resources", {}) + assert "limits" in resources, "Missing 'limits' in resources" + limits: dict[str, str] = resources.get("limits", {}) + assert "cpu" in limits, "Missing 'cpu' in resource limits" + assert "memory" in limits, "Missing 'memory' in resource limits" + + +@then("the resources should have CPU and memory requests") +def step_resources_have_requests(context: Context) -> None: + resources: dict[str, Any] = context.chart_values.get("resources", {}) + assert "requests" in resources, "Missing 'requests' in resources" + reqs: dict[str, str] = resources.get("requests", {}) + assert "cpu" in reqs, "Missing 'cpu' in resource requests" + assert "memory" in reqs, "Missing 'memory' in resource requests" + + +@then("the ingress should have an enabled toggle") +def step_ingress_has_enabled(context: Context) -> None: + ingress: dict[str, Any] = context.chart_values.get("ingress", {}) + assert "enabled" in ingress, "Missing 'enabled' toggle in ingress" + assert isinstance(ingress.get("enabled"), bool), "'enabled' should be a boolean" + + +@then("the ingress should have a TLS configuration section") +def step_ingress_has_tls(context: Context) -> None: + ingress: dict[str, Any] = context.chart_values.get("ingress", {}) + assert "tls" in ingress, "Missing 'tls' section in ingress" + + +@then("the Redis configuration should have an enabled toggle") +def step_redis_has_enabled(context: Context) -> None: + redis: dict[str, Any] = context.chart_values.get("redis", {}) + assert "enabled" in redis, "Missing 'enabled' toggle in redis" + assert isinstance(redis.get("enabled"), bool), "'enabled' should be a boolean" + + +@then("the Redis configuration should have authentication settings") +def step_redis_has_auth(context: Context) -> None: + redis: dict[str, Any] = context.chart_values.get("redis", {}) + assert "auth" in redis, "Missing 'auth' section in redis" + auth: dict[str, Any] = redis.get("auth", {}) + assert "enabled" in auth, "Missing 'enabled' in redis auth" + + +@then("the server config should have host setting") +def step_server_has_host(context: Context) -> None: + server: dict[str, Any] = context.chart_values.get("server", {}) + assert "host" in server, "Missing 'host' in server config" + + +@then("the server config should have port setting") +def step_server_has_port(context: Context) -> None: + server: dict[str, Any] = context.chart_values.get("server", {}) + assert "port" in server, "Missing 'port' in server config" + + +@then("the server config should have workers setting") +def step_server_has_workers(context: Context) -> None: + server: dict[str, Any] = context.chart_values.get("server", {}) + assert "workers" in server, "Missing 'workers' in server config" + + +@then("the database config should support existing secrets") +def step_database_supports_secrets(context: Context) -> None: + database: dict[str, Any] = context.chart_values.get("database", {}) + assert "existingSecret" in database, "Missing 'existingSecret' in database config" + assert "existingSecretKey" in database, ( + "Missing 'existingSecretKey' in database config" + ) + + +@then("the pod security context should enforce non-root execution") +def step_pod_security_nonroot(context: Context) -> None: + pod_sc: dict[str, Any] = context.chart_values.get("podSecurityContext", {}) + assert pod_sc.get("runAsNonRoot") is True, ( + "podSecurityContext.runAsNonRoot should be true" + ) + assert pod_sc.get("runAsUser") == 1000, ( + "podSecurityContext.runAsUser should be 1000" + ) + + +@then("the container security context should drop all capabilities") +def step_container_security_caps(context: Context) -> None: + sc: dict[str, Any] = context.chart_values.get("securityContext", {}) + caps: dict[str, list[str]] = sc.get("capabilities", {}) + assert "ALL" in caps.get("drop", []), "securityContext should drop ALL capabilities" + + +@then("the container security context should have a seccomp profile") +def step_container_security_seccomp(context: Context) -> None: + sc: dict[str, Any] = context.chart_values.get("securityContext", {}) + seccomp: dict[str, str] = sc.get("seccompProfile", {}) + assert seccomp.get("type") == "RuntimeDefault", ( + f"Expected seccompProfile type 'RuntimeDefault', got '{seccomp.get('type')}'" + ) + + +@then("the container security context should set readOnlyRootFilesystem to true") +def step_container_security_readonly_root(context: Context) -> None: + sc: dict[str, Any] = context.chart_values.get("securityContext", {}) + assert sc.get("readOnlyRootFilesystem") is True, ( + "securityContext.readOnlyRootFilesystem should be true" + ) + + +@then("the container security context should set allowPrivilegeEscalation to false") +def step_container_security_no_privilege_escalation(context: Context) -> None: + sc: dict[str, Any] = context.chart_values.get("securityContext", {}) + assert sc.get("allowPrivilegeEscalation") is False, ( + "securityContext.allowPrivilegeEscalation should be false" + ) + + +@then('the chart file "{filepath}" should exist') +def step_chart_file_should_exist(context: Context, filepath: str) -> None: + full_path: Path = context.project_root / filepath + assert full_path.exists(), f"File not found: {full_path}" + + +@when('I read the template "{filepath}"') +def step_read_template(context: Context, filepath: str) -> None: + template_path: Path = context.project_root / filepath + assert template_path.exists(), f"Template not found at {template_path}" + context.template_content = template_path.read_text(encoding="utf-8") + + +@then('the template should contain "{text}"') +def step_template_should_contain(context: Context, text: str) -> None: + pattern: re.Pattern[str] = re.compile(re.escape(text), re.MULTILINE) + assert pattern.search(context.template_content), ( + f"Template does not contain '{text}'" + ) + + +@then('the template should match pattern "{pattern}"') +def step_template_should_match_pattern(context: Context, pattern: str) -> None: + regex: re.Pattern[str] = re.compile(pattern, re.MULTILINE) + assert regex.search(context.template_content), ( + f"Template does not match pattern '{pattern}'" + ) + + +@when("I read the server Dockerfile") +def step_read_server_dockerfile(context: Context) -> None: + dockerfile_path: Path = context.project_root / "Dockerfile.server" + assert dockerfile_path.exists(), f"Dockerfile.server not found at {dockerfile_path}" + context.dockerfile_content = dockerfile_path.read_text(encoding="utf-8") + + +@then("it should have a builder stage") +def step_dockerfile_has_builder_stage(context: Context) -> None: + pattern: re.Pattern[str] = re.compile(r"^FROM\s+.*\s+AS\s+builder", re.MULTILINE) + assert pattern.search(context.dockerfile_content), ( + "Dockerfile.server missing builder stage (expected FROM ... AS builder)" + ) + + +@then("it should have a runtime stage") +def step_dockerfile_has_runtime_stage(context: Context) -> None: + lines: list[str] = context.dockerfile_content.splitlines() + from_count: int = sum(1 for line in lines if re.match(r"^FROM\s+", line)) + assert from_count >= 2, ( + f"Expected at least 2 FROM statements for multi-stage build, found {from_count}" + ) + + +@then("it should create a non-root user with uid 1000") +def step_dockerfile_nonroot_user(context: Context) -> None: + pattern: re.Pattern[str] = re.compile(r"useradd.*-u\s+1000") + assert pattern.search(context.dockerfile_content), ( + "Dockerfile.server should create a user with uid 1000 via useradd" + ) + assert "USER appuser" in context.dockerfile_content, ( + "Dockerfile.server should switch to appuser" + ) + + +@then("it should expose port 8000") +def step_dockerfile_expose_port(context: Context) -> None: + pattern: re.Pattern[str] = re.compile(r"^EXPOSE\s+8000", re.MULTILINE) + assert pattern.search(context.dockerfile_content), ( + "Dockerfile.server should expose port 8000" + ) + + +@then("it should not use unpinned uv latest tag") +def step_dockerfile_pinned_uv(context: Context) -> None: + assert "uv:latest" not in context.dockerfile_content, ( + "Dockerfile.server should pin the uv image version, not use :latest" + ) + + +@then("the uv image reference should use a specific semver tag") +def step_dockerfile_uv_semver(context: Context) -> None: + pattern: re.Pattern[str] = re.compile(r"uv:\d+\.\d+\.\d+", re.MULTILINE) + assert pattern.search(context.dockerfile_content), ( + "Dockerfile.server should pin uv to a specific semver version " + "(e.g., uv:0.8.0), not a floating tag" + ) + + +@then("the Dockerfile should remove uv after installation") +def step_dockerfile_removes_uv(context: Context) -> None: + pattern: re.Pattern[str] = re.compile( + r"rm\s+.*(/usr/local/bin/uv|uv)", re.MULTILINE + ) + assert pattern.search(context.dockerfile_content), ( + "Dockerfile.server should remove uv from runtime image after " + "package installation to reduce attack surface" + ) + + +@when("I read the deployment README") +def step_read_deployment_readme(context: Context) -> None: + readme_path: Path = context.project_root / "k8s" / "README.md" + assert readme_path.exists(), f"k8s/README.md not found at {readme_path}" + context.readme_content = readme_path.read_text(encoding="utf-8") + + +@then("it should contain quick start instructions") +def step_readme_has_quickstart(context: Context) -> None: + assert "Quick Start" in context.readme_content, ( + "README should contain 'Quick Start' section" + ) + + +@then("it should contain TLS configuration documentation") +def step_readme_has_tls_docs(context: Context) -> None: + assert "TLS" in context.readme_content, "README should document TLS configuration" + + +@then("it should contain Redis configuration documentation") +def step_readme_has_redis_docs(context: Context) -> None: + assert "Redis" in context.readme_content, ( + "README should document Redis configuration" + ) + + +@then("it should contain scaling instructions") +def step_readme_has_scaling_docs(context: Context) -> None: + assert ( + "Scaling" in context.readme_content or "replicaCount" in context.readme_content + ), "README should document scaling instructions" + + +# --------------------------------------------------------------------------- +# Probes validation +# --------------------------------------------------------------------------- + + +@then("the liveness probe should target the live endpoint") +def step_liveness_probe_live(context: Context) -> None: + probe: dict[str, Any] = context.chart_values.get("livenessProbe", {}) + http_get: dict[str, Any] = probe.get("httpGet", {}) + assert http_get.get("path") == "/live", ( + f"Expected liveness probe path /live, got {http_get.get('path')}" + ) + + +@then("the readiness probe should target the ready endpoint") +def step_readiness_probe_ready(context: Context) -> None: + probe: dict[str, Any] = context.chart_values.get("readinessProbe", {}) + http_get: dict[str, Any] = probe.get("httpGet", {}) + assert http_get.get("path") == "/ready", ( + f"Expected readiness probe path /ready, got {http_get.get('path')}" + ) + + +# --------------------------------------------------------------------------- +# Image defaults validation +# --------------------------------------------------------------------------- + + +@then('the image pull policy should be "{policy}"') +def step_image_pull_policy(context: Context, policy: str) -> None: + image: dict[str, Any] = context.chart_values.get("image", {}) + assert image.get("pullPolicy") == policy, ( + f"Expected pullPolicy '{policy}', got '{image.get('pullPolicy')}'" + ) + + +@then("the image tag should default to empty for appVersion fallback") +def step_image_tag_default_empty(context: Context) -> None: + image: dict[str, Any] = context.chart_values.get("image", {}) + assert image.get("tag") == "", ( + f"Expected empty tag for appVersion fallback, got '{image.get('tag')}'" + ) + + +# --------------------------------------------------------------------------- +# Service defaults validation +# --------------------------------------------------------------------------- + + +@then('the service type should be "{svc_type}"') +def step_service_type(context: Context, svc_type: str) -> None: + service: dict[str, Any] = context.chart_values.get("service", {}) + assert service.get("type") == svc_type, ( + f"Expected service type '{svc_type}', got '{service.get('type')}'" + ) + + +@then("the service port should be {port:d}") +def step_service_port(context: Context, port: int) -> None: + service: dict[str, Any] = context.chart_values.get("service", {}) + assert service.get("port") == port, ( + f"Expected service port {port}, got {service.get('port')}" + ) + + +@when("I render the Helm chart without database configuration") +def step_render_without_database_configuration(context: Context) -> None: + _render_chart(context) + + +@then("the render should fail with required database configuration error") +def step_render_fails_database_required(context: Context) -> None: + result = context.helm_render_result + if result is None: + return + assert result.returncode != 0, "Expected helm template to fail without DB config" + combined_output = f"{result.stdout}\n{result.stderr}" + assert "database configuration is required" in combined_output, ( + "Expected database required fail-fast error in Helm output" + ) + + +@when("I render the Helm chart with ingress enabled and no TLS") +def step_render_with_ingress_enabled_no_tls(context: Context) -> None: + _render_chart( + context, + set_overrides=[ + "database.url=postgresql+asyncpg://user:pass@db:5432/cleveragents", + "ingress.enabled=true", + "ingress.allowInsecure=false", + ], + ) + + +@then("the render should fail with required ingress TLS configuration error") +def step_render_fails_ingress_tls_required(context: Context) -> None: + result = context.helm_render_result + if result is None: + return + assert result.returncode != 0, ( + "Expected helm template to fail when ingress is enabled without TLS" + ) + combined_output = f"{result.stdout}\n{result.stderr}" + assert "ingress.tls is required" in combined_output, ( + "Expected ingress TLS required fail-fast error in Helm output" + ) + + +@when("I render the Helm chart with ingress enabled and insecure opt-out enabled") +def step_render_with_ingress_insecure_opt_out(context: Context) -> None: + _render_chart( + context, + set_overrides=[ + "database.url=postgresql+asyncpg://user:pass@db:5432/cleveragents", + "ingress.enabled=true", + "ingress.allowInsecure=true", + ], + ) + + +@then("the ingress render should succeed in insecure dev mode") +def step_render_succeeds_insecure_ingress_opt_out(context: Context) -> None: + result = context.helm_render_result + if result is None: + return + assert result.returncode == 0, ( + "Expected helm template to succeed when ingress.allowInsecure=true" + ) + combined_output = f"{result.stdout}\n{result.stderr}" + assert "kind: Ingress" in combined_output, ( + "Expected rendered output to include an Ingress resource" + ) + + +@when("I render the Helm chart with ingress enabled and TLS configured") +def step_render_with_ingress_tls_configured(context: Context) -> None: + _render_chart( + context, + set_overrides=[ + "database.url=postgresql+asyncpg://user:pass@db:5432/cleveragents", + "ingress.enabled=true", + "ingress.allowInsecure=false", + "ingress.tls[0].secretName=cleveragents-tls", + "ingress.tls[0].hosts[0]=cleveragents.example.com", + ], + ) + + +@then("the ingress render should succeed with TLS enabled") +def step_render_succeeds_with_ingress_tls(context: Context) -> None: + result = context.helm_render_result + if result is None: + return + assert result.returncode == 0, ( + "Expected helm template to succeed when ingress TLS is configured" + ) + combined_output = f"{result.stdout}\n{result.stderr}" + assert "kind: Ingress" in combined_output, ( + "Expected rendered output to include an Ingress resource" + ) + assert "secretName: cleveragents-tls" in combined_output, ( + "Expected rendered Ingress to include configured TLS secret" + ) diff --git a/features/steps/server_cli_coverage_boost_steps.py b/features/steps/server_cli_coverage_boost_steps.py index 1938745f7..2b7d1139a 100644 --- a/features/steps/server_cli_coverage_boost_steps.py +++ b/features/steps/server_cli_coverage_boost_steps.py @@ -9,19 +9,24 @@ Targets uncovered lines in src/cleveragents/cli/commands/server.py: from __future__ import annotations import contextlib +import re from io import StringIO from typing import Any from unittest.mock import MagicMock, patch +import typer from behave import given, then, when from behave.runner import Context from rich.console import Console +from typer.testing import CliRunner from cleveragents.application.services.config_service import ( ConfigLevel, ResolvedValue, ) +_ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -284,3 +289,147 @@ def step_assert_echo_contains(context: Context, text: str) -> None: assert text.lower() in output.lower(), ( f"Expected '{text}' in echoed output:\n{output!r}" ) + + +# --------------------------------------------------------------------------- +# server_serve — uvicorn.run mapping assertions +# --------------------------------------------------------------------------- + + +@given("uvicorn run is patched for server serve assertions") +def step_patch_uvicorn_run(context: Context) -> None: + uvicorn_run_patcher = patch( + "cleveragents.cli.commands.server.uvicorn_run", + ) + context.uvicorn_run_patcher = uvicorn_run_patcher + context.uvicorn_run_mock = uvicorn_run_patcher.start() + context.add_cleanup(uvicorn_run_patcher.stop) + + +@given("uvicorn run is unavailable for server serve assertions") +def step_patch_uvicorn_unavailable(context: Context) -> None: + uvicorn_run_patcher = patch( + "cleveragents.cli.commands.server.uvicorn_run", + None, + ) + context.uvicorn_run_patcher = uvicorn_run_patcher + uvicorn_run_patcher.start() + context.add_cleanup(uvicorn_run_patcher.stop) + + +@when( + 'I call server_serve with app "{app_target}" host "{host}" port {port:d} workers {workers:d} and log level "{log_level}"' +) +def step_call_server_serve_explicit( + context: Context, + app_target: str, + host: str, + port: int, + workers: int, + log_level: str, +) -> None: + from cleveragents.cli.commands import server as server_commands + + server_commands.server_serve( + app_target=app_target, + host=host, + port=port, + workers=workers, + log_level=log_level, + ) + + +@when("I call server_serve with default arguments") +def step_call_server_serve_defaults(context: Context) -> None: + from cleveragents.cli.commands import server as server_commands + + server_commands.server_serve() + + +@when("I call server_serve with default arguments and capture failure") +def step_call_server_serve_defaults_capture_failure(context: Context) -> None: + from cleveragents.cli.commands import server as server_commands + + buf = StringIO() + capture_console = Console(file=buf, width=200, no_color=True) + + context.server_serve_exit = None + with patch("cleveragents.cli.commands.server.console", capture_console): + try: + server_commands.server_serve() + except typer.Exit as exc: + context.server_serve_exit = exc + + context.captured_console_output = buf.getvalue() + + +@when('I invoke server_serve through the CLI with log level "{log_level}"') +def step_invoke_server_serve_cli(context: Context, log_level: str) -> None: + from cleveragents.cli.commands import server as server_commands + + runner = CliRunner() + context.cli_result = runner.invoke( + server_commands.app, + ["serve", "--log-level", log_level], + ) + + +@when('I invoke server_serve through the CLI with invalid log level "{log_level}"') +def step_invoke_server_serve_cli_invalid(context: Context, log_level: str) -> None: + from cleveragents.cli.commands import server as server_commands + + runner = CliRunner() + context.cli_result = runner.invoke( + server_commands.app, + ["serve", "--log-level", log_level], + ) + + +@then( + 'uvicorn run should be called with app "{app_target}" host "{host}" port {port:d} workers {workers:d} and log level "{log_level}"' +) +def step_assert_server_serve_mapping( + context: Context, + app_target: str, + host: str, + port: int, + workers: int, + log_level: str, +) -> None: + run_mock = context.uvicorn_run_mock + run_mock.assert_called_once_with( + app_target, + host=host, + port=port, + workers=workers, + log_level=log_level, + ) + + +@then("server_serve should exit with code {code:d}") +def step_assert_server_serve_exit_code(context: Context, code: int) -> None: + exit_exc = getattr(context, "server_serve_exit", None) + assert isinstance(exit_exc, typer.Exit), ( + f"Expected typer.Exit, got {type(exit_exc).__name__}: {exit_exc!r}" + ) + exit_code = exit_exc.exit_code + assert exit_code is not None, "Expected typer.Exit to carry an exit code" + assert exit_code == code, f"Expected exit code {code}, got {exit_code}" + + +@then("the CLI invocation should fail with exit code {code:d}") +def step_assert_cli_exit_code(context: Context, code: int) -> None: + result = context.cli_result + assert result.exit_code == code, ( + f"Expected CLI exit code {code}, got {result.exit_code}. " + f"Output:\n{result.output}" + ) + + +@then('the server CLI invocation output should contain "{text}"') +def step_assert_cli_output_contains(context: Context, text: str) -> None: + result = context.cli_result + normalized_output = _ANSI_ESCAPE_RE.sub("", result.output) + assert text in normalized_output, ( + f"Expected '{text}' in CLI output, got:\n{result.output!r}" + ) diff --git a/k8s/.helmignore b/k8s/.helmignore new file mode 100644 index 000000000..18eb92f5f --- /dev/null +++ b/k8s/.helmignore @@ -0,0 +1,16 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). +.DS_Store +*.swp +*.bak +*.tmp +*.orig +*~ +.git/ +.gitignore +.project +.idea/ +*.tmproj +.vscode/ +README.md diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml new file mode 100644 index 000000000..9fa50b1c8 --- /dev/null +++ b/k8s/Chart.yaml @@ -0,0 +1,26 @@ +apiVersion: v2 +name: cleveragents +description: A Helm chart for deploying CleverAgents server to Kubernetes +type: application +version: 0.1.0 +# NOTE: appVersion tracks the application version, not the chart version. +# Update this when the server application version changes. Currently +# set to "1.0.0" as a placeholder until the project defines a formal +# server release versioning scheme. +appVersion: "1.0.0" +keywords: + - cleveragents + - ai + - agents + - a2a +home: https://cleverthis.com/cleveragents +sources: + - https://git.cleverthis.com/cleveragents/cleveragents-core +maintainers: + - name: CleverThis Engineering + email: engineering@cleverthis.com +dependencies: + - name: redis + version: "~18.19.0" + repository: https://charts.bitnami.com/bitnami + condition: redis.enabled diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 000000000..950b8a80e --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,250 @@ +# CleverAgents Kubernetes Deployment + +This directory contains the Helm chart for deploying the CleverAgents server to +Kubernetes. The chart provisions a Deployment, Service, and optional Ingress with +TLS termination, following the architecture described in the project specification. + +## Prerequisites + +- Kubernetes cluster (v1.24+) +- Helm 3.x +- Container image built from `Dockerfile.server` at the repository root +- PostgreSQL database (managed or self-hosted) for server persistence +- (Optional) Redis for multi-instance session affinity and rate limiting + +## Helm Dependency Setup + +This chart declares Redis as an optional Helm dependency. Before running +`helm lint`, `helm template`, `helm install`, or `helm upgrade`, prepare chart +dependencies: + +```bash +# Rebuild from Chart.lock/charts if present +helm dependency build ./k8s + +# Or refresh dependency versions from upstream repositories +helm dependency update ./k8s +``` + +## Quick Start + +### 1. Build the Server Image + +```bash +docker build -f Dockerfile.server -t cleveragents/server:1.0.0 . +``` + +Then configure the chart to use the same tag: + +```bash +helm upgrade --install cleveragents ./k8s \ + --set image.repository=cleveragents/server \ + --set image.tag=1.0.0 +``` + +### 2. Install the Chart + +Build/update dependencies first: + +```bash +helm dependency build ./k8s +``` + +Create a Kubernetes Secret for the database URL (recommended): + +```bash +kubectl create secret generic cleveragents-db \ + --from-literal=database-url="postgresql+asyncpg://user:pass@db-host:5432/cleveragents" +``` + +Install the chart referencing that secret: + +```bash +helm install cleveragents ./k8s \ + --set database.existingSecret=cleveragents-db \ + --set database.existingSecretKey=database-url +``` + +### 3. Verify the Deployment + +```bash +kubectl get pods -l app.kubernetes.io/name=cleveragents +kubectl get svc cleveragents +``` + +## Configuration + +All configuration is managed through `values.yaml`. Override values at install +time using `--set` flags or a custom values file (`-f custom-values.yaml`). + +### Key Configuration Options + +| Parameter | Description | Default | +|---|---|---| +| `replicaCount` | Number of server replicas | `1` | +| `image.repository` | Container image repository | `cleveragents/server` | +| `image.tag` | Image tag (defaults to appVersion) | `""` | +| `resources.limits.cpu` | CPU limit | `500m` | +| `resources.limits.memory` | Memory limit | `512Mi` | +| `resources.requests.cpu` | CPU request | `100m` | +| `resources.requests.memory` | Memory request | `128Mi` | +| `ingress.enabled` | Enable Ingress | `false` | +| `ingress.allowInsecure` | Allow HTTP-only ingress (dev-only) | `false` | +| `ingress.className` | Ingress class name | `""` | +| `ingress.tls` | TLS configuration | `[]` | +| `redis.enabled` | Enable Redis subchart | `false` | +| `database.url` | PostgreSQL connection URL | `""` | +| `database.existingSecret` | Secret name for DB URL | `""` | +| `server.workers` | Uvicorn worker count | `1` | +| `server.logLevel` | Server log level | `info` | + +### Health Probes + +The chart uses separate probe endpoints by default: + +- **Liveness** (`livenessProbe.httpGet.path`): `/live` +- **Readiness** (`readinessProbe.httpGet.path`): `/ready` + +This split lets operators configure independent probe timing and failure +thresholds. In the current implementation, readiness is process-level and does +not yet perform dependency checks (for example live DB/Redis connectivity). + +### Scaling + +Scale the deployment by adjusting `replicaCount`: + +```bash +helm dependency build ./k8s +helm upgrade cleveragents ./k8s --set replicaCount=3 +``` + +When running multiple replicas, enable Redis for session affinity: + +```bash +helm dependency build ./k8s +helm upgrade cleveragents ./k8s \ + --set replicaCount=3 \ + --set redis.enabled=true \ + --set redis.auth.existingSecret=cleveragents-redis \ + --set redis.auth.existingSecretPasswordKey=redis-password +``` + +### TLS Configuration + +Enable Ingress with TLS termination for production deployments: + +```bash +helm dependency build ./k8s +helm upgrade cleveragents ./k8s \ + --set ingress.enabled=true \ + --set ingress.className=nginx \ + --set ingress.hosts[0].host=cleveragents.example.com \ + --set ingress.hosts[0].paths[0].path=/ \ + --set ingress.hosts[0].paths[0].pathType=Prefix \ + --set ingress.tls[0].secretName=cleveragents-tls \ + --set ingress.tls[0].hosts[0]=cleveragents.example.com \ + --set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod +``` + +TLS termination occurs at the Kubernetes Ingress controller level. The +CleverAgents server itself communicates over plain HTTP within the cluster. + +By default, the chart **fails rendering** if `ingress.enabled=true` and +`ingress.tls` is not configured. For local-only development, you can explicitly +opt out by setting `ingress.allowInsecure=true`. + +```bash +helm dependency build ./k8s +helm upgrade cleveragents ./k8s \ + --set ingress.enabled=true \ + --set ingress.allowInsecure=true +``` + +### Database Configuration + +The server requires a database connection. For production, use a Kubernetes +Secret and set `database.existingSecret`. + +The chart requires **one** of: + +- `database.existingSecret` (recommended) +- `database.url` (allowed for local/dev convenience) + +Examples: + +```yaml +# Direct URL +database: + url: "postgresql+asyncpg://user:password@postgres-host:5432/cleveragents" + +# Using an existing Secret +database: + existingSecret: "cleveragents-db-credentials" + existingSecretKey: "database-url" +``` + +### Redis (Optional) + +Redis support in this chart is currently **infrastructure scaffolding**: + +- The chart can provision Redis (Bitnami subchart) and inject Redis-related + environment variables into the server Deployment. +- The current server runtime does **not** yet implement Redis-backed session + affinity/rate-limiting behavior end-to-end. + +Use this section to provision Redis infrastructure now; runtime integration is a +follow-up capability. + +Redis is deployed as a subchart from the Bitnami Helm repository: + +```yaml +redis: + enabled: true + architecture: standalone + auth: + enabled: true + existingSecret: "redis-credentials" + existingSecretPasswordKey: "redis-password" + master: + persistence: + enabled: true + size: 2Gi +``` + +## Resource Limits + +Default resource limits are conservative. Adjust based on expected load: + +```yaml +resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi +``` + +## Architecture + +The Helm chart deploys the following Kubernetes resources: + +- **Deployment**: Runs the CleverAgents ASGI server with configurable replicas + and writable volumes for `/tmp` and `/app/data` +- **Service**: ClusterIP service exposing the server port (8000) +- **Ingress** (optional): Routes external traffic with TLS termination +- **ConfigMap**: Server configuration environment variables +- **ServiceAccount**: Dedicated service account for pod identity +- **Secrets**: Auto-generated secrets for database URL and Redis password + (when `existingSecret` is not configured) +- **Redis** (optional): Bitnami Redis subchart for session affinity + +The server container runs as a non-root user (`appuser`, uid 1000) with a +read-only root filesystem for security hardening. Writable `emptyDir` volumes +are mounted at `/tmp` and `/app/data` to support runtime operations. + +## Uninstalling + +```bash +helm uninstall cleveragents +``` diff --git a/k8s/templates/NOTES.txt b/k8s/templates/NOTES.txt new file mode 100644 index 000000000..f830073ac --- /dev/null +++ b/k8s/templates/NOTES.txt @@ -0,0 +1,34 @@ +CleverAgents Server has been deployed! + +{{- if .Values.ingress.enabled }} + +The server is accessible via Ingress: +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} + +{{- if .Values.ingress.tls }} +TLS termination is configured at the Ingress controller. +{{- else }} +⚠️ Insecure ingress mode is enabled (`ingress.allowInsecure=true`). +This mode is for local/dev use only and is not suitable for production. +{{- end }} + +{{- else }} + +To access the server, use port-forward: + + kubectl port-forward svc/{{ include "cleveragents.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + +Then access: http://localhost:{{ .Values.service.port }} + +{{- end }} + +{{- if .Values.redis.enabled }} + +Redis is enabled for session affinity and rate limiting. +{{- end }} + +For more information, see the deployment README in k8s/README.md. diff --git a/k8s/templates/_helpers.tpl b/k8s/templates/_helpers.tpl new file mode 100644 index 000000000..d1ecd1aa0 --- /dev/null +++ b/k8s/templates/_helpers.tpl @@ -0,0 +1,72 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "cleveragents.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited +to this (by the DNS naming spec). If release name contains chart name +it will be used as a full name. +*/}} +{{- define "cleveragents.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "cleveragents.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "cleveragents.labels" -}} +helm.sh/chart: {{ include "cleveragents.chart" . }} +{{ include "cleveragents.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "cleveragents.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cleveragents.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use. +*/}} +{{- define "cleveragents.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cleveragents.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Redis host helper - returns the Redis service hostname. +*/}} +{{- define "cleveragents.redisHost" -}} +{{- if .Values.redis.enabled }} +{{- printf "%s-redis-master" .Release.Name }} +{{- end }} +{{- end }} diff --git a/k8s/templates/configmap.yaml b/k8s/templates/configmap.yaml new file mode 100644 index 000000000..6d99822de --- /dev/null +++ b/k8s/templates/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cleveragents.fullname" . }}-config + labels: + {{- include "cleveragents.labels" . | nindent 4 }} +data: + # NOTE: The Deployment template overrides these values via CLI args + # (command/args in deployment.yaml). Uvicorn CLI args take precedence + # over environment variables. These ConfigMap entries serve as a + # fallback for any code paths that read env vars directly (e.g., + # Settings model defaults) rather than relying on CLI args. + CLEVERAGENTS_SERVER_HOST: {{ .Values.server.host | quote }} + CLEVERAGENTS_SERVER_PORT: {{ .Values.server.port | quote }} + CLEVERAGENTS_LOG_LEVEL: {{ .Values.server.logLevel | quote }} diff --git a/k8s/templates/deployment.yaml b/k8s/templates/deployment.yaml new file mode 100644 index 000000000..1ee3fc911 --- /dev/null +++ b/k8s/templates/deployment.yaml @@ -0,0 +1,137 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cleveragents.fullname" . }} + labels: + {{- include "cleveragents.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "cleveragents.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cleveragents.selectorLabels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cleveragents.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["python", "-m", "cleveragents"] + args: + - "server" + - "serve" + - "--app" + - "cleveragents.a2a.asgi:app" + - "--host" + - {{ .Values.server.host | quote }} + - "--port" + - {{ .Values.server.port | quote }} + - "--workers" + - {{ .Values.server.workers | quote }} + - "--log-level" + - {{ .Values.server.logLevel | quote }} + ports: + - name: http + containerPort: {{ .Values.server.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cleveragents.fullname" . }}-config + env: + {{- if .Values.database.existingSecret }} + - name: CLEVERAGENTS_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.database.existingSecret }} + key: {{ .Values.database.existingSecretKey }} + {{- else if .Values.database.url }} + - name: CLEVERAGENTS_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "cleveragents.fullname" . }}-db + key: database-url + {{- else }} + {{- fail "database configuration is required: set database.existingSecret or database.url" }} + {{- end }} + {{- if .Values.redis.enabled }} + - name: CLEVERAGENTS_REDIS_HOST + value: {{ include "cleveragents.redisHost" . }} + - name: CLEVERAGENTS_REDIS_PORT + value: "6379" + {{- if .Values.redis.auth.enabled }} + {{- if .Values.redis.auth.existingSecret }} + - name: CLEVERAGENTS_REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.redis.auth.existingSecret }} + key: {{ .Values.redis.auth.existingSecretPasswordKey }} + {{- else if .Values.redis.auth.password }} + - name: CLEVERAGENTS_REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "cleveragents.fullname" . }}-redis + key: redis-password + {{- else }} + - name: CLEVERAGENTS_REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-redis + key: redis-password + {{- end }} + {{- end }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + - name: app-data + mountPath: /app/data + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: tmp + emptyDir: + sizeLimit: 100Mi + - name: app-data + emptyDir: + sizeLimit: 1Gi + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/k8s/templates/ingress.yaml b/k8s/templates/ingress.yaml new file mode 100644 index 000000000..9cc3afcc0 --- /dev/null +++ b/k8s/templates/ingress.yaml @@ -0,0 +1,53 @@ +{{- if .Values.ingress.enabled -}} +{{- if and (not .Values.ingress.allowInsecure) (not .Values.ingress.tls) -}} +{{- fail "ingress.tls is required when ingress.enabled=true (set ingress.allowInsecure=true only for local/dev)" -}} +{{- end }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cleveragents.fullname" . }} + labels: + {{- include "cleveragents.labels" . | nindent 4 }} + {{- if or .Values.ingress.tls .Values.ingress.annotations }} + annotations: + {{- if and .Values.ingress.tls (not (hasKey (default dict .Values.ingress.annotations) "nginx.ingress.kubernetes.io/ssl-redirect")) }} + # NOTE: This annotation is specific to the NGINX Ingress Controller. + # If using a different controller (e.g., Traefik, HAProxy), adjust + # or remove this annotation accordingly. Omitted when the user + # explicitly sets the annotation in ingress.annotations. + nginx.ingress.kubernetes.io/ssl-redirect: "true" + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - secretName: {{ .secretName }} + hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "cleveragents.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/k8s/templates/secrets.yaml b/k8s/templates/secrets.yaml new file mode 100644 index 000000000..2ddd3eb85 --- /dev/null +++ b/k8s/templates/secrets.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.database.url (not .Values.database.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cleveragents.fullname" . }}-db + labels: + {{- include "cleveragents.labels" . | nindent 4 }} +type: Opaque +stringData: + database-url: {{ .Values.database.url | quote }} +{{- end }} +{{- if and .Values.redis.enabled .Values.redis.auth.enabled .Values.redis.auth.password (not .Values.redis.auth.existingSecret) }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cleveragents.fullname" . }}-redis + labels: + {{- include "cleveragents.labels" . | nindent 4 }} +type: Opaque +stringData: + redis-password: {{ .Values.redis.auth.password | quote }} +{{- end }} diff --git a/k8s/templates/service.yaml b/k8s/templates/service.yaml new file mode 100644 index 000000000..7279cc643 --- /dev/null +++ b/k8s/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cleveragents.fullname" . }} + labels: + {{- include "cleveragents.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cleveragents.selectorLabels" . | nindent 4 }} diff --git a/k8s/templates/serviceaccount.yaml b/k8s/templates/serviceaccount.yaml new file mode 100644 index 000000000..160523510 --- /dev/null +++ b/k8s/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +automountServiceAccountToken: false +metadata: + name: {{ include "cleveragents.serviceAccountName" . }} + labels: + {{- include "cleveragents.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/k8s/values.yaml b/k8s/values.yaml new file mode 100644 index 000000000..1d7c08a18 --- /dev/null +++ b/k8s/values.yaml @@ -0,0 +1,182 @@ +# CleverAgents Server Helm Chart - Default Values +# See k8s/README.md for deployment instructions. + +# -- Number of server pod replicas +replicaCount: 1 + +image: + # -- Container image repository + repository: cleveragents/server + # -- Image pull policy + pullPolicy: IfNotPresent + # -- Image tag (defaults to Chart appVersion) + tag: "" + +# -- Image pull secrets for private registries +imagePullSecrets: [] +# -- Override the chart name +nameOverride: "" +# -- Override the full release name +fullnameOverride: "" + +serviceAccount: + # -- Create a ServiceAccount + create: true + # -- Annotations for the ServiceAccount + annotations: {} + # -- ServiceAccount name (generated if not set) + name: "" + +# -- Pod-level annotations +podAnnotations: {} + +# -- Pod-level security context +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + +# -- Container-level security context +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + +service: + # -- Kubernetes Service type + type: ClusterIP + # -- Service port + port: 8000 + +ingress: + # -- Enable Ingress resource + enabled: false + # -- Allow insecure HTTP ingress without TLS (dev-only opt-out) + # When false (default), enabling ingress without tls causes template failure. + allowInsecure: false + # -- Ingress class name (e.g. nginx, traefik) + className: "" + # -- Ingress annotations + # When TLS is configured, ssl-redirect is automatically enabled. + # Add additional annotations here as needed. + annotations: {} + # cert-manager.io/cluster-issuer: "letsencrypt-prod" + hosts: + - host: cleveragents.example.com + paths: + - path: / + pathType: Prefix + # -- TLS configuration for Ingress + # HTTPS is required for all production deployments (spec requirement). + # Each entry specifies a TLS certificate and the hosts it covers. + # TLS termination happens at the Ingress controller level. + tls: [] + # - secretName: cleveragents-tls + # hosts: + # - cleveragents.example.com + +resources: + # -- Resource limits for the server container + limits: + cpu: 500m + memory: 512Mi + # -- Resource requests for the server container + requests: + cpu: 100m + memory: 128Mi + +# -- Node selector constraints +nodeSelector: {} + +# -- Pod tolerations +tolerations: [] + +# -- Pod affinity rules +affinity: {} + +# -- Liveness probe configuration +livenessProbe: + httpGet: + path: /live + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + +# -- Readiness probe configuration +readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +# -- Server configuration +# NOTE: The default port (8000) differs from the settings.py default +# (8080). This is intentional: Kubernetes deployments use 8000 as a +# common convention, and the Deployment template passes the port via +# CLI args which override the Settings model default. +server: + # -- ASGI server host binding + host: "0.0.0.0" + # -- ASGI server port + port: 8000 + # -- Number of uvicorn worker processes + workers: 1 + # -- Log level for the server + logLevel: "info" + +# -- Database configuration (PostgreSQL) +database: + # -- PostgreSQL connection URL + # Uses the format: postgresql+asyncpg://user:password@host:port/dbname + # WARNING: For production, use existingSecret instead of url to avoid + # storing credentials in Helm values. When url is set without + # existingSecret, a Kubernetes Secret is auto-generated. + url: "" + # -- Use existing secret for database URL + existingSecret: "" + # -- Key in existing secret containing the database URL + existingSecretKey: "database-url" + +# -- Redis configuration (optional, for multi-instance session affinity) +redis: + # -- Enable Redis for session affinity and rate limiting + enabled: false + # -- Redis architecture (standalone or replication) + architecture: standalone + auth: + # -- Enable Redis authentication + enabled: true + # -- Redis password (use existingSecret in production) + # WARNING: For production, use existingSecret instead of password to + # avoid storing credentials in Helm values. When password is set + # without existingSecret, a Kubernetes Secret is auto-generated. + password: "" + # -- Use existing secret for Redis password + existingSecret: "" + existingSecretPasswordKey: "redis-password" + master: + persistence: + # -- Enable Redis persistence + enabled: false + # -- Redis persistence storage size + size: 1Gi + +# -- Extra environment variables for the server container +extraEnv: [] + # - name: CLEVERAGENTS_LOG_FORMAT + # value: "json" + +# -- Extra volume mounts for the server container +extraVolumeMounts: [] + +# -- Extra volumes for the pod +extraVolumes: [] diff --git a/noxfile.py b/noxfile.py index 37ed52196..de12b5ee2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -938,6 +938,10 @@ def security_scan(session: nox.Session): - Vulture >=80% confidence: hard fail """ session.install("-e", ".[dev]") + # Python 3.13 no longer bundles pkg_resources. Semgrep transitively imports + # it via opentelemetry instrumentation, so pin setuptools to a version that + # still provides pkg_resources. + session.install("setuptools<81") # Ensure output directory exists (CI starts with a clean checkout) os.makedirs("build", exist_ok=True) diff --git a/robot/helper_k8s_helm_chart.py b/robot/helper_k8s_helm_chart.py new file mode 100644 index 000000000..9a60e75fb --- /dev/null +++ b/robot/helper_k8s_helm_chart.py @@ -0,0 +1,678 @@ +"""Helper script for Kubernetes Helm chart Robot Framework integration tests. + +Provides validation functions for Helm chart structure and content, +focusing on cross-file consistency checks that verify interactions +between multiple chart files (templates, values, Dockerfile). +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import yaml + + +def _k8s_dir() -> Path: + """Return the k8s/ directory path.""" + return Path(__file__).resolve().parent.parent / "k8s" + + +def _render_assertions_required() -> bool: + """Return whether rendered Helm assertions are required in this run.""" + value = os.environ.get("CLEVERAGENTS_REQUIRE_HELM_RENDER_ASSERTIONS", "").lower() + return value in {"1", "true", "yes", "on"} + + +def _render_chart(set_overrides: list[str] | None = None) -> str | None: + """Render the chart with ``helm template`` when Helm is available. + + Returns rendered YAML text, or ``None`` when Helm is unavailable in the + current environment. + """ + if shutil.which("helm") is None: + if _render_assertions_required(): + raise AssertionError( + "Helm CLI is required when " + "CLEVERAGENTS_REQUIRE_HELM_RENDER_ASSERTIONS=true" + ) + return None + + command: list[str] = [ + "helm", + "template", + "cleveragents", + str(_k8s_dir()), + "--dependency-update", + "--set", + # Deployment template now requires explicit DB configuration. + "database.url=postgresql+asyncpg://user:pass@db:5432/cleveragents", + ] + if set_overrides: + for item in set_overrides: + command.extend(["--set", item]) + + result = subprocess.run( + command, + cwd=str(_k8s_dir().parent), + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + "helm template failed: " + f"command={' '.join(command)}\n" + f"stdout={result.stdout}\n" + f"stderr={result.stderr}" + ) + return result.stdout + + +def _parse_rendered_resources(rendered: str) -> list[dict[str, Any]]: + """Parse rendered multi-doc YAML into Kubernetes resource dictionaries.""" + resources: list[dict[str, Any]] = [] + for document in rendered.split("\n---\n"): + if not document.strip(): + continue + loaded = yaml.safe_load(document) + if isinstance(loaded, dict): + resources.append(loaded) + return resources + + +def _find_resource( + resources: list[dict[str, Any]], + *, + kind: str, + name: str, +) -> dict[str, Any]: + for resource in resources: + metadata = resource.get("metadata", {}) + if resource.get("kind") == kind and metadata.get("name") == name: + return resource + raise AssertionError(f"Rendered resource not found: kind={kind}, name={name}") + + +def _deployment_env_entries(resources: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return rendered deployment env entries for the main container.""" + deployment = _find_resource(resources, kind="Deployment", name="cleveragents") + env_entries = deployment["spec"]["template"]["spec"]["containers"][0].get("env", []) + return [entry for entry in env_entries if isinstance(entry, dict)] + + +def _env_entry_by_name( + env_entries: list[dict[str, Any]], env_name: str +) -> dict[str, Any]: + """Return a single environment variable entry by name.""" + for entry in env_entries: + if entry.get("name") == env_name: + return entry + raise AssertionError(f"Rendered env entry not found: {env_name}") + + +def validate_cross_file_port_consistency() -> str: + """Validate port values are consistent across templates and values. + + Cross-file consistency check: verifies that the Deployment template + references server.port for containerPort, that values.yaml service.port + matches server.port, and that the Service template uses the named port. + """ + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + service_port: int = data["service"]["port"] + server_port: int = data["server"]["port"] + assert service_port == server_port, ( + f"service.port ({service_port}) != server.port ({server_port})" + ) + # Verify deployment template uses server.port for containerPort + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert ".Values.server.port" in deployment_content, ( + "Deployment containerPort should reference .Values.server.port" + ) + # Cross-file: service.yaml must use the named port "http" as targetPort + service_path: Path = _k8s_dir() / "templates" / "service.yaml" + service_content: str = service_path.read_text(encoding="utf-8") + assert "targetPort: http" in service_content, ( + "Service targetPort should reference the named port 'http'" + ) + # Cross-file: Dockerfile EXPOSE port should match values default + dockerfile_path: Path = _k8s_dir().parent / "Dockerfile.server" + dockerfile_content: str = dockerfile_path.read_text(encoding="utf-8") + expose_pattern: re.Pattern[str] = re.compile( + rf"^EXPOSE\s+{server_port}$", re.MULTILINE + ) + assert expose_pattern.search(dockerfile_content), ( + f"Dockerfile EXPOSE should match server.port ({server_port})" + ) + return "Port consistency OK" + + +def validate_secrets_structure() -> str: + """Validate secrets template references match values.yaml structure. + + Cross-file check: verifies the secrets template references the same + keys that values.yaml defines for database and Redis credentials, and + that the deployment template references the same secret names. + """ + secrets_path: Path = _k8s_dir() / "templates" / "secrets.yaml" + secrets_content: str = secrets_path.read_text(encoding="utf-8") + assert "database.url" in secrets_content, "Secrets should reference database.url" + assert "database.existingSecret" in secrets_content, ( + "Secrets should check database.existingSecret" + ) + assert "redis.auth.password" in secrets_content, ( + "Secrets should reference redis.auth.password" + ) + assert "redis.auth.existingSecret" in secrets_content, ( + "Secrets should check redis.auth.existingSecret" + ) + # Cross-file: deployment.yaml should reference the same secret keys + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert "database-url" in deployment_content, ( + "Deployment should reference database-url secret key" + ) + assert "redis-password" in deployment_content, ( + "Deployment should reference redis-password secret key" + ) + # Cross-file: values.yaml should define the keys the secrets reference + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + values: dict[str, Any] = yaml.safe_load(f) + assert "url" in values["database"], "values.yaml missing database.url" + assert "existingSecret" in values["database"], ( + "values.yaml missing database.existingSecret" + ) + assert "password" in values["redis"]["auth"], ( + "values.yaml missing redis.auth.password" + ) + assert "existingSecret" in values["redis"]["auth"], ( + "values.yaml missing redis.auth.existingSecret" + ) + return "Secrets structure OK" + + +def validate_deployment_command_args() -> str: + """Validate Deployment template command/args match Dockerfile entrypoint. + + Cross-file check: verifies the Deployment template passes server.host, + server.port, server.workers, and server.logLevel as CLI args to the + CleverAgents server command, and that the command matches Dockerfile. + """ + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert "server.host" in deployment_content, "Deployment should pass server.host" + assert "server.port" in deployment_content, "Deployment should pass server.port" + assert "server.workers" in deployment_content, ( + "Deployment should pass server.workers" + ) + assert "server.logLevel" in deployment_content, ( + "Deployment should pass server.logLevel" + ) + assert 'command: ["python", "-m", "cleveragents"]' in deployment_content, ( + "Deployment command should invoke python -m cleveragents" + ) + assert '"server"' in deployment_content, "Deployment args should include server" + assert '"serve"' in deployment_content, "Deployment args should include serve" + assert '"--app"' in deployment_content, "Deployment args should include --app" + # Cross-file: Dockerfile should also use cleveragents entrypoint + dockerfile_path: Path = _k8s_dir().parent / "Dockerfile.server" + dockerfile_content: str = dockerfile_path.read_text(encoding="utf-8") + assert 'ENTRYPOINT ["python", "-m", "cleveragents"]' in dockerfile_content, ( + "Dockerfile ENTRYPOINT should use python -m cleveragents" + ) + # Cross-file: both should reference the same ASGI app module + assert "cleveragents.a2a.asgi" in deployment_content, ( + "Deployment should reference cleveragents.a2a.asgi" + ) + assert "cleveragents.a2a.asgi" in dockerfile_content, ( + "Dockerfile should reference cleveragents.a2a.asgi" + ) + rendered = _render_chart() + if rendered is None: + return "SKIP: deployment-command-args-rendered" + + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + values: dict[str, Any] = yaml.safe_load(f) + + expected_args = [ + "server", + "serve", + "--app", + "cleveragents.a2a.asgi:app", + "--host", + str(values["server"]["host"]), + "--port", + str(values["server"]["port"]), + "--workers", + str(values["server"]["workers"]), + "--log-level", + str(values["server"]["logLevel"]), + ] + + resources = _parse_rendered_resources(rendered) + deployment = _find_resource(resources, kind="Deployment", name="cleveragents") + containers = deployment["spec"]["template"]["spec"]["containers"] + assert containers, "Rendered deployment should include at least one container" + container = containers[0] + assert container.get("command") == ["python", "-m", "cleveragents"], ( + "Rendered deployment command should be exactly ['python', '-m', 'cleveragents']" + ) + assert container.get("args") == expected_args, ( + "Rendered deployment args do not match expected command contract\n" + f"expected={expected_args}\n" + f"actual={container.get('args')}" + ) + return "OK: deployment-command-args-rendered" + + +def validate_envfrom_wiring() -> str: + """Validate Deployment envFrom references the correct ConfigMap name. + + Cross-file check: verifies the Deployment template uses envFrom with + configMapRef, and that the ConfigMap template uses the matching name + suffix. + """ + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert "envFrom" in deployment_content, "Deployment should use envFrom" + assert "configMapRef" in deployment_content, "Deployment should reference ConfigMap" + # Cross-file: ConfigMap uses -config suffix; deployment should too + configmap_path: Path = _k8s_dir() / "templates" / "configmap.yaml" + configmap_content: str = configmap_path.read_text(encoding="utf-8") + assert "-config" in configmap_content, ( + "ConfigMap name should include -config suffix" + ) + assert "-config" in deployment_content, ( + "Deployment configMapRef should reference -config suffix" + ) + # Cross-file: ConfigMap env vars should match values.yaml keys + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + values: dict[str, Any] = yaml.safe_load(f) + assert "host" in values["server"], "values.yaml missing server.host for ConfigMap" + assert "port" in values["server"], "values.yaml missing server.port for ConfigMap" + assert "logLevel" in values["server"], "values.yaml missing server.logLevel" + assert "CLEVERAGENTS_SERVER_HOST" in configmap_content, ( + "ConfigMap should map CLEVERAGENTS_SERVER_HOST" + ) + assert "CLEVERAGENTS_SERVER_PORT" in configmap_content, ( + "ConfigMap should map CLEVERAGENTS_SERVER_PORT" + ) + assert "CLEVERAGENTS_LOG_LEVEL" in configmap_content, ( + "ConfigMap should map CLEVERAGENTS_LOG_LEVEL" + ) + rendered = _render_chart() + if rendered is None: + return "SKIP: envfrom-wiring-rendered" + + resources = _parse_rendered_resources(rendered) + deployment = _find_resource(resources, kind="Deployment", name="cleveragents") + configmap = _find_resource(resources, kind="ConfigMap", name="cleveragents-config") + env_from = deployment["spec"]["template"]["spec"]["containers"][0].get( + "envFrom", [] + ) + assert env_from, "Rendered deployment should include envFrom" + ref_name = env_from[0]["configMapRef"]["name"] + assert ref_name == configmap["metadata"]["name"], ( + "Rendered envFrom configMapRef should match rendered ConfigMap name" + ) + return "OK: envfrom-wiring-rendered" + + +def validate_redis_disabled_rendering() -> str: + """Validate rendered manifests omit Redis env when redis.enabled=false.""" + rendered = _render_chart(["redis.enabled=false"]) + if rendered is None: + return "SKIP: redis-disabled-rendering" + + resources = _parse_rendered_resources(rendered) + deployment = _find_resource(resources, kind="Deployment", name="cleveragents") + env_entries = deployment["spec"]["template"]["spec"]["containers"][0].get("env", []) + env_names = {entry.get("name") for entry in env_entries if isinstance(entry, dict)} + assert "CLEVERAGENTS_REDIS_HOST" not in env_names, ( + "Redis host env var must be absent when redis.enabled=false" + ) + assert "CLEVERAGENTS_REDIS_PORT" not in env_names, ( + "Redis port env var must be absent when redis.enabled=false" + ) + assert "CLEVERAGENTS_REDIS_PASSWORD" not in env_names, ( + "Redis password env var must be absent when redis.enabled=false" + ) + return "OK: redis-disabled-rendering" + + +def validate_database_existing_secret_rendering() -> str: + """Validate rendered DB env wiring uses database.existingSecret branch.""" + rendered = _render_chart( + [ + "database.existingSecret=custom-db-secret", + "database.existingSecretKey=db-url", + ] + ) + if rendered is None: + return "SKIP: database-existing-secret-rendering" + + resources = _parse_rendered_resources(rendered) + env_entries = _deployment_env_entries(resources) + db_env = _env_entry_by_name(env_entries, "CLEVERAGENTS_DATABASE_URL") + secret_ref = db_env["valueFrom"]["secretKeyRef"] + assert secret_ref["name"] == "custom-db-secret", ( + "Deployment should reference configured database.existingSecret" + ) + assert secret_ref["key"] == "db-url", ( + "Deployment should reference configured database.existingSecretKey" + ) + return "OK: database-existing-secret-rendering" + + +def validate_redis_enabled_existing_secret_rendering() -> str: + """Validate redis.enabled + redis.auth.existingSecret render wiring.""" + rendered = _render_chart( + [ + "redis.enabled=true", + "redis.auth.enabled=true", + "redis.auth.existingSecret=custom-redis-secret", + "redis.auth.existingSecretPasswordKey=custom-redis-key", + ] + ) + if rendered is None: + return "SKIP: redis-enabled-existing-secret-rendering" + + resources = _parse_rendered_resources(rendered) + env_entries = _deployment_env_entries(resources) + password_env = _env_entry_by_name(env_entries, "CLEVERAGENTS_REDIS_PASSWORD") + secret_ref = password_env["valueFrom"]["secretKeyRef"] + assert secret_ref["name"] == "custom-redis-secret", ( + "Redis password should come from redis.auth.existingSecret" + ) + assert secret_ref["key"] == "custom-redis-key", ( + "Redis password should use redis.auth.existingSecretPasswordKey" + ) + return "OK: redis-enabled-existing-secret-rendering" + + +def validate_redis_enabled_inline_password_rendering() -> str: + """Validate redis.enabled + redis.auth.password render wiring.""" + rendered = _render_chart( + [ + "redis.enabled=true", + "redis.auth.enabled=true", + "redis.auth.password=inline-secret", + ] + ) + if rendered is None: + return "SKIP: redis-enabled-inline-password-rendering" + + resources = _parse_rendered_resources(rendered) + env_entries = _deployment_env_entries(resources) + password_env = _env_entry_by_name(env_entries, "CLEVERAGENTS_REDIS_PASSWORD") + secret_ref = password_env["valueFrom"]["secretKeyRef"] + assert secret_ref["name"] == "cleveragents-redis", ( + "Inline password path should reference generated redis Secret" + ) + assert secret_ref["key"] == "redis-password", ( + "Inline password path should use redis-password key" + ) + redis_secrets = [ + resource + for resource in resources + if resource.get("kind") == "Secret" + and resource.get("metadata", {}).get("name") == "cleveragents-redis" + ] + assert redis_secrets, ( + "Expected at least one rendered Secret named cleveragents-redis" + ) + assert any( + "redis-password" in (secret.get("stringData") or secret.get("data") or {}) + for secret in redis_secrets + ), "Expected rendered redis Secret data to include redis-password key" + return "OK: redis-enabled-inline-password-rendering" + + +def validate_redis_enabled_bitnami_fallback_rendering() -> str: + """Validate redis.enabled fallback uses Bitnami-provisioned secret wiring.""" + rendered = _render_chart(["redis.enabled=true", "redis.auth.enabled=true"]) + if rendered is None: + return "SKIP: redis-enabled-bitnami-fallback-rendering" + + resources = _parse_rendered_resources(rendered) + env_entries = _deployment_env_entries(resources) + password_env = _env_entry_by_name(env_entries, "CLEVERAGENTS_REDIS_PASSWORD") + secret_ref = password_env["valueFrom"]["secretKeyRef"] + assert secret_ref["name"] == "cleveragents-redis", ( + "Fallback Redis path should reference Bitnami redis secret" + ) + assert secret_ref["key"] == "redis-password", ( + "Fallback Redis path should use redis-password key" + ) + # Ensure dependency resources are rendered (proves dependency wiring). + _find_resource(resources, kind="Secret", name="cleveragents-redis") + return "OK: redis-enabled-bitnami-fallback-rendering" + + +def validate_ingress_tls_rendering_success() -> str: + """Validate ingress render succeeds when TLS is configured.""" + rendered = _render_chart( + [ + "ingress.enabled=true", + "ingress.allowInsecure=false", + "ingress.tls[0].secretName=cleveragents-tls", + "ingress.tls[0].hosts[0]=cleveragents.example.com", + ] + ) + if rendered is None: + return "SKIP: ingress-tls-rendering-success" + + resources = _parse_rendered_resources(rendered) + ingress = _find_resource(resources, kind="Ingress", name="cleveragents") + tls_entries = ingress.get("spec", {}).get("tls", []) + assert tls_entries, "Ingress spec.tls should be rendered when TLS is configured" + assert tls_entries[0].get("secretName") == "cleveragents-tls", ( + "Ingress tls secretName should match configured value" + ) + return "OK: ingress-tls-rendering-success" + + +def validate_redis_host_helper() -> str: + """Validate Redis host helper uses Release.Name matching Bitnami naming. + + Cross-file check: the Redis host helper should use .Release.Name + to match Bitnami subchart service naming, and the deployment template + should use this helper for the Redis host env var. + """ + helpers_path: Path = _k8s_dir() / "templates" / "_helpers.tpl" + helpers_content: str = helpers_path.read_text(encoding="utf-8") + assert "cleveragents.redisHost" in helpers_content, "Missing redisHost helper" + assert ".Release.Name" in helpers_content, "Redis host should use .Release.Name" + assert "redis-master" in helpers_content, ( + "Redis host should follow Bitnami naming convention" + ) + # Cross-file: deployment.yaml should use the helper + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert "cleveragents.redisHost" in deployment_content, ( + "Deployment should use the redisHost helper" + ) + # Cross-file: Chart.yaml should declare redis dependency + chart_path: Path = _k8s_dir() / "Chart.yaml" + with open(chart_path, encoding="utf-8") as f: + chart: dict[str, Any] = yaml.safe_load(f) + dep_names: list[str] = [d.get("name", "") for d in chart.get("dependencies", [])] + assert "redis" in dep_names, "Chart.yaml should declare redis dependency" + return "Redis host helper OK" + + +def validate_probe_config() -> str: + """Validate probe configuration matches across values and deployment. + + Cross-file check: verifies probes in values.yaml split /live and /ready, + and that the deployment template renders them via toYaml. + """ + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + liveness: dict[str, Any] = data["livenessProbe"] + readiness: dict[str, Any] = data["readinessProbe"] + assert liveness["httpGet"]["path"] == "/live", "Liveness should probe /live" + assert readiness["httpGet"]["path"] == "/ready", "Readiness should probe /ready" + assert "initialDelaySeconds" in liveness, "Liveness missing initialDelaySeconds" + assert "initialDelaySeconds" in readiness, "Readiness missing initialDelaySeconds" + # Cross-file: deployment template should render these probes + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert "livenessProbe" in deployment_content, ( + "Deployment should render livenessProbe" + ) + assert "readinessProbe" in deployment_content, ( + "Deployment should render readinessProbe" + ) + return "Probe config OK" + + +def validate_image_defaults() -> str: + """Validate image defaults are consistent across values and deployment. + + Cross-file check: verifies image section in values.yaml has sensible + defaults, and that the deployment template correctly references them. + """ + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + image: dict[str, Any] = data["image"] + assert "repository" in image, "Missing image.repository" + assert "pullPolicy" in image, "Missing image.pullPolicy" + assert image["pullPolicy"] == "IfNotPresent", ( + f"Expected IfNotPresent, got {image['pullPolicy']}" + ) + assert "tag" in image, "Missing image.tag" + # Cross-file: deployment template should reference image values + deployment_path: Path = _k8s_dir() / "templates" / "deployment.yaml" + deployment_content: str = deployment_path.read_text(encoding="utf-8") + assert ".Values.image.repository" in deployment_content, ( + "Deployment should reference .Values.image.repository" + ) + assert ".Values.image.pullPolicy" in deployment_content, ( + "Deployment should reference .Values.image.pullPolicy" + ) + assert ".Values.image.tag" in deployment_content, ( + "Deployment should reference .Values.image.tag" + ) + return "Image defaults OK" + + +def validate_dockerfile_multistage_nonroot() -> str: + """Validate Dockerfile.server multi-stage build with non-root user. + + Cross-file check: verifies the Dockerfile uses a multi-stage build + (builder + runtime stages), creates a non-root user, and the UID + matches the podSecurityContext.runAsUser in values.yaml. + """ + dockerfile_path: Path = _k8s_dir().parent / "Dockerfile.server" + assert dockerfile_path.exists(), f"Dockerfile.server not found at {dockerfile_path}" + dockerfile_content: str = dockerfile_path.read_text(encoding="utf-8") + # Multi-stage build check + builder_pattern: re.Pattern[str] = re.compile( + r"^FROM\s+.*\s+AS\s+builder", re.MULTILINE + ) + assert builder_pattern.search(dockerfile_content), ( + "Dockerfile.server missing builder stage (expected FROM ... AS builder)" + ) + expose_pattern: re.Pattern[str] = re.compile(r"^EXPOSE\s+8000$", re.MULTILINE) + assert expose_pattern.search(dockerfile_content), ( + "Dockerfile.server should EXPOSE 8000" + ) + user_pattern: re.Pattern[str] = re.compile(r"^USER\s+appuser$", re.MULTILINE) + assert user_pattern.search(dockerfile_content), ( + "Dockerfile.server should switch to appuser" + ) + # Cross-file: verify non-root UID matches values.yaml podSecurityContext + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + values: dict[str, Any] = yaml.safe_load(f) + run_as_user: int = values["podSecurityContext"]["runAsUser"] + assert run_as_user == 1000, ( + f"podSecurityContext.runAsUser should be 1000, got {run_as_user}" + ) + uid_pattern: re.Pattern[str] = re.compile( + rf"useradd.*-u\s+{run_as_user}", re.MULTILINE + ) + assert uid_pattern.search(dockerfile_content), ( + f"Dockerfile.server UID should match values.yaml runAsUser ({run_as_user})" + ) + return "Dockerfile multi-stage OK" + + +def validate_service_defaults() -> str: + """Validate service defaults are consistent across values and template. + + Cross-file check: verifies service section defaults in values.yaml + and that the service template references the correct values. + """ + values_path: Path = _k8s_dir() / "values.yaml" + with open(values_path, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + service: dict[str, Any] = data["service"] + assert service["type"] == "ClusterIP", f"Expected ClusterIP, got {service['type']}" + assert service["port"] == 8000, f"Expected port 8000, got {service['port']}" + # Cross-file: service template should reference values + service_path: Path = _k8s_dir() / "templates" / "service.yaml" + service_content: str = service_path.read_text(encoding="utf-8") + assert ".Values.service.type" in service_content, ( + "Service template should reference .Values.service.type" + ) + assert ".Values.service.port" in service_content, ( + "Service template should reference .Values.service.port" + ) + return "Service defaults OK" + + +# Typed dispatch dictionary for command-line invocation. +# Using an explicit typed dict instead of globals() dispatch to ensure +# Pyright can verify the return type of each function. +_COMMANDS: dict[str, Callable[[], str]] = { + "validate_cross_file_port_consistency": validate_cross_file_port_consistency, + "validate_secrets_structure": validate_secrets_structure, + "validate_deployment_command_args": validate_deployment_command_args, + "validate_envfrom_wiring": validate_envfrom_wiring, + "validate_redis_host_helper": validate_redis_host_helper, + "validate_probe_config": validate_probe_config, + "validate_image_defaults": validate_image_defaults, + "validate_dockerfile_multistage_nonroot": validate_dockerfile_multistage_nonroot, + "validate_service_defaults": validate_service_defaults, + "validate_redis_disabled_rendering": validate_redis_disabled_rendering, + "validate_database_existing_secret_rendering": ( + validate_database_existing_secret_rendering + ), + "validate_redis_enabled_existing_secret_rendering": ( + validate_redis_enabled_existing_secret_rendering + ), + "validate_redis_enabled_inline_password_rendering": ( + validate_redis_enabled_inline_password_rendering + ), + "validate_redis_enabled_bitnami_fallback_rendering": ( + validate_redis_enabled_bitnami_fallback_rendering + ), + "validate_ingress_tls_rendering_success": validate_ingress_tls_rendering_success, +} + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: helper_k8s_helm_chart.py <{'|'.join(_COMMANDS)}>") + sys.exit(1) + func_name: str = sys.argv[1] + func = _COMMANDS.get(func_name) + if func is None: + print(f"Unknown function: {func_name}") + sys.exit(1) + result: str = func() + print(result) diff --git a/robot/k8s_helm_chart.robot b/robot/k8s_helm_chart.robot new file mode 100644 index 000000000..3a2302d79 --- /dev/null +++ b/robot/k8s_helm_chart.robot @@ -0,0 +1,202 @@ +*** Settings *** +Documentation Integration tests for Kubernetes Helm chart cross-file consistency. +... Every test case validates interactions between multiple chart files +... (templates, values.yaml, Chart.yaml, Dockerfile) to catch wiring +... errors that single-file Behave scenarios cannot detect. +Resource ${CURDIR}/common.resource +Library OperatingSystem +Library Collections +Library String +Library Process +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${K8S_DIR} ${CURDIR}/../k8s +${TEMPLATES_DIR} ${K8S_DIR}/templates +${DOCKERFILE_SERVER} ${CURDIR}/../Dockerfile.server +${HELPER} ${CURDIR}/helper_k8s_helm_chart.py + +*** Keywords *** +Assert Rendered Check Result + [Arguments] ${result} ${ok_marker} ${skip_marker} + Should Be Equal As Integers ${result.rc} 0 Render check failed: ${result.stderr} + ${stdout}= Strip String ${result.stdout} + ${strict}= Get Environment Variable CLEVERAGENTS_REQUIRE_HELM_RENDER_ASSERTIONS false + ${strict}= Convert To Lower Case ${strict} + IF '${strict}' == 'true' or '${strict}' == '1' or '${strict}' == 'yes' + Should Be Equal As Strings ${stdout} ${ok_marker} + ELSE + ${is_ok}= Run Keyword And Return Status Should Be Equal As Strings ${stdout} ${ok_marker} + IF not ${is_ok} + Should Be Equal As Strings ${stdout} ${skip_marker} + END + END + +*** Test Cases *** +Port Values Are Consistent Across Templates Values And Dockerfile + [Documentation] Cross-file check: service.port and server.port must agree + ... in values.yaml, deployment.yaml must reference server.port + ... for containerPort, service.yaml must use named port, and + ... Dockerfile EXPOSE must match the default port. + [Tags] k8s integration critical + ${result}= Run Process ${PYTHON} ${HELPER} validate_cross_file_port_consistency + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Port consistency check failed: ${result.stderr} + Should Contain ${result.stdout} Port consistency OK + +Secrets Template References Match Values Schema And Deployment Wiring + [Documentation] Cross-file check: secrets.yaml must reference the same + ... credential keys defined in values.yaml, and deployment.yaml + ... must reference the same secret key names. + [Tags] k8s integration critical + ${result}= Run Process ${PYTHON} ${HELPER} validate_secrets_structure + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Secrets structure check failed: ${result.stderr} + Should Contain ${result.stdout} Secrets structure OK + +Deployment Command Args Match Dockerfile Entrypoint And ASGI Module + [Documentation] Cross-file check: Deployment template passes server config + ... as CLI args to uvicorn, matching the Dockerfile ENTRYPOINT, + ... and both reference the same ASGI application module. + [Tags] k8s integration critical + ${result}= Run Process ${PYTHON} ${HELPER} validate_deployment_command_args + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: deployment-command-args-rendered + ... SKIP: deployment-command-args-rendered + +Deployment EnvFrom References ConfigMap Name And Values Keys + [Documentation] Cross-file check: Deployment template uses envFrom with + ... configMapRef matching the ConfigMap name suffix, and the + ... ConfigMap env vars correspond to values.yaml server keys. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_envfrom_wiring + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: envfrom-wiring-rendered + ... SKIP: envfrom-wiring-rendered + +Redis Host Helper Matches Bitnami Naming And Chart Dependency + [Documentation] Cross-file check: the Redis host helper uses .Release.Name + ... to match Bitnami subchart naming, the deployment template + ... uses this helper, and Chart.yaml declares the dependency. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_redis_host_helper + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Redis host helper check failed: ${result.stderr} + Should Contain ${result.stdout} Redis host helper OK + +Probe Configuration Matches Between Values And Deployment Template + [Documentation] Cross-file check: probes in values.yaml split /live and /ready + ... and the deployment template renders them via toYaml. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_probe_config + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Probe config check failed: ${result.stderr} + Should Contain ${result.stdout} Probe config OK + +Image Defaults Are Consistent Between Values And Deployment Template + [Documentation] Cross-file check: image section in values.yaml has sensible + ... defaults and the deployment template correctly references + ... repository, pullPolicy, and tag values. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_image_defaults + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Image defaults check failed: ${result.stderr} + Should Contain ${result.stdout} Image defaults OK + +Service Defaults Match Between Values And Service Template + [Documentation] Cross-file check: service section in values.yaml defaults + ... to ClusterIP on port 8000 and the service template + ... references the correct values paths. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_service_defaults + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Service defaults check failed: ${result.stderr} + Should Contain ${result.stdout} Service defaults OK + +Server Dockerfile Is Multi-Stage With Non-Root User + [Documentation] Cross-file check: Dockerfile.server uses multi-stage build + ... and creates the same non-root user referenced by the + ... podSecurityContext in values.yaml. + [Tags] k8s dockerfile + ${result}= Run Process ${PYTHON} ${HELPER} validate_dockerfile_multistage_nonroot + ... cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 Dockerfile multi-stage check failed: ${result.stderr} + Should Contain ${result.stdout} Dockerfile multi-stage OK + +Redis Disabled Rendering Omits Redis Environment Wiring + [Documentation] Render-based check (when Helm is available): with + ... redis.enabled=false, rendered Deployment must not + ... include Redis environment variables. + ... If Helm is unavailable, helper returns a skip note. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_redis_disabled_rendering + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: redis-disabled-rendering + ... SKIP: redis-disabled-rendering + +Database Existing Secret Rendering Uses Existing Secret Wiring + [Documentation] Render-based check (when Helm is available): with + ... database.existingSecret configured, rendered Deployment + ... should reference that secret for CLEVERAGENTS_DATABASE_URL. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_database_existing_secret_rendering + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: database-existing-secret-rendering + ... SKIP: database-existing-secret-rendering + +Redis Enabled Existing Secret Rendering Uses Existing Secret Wiring + [Documentation] Render-based check (when Helm is available): with + ... redis.enabled=true and redis.auth.existingSecret set, + ... rendered Deployment should consume that secret. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_redis_enabled_existing_secret_rendering + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: redis-enabled-existing-secret-rendering + ... SKIP: redis-enabled-existing-secret-rendering + +Redis Enabled Inline Password Rendering Uses Generated Secret Wiring + [Documentation] Render-based check (when Helm is available): with + ... redis.enabled=true and redis.auth.password set, + ... rendered Deployment should reference generated redis Secret. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_redis_enabled_inline_password_rendering + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: redis-enabled-inline-password-rendering + ... SKIP: redis-enabled-inline-password-rendering + +Redis Enabled Fallback Rendering Uses Bitnami Secret Wiring + [Documentation] Render-based check (when Helm is available): with + ... redis.enabled=true and no explicit Redis credential, + ... rendered Deployment should reference Bitnami secret. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_redis_enabled_bitnami_fallback_rendering + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: redis-enabled-bitnami-fallback-rendering + ... SKIP: redis-enabled-bitnami-fallback-rendering + +Ingress TLS Rendering Succeeds With TLS Configured + [Documentation] Render-based positive check (when Helm is available): + ... ingress.enabled=true with TLS configured and + ... allowInsecure=false should render Ingress successfully. + [Tags] k8s integration + ${result}= Run Process ${PYTHON} ${HELPER} validate_ingress_tls_rendering_success + ... cwd=${WORKSPACE} on_timeout=kill timeout=120s + Assert Rendered Check Result + ... ${result} + ... OK: ingress-tls-rendering-success + ... SKIP: ingress-tls-rendering-success diff --git a/src/cleveragents/a2a/asgi.py b/src/cleveragents/a2a/asgi.py new file mode 100644 index 000000000..9bd501502 --- /dev/null +++ b/src/cleveragents/a2a/asgi.py @@ -0,0 +1,134 @@ +"""Minimal ASGI application for server-mode HTTP deployments. + +This module provides a concrete import target for runtime commands like: + + python -m cleveragents server serve --app cleveragents.a2a.asgi:app + +It intentionally keeps behavior minimal and dependency-free (no FastAPI/ +Starlette dependency) while providing the required health probe endpoint +for container and Kubernetes deployments. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable + +_logger: logging.Logger = logging.getLogger(__name__) + +Headers = list[tuple[bytes, bytes]] +SendCallable = Callable[[dict[str, object]], Awaitable[None]] + +# Paths recognised by this ASGI app. Used to distinguish 404 (unknown +# path) from 405 (known path, wrong method) per RFC 9110 S15.5.6. +_KNOWN_PATHS: frozenset[str] = frozenset({"/", "/live", "/ready", "/health"}) + + +async def _send_response( + send: SendCallable, + *, + status: int, + body: bytes, + headers: Headers | None = None, +) -> None: + """Send a complete HTTP response via ASGI ``send``.""" + response_headers: Headers = [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + (b"x-content-type-options", b"nosniff"), + (b"cache-control", b"no-store"), + ] + if headers: + response_headers.extend(headers) + + await send( + { + "type": "http.response.start", + "status": status, + "headers": response_headers, + } + ) + await send( + { + "type": "http.response.body", + "body": body, + } + ) + + +async def app( + scope: dict[str, object], + receive: Callable[[], Awaitable[dict[str, object]]], + send: SendCallable, +) -> None: + """Serve health and readiness endpoints for Kubernetes probes. + + Supported routes: + - ``GET /live`` -> ``200`` with ``{"status":"alive"}`` + - ``GET /ready`` -> ``200`` with ``{"status":"ready"}`` + - ``GET /health`` -> ``200`` with ``{"status":"ok"}`` (compat alias) + - ``GET /`` -> ``200`` with ``{"service":"cleveragents"}`` + - known path, wrong method -> ``405`` + - unknown path -> ``404`` + """ + scope_type = str(scope.get("type", "")) + + if scope_type == "lifespan": + while True: + message = await receive() + message_type = str(message.get("type", "")) + if message_type == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message_type == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + else: + _logger.warning( + "Ignoring unrecognised lifespan message type: %s", + message_type, + ) + + elif scope_type == "websocket": + # Consume the websocket.connect event before sending close, per + # the ASGI WebSocket protocol sequence. + await receive() + # Code 1008 (Policy Violation) signals that WebSocket is not + # supported, rather than 1000 (Normal Closure) which implies success. + await send({"type": "websocket.close", "code": 1008}) + return + + elif scope_type != "http": + raise RuntimeError(f"Unsupported ASGI scope type: {scope_type!r}") + + del receive # No HTTP request-body handling required for these endpoints. + + method = str(scope.get("method", "GET")).upper() + path = str(scope.get("path", "/")) + + if method == "GET" and path == "/live": + await _send_response(send, status=200, body=b'{"status":"alive"}') + return + + if method == "GET" and path == "/ready": + await _send_response(send, status=200, body=b'{"status":"ready"}') + return + + if method == "GET" and path == "/health": + await _send_response(send, status=200, body=b'{"status":"ok"}') + return + + if method == "GET" and path == "/": + await _send_response(send, status=200, body=b'{"service":"cleveragents"}') + return + + # Known path with wrong method -> 405 Method Not Allowed (RFC 9110 S15.5.6) + if path in _KNOWN_PATHS: + await _send_response( + send, + status=405, + body=b'{"error":"method not allowed"}', + headers=[(b"allow", b"GET")], + ) + return + + await _send_response(send, status=404, body=b'{"error":"not found"}') diff --git a/src/cleveragents/cli/commands/server.py b/src/cleveragents/cli/commands/server.py index 7d66dcfce..88d51087b 100644 --- a/src/cleveragents/cli/commands/server.py +++ b/src/cleveragents/cli/commands/server.py @@ -7,6 +7,7 @@ commands persist configuration and print explicit warnings. from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import Annotated, Any @@ -20,6 +21,12 @@ from cleveragents.application.container import get_container from cleveragents.application.services.config_service import ConfigService from cleveragents.cli.formatting import OutputFormat, format_output +uvicorn_run: Callable[..., None] | None +try: + from uvicorn import run as uvicorn_run +except ModuleNotFoundError: + uvicorn_run = None + app: Any = typer.Typer(help="Server connection management (stub).") console: Console = Console() @@ -32,6 +39,24 @@ _STUB_WARNING: str = ( "The URL has been saved to configuration but no connection will be made." ) +_LOG_LEVELS: tuple[str, ...] = ( + "critical", + "error", + "warning", + "info", + "debug", + "trace", +) + + +def _normalize_log_level(value: str) -> str: + """Normalize and validate ``--log-level`` values.""" + normalized = value.lower() + if normalized not in _LOG_LEVELS: + options = ", ".join(_LOG_LEVELS) + raise typer.BadParameter(f"must be one of: {options}") + return normalized + def _get_config_service() -> ConfigService: """Return a ``ConfigService`` wired to the standard config paths. @@ -213,9 +238,59 @@ def server_status( ) +@app.command("serve") +def server_serve( + host: Annotated[ + str, + typer.Option("--host", help="Bind address for the ASGI server"), + ] = "0.0.0.0", # nosec B104 + port: Annotated[ + int, + typer.Option("--port", min=1, max=65535, help="Bind port for the ASGI server"), + ] = 8000, + workers: Annotated[ + int, + typer.Option("--workers", min=1, help="Number of uvicorn worker processes"), + ] = 1, + log_level: Annotated[ + str, + typer.Option( + "--log-level", + help="Uvicorn log level", + callback=_normalize_log_level, + ), + ] = "info", + app_target: Annotated[ + str, + typer.Option("--app", help="ASGI app import path (module:attribute)"), + ] = "cleveragents.a2a.asgi:app", +) -> None: + """Run the CleverAgents ASGI server process. + + This command is used by container runtimes (Docker/Kubernetes) to launch + the server endpoint using the project CLI entrypoint (`python -m cleveragents`). + """ + runner = uvicorn_run + if runner is None: + console.print( + "[red]Unable to start server:[/red] optional dependency " + "'uvicorn' is not installed." + ) + raise typer.Exit(code=1) + + runner( + app_target, + host=host, + port=port, + workers=workers, + log_level=log_level, + ) + + __all__ = [ "app", "resolve_server_mode", "server_connect", + "server_serve", "server_status", ]