"""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" ) @then('the ENTRYPOINT should use "{entrypoint}"') def step_dockerfile_entrypoint(context: Context, entrypoint: str) -> None: """Assert that the Dockerfile ENTRYPOINT contains the expected command.""" pattern: re.Pattern[str] = re.compile(r"^ENTRYPOINT\s+\[.*\]", re.MULTILINE) match = pattern.search(context.dockerfile_content) assert match is not None, "Dockerfile.server is missing an ENTRYPOINT instruction" entrypoint_line = match.group(0) # Verify each token of the expected entrypoint appears in the ENTRYPOINT line for token in entrypoint.split(): assert token in entrypoint_line, ( f"Expected ENTRYPOINT to contain '{token}', but got: {entrypoint_line!r}" ) @then('the CMD should include "{cmd_a}" and "{cmd_b}"') def step_dockerfile_cmd_includes(context: Context, cmd_a: str, cmd_b: str) -> None: """Assert that the Dockerfile CMD contains the expected subcommand tokens.""" pattern: re.Pattern[str] = re.compile(r"^CMD\s+\[.*\]", re.MULTILINE) match = pattern.search(context.dockerfile_content) assert match is not None, "Dockerfile.server is missing a CMD instruction" cmd_line = match.group(0) assert cmd_a in cmd_line, ( f"Expected CMD to contain '{cmd_a}', but got: {cmd_line!r}" ) assert cmd_b in cmd_line, ( f"Expected CMD to contain '{cmd_b}', but got: {cmd_line!r}" ) @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" )