forked from cleveragents/cleveragents-core
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:
+29
-1
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
@@ -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'"
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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: []
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}')
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user