diff --git a/README.md b/README.md index c3b0ac8e75..6750f7a091 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,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 335c786b9f..17d0675861 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -20,48 +20,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.agent_engines import AdkApp @@ -383,52 +348,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], @@ -456,9 +375,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, @@ -475,6 +395,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. @@ -494,6 +416,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. @@ -508,10 +431,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 variables 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): @@ -543,7 +470,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, @@ -551,7 +478,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, @@ -564,61 +491,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 529ee7319c..ecbc1aa3a3 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -25,6 +25,7 @@ from pathlib import Path import tempfile from typing import Optional +from typing import Tuple import click from click.core import ParameterSource @@ -115,6 +116,120 @@ def main(): pass +def 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." + " --env GOOGLE_GENAI_USE_VERTEXAI=1" + ), + ), + 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( + "--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.""" @@ -1268,104 +1383,17 @@ 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, -) +@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], @@ -1379,6 +1407,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, @@ -1441,8 +1471,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, @@ -1459,12 +1490,75 @@ def cli_deploy_cloud_run( artifact_service_uri=artifact_service_uri, memory_service_uri=memory_service_uri, a2a=a2a, + 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) +@deploy.command("docker", cls=HelpfulCommand) +@deploy_options +@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, + 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 + """ + + session_service_uri = session_service_uri or session_db_url + artifact_service_uri = artifact_service_uri or artifact_storage_uri + + 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=None, + adk_version=adk_version, + session_service_uri=session_service_uri, + artifact_service_uri=artifact_service_uri, + memory_service_uri=memory_service_uri, + a2a=a2a, + 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( "--api_key", 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..ddd8cbd20e --- /dev/null +++ b/src/google/adk/cli/config/dockerfile_template.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +_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/README.md b/src/google/adk/cli/deployers/README.md new file mode 100644 index 0000000000..146b386afb --- /dev/null +++ b/src/google/adk/cli/deployers/README.md @@ -0,0 +1,56 @@ +# CLI Deployment Tools for ADK Python + +This directory contains CLI utilities for deploying ADK Python projects across multiple cloud providers and Docker environments. + +## Overview + +This generalizes the `adk deploy` command to support flexible deployment across various platforms such as: + +- Google Cloud Platform (GCP) +- Local Docker environments +- Google Kubernetes Engine (GKE) - TODO + +It provides a modular and extensible deployment interface using a factory pattern to handle cloud-specific deployment logic. + +## Features + +- **Generalized Dockerfile**: No hard-coded provider-specific variables. +- **Modular Deployers**: Cloud-specific deployers (e.g., `GCPDeployer`, `DockerDeployer`) simplify deployment logic. +- **Environment Variables Injection**: + - Via `.env` files for local Docker development. + - Directly via CLI flags for production environments. +- **Provider-Specific Arguments**: Use `--provider-args` to pass provider specific parameters. + +## Usage + +# Deploy on cloud provider (GCP) +```bash +adk deploy cloud_run --with_ui +``` + +# Deploy on cloud provider (GCP) with environment variables +```bash +adk deploy cloud_run --with_ui --env GOOGLE_GENAI_USE_VERTEXAI=1 +``` + +# Deploy locally using Docker + +```bash +adk deploy docker --with_ui +``` + +# Deploy locally using Docker with environment variables + +```bash +adk deploy docker --with_ui --env GOOGLE_GENAI_USE_VERTEXAI=1 +``` + +## Contributing + +To add new CLI commands or support for additional cloud providers: + +- Register the deployer in the deployer factory +- Implement a new deployer class +- Implement CLI entry points for the deployer +- Add corresponding test cases +- Update usage instructions and documentation \ No newline at end of file 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..d23b266c56 --- /dev/null +++ b/src/google/adk/cli/deployers/base_deployer.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Tuple + + +class Deployer(ABC): + + @abstractmethod + def deploy( + self, + temp_folder: str, + service_name: str, + provider_args: Tuple[str], + env_vars: Tuple[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..9f5a4210d0 --- /dev/null +++ b/src/google/adk/cli/deployers/cloud_run_deployer.py @@ -0,0 +1,229 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +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_gcp_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.strip() 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', encoding='utf-8') 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_gcp_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_GENAI_USE_VERTEXAI=1', + 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..7fd5ca5bd6 --- /dev/null +++ b/src/google/adk/cli/deployers/deployer_factory.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +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..869fcff11f --- /dev/null +++ b/src/google/adk/cli/deployers/docker_deployer.py @@ -0,0 +1,72 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +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_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/deployers/test_cloud_run_deployer.py b/tests/unittests/cli/deployers/test_cloud_run_deployer.py new file mode 100644 index 0000000000..b028db6e0a --- /dev/null +++ b/tests/unittests/cli/deployers/test_cloud_run_deployer.py @@ -0,0 +1,131 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for run functionality in cloud_run_deployer.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from src.google.adk.cli.deployers.cloud_run_deployer import CloudRunDeployer + + +@pytest.fixture +def cloud_run_deployer(): + return CloudRunDeployer() + + +@patch('subprocess.run') +def test_deploy_success(mock_run, cloud_run_deployer): + cloud_run_deployer.deploy( + agent_folder='path/to/agent', + temp_folder='path/to/temp', + service_name='test-service', + provider_args=(), + env_vars=('ENV_VAR1=value1', 'ENV_VAR2=value2'), + project='test-project', + region='us-central1', + port=8080, + log_level='info', + ) + + # Check that subprocess.run was called with the expected command + expected_cmd = [ + 'gcloud', + 'run', + 'deploy', + 'test-service', + '--source', + 'path/to/temp', + '--project', + 'test-project', + '--region', + 'us-central1', + '--port', + '8080', + '--set-env-vars', + 'ENV_VAR1=value1,ENV_VAR2=value2,GOOGLE_GENAI_USE_VERTEXAI=1,GOOGLE_CLOUD_PROJECT=test-project,GOOGLE_CLOUD_LOCATION=us-central1', + '--verbosity', + 'info', + '--labels', + 'created-by=adk', + ] + mock_run.assert_called_once_with(expected_cmd, check=True) + + +# Test helper functions +def test_build_env_vars_string(cloud_run_deployer): + env_vars = ('ENV_VAR1=value1', 'ENV_VAR2=value2') + result = cloud_run_deployer.build_env_vars_string(env_vars) + assert result == 'ENV_VAR1=value1,ENV_VAR2=value2' + + +def test_build_env_file_arg_without_env_file(cloud_run_deployer, tmp_path): + result = cloud_run_deployer.build_env_file_arg(str(tmp_path)) + assert result == '' + + +def test_build_env_file_arg_with_env_file(cloud_run_deployer, tmp_path): + # Create a .env file for testing + env_file_path = tmp_path / '.env' + with open(env_file_path, 'w') as f: + f.write('ENV_VAR1=value1,ENV_VAR2=value2\n') + + result = cloud_run_deployer.build_env_file_arg(str(tmp_path)) + assert result == 'ENV_VAR1=value1,ENV_VAR2=value2' + + +def test_validate_gcloud_extra_args_no_conflicts(cloud_run_deployer): + extra_gcloud_args = ['--timeout=600'] + adk_managed_args = {'--project', '--region'} + try: + cloud_run_deployer._validate_gcloud_extra_args( + extra_gcloud_args, adk_managed_args + ) + except Exception: + pytest.fail('Unexpected exception raised') + + +def test_validate_gcloud_extra_args_with_conflicts(cloud_run_deployer): + extra_gcloud_args = ['--project=test-project'] + adk_managed_args = {'--project', '--region'} + with pytest.raises(Exception) as excinfo: + cloud_run_deployer._validate_gcloud_extra_args( + extra_gcloud_args, adk_managed_args + ) + assert "conflicts with ADK's automatic configuration" in str(excinfo.value) + + +def test_resolve_project_with_provided_project(cloud_run_deployer): + project = cloud_run_deployer._resolve_project('test-project') + assert project == 'test-project' + + +@patch('subprocess.run') +def test_resolve_project_without_provided_project(mock_run, cloud_run_deployer): + mock_run.return_value.stdout = 'default-project\n' + project = cloud_run_deployer._resolve_project() + assert project == 'default-project' + + +@patch('subprocess.run') +def test_resolve_project_error(mock_run, cloud_run_deployer): + mock_run.side_effect = subprocess.CalledProcessError(1, 'gcloud') + with pytest.raises(Exception) as excinfo: + cloud_run_deployer._resolve_project() + assert 'Failed to get project from gcloud' in str(excinfo.value) diff --git a/tests/unittests/cli/deployers/test_docker_deployer.py b/tests/unittests/cli/deployers/test_docker_deployer.py new file mode 100644 index 0000000000..8d57ab445e --- /dev/null +++ b/tests/unittests/cli/deployers/test_docker_deployer.py @@ -0,0 +1,162 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for run functionality in docker_deployer.""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from src.google.adk.cli.deployers.docker_deployer import DockerDeployer + + +@pytest.fixture +def docker_deployer(): + return DockerDeployer() + + +@patch('subprocess.run') +def test_deploy_success(mock_run, docker_deployer): + agent_folder = 'path/to/agent' + temp_folder = 'path/to/temp' + service_name = 'test-service' + provider_args = () + env_vars = ('ENV_VAR1=value1', 'ENV_VAR2=value2') + port = 8080 + + docker_deployer.deploy( + agent_folder=agent_folder, + temp_folder=temp_folder, + service_name=service_name, + provider_args=provider_args, + env_vars=env_vars, + port=port, + ) + + expected_build_cmd = [ + 'docker', + 'build', + '-t', + 'adk-python-test-service', + temp_folder, + ] + mock_run.assert_any_call(expected_build_cmd, check=True) + + expected_run_cmd = [ + 'docker', + 'run', + '-d', + '-p', + f'{port}:{port}', + '-e', + 'ENV_VAR1=value1', + '-e', + 'ENV_VAR2=value2', + 'adk-python-test-service', + ] + mock_run.assert_any_call(expected_run_cmd, check=True) + + +@patch('subprocess.run') +def test_deploy_with_env_file(mock_run, docker_deployer, tmp_path): + # Create a .env file for testing + env_file_path = tmp_path / '.env' + with open(env_file_path, 'w') as f: + f.write('ENV_VAR1=value1\nENV_VAR2=value2\n') + + agent_folder = str(tmp_path) + temp_folder = 'path/to/temp' + service_name = 'test-service' + provider_args = () + env_vars = () + port = 8080 + + docker_deployer.deploy( + agent_folder=agent_folder, + temp_folder=temp_folder, + service_name=service_name, + provider_args=provider_args, + env_vars=env_vars, + port=port, + ) + + # Check that subprocess.run was called to run the Docker container with --env-file + expected_run_cmd = [ + 'docker', + 'run', + '-d', + '-p', + f'{port}:{port}', + '--env-file', + str(env_file_path), + 'adk-python-test-service', + ] + mock_run.assert_any_call(expected_run_cmd, check=True) + + +@patch('subprocess.run') +def test_deploy_without_env_file(mock_run, docker_deployer, tmp_path): + agent_folder = str(tmp_path) + temp_folder = 'path/to/temp' + service_name = 'test-service' + provider_args = () + env_vars = ('ENV_VAR1=value1',) + port = 8080 + + docker_deployer.deploy( + agent_folder=agent_folder, + temp_folder=temp_folder, + service_name=service_name, + provider_args=provider_args, + env_vars=env_vars, + port=port, + ) + + # Check that subprocess.run was called to run the Docker container without --env-file + expected_run_cmd = [ + 'docker', + 'run', + '-d', + '-p', + f'{port}:{port}', + '-e', + 'ENV_VAR1=value1', + 'adk-python-test-service', + ] + mock_run.assert_any_call(expected_run_cmd, check=True) + + +# Test helper functions +def test_get_cli_env_args(docker_deployer): + env_vars = ('ENV_VAR1=value1', 'ENV_VAR2=value2') + result = docker_deployer.get_cli_env_args(env_vars) + assert result == ['-e', 'ENV_VAR1=value1', '-e', 'ENV_VAR2=value2'] + + +def test_get_env_file_arg_with_env_file(docker_deployer, tmp_path): + # Create a .env file for testing + env_file_path = tmp_path / '.env' + with open(env_file_path, 'w') as f: + f.write('ENV_VAR1=value1\nENV_VAR2=value2\n') + + result = docker_deployer.get_env_file_arg(str(tmp_path)) + assert result == ['--env-file', str(env_file_path)] + + +def test_get_env_file_arg_without_env_file(docker_deployer, tmp_path): + result = docker_deployer.get_env_file_arg(str(tmp_path)) + assert result == [] diff --git a/tests/unittests/cli/test_cli_tools_click_option_mismatch.py b/tests/unittests/cli/test_cli_tools_click_option_mismatch.py index 346fd421d0..b5f5b12745 100644 --- a/tests/unittests/cli/test_cli_tools_click_option_mismatch.py +++ b/tests/unittests/cli/test_cli_tools_click_option_mismatch.py @@ -22,8 +22,8 @@ from google.adk.cli.cli_tools_click import cli_api_server from google.adk.cli.cli_tools_click import cli_create_cmd from google.adk.cli.cli_tools_click import cli_deploy_agent_engine -from google.adk.cli.cli_tools_click import cli_deploy_cloud_run from google.adk.cli.cli_tools_click import cli_deploy_gke +from google.adk.cli.cli_tools_click import cli_deploy_to_cloud_run from google.adk.cli.cli_tools_click import cli_eval from google.adk.cli.cli_tools_click import cli_run from google.adk.cli.cli_tools_click import cli_web @@ -129,13 +129,13 @@ def test_adk_api_server(): def test_adk_deploy_cloud_run(): - """Test that cli_deploy_cloud_run has all required parameters.""" + """Test that cli_deploy_to_cloud_run has all required parameters.""" cloud_run_command = _get_command_by_name(deploy.commands, "cloud_run") assert cloud_run_command is not None, "Cloud Run deploy command not found" _check_options_in_parameters( cloud_run_command, - cli_deploy_cloud_run.callback, + cli_deploy_to_cloud_run.callback, "deploy cloud_run", ignore_params={"verbose", "ctx"}, ) diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index 696344eb44..7b1ffa0782 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 @@ -98,7 +99,8 @@ def _factory(include_requirements: bool, include_env: bool) -> Path: # _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: @@ -110,7 +112,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() @@ -123,8 +126,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_deploy_to_cloud_run.py b/tests/unittests/cli/utils/test_cli_deploy_to_cloud_run.py index cc5c30c23d..5ddbab359b 100644 --- a/tests/unittests/cli/utils/test_cli_deploy_to_cloud_run.py +++ b/tests/unittests/cli/utils/test_cli_deploy_to_cloud_run.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for to_cloud_run functionality in cli_deploy.""" +"""Tests for run functionality in cli_deploy.""" from __future__ import annotations @@ -113,8 +113,9 @@ def test_to_cloud_run_happy_path( rmtree_recorder = _Recorder() monkeypatch.setattr(shutil, "rmtree", rmtree_recorder) - cli_deploy.to_cloud_run( + cli_deploy.run( agent_folder=str(src_dir), + provider="cloud_run", project="proj", region="asia-northeast1", service_name="svc", @@ -130,6 +131,8 @@ def test_to_cloud_run_happy_path( artifact_service_uri="gs://bucket", memory_service_uri="rag://", adk_version="1.3.0", + provider_args=(), + env=(), ) agent_dest_path = tmp_path / "agents" / "agent" @@ -150,8 +153,6 @@ def test_to_cloud_run_happy_path( 'RUN adduser --disabled-password --gecos "" myuser' in dockerfile_content ) assert "USER myuser" in dockerfile_content - assert "ENV GOOGLE_CLOUD_PROJECT=proj" in dockerfile_content - assert "ENV GOOGLE_CLOUD_LOCATION=asia-northeast1" in dockerfile_content assert "RUN pip install google-adk==1.3.0" in dockerfile_content assert "--trace_to_cloud" in dockerfile_content @@ -185,6 +186,8 @@ def test_to_cloud_run_happy_path( "asia-northeast1", "--port", "8080", + "--set-env-vars", + "GOOGLE_GENAI_USE_VERTEXAI=1,GOOGLE_CLOUD_PROJECT=proj,GOOGLE_CLOUD_LOCATION=asia-northeast1", "--verbosity", "info", "--labels", @@ -211,8 +214,9 @@ def _fake_rmtree(path: str | Path, *_a: Any, **_k: Any) -> None: monkeypatch.setattr(shutil, "rmtree", _fake_rmtree) monkeypatch.setattr(subprocess, "run", _Recorder()) - cli_deploy.to_cloud_run( + cli_deploy.run( agent_folder=str(src_dir), + provider="cloud_run", project="proj", region=None, service_name="svc", @@ -227,6 +231,8 @@ def _fake_rmtree(path: str | Path, *_a: Any, **_k: Any) -> None: session_service_uri=None, artifact_service_uri=None, memory_service_uri=None, + provider_args=(), + env=(), ) assert deleted["path"] == tmp_dir @@ -236,7 +242,7 @@ def test_to_cloud_run_cleans_temp_dir_on_failure( monkeypatch: pytest.MonkeyPatch, agent_dir: AgentDirFixture, ) -> None: - """`to_cloud_run` should delete the temp folder on exit, even if gcloud fails.""" + """`run` should delete the temp folder on exit, even if gcloud fails.""" tmp_dir = Path(tempfile.mkdtemp()) src_dir = agent_dir(include_requirements=False, include_env=False) @@ -249,8 +255,9 @@ def test_to_cloud_run_cleans_temp_dir_on_failure( ) with pytest.raises(subprocess.CalledProcessError): - cli_deploy.to_cloud_run( + cli_deploy.run( agent_folder=str(src_dir), + provider="cloud_run", project="proj", region="us-central1", service_name="svc", @@ -265,6 +272,8 @@ def test_to_cloud_run_cleans_temp_dir_on_failure( session_service_uri=None, artifact_service_uri=None, memory_service_uri=None, + provider_args=(), + env=(), ) assert rmtree_recorder.calls, "shutil.rmtree should have been called" @@ -317,8 +326,9 @@ def test_cloud_run_label_merging( monkeypatch.setattr(shutil, "rmtree", lambda _x: None) # Execute the function under test - cli_deploy.to_cloud_run( + cli_deploy.run( agent_folder=str(src_dir), + provider="cloud_run", project="test-project", region="us-central1", service_name="test-service", @@ -331,6 +341,8 @@ def test_cloud_run_label_merging( verbosity="info", adk_version="1.0.0", extra_gcloud_args=tuple(extra_gcloud_args) if extra_gcloud_args else None, + provider_args=(), + env=(), ) # Verify that the gcloud command was called diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index be9015ca87..b33ec98f50 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -31,6 +31,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 @@ -153,9 +154,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() @@ -173,18 +174,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_deploy_docker_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() @@ -202,7 +226,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() @@ -230,7 +254,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] @@ -246,7 +270,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() @@ -268,7 +292,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( @@ -276,7 +300,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() @@ -302,7 +326,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( @@ -310,7 +334,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() @@ -331,7 +355,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] @@ -761,21 +785,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()