From e3778c1890e9e935920d173602ef2b19b00c6738 Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Tue, 3 Feb 2026 15:49:01 -0800 Subject: [PATCH 1/3] 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 --- src/agentex/lib/cli/commands/agents.py | 6 + .../lib/cli/handlers/deploy_handlers.py | 221 ++++++++++++++++-- .../lib/sdk/config/environment_config.py | 29 ++- 3 files changed, 234 insertions(+), 22 deletions(-) diff --git a/src/agentex/lib/cli/commands/agents.py b/src/agentex/lib/cli/commands/agents.py index 10dab0d03..ea8204611 100644 --- a/src/agentex/lib/cli/commands/agents.py +++ b/src/agentex/lib/cli/commands/agents.py @@ -262,6 +262,9 @@ def deploy( repository: str | None = typer.Option( None, help="Override the repository for deployment" ), + use_latest_chart: bool = typer.Option( + False, "--use-latest-chart", help="Fetch and use the latest Helm chart version from OCI registry" + ), interactive: bool = typer.Option( True, "--interactive/--no-interactive", help="Enable interactive prompts" ), @@ -322,6 +325,8 @@ def deploy( console.print(f" Namespace: {namespace}") if tag: console.print(f" Image Tag: {tag}") + if use_latest_chart: + console.print(" Chart Version: [cyan]latest (will be fetched)[/cyan]") if interactive: proceed = questionary.confirm("Proceed with deployment?").ask() @@ -351,6 +356,7 @@ def deploy( namespace=namespace, deploy_overrides=deploy_overrides, environment_name=environment, + use_latest_chart=use_latest_chart, ) # Use the already loaded manifest object diff --git a/src/agentex/lib/cli/handlers/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py index c5af88cd7..cb6432f23 100644 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -23,7 +23,7 @@ console = Console() TEMPORAL_WORKER_KEY = "temporal-worker" -AGENTEX_AGENTS_HELM_CHART_VERSION = "0.1.9" +DEFAULT_HELM_CHART_VERSION = "0.1.9" class InputDeployOverrides(BaseModel): @@ -42,7 +42,7 @@ def check_helm_installed() -> bool: def add_helm_repo(helm_repository_name: str, helm_repository_url: str) -> None: - """Add the agentex helm repository if not already added""" + """Add the agentex helm repository if not already added (classic mode)""" try: # Check if repo already exists result = subprocess.run(["helm", "repo", "list"], capture_output=True, text=True, check=True) @@ -69,6 +69,157 @@ def add_helm_repo(helm_repository_name: str, helm_repository_url: str) -> None: raise HelmError(f"Failed to add helm repository: {e}") from e +def login_to_gar_registry(oci_registry: str) -> None: + """Auto-login to Google Artifact Registry using gcloud credentials. + + Args: + oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name') + """ + try: + # Extract the registry host (e.g., 'us-west1-docker.pkg.dev') + registry_host = oci_registry.split("/")[0] + + # Get access token from gcloud + console.print(f"[blue]ℹ[/blue] Authenticating with Google Artifact Registry: {registry_host}") + result = subprocess.run( + ["gcloud", "auth", "print-access-token"], + capture_output=True, + text=True, + check=True, + ) + access_token = result.stdout.strip() + + # Login to helm registry using the access token + subprocess.run( + [ + "helm", + "registry", + "login", + registry_host, + "--username", + "oauth2accesstoken", + "--password-stdin", + ], + input=access_token, + text=True, + check=True, + ) + console.print(f"[green]✓[/green] Authenticated with GAR: {registry_host}") + + except subprocess.CalledProcessError as e: + raise HelmError( + f"Failed to authenticate with Google Artifact Registry: {e}\n" + "Ensure you are logged in with 'gcloud auth login' and have access to the registry." + ) from e + except FileNotFoundError: + raise HelmError( + "gcloud CLI not found. Please install the Google Cloud SDK: " + "https://cloud.google.com/sdk/docs/install" + ) from None + + +def get_latest_gar_chart_version(oci_registry: str, chart_name: str = "agentex-agent") -> str: + """Fetch the latest version of a Helm chart from Google Artifact Registry. + + GAR stores Helm chart versions as tags (e.g., '0.1.9'), not as versions (which are SHA digests). + This function lists tags sorted by creation time and returns the most recent one. + + Args: + oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name') + chart_name: Name of the Helm chart + + Returns: + The latest version string (e.g., '0.2.0') + """ + try: + # Parse the OCI registry URL to extract components + # Format: REGION-docker.pkg.dev/PROJECT/REPOSITORY + parts = oci_registry.split("/") + if len(parts) < 3: + raise HelmError( + f"Invalid OCI registry format: {oci_registry}. " + "Expected format: REGION-docker.pkg.dev/PROJECT/REPOSITORY" + ) + + location = parts[0].replace("-docker.pkg.dev", "") + project = parts[1] + repository = parts[2] + + console.print(f"[blue]ℹ[/blue] Fetching latest chart version from GAR...") + + # Use gcloud to list tags (not versions - versions are SHA digests) + # Tags contain the semantic versions like '0.1.9' + result = subprocess.run( + [ + "gcloud", + "artifacts", + "tags", + "list", + f"--repository={repository}", + f"--location={location}", + f"--project={project}", + f"--package={chart_name}", + "--sort-by=~createTime", + "--limit=1", + "--format=value(tag)", + ], + capture_output=True, + text=True, + check=True, + ) + + output = result.stdout.strip() + if not output: + raise HelmError( + f"No tags found for chart '{chart_name}' in {oci_registry}" + ) + + # The output is the tag name (semantic version) + version = output + console.print(f"[green]✓[/green] Latest chart version: {version}") + return version + + except subprocess.CalledProcessError as e: + raise HelmError( + f"Failed to fetch chart tags from GAR: {e.stderr}\n" + "Ensure you have access to the Artifact Registry." + ) from e + except FileNotFoundError: + raise HelmError( + "gcloud CLI not found. Please install the Google Cloud SDK: " + "https://cloud.google.com/sdk/docs/install" + ) from None + + +def get_chart_reference( + use_oci: bool, + helm_repository_name: str | None = None, + oci_registry: str | None = None, + chart_name: str = "agentex-agent", +) -> str: + """Get the chart reference based on the deployment mode. + + Args: + use_oci: Whether to use OCI registry mode + helm_repository_name: Name of the classic helm repo (required if use_oci=False) + oci_registry: OCI registry URL (required if use_oci=True) + chart_name: Name of the helm chart + + Returns: + Chart reference string for helm install/upgrade commands + """ + if use_oci: + if not oci_registry: + raise HelmError("OCI registry URL is required for OCI mode") + # OCI format: oci://registry/path/chart-name + return f"oci://{oci_registry}/{chart_name}" + else: + if not helm_repository_name: + raise HelmError("Helm repository name is required for classic mode") + # Classic format: repo-name/chart-name + return f"{helm_repository_name}/{chart_name}" + + 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()] @@ -281,8 +432,18 @@ def deploy_agent( namespace: str, deploy_overrides: InputDeployOverrides, environment_name: str | None = None, + use_latest_chart: bool = False, ) -> None: - """Deploy an agent using helm""" + """Deploy an agent using helm + + Args: + manifest_path: Path to the agent manifest file + cluster_name: Target Kubernetes cluster name + namespace: Kubernetes namespace to deploy to + deploy_overrides: Image repository/tag overrides + environment_name: Environment name from environments.yaml + use_latest_chart: If True, fetch and use the latest chart version from OCI registry (OCI mode only) + """ # Validate prerequisites if not check_helm_installed(): @@ -304,14 +465,46 @@ def deploy_agent( else: console.print(f"[yellow]⚠[/yellow] No environments.yaml found, skipping environment-specific config") - if agent_env_config: - helm_repository_name = agent_env_config.helm_repository_name - helm_repository_url = agent_env_config.helm_repository_url + # Determine if using OCI or classic helm repo mode + use_oci = agent_env_config.uses_oci_registry() if agent_env_config else False + helm_repository_name: str | None = None + oci_registry: str | None = None + + if use_oci: + oci_registry = agent_env_config.helm_oci_registry # type: ignore[union-attr] + console.print(f"[blue]ℹ[/blue] Using OCI Helm registry: {oci_registry}") + login_to_gar_registry(oci_registry) # type: ignore[arg-type] + else: + if agent_env_config: + helm_repository_name = agent_env_config.helm_repository_name + helm_repository_url = agent_env_config.helm_repository_url + else: + helm_repository_name = "scale-egp" + helm_repository_url = "https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts" + # Add helm repository/update (classic mode only) + add_helm_repo(helm_repository_name, helm_repository_url) + + # Get the chart reference based on deployment mode + chart_reference = get_chart_reference( + use_oci=use_oci, + helm_repository_name=helm_repository_name, + oci_registry=oci_registry, + ) + + # Determine chart version + # Priority: --use-latest-chart > env config > default + if use_latest_chart: + if not use_oci: + console.print("[yellow]⚠[/yellow] --use-latest-chart only works with OCI registries, using default version") + chart_version = DEFAULT_HELM_CHART_VERSION + else: + chart_version = get_latest_gar_chart_version(oci_registry) # type: ignore[arg-type] + elif agent_env_config and agent_env_config.helm_chart_version: + chart_version = agent_env_config.helm_chart_version else: - helm_repository_name = "scale-egp" - helm_repository_url = "https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts" - # Add helm repository/update - add_helm_repo(helm_repository_name, helm_repository_url) + chart_version = DEFAULT_HELM_CHART_VERSION + + console.print(f"[blue]ℹ[/blue] Using Helm chart version: {chart_version}") # Merge configurations helm_values = merge_deployment_configs(manifest, agent_env_config, deploy_overrides, manifest_path) @@ -341,9 +534,9 @@ def deploy_agent( "helm", "upgrade", release_name, - f"{helm_repository_name}/agentex-agent", + chart_reference, "--version", - AGENTEX_AGENTS_HELM_CHART_VERSION, + chart_version, "-f", values_file, "-n", @@ -363,9 +556,9 @@ def deploy_agent( "helm", "install", release_name, - f"{helm_repository_name}/agentex-agent", + chart_reference, "--version", - AGENTEX_AGENTS_HELM_CHART_VERSION, + chart_version, "-f", values_file, "-n", diff --git a/src/agentex/lib/sdk/config/environment_config.py b/src/agentex/lib/sdk/config/environment_config.py index 959e26830..e39e3ef73 100644 --- a/src/agentex/lib/sdk/config/environment_config.py +++ b/src/agentex/lib/sdk/config/environment_config.py @@ -64,28 +64,41 @@ def validate_namespace_format(cls, v: str) -> str: class AgentEnvironmentConfig(BaseModel): """Complete configuration for an agent in a specific environment.""" - + kubernetes: AgentKubernetesConfig | None = Field( - default=None, + default=None, description="Kubernetes deployment configuration" ) auth: AgentAuthConfig = Field( - ..., + ..., description="Authentication and authorization configuration" ) helm_repository_name: str = Field( - default="scale-egp", - description="Helm repository name for the environment" + default="scale-egp", + description="Helm repository name for the environment (classic mode)" ) helm_repository_url: str = Field( - default="https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts", - description="Helm repository url for the environment" + default="https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts", + description="Helm repository url for the environment (classic mode)" + ) + helm_oci_registry: str | None = Field( + default=None, + description="OCI registry URL for Helm charts (e.g., 'us-west1-docker.pkg.dev/project/repo'). " + "When set, OCI mode is used instead of classic helm repo." + ) + helm_chart_version: str | None = Field( + default=None, + description="Helm chart version to deploy. If not set, uses the default version from the CLI." ) helm_overrides: Dict[str, Any] = Field( - default_factory=dict, + default_factory=dict, description="Helm chart value overrides for environment-specific tuning" ) + def uses_oci_registry(self) -> bool: + """Check if this environment uses OCI registry for Helm charts.""" + return self.helm_oci_registry is not None + class AgentEnvironmentsConfig(UtilsBaseModel): """All environment configurations for an agent.""" From b0dfe1afea858784cf6b89764db2c9c9243f79a5 Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Tue, 3 Feb 2026 15:54:32 -0800 Subject: [PATCH 2/3] . --- src/agentex/lib/cli/commands/agents.py | 8 ++------ src/agentex/lib/sdk/config/environment_config.py | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/agentex/lib/cli/commands/agents.py b/src/agentex/lib/cli/commands/agents.py index d3fe59776..05e613a99 100644 --- a/src/agentex/lib/cli/commands/agents.py +++ b/src/agentex/lib/cli/commands/agents.py @@ -340,15 +340,11 @@ def deploy( help="Environment name (dev, prod, etc.) - must be defined in environments.yaml. If not provided, the namespace must be set explicitly.", ), tag: str | None = typer.Option(None, help="Override the image tag for deployment"), - repository: str | None = typer.Option( - None, help="Override the repository for deployment" - ), + repository: str | None = typer.Option(None, help="Override the repository for deployment"), use_latest_chart: bool = typer.Option( False, "--use-latest-chart", help="Fetch and use the latest Helm chart version from OCI registry" ), - interactive: bool = typer.Option( - True, "--interactive/--no-interactive", help="Enable interactive prompts" - ), + interactive: bool = typer.Option(True, "--interactive/--no-interactive", help="Enable interactive prompts"), ): """Deploy an agent to a Kubernetes cluster using Helm""" diff --git a/src/agentex/lib/sdk/config/environment_config.py b/src/agentex/lib/sdk/config/environment_config.py index 38629d36f..97803e667 100644 --- a/src/agentex/lib/sdk/config/environment_config.py +++ b/src/agentex/lib/sdk/config/environment_config.py @@ -79,8 +79,7 @@ class AgentEnvironmentConfig(BaseModel): description="Helm chart version to deploy. If not set, uses the default version from the CLI." ) helm_overrides: Dict[str, Any] = Field( - default_factory=dict, - description="Helm chart value overrides for environment-specific tuning" + default_factory=dict, description="Helm chart value overrides for environment-specific tuning" ) def uses_oci_registry(self) -> bool: From 257ca6c3417ed0781d916b359921e4cf05035f87 Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Tue, 3 Feb 2026 16:01:02 -0800 Subject: [PATCH 3/3] . --- src/agentex/lib/cli/handlers/deploy_handlers.py | 14 +++++++++++++- src/agentex/lib/sdk/config/environment_config.py | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/agentex/lib/cli/handlers/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py index cb6432f23..bff7aba0f 100644 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -470,10 +470,19 @@ def deploy_agent( helm_repository_name: str | None = None oci_registry: str | None = None + # Track OCI provider for provider-specific features + oci_provider: str | None = None + if use_oci: oci_registry = agent_env_config.helm_oci_registry # type: ignore[union-attr] + oci_provider = agent_env_config.helm_oci_provider # type: ignore[union-attr] console.print(f"[blue]ℹ[/blue] Using OCI Helm registry: {oci_registry}") - login_to_gar_registry(oci_registry) # type: ignore[arg-type] + + # Only auto-authenticate for GAR provider + if oci_provider == "gar": + login_to_gar_registry(oci_registry) # type: ignore[arg-type] + else: + console.print("[blue]ℹ[/blue] Skipping auto-authentication (no provider specified, assuming already authenticated)") else: if agent_env_config: helm_repository_name = agent_env_config.helm_repository_name @@ -497,6 +506,9 @@ def deploy_agent( if not use_oci: console.print("[yellow]⚠[/yellow] --use-latest-chart only works with OCI registries, using default version") chart_version = DEFAULT_HELM_CHART_VERSION + elif oci_provider != "gar": + console.print("[yellow]⚠[/yellow] --use-latest-chart only works with GAR provider (helm_oci_provider: gar), using default version") + chart_version = DEFAULT_HELM_CHART_VERSION else: chart_version = get_latest_gar_chart_version(oci_registry) # type: ignore[arg-type] elif agent_env_config and agent_env_config.helm_chart_version: diff --git a/src/agentex/lib/sdk/config/environment_config.py b/src/agentex/lib/sdk/config/environment_config.py index 97803e667..9e7436763 100644 --- a/src/agentex/lib/sdk/config/environment_config.py +++ b/src/agentex/lib/sdk/config/environment_config.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any, Dict, override +from typing import Any, Dict, Literal, override from pathlib import Path import yaml @@ -74,6 +74,12 @@ class AgentEnvironmentConfig(BaseModel): description="OCI registry URL for Helm charts (e.g., 'us-west1-docker.pkg.dev/project/repo'). " "When set, OCI mode is used instead of classic helm repo." ) + helm_oci_provider: Literal["gar"] | None = Field( + default=None, + description="OCI registry provider for provider-specific features. " + "Set to 'gar' for Google Artifact Registry to enable auto-authentication via gcloud " + "and latest version fetching. When not set, assumes user has already authenticated." + ) helm_chart_version: str | None = Field( default=None, description="Helm chart version to deploy. If not set, uses the default version from the CLI."