From 83bdcfa93bfce849f9e50be7ffebafba06229fe7 Mon Sep 17 00:00:00 2001 From: faisal rafiuddin Date: Tue, 23 Sep 2025 01:01:33 +0200 Subject: [PATCH 1/4] enhancement: generalising docker build and run for multicloud environments --- README.md | 20 ++ src/google/adk/cli/cli_deploy.py | 175 ++-------- src/google/adk/cli/cli_tools_click.py | 319 ++++++++++++------ .../adk/cli/config/dockerfile_template.py | 27 ++ src/google/adk/cli/deployers/base_deployer.py | 18 + .../adk/cli/deployers/cloud_run_deployer.py | 214 ++++++++++++ .../adk/cli/deployers/deployer_factory.py | 21 ++ .../adk/cli/deployers/docker_deployer.py | 58 ++++ tests/unittests/cli/utils/test_cli_deploy.py | 15 +- .../cli/utils/test_cli_tools_click.py | 53 ++- 10 files changed, 662 insertions(+), 258 deletions(-) create mode 100644 src/google/adk/cli/config/dockerfile_template.py create mode 100644 src/google/adk/cli/deployers/base_deployer.py create mode 100644 src/google/adk/cli/deployers/cloud_run_deployer.py create mode 100644 src/google/adk/cli/deployers/deployer_factory.py create mode 100644 src/google/adk/cli/deployers/docker_deployer.py diff --git a/README.md b/README.md index 43e33561d6..27efd34b99 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,26 @@ coordinator = LlmAgent( ) ``` +### 🚀 Deployment Options + + Deploying the Agent Locally with Docker Container: + +```bash +adk deploy docker --with_ui +``` + + Deploying the Agent in Google Cloud (Cloud Run) + +```bash +adk deploy cloud_run --with_ui +``` + + You may set the following environment variables in adk command, or in a .env file instead. + +```bash +adk deploy cloud_run --with_ui --env GOOGLE_GENAI_USE_VERTEXAI=1 +``` + ### Development UI A built-in development UI to help you test, evaluate, debug, and showcase your agent(s). diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index d26b3c9660..51068404a1 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -19,48 +19,13 @@ import subprocess from typing import Final from typing import Optional +from typing import Tuple import click from packaging.version import parse -_DOCKERFILE_TEMPLATE: Final[str] = """ -FROM python:3.11-slim -WORKDIR /app - -# Create a non-root user -RUN adduser --disabled-password --gecos "" myuser - -# Switch to the non-root user -USER myuser - -# Set up environment variables - Start -ENV PATH="/home/myuser/.local/bin:$PATH" - -ENV GOOGLE_GENAI_USE_VERTEXAI=1 -ENV GOOGLE_CLOUD_PROJECT={gcp_project_id} -ENV GOOGLE_CLOUD_LOCATION={gcp_region} - -# Set up environment variables - End - -# Install ADK - Start -RUN pip install google-adk=={adk_version} -# Install ADK - End - -# Copy agent - Start - -# Set permission -COPY --chown=myuser:myuser "agents/{app_name}/" "/app/agents/{app_name}/" - -# Copy agent - End - -# Install Agent Deps - Start -{install_agent_deps} -# Install Agent Deps - End - -EXPOSE {port} - -CMD adk {command} --port={port} {host_option} {service_option} {trace_to_cloud_option} {allow_origins_option} {a2a_option} "/app/agents" -""" +from .config.dockerfile_template import _DOCKERFILE_TEMPLATE +from .deployers.deployer_factory import DeployerFactory _AGENT_ENGINE_APP_TEMPLATE: Final[str] = """ from vertexai.preview.reasoning_engines import AdkApp @@ -97,53 +62,6 @@ def _resolve_project(project_in_option: Optional[str]) -> str: click.echo(f'Use default project: {project}') return project - -def _validate_gcloud_extra_args( - extra_gcloud_args: Optional[tuple[str, ...]], adk_managed_args: set[str] -) -> None: - """Validates that extra gcloud args don't conflict with ADK-managed args. - - This function dynamically checks for conflicts based on the actual args - that ADK will set, rather than using a hardcoded list. - - Args: - extra_gcloud_args: User-provided extra arguments for gcloud. - adk_managed_args: Set of argument names that ADK will set automatically. - Should include '--' prefix (e.g., '--project'). - - Raises: - click.ClickException: If any conflicts are found. - """ - if not extra_gcloud_args: - return - - # Parse user arguments into a set of argument names for faster lookup - user_arg_names = set() - for arg in extra_gcloud_args: - if arg.startswith('--'): - # Handle both '--arg=value' and '--arg value' formats - arg_name = arg.split('=')[0] - user_arg_names.add(arg_name) - - # Check for conflicts with ADK-managed args - conflicts = user_arg_names.intersection(adk_managed_args) - - if conflicts: - conflict_list = ', '.join(f"'{arg}'" for arg in sorted(conflicts)) - if len(conflicts) == 1: - raise click.ClickException( - f"The argument {conflict_list} conflicts with ADK's automatic" - ' configuration. ADK will set this argument automatically, so please' - ' remove it from your command.' - ) - else: - raise click.ClickException( - f"The arguments {conflict_list} conflict with ADK's automatic" - ' configuration. ADK will set these arguments automatically, so' - ' please remove them from your command.' - ) - - def _get_service_option_by_adk_version( adk_version: str, session_uri: Optional[str], @@ -171,9 +89,10 @@ def _get_service_option_by_adk_version( return f'--session_db_url={session_uri}' if session_uri else '' -def to_cloud_run( +def run( *, agent_folder: str, + provider: str, project: Optional[str], region: Optional[str], service_name: str, @@ -190,6 +109,8 @@ def to_cloud_run( artifact_service_uri: Optional[str] = None, memory_service_uri: Optional[str] = None, a2a: bool = False, + provider_args: Tuple[str], + env: Tuple[str], extra_gcloud_args: Optional[tuple[str, ...]] = None, ): """Deploys an agent to Google Cloud Run. @@ -209,6 +130,7 @@ def to_cloud_run( Args: agent_folder: The folder (absolute path) containing the agent source code. + provider: Target deployment platform (cloud_run, docker, etc). project: Google Cloud project id. region: Google Cloud region. service_name: The service name in Cloud Run. @@ -223,10 +145,14 @@ def to_cloud_run( session_service_uri: The URI of the session service. artifact_service_uri: The URI of the artifact service. memory_service_uri: The URI of the memory service. + provider_args: The arguments specific to cloud provider + env: The environment valriables provided """ app_name = app_name or os.path.basename(agent_folder) + mode = 'web' if with_ui else 'api_server' + trace_to_cloud_option = '--trace_to_cloud' if trace_to_cloud else '' - click.echo(f'Start generating Cloud Run source files in {temp_folder}') + click.echo(f'Start generating deployment files in {temp_folder}') # remove temp_folder if exists if os.path.exists(temp_folder): @@ -258,7 +184,7 @@ def to_cloud_run( gcp_region=region, app_name=app_name, port=port, - command='web' if with_ui else 'api_server', + command=mode, install_agent_deps=install_agent_deps, service_option=_get_service_option_by_adk_version( adk_version, @@ -266,7 +192,7 @@ def to_cloud_run( artifact_service_uri, memory_service_uri, ), - trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '', + trace_to_cloud_option=trace_to_cloud_option, allow_origins_option=allow_origins_option, adk_version=adk_version, host_option=host_option, @@ -279,61 +205,24 @@ def to_cloud_run( dockerfile_content, ) click.echo(f'Creating Dockerfile complete: {dockerfile_path}') + click.echo(f'Deploying to {provider}...') + + deployer = DeployerFactory.get_deployer(provider) + deployer.deploy( + agent_folder=agent_folder, + temp_folder=temp_folder, + service_name=service_name, + provider_args=provider_args, + env_vars=env, + project=project, + region=region, + port=port, + verbosity=verbosity, + extra_gcloud_args=extra_gcloud_args, + log_level=log_level, + ) - # Deploy to Cloud Run - click.echo('Deploying to Cloud Run...') - region_options = ['--region', region] if region else [] - project = _resolve_project(project) - - # Build the set of args that ADK will manage - adk_managed_args = {'--source', '--project', '--port', '--verbosity'} - if region: - adk_managed_args.add('--region') - - # Validate that extra gcloud args don't conflict with ADK-managed args - _validate_gcloud_extra_args(extra_gcloud_args, adk_managed_args) - - # Build the command with extra gcloud args - gcloud_cmd = [ - 'gcloud', - 'run', - 'deploy', - service_name, - '--source', - temp_folder, - '--project', - project, - *region_options, - '--port', - str(port), - '--verbosity', - log_level.lower() if log_level else verbosity, - ] - - # Handle labels specially - merge user labels with ADK label - user_labels = [] - extra_args_without_labels = [] - - if extra_gcloud_args: - for arg in extra_gcloud_args: - if arg.startswith('--labels='): - # Extract user-provided labels - user_labels_value = arg[9:] # Remove '--labels=' prefix - user_labels.append(user_labels_value) - else: - extra_args_without_labels.append(arg) - - # Combine ADK label with user labels - all_labels = ['created-by=adk'] - all_labels.extend(user_labels) - labels_arg = ','.join(all_labels) - - gcloud_cmd.extend(['--labels', labels_arg]) - - # Add any remaining extra passthrough args - gcloud_cmd.extend(extra_args_without_labels) - - subprocess.run(gcloud_cmd, check=True) + click.echo(f'Deployment to {provider} complete.') finally: click.echo(f'Cleaning up the temp folder: {temp_folder}') shutil.rmtree(temp_folder) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 25e259bc7d..ab5a34a16c 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -23,6 +23,7 @@ from pathlib import Path import tempfile from typing import Optional +from typing import Tuple import click from click.core import ParameterSource @@ -113,6 +114,123 @@ def main(): pass +def add_common_deploy_options(command): + """Add common options to deploy subcommands.""" + options = [ + click.option( + "--service_name", + type=str, + default="adk-default-service-name", + help=( + "Optional. The service name to use in target environment" + " (default: 'adk-default-service-name')." + ), + ), + click.option( + "--env", + multiple=True, + help=( + "Optional. Environment variables as multiple --env key=value" + " pairs." + ), + ), + click.option( + "--provider-args", + multiple=True, + help=( + "Optional. Provider-specific arguments as multiple" + " --provider-args key=value pairs." + ), + ), + click.option( + "--app_name", + type=str, + default="", + help=( + "Optional. App name of the ADK API server (default: the folder" + " name of the AGENT source code)." + ), + ), + click.option( + "--port", + type=int, + default=8000, + help="Optional. The port of the ADK API server (default: 8000).", + ), + click.option( + "--trace_to_cloud", + is_flag=True, + show_default=True, + default=False, + help="Optional. Whether to enable cloud tracing for deployment.", + ), + click.option( + "--with_ui", + is_flag=True, + show_default=True, + default=False, + help=( + "Optional. Deploy ADK Web UI if set. (default: deploy ADK API" + " server only)" + ), + ), + click.option( + "--temp_folder", + type=str, + default=os.path.join( + tempfile.gettempdir(), + "deploy_src", + datetime.now().strftime("%Y%m%d_%H%M%S"), + ), + help=( + "Optional. Temp folder for the generated source files" + " (default: a timestamped folder in the system temp directory)." + ), + ), + click.option( + "--log_level", + type=LOG_LEVELS, + default="INFO", + help="Optional. Set the logging level", + ), + click.option( + "--verbosity", + type=LOG_LEVELS, + help="Deprecated. Use --log_level instead.", + ), + click.option( + "--adk_version", + type=str, + default=version.__version__, + show_default=True, + help=( + "Optional. The ADK version used in deployment. (default: the" + " version in the dev environment)" + ), + ), + click.argument( + "agent", + type=click.Path( + exists=True, dir_okay=True, file_okay=False, resolve_path=True + ), + ), + click.option( + "--a2a", + is_flag=True, + show_default=True, + default=False, + help="Optional. Whether to enable A2A endpoint.", + ), + click.option( + "--allow_origins", + help="Optional. Any additional origins to allow for CORS.", + multiple=True, + ), + ] + for option in options: + command = option(command) + return command + @main.group() def deploy(): """Deploys agent to hosted environments.""" @@ -1062,104 +1180,12 @@ def cli_api_server( " gcloud run deploy will prompt later." ), ) -@click.option( - "--service_name", - type=str, - default="adk-default-service-name", - help=( - "Optional. The service name to use in Cloud Run (default:" - " 'adk-default-service-name')." - ), -) -@click.option( - "--app_name", - type=str, - default="", - help=( - "Optional. App name of the ADK API server (default: the folder name" - " of the AGENT source code)." - ), -) -@click.option( - "--port", - type=int, - default=8000, - help="Optional. The port of the ADK API server (default: 8000).", -) -@click.option( - "--trace_to_cloud", - is_flag=True, - show_default=True, - default=False, - help="Optional. Whether to enable Cloud Trace for cloud run.", -) -@click.option( - "--with_ui", - is_flag=True, - show_default=True, - default=False, - help=( - "Optional. Deploy ADK Web UI if set. (default: deploy ADK API server" - " only)" - ), -) -@click.option( - "--temp_folder", - type=str, - default=os.path.join( - tempfile.gettempdir(), - "cloud_run_deploy_src", - datetime.now().strftime("%Y%m%d_%H%M%S"), - ), - help=( - "Optional. Temp folder for the generated Cloud Run source files" - " (default: a timestamped folder in the system temp directory)." - ), -) -@click.option( - "--log_level", - type=LOG_LEVELS, - default="INFO", - help="Optional. Set the logging level", -) -@click.option( - "--verbosity", - type=LOG_LEVELS, - help="Deprecated. Use --log_level instead.", -) -@click.argument( - "agent", - type=click.Path( - exists=True, dir_okay=True, file_okay=False, resolve_path=True - ), -) -@click.option( - "--adk_version", - type=str, - default=version.__version__, - show_default=True, - help=( - "Optional. The ADK version used in Cloud Run deployment. (default: the" - " version in the dev environment)" - ), -) -@click.option( - "--a2a", - is_flag=True, - show_default=True, - default=False, - help="Optional. Whether to enable A2A endpoint.", -) -@click.option( - "--allow_origins", - help="Optional. Any additional origins to allow for CORS.", - multiple=True, -) +@add_common_deploy_options # TODO: Add eval_storage_uri option back when evals are supported in Cloud Run. @adk_services_options() @deprecated_adk_services_options() @click.pass_context -def cli_deploy_cloud_run( +def cli_deploy_to_cloud_run( ctx, agent: str, project: Optional[str], @@ -1173,6 +1199,8 @@ def cli_deploy_cloud_run( adk_version: str, log_level: str, verbosity: Optional[str], + provider_args: Tuple[str], + env: Tuple[str], allow_origins: Optional[list[str]] = None, session_service_uri: Optional[str] = None, artifact_service_uri: Optional[str] = None, @@ -1235,8 +1263,9 @@ def cli_deploy_cloud_run( ctx.exit(2) try: - cli_deploy.to_cloud_run( + cli_deploy.run( agent_folder=agent, + provider="cloud_run", project=project, region=region, service_name=service_name, @@ -1254,10 +1283,110 @@ def cli_deploy_cloud_run( memory_service_uri=memory_service_uri, a2a=a2a, extra_gcloud_args=tuple(gcloud_args), + provider_args=provider_args, + env=env, ) except Exception as e: click.secho(f"Deploy failed: {e}", fg="red", err=True) +@deploy.command("docker", cls=HelpfulCommand) +@add_common_deploy_options +@adk_services_options() +@deprecated_adk_services_options() +@click.pass_context +def cli_deploy_docker( + ctx, + agent: str, + service_name: str, + app_name: str, + temp_folder: str, + port: int, + trace_to_cloud: bool, + with_ui: bool, + adk_version: str, + log_level: str, + verbosity: Optional[str], + provider_args: Tuple[str], + env: Tuple[str], + allow_origins: Optional[list[str]] = None, + session_service_uri: Optional[str] = None, + artifact_service_uri: Optional[str] = None, + memory_service_uri: Optional[str] = None, + session_db_url: Optional[str] = None, # Deprecated + artifact_storage_uri: Optional[str] = None, # Deprecated + a2a: bool = False, +): + """Deploys an agent to Docker container. + AGENT: The path to the agent source code folder. + Example: + adk deploy docker path/to/my_agent + """ + if verbosity: + click.secho( + "WARNING: The --verbosity option is deprecated. Use --log_level" + " instead.", + fg="yellow", + err=True, + ) + + session_service_uri = session_service_uri or session_db_url + artifact_service_uri = artifact_service_uri or artifact_storage_uri + + # Parse arguments to separate gcloud args (after --) from regular args + gcloud_args = [] + if "--" in ctx.args: + separator_index = ctx.args.index("--") + gcloud_args = ctx.args[separator_index + 1 :] + regular_args = ctx.args[:separator_index] + + # If there are regular args before --, that's an error + if regular_args: + click.secho( + "Error: Unexpected arguments after agent path and before '--':" + f" {' '.join(regular_args)}. \nOnly arguments after '--' are passed" + " to gcloud.", + fg="red", + err=True, + ) + ctx.exit(2) + else: + # No -- separator, treat all args as an error to enforce the new behavior + if ctx.args: + click.secho( + f"Error: Unexpected arguments: {' '.join(ctx.args)}. \nUse '--' to" + " separate gcloud arguments, e.g.: adk deploy cloud_run [options]" + " agent_path -- --min-instances=2", + fg="red", + err=True, + ) + ctx.exit(2) + + try: + cli_deploy.run( + agent_folder=agent, + provider="docker", + project=None, + region=None, + service_name=service_name, + app_name=app_name, + temp_folder=temp_folder, + port=port, + trace_to_cloud=trace_to_cloud, + allow_origins=allow_origins, + with_ui=with_ui, + log_level=log_level, + verbosity=verbosity, + adk_version=adk_version, + session_service_uri=session_service_uri, + artifact_service_uri=artifact_service_uri, + memory_service_uri=memory_service_uri, + a2a=a2a, + extra_gcloud_args=tuple(gcloud_args), + provider_args=provider_args, + env=env, + ) + except Exception as e: + click.secho(f"Deploy failed: {e}", fg="red", err=True) @deploy.command("agent_engine") @click.option( diff --git a/src/google/adk/cli/config/dockerfile_template.py b/src/google/adk/cli/config/dockerfile_template.py new file mode 100644 index 0000000000..7720b48f44 --- /dev/null +++ b/src/google/adk/cli/config/dockerfile_template.py @@ -0,0 +1,27 @@ +# config/dockerfile_template.py + +_DOCKERFILE_TEMPLATE = """ +FROM python:3.11-slim +WORKDIR /app + +# Create a non-root user +RUN adduser --disabled-password --gecos "" myuser + +# Switch to the non-root user +USER myuser + +# Set up environment variables +ENV PATH="/home/myuser/.local/bin:$PATH" + +# Install ADK +RUN pip install google-adk=={adk_version} + +# Copy agent +# Set permission +COPY --chown=myuser:myuser "agents/{app_name}/" "/app/agents/{app_name}/" +{install_agent_deps} + +EXPOSE {port} + +CMD adk {command} --port={port} {host_option} {service_option} {trace_to_cloud_option} {allow_origins_option} {a2a_option} "/app/agents" +""" diff --git a/src/google/adk/cli/deployers/base_deployer.py b/src/google/adk/cli/deployers/base_deployer.py new file mode 100644 index 0000000000..0fc6fd0cb7 --- /dev/null +++ b/src/google/adk/cli/deployers/base_deployer.py @@ -0,0 +1,18 @@ +from abc import ABC +from abc import abstractmethod +from typing import Dict + + +class Deployer(ABC): + + @abstractmethod + def deploy( + self, + temp_folder: str, + service_name: str, + provider_args: Dict[str, str], + env_vars: Dict[str, str], + **kwargs, + ): + """Deploys the agent to the target platform.""" + pass diff --git a/src/google/adk/cli/deployers/cloud_run_deployer.py b/src/google/adk/cli/deployers/cloud_run_deployer.py new file mode 100644 index 0000000000..5600869120 --- /dev/null +++ b/src/google/adk/cli/deployers/cloud_run_deployer.py @@ -0,0 +1,214 @@ +# deployers/cloud_run_deployer.py + +import os +import subprocess +from typing import Optional, Tuple + +import click + +from ..deployers.base_deployer import Deployer + + +class CloudRunDeployer(Deployer): + + def deploy( + self, + agent_folder: str, + temp_folder: str, + service_name: str, + provider_args: Tuple[str], # optional for Deployer + env_vars: Tuple[str], + **kwargs, + ): + project = self._resolve_project(kwargs.get('project')) + region = kwargs.get('region', 'us-central1') + port = kwargs.get('port', 8000) + verbosity = kwargs.get('verbosity', 'info') + extra_gcloud_args = kwargs.get('extra_gcloud_args') + log_level = kwargs.get('log_level') + region_options = ['--region', region] if region else [] + + # Build the set of args that ADK will manage + adk_managed_args = {'--source', '--project', '--port', '--verbosity'} + if region: + adk_managed_args.add('--region') + + # Validate that extra gcloud args don't conflict with ADK-managed args + self._validate_gcloud_extra_args(extra_gcloud_args, adk_managed_args) + + # Add environment variables + env_vars_str = self.build_env_vars_string(env_vars) + env_file_str = self.build_env_file_arg(agent_folder) + if env_vars_str and env_file_str: + env_vars_str += ',' + env_file_str + elif not env_vars_str: + env_vars_str = env_file_str + + env_vars_str = self.add_required_env_vars(env_vars_str, project, region) + + # Build the command with extra gcloud args + gcloud_cmd = [ + 'gcloud', + 'run', + 'deploy', + service_name, + '--source', + temp_folder, + '--project', + project, + *region_options, + '--port', + str(port), + '--set-env-vars', + env_vars_str, + '--verbosity', + log_level.lower() if log_level else verbosity, + ] + + # Handle labels specially - merge user labels with ADK label + user_labels = [] + extra_args_without_labels = [] + + if extra_gcloud_args: + for arg in extra_gcloud_args: + if arg.startswith('--labels='): + # Extract user-provided labels + user_labels_value = arg[9:] # Remove '--labels=' prefix + user_labels.append(user_labels_value) + else: + extra_args_without_labels.append(arg) + + # Combine ADK label with user labels + all_labels = ['created-by=adk'] + all_labels.extend(user_labels) + labels_arg = ','.join(all_labels) + + gcloud_cmd.extend(['--labels', labels_arg]) + + # Add any remaining extra passthrough args + gcloud_cmd.extend(extra_args_without_labels) + + subprocess.run(gcloud_cmd, check=True) + + def _resolve_project(self, project_in_option: str = None) -> str: + """ + Resolves the Google Cloud project ID. If a project is provided in the options, it will use that. + Otherwise, it retrieves the default project from the active gcloud configuration. + + Args: + project_in_option: Optional project ID to override the default. + + Returns: + str: The resolved project ID. + """ + if project_in_option: + return project_in_option + + try: + result = subprocess.run( + ['gcloud', 'config', 'get-value', 'project'], + check=True, + capture_output=True, + text=True, + ) + project = result.stdout.strip() + if not project: + raise click.ClickException('No project ID found in gcloud config.') + + click.echo(f'Using default project: {project}') + return project + except subprocess.CalledProcessError as e: + raise click.ClickException(f'Failed to get project from gcloud: {e}') + + def _validate_gcloud_extra_args( + self, + extra_gcloud_args: Optional[tuple[str, ...]], + adk_managed_args: set[str] + ) -> None: + """Validates that extra gcloud args don't conflict with ADK-managed args. + + This function dynamically checks for conflicts based on the actual args + that ADK will set, rather than using a hardcoded list. + + Args: + extra_gcloud_args: User-provided extra arguments for gcloud. + adk_managed_args: Set of argument names that ADK will set automatically. + Should include '--' prefix (e.g., '--project'). + + Raises: + click.ClickException: If any conflicts are found. + """ + if not extra_gcloud_args: + return + + # Parse user arguments into a set of argument names for faster lookup + user_arg_names = set() + for arg in extra_gcloud_args: + if arg.startswith('--'): + # Handle both '--arg=value' and '--arg value' formats + arg_name = arg.split('=')[0] + user_arg_names.add(arg_name) + + # Check for conflicts with ADK-managed args + conflicts = user_arg_names.intersection(adk_managed_args) + + if conflicts: + conflict_list = ', '.join(f"'{arg}'" for arg in sorted(conflicts)) + if len(conflicts) == 1: + raise click.ClickException( + f"The argument {conflict_list} conflicts with ADK's automatic" + ' configuration. ADK will set this argument automatically, so please' + ' remove it from your command.' + ) + else: + raise click.ClickException( + f"The arguments {conflict_list} conflict with ADK's automatic" + ' configuration. ADK will set these arguments automatically, so' + ' please remove them from your command.' + ) + + def build_env_vars_string(self, env_vars: Tuple[str]) -> str: + """ + Returns a comma-separated string of 'KEY=value' entries + from a tuple of environment variable strings. + """ + valid_pairs = [item for item in env_vars if '=' in item] + return ','.join(valid_pairs) + + def build_env_file_arg(self, agent_folder: str) -> str: + """ + Reads the `.env` file (if present) and returns a comma-separated `KEY=VALUE` string + for use with `--set-env-vars` in `gcloud run deploy`. + """ + env_file_path = os.path.join(agent_folder, '.env') + env_vars_str = '' + + if os.path.exists(env_file_path): + with open(env_file_path, 'r') as f: + lines = f.readlines() + + env_vars = [] + for line in lines: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars.append(f'{key}={value}') + + env_vars_str = ','.join(env_vars) + + return env_vars_str + + def add_required_env_vars( + self, env_vars_str: str, project: str, region: str + ) -> str: + """ + Appends required Google-specific environment variables to the existing env var string. + """ + extra_envs = [ + f'GOOGLE_CLOUD_PROJECT={project}', + f'GOOGLE_CLOUD_LOCATION={region}', + ] + + if env_vars_str: + return env_vars_str + ',' + ','.join(extra_envs) + return ','.join(extra_envs) diff --git a/src/google/adk/cli/deployers/deployer_factory.py b/src/google/adk/cli/deployers/deployer_factory.py new file mode 100644 index 0000000000..bfeaf3f1eb --- /dev/null +++ b/src/google/adk/cli/deployers/deployer_factory.py @@ -0,0 +1,21 @@ +from ..deployers.cloud_run_deployer import CloudRunDeployer +from ..deployers.docker_deployer import DockerDeployer + +# Future deployers can be added here + + +class DeployerFactory: + + @staticmethod + def get_deployer(cloud_provider: str): + """Returns the appropriate deployer based on the cloud provider.""" + deployers = { + 'docker': DockerDeployer(), + 'cloud_run': CloudRunDeployer(), + # Future providers: 'aws': AWSDeployer(), 'k8s': KubernetesDeployer() + } + + if cloud_provider not in deployers: + raise ValueError(f'Unsupported cloud provider: {cloud_provider}') + + return deployers[cloud_provider] diff --git a/src/google/adk/cli/deployers/docker_deployer.py b/src/google/adk/cli/deployers/docker_deployer.py new file mode 100644 index 0000000000..01993a8a6c --- /dev/null +++ b/src/google/adk/cli/deployers/docker_deployer.py @@ -0,0 +1,58 @@ +import os +import subprocess +from typing import List +from typing import Tuple + +import click + +from ..deployers.base_deployer import Deployer + + +class DockerDeployer(Deployer): + + def deploy( + self, + agent_folder: str, + temp_folder: str, + service_name: str, + provider_args: Tuple[str], # optional for Deployer + env_vars: Tuple[str], + **kwargs, + ): + port = kwargs.get('port', 8000) + image_name = f'adk-python-{service_name.lower()}' + + click.echo('Deploying to Local Docker') + + # Build Docker image + subprocess.run( + ['docker', 'build', '-t', image_name, temp_folder], + check=True, + ) + + env_args = self.get_cli_env_args(env_vars) + env_args.extend(self.get_env_file_arg(agent_folder)) + + # Run Docker container + subprocess.run( + ['docker', 'run', '-d', '-p', f'{port}:{port}', *env_args, image_name], + check=True, + ) + click.echo(f'Container running locally at http://localhost:{port}') + + def get_cli_env_args(self, env_vars: Tuple[str]) -> List[str]: + """Converts tuple of 'KEY=value' strings into Docker -e arguments.""" + env_args = [] + for item in env_vars: + if '=' in item: + key, value = item.split('=', 1) + env_args.extend(['-e', f'{key}={value}']) + return env_args + + def get_env_file_arg(self, agent_folder: str) -> List[str]: + """Returns Docker `--env-file` argument if .env file exists in agent_folder.""" + env_args = [] + env_file_path = os.path.join(agent_folder, '.env') + if os.path.exists(env_file_path): + env_args.extend(['--env-file', env_file_path]) + return env_args diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index b2a31f70f3..8b8cb63b67 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -35,6 +35,7 @@ import pytest import src.google.adk.cli.cli_deploy as cli_deploy +from google.adk.cli.deployers.deployer_factory import DeployerFactory # Helpers @@ -126,7 +127,8 @@ def mock_vertex_ai( # _resolve_project def test_resolve_project_with_option() -> None: """It should return the explicit project value untouched.""" - assert cli_deploy._resolve_project("my-project") == "my-project" + cloudRunDeployer = DeployerFactory.get_deployer("cloud_run") + assert cloudRunDeployer._resolve_project("my-project") == "my-project" def test_resolve_project_from_gcloud(monkeypatch: pytest.MonkeyPatch) -> None: @@ -138,7 +140,8 @@ def test_resolve_project_from_gcloud(monkeypatch: pytest.MonkeyPatch) -> None: ) with mock.patch("click.echo") as mocked_echo: - assert cli_deploy._resolve_project(None) == "gcp-proj" + cloudRunDeployer = DeployerFactory.get_deployer("cloud_run") + assert cloudRunDeployer._resolve_project(None) == "gcp-proj" mocked_echo.assert_called_once() @@ -151,8 +154,12 @@ def test_resolve_project_from_gcloud_fails( "run", mock.Mock(side_effect=subprocess.CalledProcessError(1, "cmd", "err")), ) - with pytest.raises(subprocess.CalledProcessError): - cli_deploy._resolve_project(None) + + cloudRunDeployer = DeployerFactory.get_deployer("cloud_run") + with pytest.raises(click.ClickException) as exc_info: + cloudRunDeployer._resolve_project(None) + + assert "Failed to get project from gcloud" in str(exc_info.value) @pytest.mark.parametrize( diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 138289ed2e..55d3091db5 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -152,9 +152,9 @@ async def test_cli_run_invokes_run_cli( def test_cli_deploy_cloud_run_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Successful path should call cli_deploy.to_cloud_run once.""" + """Successful path should call cli_deploy.run once.""" rec = _Recorder() - monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", rec) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", rec) agent_dir = tmp_path / "agent2" agent_dir.mkdir() @@ -172,18 +172,39 @@ def test_cli_deploy_cloud_run_success( ], ) assert result.exit_code == 0 - assert rec.calls, "cli_deploy.to_cloud_run must be invoked" + assert rec.calls, "cli_deploy.run must be invoked" +# cli deploy docker +def test_cli_docker_cloud_run_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Successful path should call cli_deploy.run once.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", rec) + + agent_dir = tmp_path / "agent2" + agent_dir.mkdir() + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + [ + "deploy", + "docker", + str(agent_dir), + ], + ) + assert result.exit_code == 0 + assert rec.calls, "cli_deploy.run must be invoked" def test_cli_deploy_cloud_run_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Exception from to_cloud_run should be caught and surfaced via click.secho.""" + """Exception from run should be caught and surfaced via click.secho.""" def _boom(*_a: Any, **_k: Any) -> None: # noqa: D401 raise RuntimeError("boom") - monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", _boom) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", _boom) agent_dir = tmp_path / "agent3" agent_dir.mkdir() @@ -201,7 +222,7 @@ def test_cli_deploy_cloud_run_passthrough_args( ) -> None: """Extra args after '--' should be passed through to the gcloud command.""" rec = _Recorder() - monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", rec) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", rec) agent_dir = tmp_path / "agent_passthrough" agent_dir.mkdir() @@ -229,7 +250,7 @@ def test_cli_deploy_cloud_run_passthrough_args( print(f"Exception: {result.exception}") assert result.exit_code == 0 - assert rec.calls, "cli_deploy.to_cloud_run must be invoked" + assert rec.calls, "cli_deploy.run must be invoked" # Check that extra_gcloud_args were passed correctly called_kwargs = rec.calls[0][1] @@ -245,7 +266,7 @@ def test_cli_deploy_cloud_run_rejects_args_without_separator( ) -> None: """Args without '--' separator should be rejected with helpful error message.""" rec = _Recorder() - monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", rec) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", rec) agent_dir = tmp_path / "agent_no_sep" agent_dir.mkdir() @@ -267,7 +288,7 @@ def test_cli_deploy_cloud_run_rejects_args_without_separator( assert result.exit_code == 2 assert "Unexpected arguments:" in result.output assert "Use '--' to separate gcloud arguments" in result.output - assert not rec.calls, "cli_deploy.to_cloud_run should not be called" + assert not rec.calls, "cli_deploy.run should not be called" def test_cli_deploy_cloud_run_rejects_args_before_separator( @@ -275,7 +296,7 @@ def test_cli_deploy_cloud_run_rejects_args_before_separator( ) -> None: """Args before '--' separator should be rejected.""" rec = _Recorder() - monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", rec) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", rec) agent_dir = tmp_path / "agent_before_sep" agent_dir.mkdir() @@ -301,7 +322,7 @@ def test_cli_deploy_cloud_run_rejects_args_before_separator( "Unexpected arguments after agent path and before '--':" in result.output ) assert "unexpected_arg" in result.output - assert not rec.calls, "cli_deploy.to_cloud_run should not be called" + assert not rec.calls, "cli_deploy.run should not be called" def test_cli_deploy_cloud_run_allows_empty_gcloud_args( @@ -309,7 +330,7 @@ def test_cli_deploy_cloud_run_allows_empty_gcloud_args( ) -> None: """No gcloud args after '--' should be allowed.""" rec = _Recorder() - monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", rec) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", rec) agent_dir = tmp_path / "agent_empty_gcloud" agent_dir.mkdir() @@ -330,7 +351,7 @@ def test_cli_deploy_cloud_run_allows_empty_gcloud_args( ) assert result.exit_code == 0 - assert rec.calls, "cli_deploy.to_cloud_run must be invoked" + assert rec.calls, "cli_deploy.run must be invoked" # Check that extra_gcloud_args is empty called_kwargs = rec.calls[0][1] @@ -625,9 +646,9 @@ def test_cli_deploy_cloud_run_gcloud_arg_conflict( ) -> None: """Extra gcloud args that conflict with ADK deploy args should raise ClickException.""" - def _mock_to_cloud_run(*_a, **kwargs): + def _mock_run(*_a, **kwargs): # Import and call the validation function - from google.adk.cli.cli_deploy import _validate_gcloud_extra_args + from google.adk.cli.deployers.cloud_run_deployer import _validate_gcloud_extra_args # Build the same set of managed args as the real function would adk_managed_args = {"--source", "--project", "--port", "--verbosity"} @@ -638,7 +659,7 @@ def _mock_to_cloud_run(*_a, **kwargs): ) monkeypatch.setattr( - cli_tools_click.cli_deploy, "to_cloud_run", _mock_to_cloud_run + cli_tools_click.cli_deploy, "run", _mock_run ) agent_dir = tmp_path / "agent_conflict" From 01b308998f613cf1bbd906ea9ccc431fd380ce70 Mon Sep 17 00:00:00 2001 From: faisal rafiuddin Date: Tue, 23 Sep 2025 01:43:53 +0200 Subject: [PATCH 2/4] Update readme file - use agent-folder after adk options --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 27efd34b99..19d7bce3ff 100644 --- a/README.md +++ b/README.md @@ -131,19 +131,19 @@ coordinator = LlmAgent( Deploying the Agent Locally with Docker Container: ```bash -adk deploy docker --with_ui +adk deploy docker --with_ui ``` Deploying the Agent in Google Cloud (Cloud Run) ```bash -adk deploy cloud_run --with_ui +adk deploy cloud_run --with_ui ``` You may set the following environment variables in adk command, or in a .env file instead. ```bash -adk deploy cloud_run --with_ui --env GOOGLE_GENAI_USE_VERTEXAI=1 +adk deploy cloud_run --with_ui --env GOOGLE_GENAI_USE_VERTEXAI=1 ``` ### Development UI From a340b0497fecaf660923e25c5d78b991f9c0e535 Mon Sep 17 00:00:00 2001 From: faisal rafiuddin Date: Tue, 23 Sep 2025 02:25:22 +0200 Subject: [PATCH 3/4] fix unittest case to use _validate_gcloud_extra_args from deployer --- tests/unittests/cli/utils/test_cli_tools_click.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 55d3091db5..fd0a9bafa7 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -34,6 +34,7 @@ from google.adk.evaluation.eval_set import EvalSet from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager from google.adk.evaluation.local_eval_sets_manager import LocalEvalSetsManager +from google.adk.cli.deployers.cloud_run_deployer import CloudRunDeployer from pydantic import BaseModel import pytest @@ -648,13 +649,13 @@ def test_cli_deploy_cloud_run_gcloud_arg_conflict( def _mock_run(*_a, **kwargs): # Import and call the validation function - from google.adk.cli.deployers.cloud_run_deployer import _validate_gcloud_extra_args + deployer = CloudRunDeployer() # Build the same set of managed args as the real function would adk_managed_args = {"--source", "--project", "--port", "--verbosity"} if kwargs.get("region"): adk_managed_args.add("--region") - _validate_gcloud_extra_args( + deployer._validate_gcloud_extra_args( kwargs.get("extra_gcloud_args"), adk_managed_args ) From 5b1a0ddeccd223ec259e95e9f7bad5d4c57c3b45 Mon Sep 17 00:00:00 2001 From: faisal rafiuddin Date: Tue, 23 Sep 2025 22:12:45 +0200 Subject: [PATCH 4/4] auto-formating the code changes --- src/google/adk/cli/cli_deploy.py | 1 + src/google/adk/cli/cli_tools_click.py | 3 +++ .../adk/cli/deployers/cloud_run_deployer.py | 15 ++++++++------- tests/unittests/cli/utils/test_cli_deploy.py | 4 ++-- tests/unittests/cli/utils/test_cli_tools_click.py | 8 ++++---- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 51068404a1..11f2408246 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -62,6 +62,7 @@ def _resolve_project(project_in_option: Optional[str]) -> str: click.echo(f'Use default project: {project}') return project + def _get_service_option_by_adk_version( adk_version: str, session_uri: Optional[str], diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index ab5a34a16c..2ca931d73a 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -231,6 +231,7 @@ def add_common_deploy_options(command): command = option(command) return command + @main.group() def deploy(): """Deploys agent to hosted environments.""" @@ -1289,6 +1290,7 @@ def cli_deploy_to_cloud_run( except Exception as e: click.secho(f"Deploy failed: {e}", fg="red", err=True) + @deploy.command("docker", cls=HelpfulCommand) @add_common_deploy_options @adk_services_options() @@ -1388,6 +1390,7 @@ def cli_deploy_docker( except Exception as e: click.secho(f"Deploy failed: {e}", fg="red", err=True) + @deploy.command("agent_engine") @click.option( "--project", diff --git a/src/google/adk/cli/deployers/cloud_run_deployer.py b/src/google/adk/cli/deployers/cloud_run_deployer.py index 5600869120..86d8ed69f5 100644 --- a/src/google/adk/cli/deployers/cloud_run_deployer.py +++ b/src/google/adk/cli/deployers/cloud_run_deployer.py @@ -2,7 +2,8 @@ import os import subprocess -from typing import Optional, Tuple +from typing import Optional +from typing import Tuple import click @@ -32,7 +33,7 @@ def deploy( adk_managed_args = {'--source', '--project', '--port', '--verbosity'} if region: adk_managed_args.add('--region') - + # Validate that extra gcloud args don't conflict with ADK-managed args self._validate_gcloud_extra_args(extra_gcloud_args, adk_managed_args) @@ -64,7 +65,7 @@ def deploy( '--verbosity', log_level.lower() if log_level else verbosity, ] - + # Handle labels specially - merge user labels with ADK label user_labels = [] extra_args_without_labels = [] @@ -122,8 +123,8 @@ def _resolve_project(self, project_in_option: str = None) -> str: def _validate_gcloud_extra_args( self, - extra_gcloud_args: Optional[tuple[str, ...]], - adk_managed_args: set[str] + extra_gcloud_args: Optional[tuple[str, ...]], + adk_managed_args: set[str], ) -> None: """Validates that extra gcloud args don't conflict with ADK-managed args. @@ -157,8 +158,8 @@ def _validate_gcloud_extra_args( if len(conflicts) == 1: raise click.ClickException( f"The argument {conflict_list} conflicts with ADK's automatic" - ' configuration. ADK will set this argument automatically, so please' - ' remove it from your command.' + ' configuration. ADK will set this argument automatically, so' + ' please remove it from your command.' ) else: raise click.ClickException( diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index 8b8cb63b67..22d68f9cac 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -32,10 +32,10 @@ from unittest import mock import click +from google.adk.cli.deployers.deployer_factory import DeployerFactory import pytest import src.google.adk.cli.cli_deploy as cli_deploy -from google.adk.cli.deployers.deployer_factory import DeployerFactory # Helpers @@ -157,7 +157,7 @@ def test_resolve_project_from_gcloud_fails( cloudRunDeployer = DeployerFactory.get_deployer("cloud_run") with pytest.raises(click.ClickException) as exc_info: - cloudRunDeployer._resolve_project(None) + cloudRunDeployer._resolve_project(None) assert "Failed to get project from gcloud" in str(exc_info.value) diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index fd0a9bafa7..6b4ae7861e 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -30,11 +30,11 @@ from click.testing import CliRunner from google.adk.agents.base_agent import BaseAgent from google.adk.cli import cli_tools_click +from google.adk.cli.deployers.cloud_run_deployer import CloudRunDeployer from google.adk.evaluation.eval_case import EvalCase from google.adk.evaluation.eval_set import EvalSet from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager from google.adk.evaluation.local_eval_sets_manager import LocalEvalSetsManager -from google.adk.cli.deployers.cloud_run_deployer import CloudRunDeployer from pydantic import BaseModel import pytest @@ -175,6 +175,7 @@ def test_cli_deploy_cloud_run_success( assert result.exit_code == 0 assert rec.calls, "cli_deploy.run must be invoked" + # cli deploy docker def test_cli_docker_cloud_run_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -197,6 +198,7 @@ def test_cli_docker_cloud_run_success( assert result.exit_code == 0 assert rec.calls, "cli_deploy.run must be invoked" + def test_cli_deploy_cloud_run_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -659,9 +661,7 @@ def _mock_run(*_a, **kwargs): kwargs.get("extra_gcloud_args"), adk_managed_args ) - monkeypatch.setattr( - cli_tools_click.cli_deploy, "run", _mock_run - ) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", _mock_run) agent_dir = tmp_path / "agent_conflict" agent_dir.mkdir()