Files
temp/features/steps/k8s_helm_chart_steps.py
brent.edwards b51df2ee0f 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>
2026-03-27 23:53:22 +00:00

552 lines
21 KiB
Python

"""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"
)