Skip to content

Commit 5f054b5

Browse files
authored
feat: add OCI Helm registry support for agent deployments (#255)
* feat: add OCI Helm registry support for agent deployments Add support for deploying agents using OCI-based Helm registries (e.g., Google Artifact Registry) as an alternative to classic Helm repositories. Changes: - Add `helm_oci_registry` and `helm_chart_version` fields to AgentEnvironmentConfig - Implement auto-login to Google Artifact Registry using gcloud credentials - Add `--use-latest-chart` CLI flag to fetch the latest chart version from OCI registry - Support both classic Helm repo mode and OCI registry mode based on environment config * . * . * refactor: nest OCI fields into sub-model and extract resolve_chart helper Address PR #255 review comments: - Create OciRegistryConfig sub-model replacing flat helm_oci_* fields - Remove use_oci boolean, branch on oci_registry presence directly - Extract resolve_chart() combining chart reference + version resolution - Add tests for nested OCI config YAML parsing
1 parent 6797c3b commit 5f054b5

File tree

4 files changed

+284
-17
lines changed

4 files changed

+284
-17
lines changed

src/agentex/lib/cli/commands/agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ def deploy(
341341
),
342342
tag: str | None = typer.Option(None, help="Override the image tag for deployment"),
343343
repository: str | None = typer.Option(None, help="Override the repository for deployment"),
344+
use_latest_chart: bool = typer.Option(
345+
False, "--use-latest-chart", help="Fetch and use the latest Helm chart version from OCI registry"
346+
),
344347
interactive: bool = typer.Option(True, "--interactive/--no-interactive", help="Enable interactive prompts"),
345348
):
346349
"""Deploy an agent to a Kubernetes cluster using Helm"""
@@ -396,6 +399,8 @@ def deploy(
396399
console.print(f" Namespace: {namespace}")
397400
if tag:
398401
console.print(f" Image Tag: {tag}")
402+
if use_latest_chart:
403+
console.print(" Chart Version: [cyan]latest (will be fetched)[/cyan]")
399404

400405
if interactive:
401406
proceed = questionary.confirm("Proceed with deployment?").ask()
@@ -421,6 +426,7 @@ def deploy(
421426
namespace=namespace,
422427
deploy_overrides=deploy_overrides,
423428
environment_name=environment,
429+
use_latest_chart=use_latest_chart,
424430
)
425431

426432
# Use the already loaded manifest object

src/agentex/lib/cli/handlers/deploy_handlers.py

Lines changed: 207 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
from agentex.lib.cli.utils.kubectl_utils import check_and_switch_cluster_context
1818
from agentex.lib.sdk.config.agent_config import AgentConfig
1919
from agentex.lib.sdk.config.agent_manifest import AgentManifest
20-
from agentex.lib.sdk.config.environment_config import AgentEnvironmentConfig
20+
from agentex.lib.sdk.config.environment_config import OciRegistryConfig, AgentEnvironmentConfig
2121

2222
logger = make_logger(__name__)
2323
console = Console()
2424

2525
TEMPORAL_WORKER_KEY = "temporal-worker"
26-
AGENTEX_AGENTS_HELM_CHART_VERSION = "0.1.9"
26+
DEFAULT_HELM_CHART_VERSION = "0.1.9"
2727

2828

2929
class InputDeployOverrides(BaseModel):
@@ -42,7 +42,7 @@ def check_helm_installed() -> bool:
4242

4343

4444
def add_helm_repo(helm_repository_name: str, helm_repository_url: str) -> None:
45-
"""Add the agentex helm repository if not already added"""
45+
"""Add the agentex helm repository if not already added (classic mode)"""
4646
try:
4747
# Check if repo already exists
4848
result = subprocess.run(["helm", "repo", "list"], capture_output=True, text=True, check=True)
@@ -69,6 +69,166 @@ def add_helm_repo(helm_repository_name: str, helm_repository_url: str) -> None:
6969
raise HelmError(f"Failed to add helm repository: {e}") from e
7070

7171

72+
def login_to_gar_registry(oci_registry: str) -> None:
73+
"""Auto-login to Google Artifact Registry using gcloud credentials.
74+
75+
Args:
76+
oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name')
77+
"""
78+
try:
79+
# Extract the registry host (e.g., 'us-west1-docker.pkg.dev')
80+
registry_host = oci_registry.split("/")[0]
81+
82+
# Get access token from gcloud
83+
console.print(f"[blue]ℹ[/blue] Authenticating with Google Artifact Registry: {registry_host}")
84+
result = subprocess.run(
85+
["gcloud", "auth", "print-access-token"],
86+
capture_output=True,
87+
text=True,
88+
check=True,
89+
)
90+
access_token = result.stdout.strip()
91+
92+
# Login to helm registry using the access token
93+
subprocess.run(
94+
[
95+
"helm",
96+
"registry",
97+
"login",
98+
registry_host,
99+
"--username",
100+
"oauth2accesstoken",
101+
"--password-stdin",
102+
],
103+
input=access_token,
104+
text=True,
105+
check=True,
106+
)
107+
console.print(f"[green]✓[/green] Authenticated with GAR: {registry_host}")
108+
109+
except subprocess.CalledProcessError as e:
110+
raise HelmError(
111+
f"Failed to authenticate with Google Artifact Registry: {e}\n"
112+
"Ensure you are logged in with 'gcloud auth login' and have access to the registry."
113+
) from e
114+
except FileNotFoundError:
115+
raise HelmError(
116+
"gcloud CLI not found. Please install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install"
117+
) from None
118+
119+
120+
def get_latest_gar_chart_version(oci_registry: str, chart_name: str = "agentex-agent") -> str:
121+
"""Fetch the latest version of a Helm chart from Google Artifact Registry.
122+
123+
GAR stores Helm chart versions as tags (e.g., '0.1.9'), not as versions (which are SHA digests).
124+
This function lists tags sorted by creation time and returns the most recent one.
125+
126+
Args:
127+
oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name')
128+
chart_name: Name of the Helm chart
129+
130+
Returns:
131+
The latest version string (e.g., '0.2.0')
132+
"""
133+
try:
134+
# Parse the OCI registry URL to extract components
135+
# Format: REGION-docker.pkg.dev/PROJECT/REPOSITORY
136+
parts = oci_registry.split("/")
137+
if len(parts) < 3:
138+
raise HelmError(
139+
f"Invalid OCI registry format: {oci_registry}. "
140+
"Expected format: REGION-docker.pkg.dev/PROJECT/REPOSITORY"
141+
)
142+
143+
location = parts[0].replace("-docker.pkg.dev", "")
144+
project = parts[1]
145+
repository = parts[2]
146+
147+
console.print(f"[blue]ℹ[/blue] Fetching latest chart version from GAR...")
148+
149+
# Use gcloud to list tags (not versions - versions are SHA digests)
150+
# Tags contain the semantic versions like '0.1.9'
151+
result = subprocess.run(
152+
[
153+
"gcloud",
154+
"artifacts",
155+
"tags",
156+
"list",
157+
f"--repository={repository}",
158+
f"--location={location}",
159+
f"--project={project}",
160+
f"--package={chart_name}",
161+
"--sort-by=~createTime",
162+
"--limit=1",
163+
"--format=value(tag)",
164+
],
165+
capture_output=True,
166+
text=True,
167+
check=True,
168+
)
169+
170+
output = result.stdout.strip()
171+
if not output:
172+
raise HelmError(f"No tags found for chart '{chart_name}' in {oci_registry}")
173+
174+
# The output is the tag name (semantic version)
175+
version = output
176+
console.print(f"[green]✓[/green] Latest chart version: {version}")
177+
return version
178+
179+
except subprocess.CalledProcessError as e:
180+
raise HelmError(
181+
f"Failed to fetch chart tags from GAR: {e.stderr}\nEnsure you have access to the Artifact Registry."
182+
) from e
183+
except FileNotFoundError:
184+
raise HelmError(
185+
"gcloud CLI not found. Please install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install"
186+
) from None
187+
188+
189+
def resolve_chart(
190+
oci_registry: OciRegistryConfig | None,
191+
helm_repository_name: str | None,
192+
use_latest_chart: bool,
193+
chart_name: str = "agentex-agent",
194+
) -> tuple[str, str]:
195+
"""Resolve the chart reference and version based on the deployment mode.
196+
197+
For OCI mode, builds an oci:// reference and resolves version from:
198+
--use-latest-chart (GAR only) > oci_registry.chart_version > default.
199+
For classic mode, builds a repo/chart reference and uses default version.
200+
201+
Returns:
202+
(chart_reference, chart_version)
203+
"""
204+
if oci_registry:
205+
chart_reference = f"oci://{oci_registry.url}/{chart_name}"
206+
207+
if use_latest_chart:
208+
if oci_registry.provider != "gar":
209+
console.print(
210+
"[yellow]⚠[/yellow] --use-latest-chart only works with GAR provider (provider: gar), using default version"
211+
)
212+
chart_version = DEFAULT_HELM_CHART_VERSION
213+
else:
214+
chart_version = get_latest_gar_chart_version(oci_registry.url)
215+
elif oci_registry.chart_version:
216+
chart_version = oci_registry.chart_version
217+
else:
218+
chart_version = DEFAULT_HELM_CHART_VERSION
219+
else:
220+
if not helm_repository_name:
221+
raise HelmError("Helm repository name is required for classic mode")
222+
chart_reference = f"{helm_repository_name}/{chart_name}"
223+
224+
if use_latest_chart:
225+
console.print("[yellow]⚠[/yellow] --use-latest-chart only works with OCI registries, using default version")
226+
chart_version = DEFAULT_HELM_CHART_VERSION
227+
228+
console.print(f"[blue]ℹ[/blue] Using Helm chart version: {chart_version}")
229+
return chart_reference, chart_version
230+
231+
72232
def convert_env_vars_dict_to_list(env_vars: dict[str, str]) -> list[dict[str, str]]:
73233
"""Convert a dictionary of environment variables to a list of dictionaries"""
74234
return [{"name": key, "value": value} for key, value in env_vars.items()]
@@ -281,8 +441,18 @@ def deploy_agent(
281441
namespace: str,
282442
deploy_overrides: InputDeployOverrides,
283443
environment_name: str | None = None,
444+
use_latest_chart: bool = False,
284445
) -> None:
285-
"""Deploy an agent using helm"""
446+
"""Deploy an agent using helm
447+
448+
Args:
449+
manifest_path: Path to the agent manifest file
450+
cluster_name: Target Kubernetes cluster name
451+
namespace: Kubernetes namespace to deploy to
452+
deploy_overrides: Image repository/tag overrides
453+
environment_name: Environment name from environments.yaml
454+
use_latest_chart: If True, fetch and use the latest chart version from OCI registry (OCI mode only)
455+
"""
286456

287457
# Validate prerequisites
288458
if not check_helm_installed():
@@ -304,14 +474,36 @@ def deploy_agent(
304474
else:
305475
console.print(f"[yellow]⚠[/yellow] No environments.yaml found, skipping environment-specific config")
306476

307-
if agent_env_config:
308-
helm_repository_name = agent_env_config.helm_repository_name
309-
helm_repository_url = agent_env_config.helm_repository_url
477+
# Determine deployment mode: OCI registry or classic helm repo
478+
oci_registry = agent_env_config.oci_registry if agent_env_config else None
479+
helm_repository_name: str | None = None
480+
481+
if oci_registry:
482+
console.print(f"[blue]ℹ[/blue] Using OCI Helm registry: {oci_registry.url}")
483+
484+
# Only auto-authenticate for GAR provider
485+
if oci_registry.provider == "gar":
486+
login_to_gar_registry(oci_registry.url)
487+
else:
488+
console.print(
489+
"[blue]ℹ[/blue] Skipping auto-authentication (no provider specified, assuming already authenticated)"
490+
)
310491
else:
311-
helm_repository_name = "scale-egp"
312-
helm_repository_url = "https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts"
313-
# Add helm repository/update
314-
add_helm_repo(helm_repository_name, helm_repository_url)
492+
if agent_env_config:
493+
helm_repository_name = agent_env_config.helm_repository_name
494+
helm_repository_url = agent_env_config.helm_repository_url
495+
else:
496+
helm_repository_name = "scale-egp"
497+
helm_repository_url = "https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts"
498+
# Add helm repository/update (classic mode only)
499+
add_helm_repo(helm_repository_name, helm_repository_url)
500+
501+
# Resolve chart reference and version in one step
502+
chart_reference, chart_version = resolve_chart(
503+
oci_registry=oci_registry,
504+
helm_repository_name=helm_repository_name,
505+
use_latest_chart=use_latest_chart,
506+
)
315507

316508
# Merge configurations
317509
helm_values = merge_deployment_configs(manifest, agent_env_config, deploy_overrides, manifest_path)
@@ -341,9 +533,9 @@ def deploy_agent(
341533
"helm",
342534
"upgrade",
343535
release_name,
344-
f"{helm_repository_name}/agentex-agent",
536+
chart_reference,
345537
"--version",
346-
AGENTEX_AGENTS_HELM_CHART_VERSION,
538+
chart_version,
347539
"-f",
348540
values_file,
349541
"-n",
@@ -363,9 +555,9 @@ def deploy_agent(
363555
"helm",
364556
"install",
365557
release_name,
366-
f"{helm_repository_name}/agentex-agent",
558+
chart_reference,
367559
"--version",
368-
AGENTEX_AGENTS_HELM_CHART_VERSION,
560+
chart_version,
369561
"-f",
370562
values_file,
371563
"-n",

src/agentex/lib/sdk/config/environment_config.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from __future__ import annotations
99

10-
from typing import Any, Dict, override
10+
from typing import Any, Dict, Literal, override
1111
from pathlib import Path
1212

1313
import yaml
@@ -55,6 +55,25 @@ def validate_namespace_format(cls, v: str) -> str:
5555
return namespace
5656

5757

58+
class OciRegistryConfig(BaseModel):
59+
"""OCI registry configuration for Helm chart deployments."""
60+
61+
url: str = Field(
62+
...,
63+
description="OCI registry URL for Helm charts (e.g., 'us-west1-docker.pkg.dev/project/repo'). "
64+
"When set, OCI mode is used instead of classic helm repo.",
65+
)
66+
provider: Literal["gar"] | None = Field(
67+
default=None,
68+
description="OCI registry provider for provider-specific features. "
69+
"Set to 'gar' for Google Artifact Registry to enable auto-authentication via gcloud "
70+
"and latest version fetching. When not set, assumes user has already authenticated.",
71+
)
72+
chart_version: str | None = Field(
73+
default=None, description="Helm chart version to deploy. If not set, uses the default version from the CLI."
74+
)
75+
76+
5877
class AgentEnvironmentConfig(BaseModel):
5978
"""Complete configuration for an agent in a specific environment."""
6079

@@ -67,7 +86,10 @@ class AgentEnvironmentConfig(BaseModel):
6786
helm_repository_name: str = Field(default="scale-egp", description="Helm repository name for the environment")
6887
helm_repository_url: str = Field(
6988
default="https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts",
70-
description="Helm repository url for the environment",
89+
description="Helm repository url for the environment (classic mode)",
90+
)
91+
oci_registry: OciRegistryConfig | None = Field(
92+
default=None, description="OCI registry configuration. When set, OCI mode is used instead of classic helm repo."
7193
)
7294
helm_overrides: Dict[str, Any] = Field(
7395
default_factory=dict, description="Helm chart value overrides for environment-specific tuning"

0 commit comments

Comments
 (0)