diff --git a/src/agentex/config/build_config.py b/src/agentex/config/build_config.py index bf4a0b309..9091de712 100644 --- a/src/agentex/config/build_config.py +++ b/src/agentex/config/build_config.py @@ -24,7 +24,7 @@ class BuildContext(ConfigBaseModel): description="The path to the Dockerfile. Should be specified relative to the root directory.", ) dockerignore: str | None = Field( - None, + default=None, description="The path to the .dockerignore file. Should be specified relative to the root directory.", ) diff --git a/src/agentex/config/helm_values.py b/src/agentex/config/helm_values.py new file mode 100644 index 000000000..d977a2b2f --- /dev/null +++ b/src/agentex/config/helm_values.py @@ -0,0 +1,266 @@ +"""Pure helm-values generation for the ``agentex-agent`` chart. + +Maps an :class:`~agentex.config.agent_manifest.AgentManifest` plus an optional +:class:`~agentex.config.environment_config.AgentEnvironmentConfig` to the values +dict the ``agentex-agent`` helm chart consumes. Depends only on pydantic and the +stdlib, so it is safe to import from a slim REST-only install without the ADK +runtime — the same contract as the other ``agentex.config`` modules. + +Filesystem-aware ACP module resolution stays in +``agentex.lib.cli.utils.path_utils``: callers that have the agent source tree on +disk should resolve the module themselves and pass ``acp_module``. Callers +without one (e.g. server-side deployers) get :func:`derive_acp_module`'s +pure-string derivation by default. +""" + +from __future__ import annotations + +import json +import base64 +import logging +from typing import Any + +from agentex.config.agent_manifest import AgentManifest +from agentex.config.environment_config import AgentAuthConfig, AgentEnvironmentConfig + +logger = logging.getLogger(__name__) + +TEMPORAL_WORKER_KEY = "temporal-worker" +AUTH_PRINCIPAL_ENV_VAR = "AUTH_PRINCIPAL_B64" + +__all__ = [ + "AUTH_PRINCIPAL_ENV_VAR", + "TEMPORAL_WORKER_KEY", + "build_acp_command", + "derive_acp_module", + "encode_principal_context", + "convert_env_vars_dict_to_list", + "merge_deployment_configs", +] + + +def convert_env_vars_dict_to_list(env_vars: dict[str, str]) -> list[dict[str, str]]: + """Convert a dictionary of environment variables to a list of dictionaries""" + return [{"name": key, "value": value} for key, value in env_vars.items()] + + +def encode_principal_context(auth_config: AgentAuthConfig | None) -> str | None: + """Base64-encode the auth principal as compact JSON, or None if unset.""" + if auth_config is None: + return None + + principal = auth_config.principal + if not principal: + return None + + json_str = json.dumps(principal, separators=(",", ":")) + return base64.b64encode(json_str.encode("utf-8")).decode("utf-8") + + +def derive_acp_module(manifest: AgentManifest) -> str: + """Derive the ACP module from the manifest by pure string transform. + + Callers with the agent source tree on disk should prefer the filesystem-aware + resolution in ``agentex.lib.cli.utils.path_utils.calculate_docker_acp_module`` + and pass its result to :func:`merge_deployment_configs` as ``acp_module``. + """ + if manifest.local_development and manifest.local_development.paths: + acp_path = manifest.local_development.paths.acp + if acp_path: + return acp_path.replace(".py", "").replace("/", ".") + return "project.acp" + + +def build_acp_command(acp_module: str) -> list[str]: + """Build the uvicorn command that runs the agent's ACP server.""" + return ["uvicorn", f"{acp_module}:acp", "--host", "0.0.0.0", "--port", "8000"] + + +def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> None: + """Deep merge override_dict into base_dict""" + for key, value in override_dict.items(): + if key in base_dict and isinstance(base_dict[key], dict) and isinstance(value, dict): + _deep_merge(base_dict[key], value) + else: + base_dict[key] = value + + +def merge_deployment_configs( + manifest: AgentManifest, + agent_env_config: AgentEnvironmentConfig | None, + *, + repository: str, + image_tag: str, + acp_module: str | None = None, +) -> dict[str, Any]: + """Merge global deployment config with environment-specific overrides into helm values. + + Args: + manifest: The agent manifest configuration. + agent_env_config: Environment-specific configuration (optional). + repository: Container image repository to deploy. + image_tag: Container image tag to deploy. + acp_module: Pre-resolved ACP module for the uvicorn command. Defaults to + :func:`derive_acp_module`'s pure-string derivation. + + Returns: + Dictionary of helm values ready for deployment. + + Raises: + ValueError: If deployment configuration is missing or invalid. + """ + agent_config = manifest.agent + + if not manifest.deployment: + raise ValueError("No deployment configuration found in manifest") + + if not repository or not image_tag: + raise ValueError("Repository and image tag are required") + + # Start with global configuration + helm_values: dict[str, Any] = { + "global": { + "image": { + "repository": repository, + "tag": image_tag, + "pullPolicy": "IfNotPresent", + }, + "agent": { + "name": manifest.agent.name, + "description": manifest.agent.description, + "acp_type": manifest.agent.acp_type, + }, + }, + "replicaCount": manifest.deployment.global_config.replicaCount, + "resources": { + "requests": { + "cpu": manifest.deployment.global_config.resources.requests.cpu, + "memory": manifest.deployment.global_config.resources.requests.memory, + }, + "limits": { + "cpu": manifest.deployment.global_config.resources.limits.cpu, + "memory": manifest.deployment.global_config.resources.limits.memory, + }, + }, + # Enable autoscaling by default for production deployments + "autoscaling": { + "enabled": True, + "minReplicas": 1, + "maxReplicas": 10, + "targetCPUUtilizationPercentage": 50, + }, + } + + # Handle temporal configuration using new helper methods + if agent_config.is_temporal_agent(): + temporal_config = agent_config.get_temporal_workflow_config() + if temporal_config: + helm_values[TEMPORAL_WORKER_KEY] = { + "enabled": True, + # Enable autoscaling for temporal workers as well + "autoscaling": { + "enabled": True, + "minReplicas": 1, + "maxReplicas": 10, + "targetCPUUtilizationPercentage": 50, + }, + } + helm_values["global"]["workflow"] = { + "name": temporal_config.name, + "taskQueue": temporal_config.queue_name, + } + + # Collect all environment variables with proper precedence + # Priority: manifest -> environments.yaml -> secrets (highest) + all_env_vars: dict[str, str] = {} + secret_env_vars: list[dict[str, str]] = [] + + # Start with agent_config env vars from manifest + if agent_config.env: + all_env_vars.update(agent_config.env) + + # Override with environment config env vars if they exist + if agent_env_config and agent_env_config.helm_overrides and "env" in agent_env_config.helm_overrides: + env_overrides = agent_env_config.helm_overrides["env"] + if isinstance(env_overrides, list): + # Convert list format to dict for easier merging + env_override_dict: dict[str, str] = {} + for env_var in env_overrides: + if isinstance(env_var, dict) and "name" in env_var and "value" in env_var: + env_override_dict[str(env_var["name"])] = str(env_var["value"]) + all_env_vars.update(env_override_dict) + + # Handle credentials and check for conflicts + if agent_config.credentials: + for credential in agent_config.credentials: + # Handle both CredentialMapping objects and legacy dict format + if isinstance(credential, dict): + env_var_name = credential["env_var_name"] + secret_name = credential["secret_name"] + secret_key = credential["secret_key"] + else: + env_var_name = credential.env_var_name + secret_name = credential.secret_name + secret_key = credential.secret_key + + # Check if the environment variable name conflicts with existing env vars + if env_var_name in all_env_vars: + logger.warning( + f"Environment variable '{env_var_name}' is defined in both " + f"env and secretEnvVars. The secret value will take precedence." + ) + # Remove from regular env vars since secret takes precedence + del all_env_vars[env_var_name] + + secret_env_vars.append( + { + "name": env_var_name, + "secretName": secret_name, + "secretKey": secret_key, + } + ) + + # Apply agent environment configuration overrides + if agent_env_config: + # Add auth principal env var if environment config is set + if agent_env_config.auth: + encoded_principal = encode_principal_context(agent_env_config.auth) + if encoded_principal: + all_env_vars[AUTH_PRINCIPAL_ENV_VAR] = encoded_principal + else: + raise ValueError(f"Auth principal unable to be encoded for agent_env_config: {agent_env_config}") + + if agent_env_config.helm_overrides: + _deep_merge(helm_values, agent_env_config.helm_overrides) + + # Set final environment variables + # Environment variable precedence: manifest -> environments.yaml -> secrets (highest) + if all_env_vars: + helm_values["env"] = convert_env_vars_dict_to_list(all_env_vars) + + if secret_env_vars: + helm_values["secretEnvVars"] = secret_env_vars + + # Set environment variables for temporal worker if enabled + if TEMPORAL_WORKER_KEY in helm_values: + if all_env_vars: + helm_values[TEMPORAL_WORKER_KEY]["env"] = convert_env_vars_dict_to_list(all_env_vars) + if secret_env_vars: + helm_values[TEMPORAL_WORKER_KEY]["secretEnvVars"] = secret_env_vars + + # Handle image pull secrets + if manifest.deployment and manifest.deployment.imagePullSecrets: + pull_secrets = [pull_secret.model_dump() for pull_secret in manifest.deployment.imagePullSecrets] + helm_values["global"]["imagePullSecrets"] = pull_secrets + helm_values["imagePullSecrets"] = pull_secrets + + # Add dynamic ACP command based on manifest configuration if command is not set in helm overrides + helm_overrides_command = ( + agent_env_config and agent_env_config.helm_overrides and "command" in agent_env_config.helm_overrides + ) + if not helm_overrides_command: + module = acp_module or derive_acp_module(manifest) + helm_values["command"] = build_acp_command(module) + logger.info(f"Using ACP command: uvicorn {module}:acp") + + return helm_values diff --git a/src/agentex/lib/cli/handlers/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py index 605d91709..e82de681f 100644 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -11,20 +11,23 @@ from rich.console import Console from agentex.lib.utils.logging import make_logger -from agentex.config.agent_config import AgentConfig +from agentex.config.helm_values import ( + TEMPORAL_WORKER_KEY, # noqa: F401 # back-compat re-export + _deep_merge, # noqa: F401 # back-compat re-export + build_acp_command, + merge_deployment_configs as merge_helm_values, + convert_env_vars_dict_to_list, # noqa: F401 # back-compat re-export +) from agentex.config.agent_manifest import AgentManifest from agentex.lib.cli.utils.exceptions import HelmError, DeploymentError from agentex.lib.cli.utils.path_utils import PathResolutionError, calculate_docker_acp_module from agentex.config.environment_config import OciRegistryConfig, AgentEnvironmentConfig -from agentex.lib.environment_variables import EnvVarKeys from agentex.lib.cli.utils.kubectl_utils import check_and_switch_cluster_context from agentex.lib.sdk.config.agent_manifest import load_agent_manifest from agentex.lib.sdk.config.environment_config import load_environments_config_from_manifest_dir logger = make_logger(__name__) console = Console() - -TEMPORAL_WORKER_KEY = "temporal-worker" DEFAULT_HELM_CHART_VERSION = "0.1.9" @@ -231,22 +234,21 @@ def resolve_chart( return chart_reference, chart_version -def convert_env_vars_dict_to_list(env_vars: dict[str, str]) -> list[dict[str, str]]: - """Convert a dictionary of environment variables to a list of dictionaries""" - return [{"name": key, "value": value} for key, value in env_vars.items()] - - -def add_acp_command_to_helm_values(helm_values: dict[str, Any], manifest: AgentManifest, manifest_path: str) -> None: - """Add dynamic ACP command to helm values based on manifest configuration""" +def _resolve_acp_module(manifest: AgentManifest, manifest_path: str) -> str: + """Resolve the ACP module from the source tree, falling back to the default.""" try: docker_acp_module = calculate_docker_acp_module(manifest, manifest_path) - # Create the uvicorn command with the correct module path - helm_values["command"] = ["uvicorn", f"{docker_acp_module}:acp", "--host", "0.0.0.0", "--port", "8000"] logger.info(f"Using dynamic ACP command: uvicorn {docker_acp_module}:acp") + return docker_acp_module except (PathResolutionError, Exception) as e: # Fallback to default command structure logger.warning(f"Could not calculate dynamic ACP module ({e}), using default: project.acp") - helm_values["command"] = ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + return "project.acp" + + +def add_acp_command_to_helm_values(helm_values: dict[str, Any], manifest: AgentManifest, manifest_path: str) -> None: + """Add dynamic ACP command to helm values based on manifest configuration""" + helm_values["command"] = build_acp_command(_resolve_acp_module(manifest, manifest_path)) def merge_deployment_configs( @@ -255,9 +257,12 @@ def merge_deployment_configs( deploy_overrides: InputDeployOverrides, manifest_path: str, ) -> dict[str, Any]: - agent_config: AgentConfig = manifest.agent + """Merge global deployment config with environment-specific overrides into helm values. - """Merge global deployment config with environment-specific overrides into helm values""" + Resolves the CLI-side inputs (deploy overrides, filesystem ACP module + resolution), then delegates the pure mapping to + :func:`agentex.config.helm_values.merge_deployment_configs`. + """ if not manifest.deployment: raise DeploymentError("No deployment configuration found in manifest") @@ -267,169 +272,28 @@ def merge_deployment_configs( if not repository or not image_tag: raise DeploymentError("Repository and image tag are required") - # Start with global configuration - helm_values: dict[str, Any] = { - "global": { - "image": { - "repository": repository, - "tag": image_tag, - "pullPolicy": "IfNotPresent", - }, - "agent": { - "name": manifest.agent.name, - "description": manifest.agent.description, - "acp_type": manifest.agent.acp_type, - }, - }, - "replicaCount": manifest.deployment.global_config.replicaCount, - "resources": { - "requests": { - "cpu": manifest.deployment.global_config.resources.requests.cpu, - "memory": manifest.deployment.global_config.resources.requests.memory, - }, - "limits": { - "cpu": manifest.deployment.global_config.resources.limits.cpu, - "memory": manifest.deployment.global_config.resources.limits.memory, - }, - }, - # Enable autoscaling by default for production deployments - "autoscaling": { - "enabled": True, - "minReplicas": 1, - "maxReplicas": 10, - "targetCPUUtilizationPercentage": 50, - }, - } - - # Handle temporal configuration using new helper methods - if agent_config.is_temporal_agent(): - temporal_config = agent_config.get_temporal_workflow_config() - if temporal_config: - helm_values[TEMPORAL_WORKER_KEY] = { - "enabled": True, - # Enable autoscaling for temporal workers as well - "autoscaling": { - "enabled": True, - "minReplicas": 1, - "maxReplicas": 10, - "targetCPUUtilizationPercentage": 50, - }, - } - helm_values["global"]["workflow"] = { - "name": temporal_config.name, - "taskQueue": temporal_config.queue_name, - } - - # Collect all environment variables with proper precedence - # Priority: manifest -> environments.yaml -> secrets (highest) - all_env_vars: dict[str, str] = {} - secret_env_vars: list[dict[str, str]] = [] - - # Start with agent_config env vars from manifest - if agent_config.env: - all_env_vars.update(agent_config.env) - - # Override with environment config env vars if they exist - if agent_env_config and agent_env_config.helm_overrides and "env" in agent_env_config.helm_overrides: - env_overrides = agent_env_config.helm_overrides["env"] - if isinstance(env_overrides, list): - # Convert list format to dict for easier merging - env_override_dict: dict[str, str] = {} - for env_var in env_overrides: - if isinstance(env_var, dict) and "name" in env_var and "value" in env_var: - env_override_dict[str(env_var["name"])] = str(env_var["value"]) - all_env_vars.update(env_override_dict) - - # Handle credentials and check for conflicts - if agent_config.credentials: - for credential in agent_config.credentials: - # Handle both CredentialMapping objects and legacy dict format - if isinstance(credential, dict): - env_var_name = credential["env_var_name"] - secret_name = credential["secret_name"] - secret_key = credential["secret_key"] - else: - env_var_name = credential.env_var_name - secret_name = credential.secret_name - secret_key = credential.secret_key - - # Check if the environment variable name conflicts with existing env vars - if env_var_name in all_env_vars: - logger.warning( - f"Environment variable '{env_var_name}' is defined in both " - f"env and secretEnvVars. The secret value will take precedence." - ) - # Remove from regular env vars since secret takes precedence - del all_env_vars[env_var_name] - - secret_env_vars.append( - { - "name": env_var_name, - "secretName": secret_name, - "secretKey": secret_key, - } - ) - - # Apply agent environment configuration overrides - if agent_env_config: - # Add auth principal env var if environment config is set - if agent_env_config.auth: - from agentex.lib.cli.utils.auth_utils import _encode_principal_context_from_env_config - - encoded_principal = _encode_principal_context_from_env_config(agent_env_config.auth) - logger.info(f"Encoding auth principal from {agent_env_config.auth}") - if encoded_principal: - all_env_vars[EnvVarKeys.AUTH_PRINCIPAL_B64.value] = encoded_principal - else: - raise DeploymentError(f"Auth principal unable to be encoded for agent_env_config: {agent_env_config}") - - logger.info(f"Defined agent helm overrides: {agent_env_config.helm_overrides}") - logger.info(f"Before-merge helm values: {helm_values}") - if agent_env_config.helm_overrides: - _deep_merge(helm_values, agent_env_config.helm_overrides) - logger.info(f"After-merge helm values: {helm_values}") - - # Set final environment variables - # Environment variable precedence: manifest -> environments.yaml -> secrets (highest) - if all_env_vars: - helm_values["env"] = convert_env_vars_dict_to_list(all_env_vars) - - if secret_env_vars: - helm_values["secretEnvVars"] = secret_env_vars - - # Set environment variables for temporal worker if enabled - if TEMPORAL_WORKER_KEY in helm_values: - if all_env_vars: - helm_values[TEMPORAL_WORKER_KEY]["env"] = convert_env_vars_dict_to_list(all_env_vars) - if secret_env_vars: - helm_values[TEMPORAL_WORKER_KEY]["secretEnvVars"] = secret_env_vars - - # Handle image pull secrets - if manifest.deployment and manifest.deployment.imagePullSecrets: - pull_secrets = [pull_secret.model_dump() for pull_secret in manifest.deployment.imagePullSecrets] - helm_values["global"]["imagePullSecrets"] = pull_secrets - helm_values["imagePullSecrets"] = pull_secrets - - # Add dynamic ACP command based on manifest configuration if command is not set in helm overrides + # Only resolve the module when the command isn't overridden, matching the + # original conditional add_acp_command_to_helm_values call (and its logs). helm_overrides_command = ( agent_env_config and agent_env_config.helm_overrides and "command" in agent_env_config.helm_overrides ) - if not helm_overrides_command: - add_acp_command_to_helm_values(helm_values, manifest, manifest_path) + acp_module = None if helm_overrides_command else _resolve_acp_module(manifest, manifest_path) + + try: + helm_values = merge_helm_values( + manifest, + agent_env_config, + repository=repository, + image_tag=image_tag, + acp_module=acp_module, + ) + except ValueError as e: + raise DeploymentError(str(e)) from e logger.info("Deploying with the following helm values: %s", helm_values) return helm_values -def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> None: - """Deep merge override_dict into base_dict""" - for key, value in override_dict.items(): - if key in base_dict and isinstance(base_dict[key], dict) and isinstance(value, dict): - _deep_merge(base_dict[key], value) - else: - base_dict[key] = value - - def create_helm_values_file(helm_values: dict[str, Any]) -> str: """Create a temporary helm values file""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: diff --git a/src/agentex/lib/cli/utils/auth_utils.py b/src/agentex/lib/cli/utils/auth_utils.py index b2a747456..4fb016c59 100644 --- a/src/agentex/lib/cli/utils/auth_utils.py +++ b/src/agentex/lib/cli/utils/auth_utils.py @@ -4,6 +4,7 @@ import base64 from typing import Any, Dict +from agentex.config.helm_values import encode_principal_context from agentex.config.agent_manifest import AgentManifest from agentex.config.environment_config import AgentAuthConfig @@ -14,7 +15,7 @@ def _encode_principal_context(manifest: AgentManifest) -> str | None: # noqa: A """ DEPRECATED: This function is deprecated as AgentManifest no longer contains auth. Use _encode_principal_context_from_env_config instead. - + This function is kept temporarily for backward compatibility during migration. """ # AgentManifest no longer has auth field - this will always return None @@ -24,38 +25,29 @@ def _encode_principal_context(manifest: AgentManifest) -> str | None: # noqa: A def _encode_principal_context_from_env_config(auth_config: "AgentAuthConfig | None") -> str | None: """ Encode principal context from environment configuration. - + Args: auth_config: AgentAuthConfig containing principal configuration - + Returns: Base64-encoded JSON string of the principal, or None if no principal """ - if auth_config is None: - return None - - principal = auth_config.principal - if not principal: - return None - - json_str = json.dumps(principal, separators=(',', ':')) - encoded_bytes = base64.b64encode(json_str.encode('utf-8')) - return encoded_bytes.decode('utf-8') + return encode_principal_context(auth_config) def _encode_principal_dict(principal: Dict[str, Any]) -> str | None: """ Encode principal dictionary directly. - + Args: principal: Dictionary containing principal configuration - + Returns: Base64-encoded JSON string of the principal, or None if principal is empty """ if not principal: return None - json_str = json.dumps(principal, separators=(',', ':')) - encoded_bytes = base64.b64encode(json_str.encode('utf-8')) - return encoded_bytes.decode('utf-8') + json_str = json.dumps(principal, separators=(",", ":")) + encoded_bytes = base64.b64encode(json_str.encode("utf-8")) + return encoded_bytes.decode("utf-8") diff --git a/tests/lib/cli/test_deploy_handlers.py b/tests/lib/cli/test_deploy_handlers.py new file mode 100644 index 000000000..acf29558f --- /dev/null +++ b/tests/lib/cli/test_deploy_handlers.py @@ -0,0 +1,95 @@ +"""Tests for the CLI wrapper around ``agentex.config.helm_values``. + +The wrapper keeps the historical ``deploy_handlers`` signature and +``DeploymentError`` contract while delegating the pure mapping to the slim +module; these tests pin that delegation. +""" + +from __future__ import annotations + +import pytest + +from agentex.lib.cli.handlers import deploy_handlers +from agentex.config.helm_values import merge_deployment_configs as merge_helm_values +from agentex.config.agent_config import AgentConfig +from agentex.config.build_config import BuildConfig, BuildContext +from agentex.config.agent_manifest import AgentManifest +from agentex.config.deployment_config import ImageConfig, DeploymentConfig +from agentex.lib.cli.utils.exceptions import DeploymentError +from agentex.config.environment_config import AgentAuthConfig, AgentEnvironmentConfig +from agentex.lib.cli.handlers.deploy_handlers import InputDeployOverrides, merge_deployment_configs + + +def make_manifest(**kwargs) -> AgentManifest: + defaults = dict( + build=BuildConfig(context=BuildContext(root=".", dockerfile="Dockerfile")), + agent=AgentConfig(name="test-agent", acp_type="sync", description="test"), + deployment=DeploymentConfig(image=ImageConfig(repository="manifest-repo", tag="manifest-tag")), + ) + defaults.update(kwargs) + return AgentManifest(**defaults) + + +@pytest.fixture +def fixed_acp_module(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(deploy_handlers, "calculate_docker_acp_module", lambda manifest, path: "resolved.module") + + +class TestMergeDeploymentConfigsWrapper: + def test_matches_slim_module_output(self, fixed_acp_module) -> None: + manifest = make_manifest() + wrapper_values = merge_deployment_configs(manifest, None, InputDeployOverrides(), "manifest.yaml") + core_values = merge_helm_values( + manifest, None, repository="manifest-repo", image_tag="manifest-tag", acp_module="resolved.module" + ) + assert wrapper_values == core_values + + def test_deploy_overrides_win_over_manifest_image(self, fixed_acp_module) -> None: + overrides = InputDeployOverrides(repository="override-repo", image_tag="override-tag") + helm_values = merge_deployment_configs(make_manifest(), None, overrides, "manifest.yaml") + assert helm_values["global"]["image"]["repository"] == "override-repo" + assert helm_values["global"]["image"]["tag"] == "override-tag" + + def test_missing_deployment_raises_deployment_error(self) -> None: + with pytest.raises(DeploymentError, match="No deployment configuration"): + merge_deployment_configs(make_manifest(deployment=None), None, InputDeployOverrides(), "manifest.yaml") + + def test_value_error_from_core_maps_to_deployment_error(self, fixed_acp_module) -> None: + env_config = AgentEnvironmentConfig(auth=AgentAuthConfig(principal={})) + with pytest.raises(DeploymentError, match="Auth principal unable to be encoded"): + merge_deployment_configs(make_manifest(), env_config, InputDeployOverrides(), "manifest.yaml") + + def test_unresolvable_acp_module_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(manifest, path): + raise RuntimeError("no source tree") + + monkeypatch.setattr(deploy_handlers, "calculate_docker_acp_module", boom) + helm_values = merge_deployment_configs(make_manifest(), None, InputDeployOverrides(), "manifest.yaml") + assert helm_values["command"][1] == "project.acp:acp" + + def test_module_resolution_skipped_when_command_overridden(self, monkeypatch: pytest.MonkeyPatch) -> None: + def fail_if_called(manifest, path): + raise AssertionError("calculate_docker_acp_module should not run when command is overridden") + + monkeypatch.setattr(deploy_handlers, "calculate_docker_acp_module", fail_if_called) + env_config = AgentEnvironmentConfig( + auth=AgentAuthConfig(principal={"user_id": "u1"}), + helm_overrides={"command": ["python", "-m", "custom"]}, + ) + helm_values = merge_deployment_configs(make_manifest(), env_config, InputDeployOverrides(), "manifest.yaml") + assert helm_values["command"] == ["python", "-m", "custom"] + + +class TestBackCompatReExports: + def test_historical_names_still_importable(self) -> None: + from agentex.lib.cli.handlers.deploy_handlers import ( # noqa: F401 + TEMPORAL_WORKER_KEY, + _deep_merge, + convert_env_vars_dict_to_list, + add_acp_command_to_helm_values, + ) + + def test_add_acp_command_to_helm_values_still_writes_command(self, fixed_acp_module) -> None: + helm_values: dict = {} + deploy_handlers.add_acp_command_to_helm_values(helm_values, make_manifest(), "manifest.yaml") + assert helm_values["command"] == ["uvicorn", "resolved.module:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tests/test_helm_values.py b/tests/test_helm_values.py new file mode 100644 index 000000000..e4906c0d9 --- /dev/null +++ b/tests/test_helm_values.py @@ -0,0 +1,250 @@ +"""Tests for the slim-safe helm-values generation in ``agentex.config.helm_values``. + +These pin the values contract for the ``agentex-agent`` chart so consumers (the +CLI's ``deploy_handlers`` and server-side deployers) can rely on a single +implementation. See AGX1-357 for the promotion plan. +""" + +from __future__ import annotations + +import json +import base64 + +import pytest + +from agentex.config.helm_values import ( + TEMPORAL_WORKER_KEY, + AUTH_PRINCIPAL_ENV_VAR, + build_acp_command, + derive_acp_module, + encode_principal_context, + merge_deployment_configs, + convert_env_vars_dict_to_list, +) +from agentex.config.agent_config import AgentConfig +from agentex.config.build_config import BuildConfig, BuildContext +from agentex.config.agent_configs import TemporalConfig, TemporalWorkflowConfig +from agentex.config.agent_manifest import AgentManifest +from agentex.config.deployment_config import ( + ImageConfig, + ResourceConfig, + DeploymentConfig, + ResourceRequirements, + ImagePullSecretConfig, +) +from agentex.config.environment_config import AgentAuthConfig, AgentEnvironmentConfig +from agentex.config.local_development_config import LocalAgentConfig, LocalPathsConfig, LocalDevelopmentConfig + +BUILD = BuildConfig(context=BuildContext(root=".", dockerfile="Dockerfile")) + + +def make_manifest(**kwargs) -> AgentManifest: + defaults = dict( + build=BUILD, + agent=AgentConfig(name="test-agent", acp_type="sync", description="test"), + deployment=DeploymentConfig(image=ImageConfig(repository="manifest-repo", tag="manifest-tag")), + ) + defaults.update(kwargs) + return AgentManifest(**defaults) + + +def make_env_config(**kwargs) -> AgentEnvironmentConfig: + defaults = dict(auth=AgentAuthConfig(principal={"user_id": "u1", "account_id": "a1"})) + defaults.update(kwargs) + return AgentEnvironmentConfig(**defaults) + + +def merge(manifest: AgentManifest, env_config: AgentEnvironmentConfig | None = None, **kwargs): + kwargs.setdefault("repository", "repo.example.com/agent") + kwargs.setdefault("image_tag", "abc123") + return merge_deployment_configs(manifest, env_config, **kwargs) + + +class TestMergeDeploymentConfigs: + def test_basic_merge_produces_chart_contract(self) -> None: + helm_values = merge(make_manifest()) + + assert helm_values["global"]["image"] == { + "repository": "repo.example.com/agent", + "tag": "abc123", + "pullPolicy": "IfNotPresent", + } + assert helm_values["global"]["agent"] == { + "name": "test-agent", + "description": "test", + "acp_type": "sync", + } + assert helm_values["replicaCount"] == 1 + assert helm_values["resources"]["requests"] == {"cpu": "500m", "memory": "1Gi"} + assert helm_values["autoscaling"] == { + "enabled": True, + "minReplicas": 1, + "maxReplicas": 10, + "targetCPUUtilizationPercentage": 50, + } + # No temporal config -> no temporal-worker block + assert TEMPORAL_WORKER_KEY not in helm_values + + def test_missing_deployment_raises(self) -> None: + manifest = make_manifest(deployment=None) + with pytest.raises(ValueError, match="No deployment configuration"): + merge(manifest) + + @pytest.mark.parametrize("field", ["repository", "image_tag"]) + def test_empty_image_inputs_raise(self, field: str) -> None: + with pytest.raises(ValueError, match="Repository and image tag are required"): + merge(make_manifest(), **{field: ""}) + + def test_default_command_derived_from_manifest(self) -> None: + helm_values = merge(make_manifest()) + assert helm_values["command"] == ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + + def test_explicit_acp_module_wins(self) -> None: + helm_values = merge(make_manifest(), acp_module="custom.entrypoint") + assert helm_values["command"][1] == "custom.entrypoint:acp" + + def test_command_in_helm_overrides_suppresses_injection(self) -> None: + env_config = make_env_config(helm_overrides={"command": ["python", "-m", "custom"]}) + helm_values = merge(make_manifest(), env_config) + assert helm_values["command"] == ["python", "-m", "custom"] + + def test_temporal_agent_gets_worker_block_and_workflow(self) -> None: + manifest = make_manifest( + agent=AgentConfig( + name="t-agent", + acp_type="async", + description="t", + temporal=TemporalConfig( + enabled=True, + workflows=[TemporalWorkflowConfig(name="wf", queue_name="q")], + ), + ) + ) + helm_values = merge(manifest) + assert helm_values[TEMPORAL_WORKER_KEY]["enabled"] is True + assert helm_values["global"]["workflow"] == {"name": "wf", "taskQueue": "q"} + + def test_env_precedence_manifest_then_overrides_then_secrets(self) -> None: + manifest = make_manifest( + agent=AgentConfig( + name="e-agent", + acp_type="sync", + description="e", + env={"A": "manifest", "B": "manifest", "C": "manifest"}, + credentials=[{"env_var_name": "C", "secret_name": "s", "secret_key": "k"}], + ) + ) + env_config = make_env_config(helm_overrides={"env": [{"name": "B", "value": "override"}]}) + helm_values = merge(manifest, env_config) + + env_by_name = {e["name"]: e["value"] for e in helm_values["env"]} + assert env_by_name["A"] == "manifest" + assert env_by_name["B"] == "override" + # C moved to secretEnvVars; the secret wins over the plain env var + assert "C" not in env_by_name + assert helm_values["secretEnvVars"] == [{"name": "C", "secretName": "s", "secretKey": "k"}] + + def test_temporal_worker_inherits_env_and_secrets(self) -> None: + manifest = make_manifest( + agent=AgentConfig( + name="t-agent", + acp_type="async", + description="t", + env={"A": "v"}, + credentials=[{"env_var_name": "S", "secret_name": "s", "secret_key": "k"}], + temporal=TemporalConfig( + enabled=True, + workflows=[TemporalWorkflowConfig(name="wf", queue_name="q")], + ), + ) + ) + helm_values = merge(manifest, make_env_config()) + worker = helm_values[TEMPORAL_WORKER_KEY] + assert {e["name"] for e in worker["env"]} == {"A", AUTH_PRINCIPAL_ENV_VAR} + assert worker["secretEnvVars"] == [{"name": "S", "secretName": "s", "secretKey": "k"}] + + def test_auth_principal_encoded_into_env(self) -> None: + helm_values = merge(make_manifest(), make_env_config()) + env_by_name = {e["name"]: e["value"] for e in helm_values["env"]} + decoded = json.loads(base64.b64decode(env_by_name[AUTH_PRINCIPAL_ENV_VAR])) + assert decoded == {"user_id": "u1", "account_id": "a1"} + + def test_empty_auth_principal_raises(self) -> None: + env_config = make_env_config(auth=AgentAuthConfig(principal={})) + with pytest.raises(ValueError, match="Auth principal unable to be encoded"): + merge(make_manifest(), env_config) + + def test_image_pull_secrets_copied_from_manifest(self) -> None: + manifest = make_manifest( + deployment=DeploymentConfig( + image=ImageConfig(repository="r", tag="t"), + imagePullSecrets=[ImagePullSecretConfig(name="regcred")], + ) + ) + helm_values = merge(manifest) + assert helm_values["imagePullSecrets"] == [{"name": "regcred"}] + assert helm_values["global"]["imagePullSecrets"] == [{"name": "regcred"}] + + def test_helm_overrides_deep_merge(self) -> None: + env_config = make_env_config(helm_overrides={"resources": {"limits": {"cpu": "2"}}, "extraKey": {"a": 1}}) + helm_values = merge(make_manifest(), env_config) + # Override applied without clobbering sibling keys + assert helm_values["resources"]["limits"]["cpu"] == "2" + assert helm_values["resources"]["limits"]["memory"] == "1Gi" + assert helm_values["resources"]["requests"] == {"cpu": "500m", "memory": "1Gi"} + assert helm_values["extraKey"] == {"a": 1} + + def test_resources_from_manifest(self) -> None: + manifest = make_manifest( + deployment=DeploymentConfig( + image=ImageConfig(repository="r", tag="t"), + **{ + "global": { + "replicaCount": 3, + "resources": ResourceConfig( + requests=ResourceRequirements(cpu="1", memory="2Gi"), + limits=ResourceRequirements(cpu="4", memory="8Gi"), + ), + } + }, + ) + ) + helm_values = merge(manifest) + assert helm_values["replicaCount"] == 3 + assert helm_values["resources"] == { + "requests": {"cpu": "1", "memory": "2Gi"}, + "limits": {"cpu": "4", "memory": "8Gi"}, + } + + +class TestDeriveAcpModule: + def test_default_when_no_local_development(self) -> None: + assert derive_acp_module(make_manifest()) == "project.acp" + + def test_derived_from_configured_path(self) -> None: + manifest = make_manifest( + local_development=LocalDevelopmentConfig( + agent=LocalAgentConfig(port=5000), + paths=LocalPathsConfig(acp="src/agents/main.py"), + ) + ) + assert derive_acp_module(manifest) == "src.agents.main" + + +class TestSmallHelpers: + def test_build_acp_command(self) -> None: + assert build_acp_command("project.acp") == [ + "uvicorn", + "project.acp:acp", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + + def test_convert_env_vars_dict_to_list(self) -> None: + assert convert_env_vars_dict_to_list({"A": "1"}) == [{"name": "A", "value": "1"}] + + def test_encode_principal_context_none_cases(self) -> None: + assert encode_principal_context(None) is None + assert encode_principal_context(AgentAuthConfig(principal={})) is None diff --git a/uv.lock b/uv.lock index be3d8bbd8..8a41ba29c 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,7 @@ members = [ [[package]] name = "agentex-client" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -91,7 +91,7 @@ dev = [ [[package]] name = "agentex-sdk" -version = "0.12.0" +version = "0.13.0" source = { editable = "adk" } dependencies = [ { name = "agentex-client" },