From b228bc291bdf8b085f939f2b4a1806eef3f8e576 Mon Sep 17 00:00:00 2001 From: raflFaisal Date: Thu, 9 Oct 2025 23:39:31 +0200 Subject: [PATCH 1/4] enhancement: generalising docker build and run for multicloud environments --- README.md | 20 ++ src/google/adk/cli/cli_deploy.py | 174 ++-------- src/google/adk/cli/cli_tools_click.py | 322 ++++++++++++------ .../adk/cli/config/dockerfile_template.py | 27 ++ src/google/adk/cli/deployers/base_deployer.py | 18 + .../adk/cli/deployers/cloud_run_deployer.py | 215 ++++++++++++ .../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 | 60 ++-- 10 files changed, 670 insertions(+), 260 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..19d7bce3ff 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..11f2408246 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 @@ -98,52 +63,6 @@ def _resolve_project(project_in_option: Optional[str]) -> str: 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 +90,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 +110,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 +131,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 +146,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 +185,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 +193,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 +206,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 e76d903f28..ca4ab07f6a 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,124 @@ 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.""" @@ -1087,104 +1206,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], @@ -1198,6 +1225,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, @@ -1260,8 +1289,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, @@ -1279,6 +1309,108 @@ 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) 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..86d8ed69f5 --- /dev/null +++ b/src/google/adk/cli/deployers/cloud_run_deployer.py @@ -0,0 +1,215 @@ +# deployers/cloud_run_deployer.py + +import os +import subprocess +from typing import Optional +from typing import 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..22d68f9cac 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -32,6 +32,7 @@ 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 @@ -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..6b4ae7861e 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -30,6 +30,7 @@ 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 @@ -152,9 +153,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 +173,41 @@ 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 +225,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 +253,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 +269,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 +291,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 +299,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 +325,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 +333,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 +354,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,21 +649,19 @@ 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 + 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 ) - monkeypatch.setattr( - cli_tools_click.cli_deploy, "to_cloud_run", _mock_to_cloud_run - ) + monkeypatch.setattr(cli_tools_click.cli_deploy, "run", _mock_run) agent_dir = tmp_path / "agent_conflict" agent_dir.mkdir() From 8d998ebef80fb3a90fb356f98171dcbb21b22fcf Mon Sep 17 00:00:00 2001 From: raflFaisal Date: Fri, 10 Oct 2025 00:26:44 +0200 Subject: [PATCH 2/4] incorporating review comments by gemini --- src/google/adk/cli/cli_deploy.py | 2 +- src/google/adk/cli/cli_tools_click.py | 34 ++----------------- src/google/adk/cli/deployers/base_deployer.py | 6 ++-- .../adk/cli/deployers/cloud_run_deployer.py | 2 +- .../adk/cli/deployers/docker_deployer.py | 13 ++++--- .../cli/utils/test_cli_tools_click.py | 2 +- 6 files changed, 14 insertions(+), 45 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 11f2408246..1a69cff504 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -147,7 +147,7 @@ def run( 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 + env: The environment variables provided """ app_name = app_name or os.path.basename(agent_folder) mode = 'web' if with_ui else 'api_server' diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index ca4ab07f6a..a589524d5e 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -1281,7 +1281,7 @@ def cli_deploy_to_cloud_run( if ctx.args: click.secho( f"Error: Unexpected arguments: {' '.join(ctx.args)}. \nUse '--' to" - " separate gcloud arguments, e.g.: adk deploy cloud_run [options]" + " separate gcloud arguments, e.g.: adk deploy cloud_run [options]", " agent_path -- --min-instances=2", fg="red", err=True, @@ -1308,9 +1308,9 @@ def cli_deploy_to_cloud_run( 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, + extra_gcloud_args=tuple(gcloud_args), ) except Exception as e: click.secho(f"Deploy failed: {e}", fg="red", err=True) @@ -1359,35 +1359,6 @@ def cli_deploy_docker( 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, @@ -1408,7 +1379,6 @@ def cli_deploy_docker( 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, ) diff --git a/src/google/adk/cli/deployers/base_deployer.py b/src/google/adk/cli/deployers/base_deployer.py index 0fc6fd0cb7..f26402344f 100644 --- a/src/google/adk/cli/deployers/base_deployer.py +++ b/src/google/adk/cli/deployers/base_deployer.py @@ -1,6 +1,6 @@ from abc import ABC from abc import abstractmethod -from typing import Dict +from typing import Tuple class Deployer(ABC): @@ -10,8 +10,8 @@ def deploy( self, temp_folder: str, service_name: str, - provider_args: Dict[str, str], - env_vars: Dict[str, str], + provider_args: Tuple[str], + env_vars: Tuple[str], **kwargs, ): """Deploys the agent to the target platform.""" diff --git a/src/google/adk/cli/deployers/cloud_run_deployer.py b/src/google/adk/cli/deployers/cloud_run_deployer.py index 86d8ed69f5..7f4ac117a5 100644 --- a/src/google/adk/cli/deployers/cloud_run_deployer.py +++ b/src/google/adk/cli/deployers/cloud_run_deployer.py @@ -185,7 +185,7 @@ def build_env_file_arg(self, agent_folder: str) -> str: env_vars_str = '' if os.path.exists(env_file_path): - with open(env_file_path, 'r') as f: + with open(env_file_path, 'r', encoding='utf-8') as f: lines = f.readlines() env_vars = [] diff --git a/src/google/adk/cli/deployers/docker_deployer.py b/src/google/adk/cli/deployers/docker_deployer.py index 01993a8a6c..41ad4f03ea 100644 --- a/src/google/adk/cli/deployers/docker_deployer.py +++ b/src/google/adk/cli/deployers/docker_deployer.py @@ -49,10 +49,9 @@ def get_cli_env_args(self, env_vars: Tuple[str]) -> List[str]: 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 + def get_env_file_arg(self, agent_folder: str) -> List[str]: + """Returns Docker `--env-file` argument if .env file exists in agent_folder.""" + env_file_path = os.path.join(agent_folder, '.env') + if os.path.exists(env_file_path): + return ['--env-file', env_file_path] + return [] diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 6b4ae7861e..69756dafd8 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -177,7 +177,7 @@ def test_cli_deploy_cloud_run_success( # cli deploy docker -def test_cli_docker_cloud_run_success( +def test_cli_deploy_docker_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Successful path should call cli_deploy.run once.""" From b1f0595d9ed50f792643f76843f8275000711e39 Mon Sep 17 00:00:00 2001 From: raflFaisal Date: Fri, 10 Oct 2025 00:32:39 +0200 Subject: [PATCH 3/4] remove comma left as typo --- src/google/adk/cli/cli_tools_click.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index a589524d5e..b6b92bd1d5 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -1281,7 +1281,7 @@ def cli_deploy_to_cloud_run( if ctx.args: click.secho( f"Error: Unexpected arguments: {' '.join(ctx.args)}. \nUse '--' to" - " separate gcloud arguments, e.g.: adk deploy cloud_run [options]", + " separate gcloud arguments, e.g.: adk deploy cloud_run [options]" " agent_path -- --min-instances=2", fg="red", err=True, From bd7913afd93ecef5b9abbb1ea98e27acc0472915 Mon Sep 17 00:00:00 2001 From: raflFaisal Date: Fri, 10 Oct 2025 00:49:10 +0200 Subject: [PATCH 4/4] fix pyink reported issues --- src/google/adk/cli/deployers/base_deployer.py | 2 +- src/google/adk/cli/deployers/cloud_run_deployer.py | 2 +- src/google/adk/cli/deployers/docker_deployer.py | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/google/adk/cli/deployers/base_deployer.py b/src/google/adk/cli/deployers/base_deployer.py index f26402344f..8d825d67cd 100644 --- a/src/google/adk/cli/deployers/base_deployer.py +++ b/src/google/adk/cli/deployers/base_deployer.py @@ -10,7 +10,7 @@ def deploy( self, temp_folder: str, service_name: str, - provider_args: Tuple[str], + provider_args: Tuple[str], env_vars: Tuple[str], **kwargs, ): diff --git a/src/google/adk/cli/deployers/cloud_run_deployer.py b/src/google/adk/cli/deployers/cloud_run_deployer.py index 7f4ac117a5..e6b29a434f 100644 --- a/src/google/adk/cli/deployers/cloud_run_deployer.py +++ b/src/google/adk/cli/deployers/cloud_run_deployer.py @@ -185,7 +185,7 @@ def build_env_file_arg(self, agent_folder: str) -> str: env_vars_str = '' if os.path.exists(env_file_path): - with open(env_file_path, 'r', encoding='utf-8') as f: + with open(env_file_path, 'r', encoding='utf-8') as f: lines = f.readlines() env_vars = [] diff --git a/src/google/adk/cli/deployers/docker_deployer.py b/src/google/adk/cli/deployers/docker_deployer.py index 41ad4f03ea..e3604d4625 100644 --- a/src/google/adk/cli/deployers/docker_deployer.py +++ b/src/google/adk/cli/deployers/docker_deployer.py @@ -49,9 +49,9 @@ def get_cli_env_args(self, env_vars: Tuple[str]) -> List[str]: 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_file_path = os.path.join(agent_folder, '.env') - if os.path.exists(env_file_path): - return ['--env-file', env_file_path] + def get_env_file_arg(self, agent_folder: str) -> List[str]: + """Returns Docker `--env-file` argument if .env file exists in agent_folder.""" + env_file_path = os.path.join(agent_folder, '.env') + if os.path.exists(env_file_path): + return ['--env-file', env_file_path] return []