Skip to content

Commit 9a8cf39

Browse files
refactor(config): promote helm-values generation to agentex.config.helm_values
The manifest+environments.yaml -> helm-values mapping in agentex.lib.cli.handlers.deploy_handlers depends only on the stdlib and the agentex.config models, but living in the heavy ADK forced server-side deployers (egp-api-backend) to fork it — and the forks have drifted. Promote the pure mapping to agentex.config.helm_values (slim-safe, same contract as the #396 config-models promotion) and parameterize the consumer differences: repository/image_tag as explicit args, acp_module for pre-resolved ACP modules (filesystem resolution stays in agentex.lib.cli.utils.path_utils). The CLI wrapper keeps its signature, DeploymentError contract, conditional module resolution, and all current policy defaults — behavior-preserving except two before/after-merge debug log lines that no longer fire. Part of AGX1-357. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 35fc4b8 commit 9a8cf39

5 files changed

Lines changed: 656 additions & 189 deletions

File tree

src/agentex/config/helm_values.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""Pure helm-values generation for the ``agentex-agent`` chart.
2+
3+
Maps an :class:`~agentex.config.agent_manifest.AgentManifest` plus an optional
4+
:class:`~agentex.config.environment_config.AgentEnvironmentConfig` to the values
5+
dict the ``agentex-agent`` helm chart consumes. Depends only on pydantic and the
6+
stdlib, so it is safe to import from a slim REST-only install without the ADK
7+
runtime — the same contract as the other ``agentex.config`` modules.
8+
9+
Filesystem-aware ACP module resolution stays in
10+
``agentex.lib.cli.utils.path_utils``: callers that have the agent source tree on
11+
disk should resolve the module themselves and pass ``acp_module``. Callers
12+
without one (e.g. server-side deployers) get :func:`derive_acp_module`'s
13+
pure-string derivation by default.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import json
19+
import base64
20+
import logging
21+
from typing import Any
22+
23+
from agentex.config.agent_manifest import AgentManifest
24+
from agentex.config.environment_config import AgentAuthConfig, AgentEnvironmentConfig
25+
26+
logger = logging.getLogger(__name__)
27+
28+
TEMPORAL_WORKER_KEY = "temporal-worker"
29+
AUTH_PRINCIPAL_ENV_VAR = "AUTH_PRINCIPAL_B64"
30+
31+
__all__ = [
32+
"AUTH_PRINCIPAL_ENV_VAR",
33+
"TEMPORAL_WORKER_KEY",
34+
"build_acp_command",
35+
"derive_acp_module",
36+
"encode_principal_context",
37+
"convert_env_vars_dict_to_list",
38+
"merge_deployment_configs",
39+
]
40+
41+
42+
def convert_env_vars_dict_to_list(env_vars: dict[str, str]) -> list[dict[str, str]]:
43+
"""Convert a dictionary of environment variables to a list of dictionaries"""
44+
return [{"name": key, "value": value} for key, value in env_vars.items()]
45+
46+
47+
def encode_principal_context(auth_config: AgentAuthConfig | None) -> str | None:
48+
"""Base64-encode the auth principal as compact JSON, or None if unset."""
49+
if auth_config is None:
50+
return None
51+
52+
principal = auth_config.principal
53+
if not principal:
54+
return None
55+
56+
json_str = json.dumps(principal, separators=(",", ":"))
57+
return base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
58+
59+
60+
def derive_acp_module(manifest: AgentManifest) -> str:
61+
"""Derive the ACP module from the manifest by pure string transform.
62+
63+
Callers with the agent source tree on disk should prefer the filesystem-aware
64+
resolution in ``agentex.lib.cli.utils.path_utils.calculate_docker_acp_module``
65+
and pass its result to :func:`merge_deployment_configs` as ``acp_module``.
66+
"""
67+
if manifest.local_development and manifest.local_development.paths:
68+
acp_path = manifest.local_development.paths.acp
69+
if acp_path:
70+
return acp_path.replace(".py", "").replace("/", ".")
71+
return "project.acp"
72+
73+
74+
def build_acp_command(acp_module: str) -> list[str]:
75+
"""Build the uvicorn command that runs the agent's ACP server."""
76+
return ["uvicorn", f"{acp_module}:acp", "--host", "0.0.0.0", "--port", "8000"]
77+
78+
79+
def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> None:
80+
"""Deep merge override_dict into base_dict"""
81+
for key, value in override_dict.items():
82+
if key in base_dict and isinstance(base_dict[key], dict) and isinstance(value, dict):
83+
_deep_merge(base_dict[key], value)
84+
else:
85+
base_dict[key] = value
86+
87+
88+
def merge_deployment_configs(
89+
manifest: AgentManifest,
90+
agent_env_config: AgentEnvironmentConfig | None,
91+
*,
92+
repository: str,
93+
image_tag: str,
94+
acp_module: str | None = None,
95+
) -> dict[str, Any]:
96+
"""Merge global deployment config with environment-specific overrides into helm values.
97+
98+
Args:
99+
manifest: The agent manifest configuration.
100+
agent_env_config: Environment-specific configuration (optional).
101+
repository: Container image repository to deploy.
102+
image_tag: Container image tag to deploy.
103+
acp_module: Pre-resolved ACP module for the uvicorn command. Defaults to
104+
:func:`derive_acp_module`'s pure-string derivation.
105+
106+
Returns:
107+
Dictionary of helm values ready for deployment.
108+
109+
Raises:
110+
ValueError: If deployment configuration is missing or invalid.
111+
"""
112+
agent_config = manifest.agent
113+
114+
if not manifest.deployment:
115+
raise ValueError("No deployment configuration found in manifest")
116+
117+
if not repository or not image_tag:
118+
raise ValueError("Repository and image tag are required")
119+
120+
# Start with global configuration
121+
helm_values: dict[str, Any] = {
122+
"global": {
123+
"image": {
124+
"repository": repository,
125+
"tag": image_tag,
126+
"pullPolicy": "IfNotPresent",
127+
},
128+
"agent": {
129+
"name": manifest.agent.name,
130+
"description": manifest.agent.description,
131+
"acp_type": manifest.agent.acp_type,
132+
},
133+
},
134+
"replicaCount": manifest.deployment.global_config.replicaCount,
135+
"resources": {
136+
"requests": {
137+
"cpu": manifest.deployment.global_config.resources.requests.cpu,
138+
"memory": manifest.deployment.global_config.resources.requests.memory,
139+
},
140+
"limits": {
141+
"cpu": manifest.deployment.global_config.resources.limits.cpu,
142+
"memory": manifest.deployment.global_config.resources.limits.memory,
143+
},
144+
},
145+
# Enable autoscaling by default for production deployments
146+
"autoscaling": {
147+
"enabled": True,
148+
"minReplicas": 1,
149+
"maxReplicas": 10,
150+
"targetCPUUtilizationPercentage": 50,
151+
},
152+
}
153+
154+
# Handle temporal configuration using new helper methods
155+
if agent_config.is_temporal_agent():
156+
temporal_config = agent_config.get_temporal_workflow_config()
157+
if temporal_config:
158+
helm_values[TEMPORAL_WORKER_KEY] = {
159+
"enabled": True,
160+
# Enable autoscaling for temporal workers as well
161+
"autoscaling": {
162+
"enabled": True,
163+
"minReplicas": 1,
164+
"maxReplicas": 10,
165+
"targetCPUUtilizationPercentage": 50,
166+
},
167+
}
168+
helm_values["global"]["workflow"] = {
169+
"name": temporal_config.name,
170+
"taskQueue": temporal_config.queue_name,
171+
}
172+
173+
# Collect all environment variables with proper precedence
174+
# Priority: manifest -> environments.yaml -> secrets (highest)
175+
all_env_vars: dict[str, str] = {}
176+
secret_env_vars: list[dict[str, str]] = []
177+
178+
# Start with agent_config env vars from manifest
179+
if agent_config.env:
180+
all_env_vars.update(agent_config.env)
181+
182+
# Override with environment config env vars if they exist
183+
if agent_env_config and agent_env_config.helm_overrides and "env" in agent_env_config.helm_overrides:
184+
env_overrides = agent_env_config.helm_overrides["env"]
185+
if isinstance(env_overrides, list):
186+
# Convert list format to dict for easier merging
187+
env_override_dict: dict[str, str] = {}
188+
for env_var in env_overrides:
189+
if isinstance(env_var, dict) and "name" in env_var and "value" in env_var:
190+
env_override_dict[str(env_var["name"])] = str(env_var["value"])
191+
all_env_vars.update(env_override_dict)
192+
193+
# Handle credentials and check for conflicts
194+
if agent_config.credentials:
195+
for credential in agent_config.credentials:
196+
# Handle both CredentialMapping objects and legacy dict format
197+
if isinstance(credential, dict):
198+
env_var_name = credential["env_var_name"]
199+
secret_name = credential["secret_name"]
200+
secret_key = credential["secret_key"]
201+
else:
202+
env_var_name = credential.env_var_name
203+
secret_name = credential.secret_name
204+
secret_key = credential.secret_key
205+
206+
# Check if the environment variable name conflicts with existing env vars
207+
if env_var_name in all_env_vars:
208+
logger.warning(
209+
f"Environment variable '{env_var_name}' is defined in both "
210+
f"env and secretEnvVars. The secret value will take precedence."
211+
)
212+
# Remove from regular env vars since secret takes precedence
213+
del all_env_vars[env_var_name]
214+
215+
secret_env_vars.append(
216+
{
217+
"name": env_var_name,
218+
"secretName": secret_name,
219+
"secretKey": secret_key,
220+
}
221+
)
222+
223+
# Apply agent environment configuration overrides
224+
if agent_env_config:
225+
# Add auth principal env var if environment config is set
226+
if agent_env_config.auth:
227+
encoded_principal = encode_principal_context(agent_env_config.auth)
228+
if encoded_principal:
229+
all_env_vars[AUTH_PRINCIPAL_ENV_VAR] = encoded_principal
230+
else:
231+
raise ValueError(f"Auth principal unable to be encoded for agent_env_config: {agent_env_config}")
232+
233+
if agent_env_config.helm_overrides:
234+
_deep_merge(helm_values, agent_env_config.helm_overrides)
235+
236+
# Set final environment variables
237+
# Environment variable precedence: manifest -> environments.yaml -> secrets (highest)
238+
if all_env_vars:
239+
helm_values["env"] = convert_env_vars_dict_to_list(all_env_vars)
240+
241+
if secret_env_vars:
242+
helm_values["secretEnvVars"] = secret_env_vars
243+
244+
# Set environment variables for temporal worker if enabled
245+
if TEMPORAL_WORKER_KEY in helm_values:
246+
if all_env_vars:
247+
helm_values[TEMPORAL_WORKER_KEY]["env"] = convert_env_vars_dict_to_list(all_env_vars)
248+
if secret_env_vars:
249+
helm_values[TEMPORAL_WORKER_KEY]["secretEnvVars"] = secret_env_vars
250+
251+
# Handle image pull secrets
252+
if manifest.deployment and manifest.deployment.imagePullSecrets:
253+
pull_secrets = [pull_secret.model_dump() for pull_secret in manifest.deployment.imagePullSecrets]
254+
helm_values["global"]["imagePullSecrets"] = pull_secrets
255+
helm_values["imagePullSecrets"] = pull_secrets
256+
257+
# Add dynamic ACP command based on manifest configuration if command is not set in helm overrides
258+
helm_overrides_command = (
259+
agent_env_config and agent_env_config.helm_overrides and "command" in agent_env_config.helm_overrides
260+
)
261+
if not helm_overrides_command:
262+
module = acp_module or derive_acp_module(manifest)
263+
helm_values["command"] = build_acp_command(module)
264+
logger.info(f"Using ACP command: uvicorn {module}:acp")
265+
266+
return helm_values

0 commit comments

Comments
 (0)