feat(server): add Kubernetes Helm chart for server deployment (#1085)

## 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: cleveragents/cleveragents-core#1085
Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
This commit is contained in:
2026-03-27 23:53:22 +00:00
committed by Forgejo
parent 3fa07fb772
commit b51df2ee0f
33 changed files with 3714 additions and 9 deletions
+29 -1
View File
@@ -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/
+87 -8
View File
@@ -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
+1
View File
@@ -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
+7
View File
@@ -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)
+1
View File
@@ -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
+71
View File
@@ -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"]
+40
View File
@@ -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/
+94
View File
@@ -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
+78
View File
@@ -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
+21
View File
@@ -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/"
+289
View File
@@ -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
@@ -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'"
+283
View File
@@ -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)
@@ -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}"
)
+551
View File
@@ -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"
)
@@ -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}"
)
+16
View File
@@ -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
+26
View File
@@ -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
+250
View File
@@ -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
```
+34
View File
@@ -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.
+72
View File
@@ -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 }}
+15
View File
@@ -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 }}
+137
View File
@@ -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 }}
+53
View File
@@ -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 }}
+23
View File
@@ -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 }}
+15
View File
@@ -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 }}
+13
View File
@@ -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 }}
+182
View File
@@ -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: []
+4
View File
@@ -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)
+678
View File
@@ -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)
+202
View File
@@ -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
+134
View File
@@ -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"}')
+75
View File
@@ -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",
]