From b3e5e6d58b75223082ef44fcbd05b71f7a90903b Mon Sep 17 00:00:00 2001 From: Vicente Ferrara Date: Wed, 23 Jul 2025 22:44:12 +0000 Subject: [PATCH] feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint test not working locally feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint test not working locally updated test based on merge conflict feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint test not working locally feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint feat: cli funcionality to deploy an Agent to a running GKE cluster added tests for tools click adn cli deploy revert some changes on cli tools click added test for tools click fixed lint issues removed eval storage uri scope for now addressed comments on allow origins and nits formatting improved logging and updated tests added label to deployment so that we can keep track in keywords pipeline added labels to pod spec fixing conflicts when rebasing solving conflicts and log_level updated test pylint test not working locally updated test based on merge conflict fixed cli deploy test --- src/google/adk/cli/cli_deploy.py | 234 ++++++- src/google/adk/cli/cli_tools_click.py | 608 +++++++++++++----- tests/unittests/cli/utils/test_cli_deploy.py | 549 ++++++++++++++-- .../cli/utils/test_cli_tools_click.py | 263 ++++---- 4 files changed, 1299 insertions(+), 355 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index e9eecb7f4f..5082ba3209 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -153,11 +153,11 @@ def to_cloud_run( app_name: The name of the app, by default, it's basename of `agent_folder`. temp_folder: The temp folder for the generated Cloud Run source files. port: The port of the ADK api server. - allow_origins: The list of allowed origins for the ADK api server. trace_to_cloud: Whether to enable Cloud Trace. with_ui: Whether to deploy with UI. verbosity: The verbosity level of the CLI. adk_version: The ADK version to use in Cloud Run. + allow_origins: The list of allowed origins for the ADK api server. 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. @@ -182,7 +182,7 @@ def to_cloud_run( if os.path.exists(requirements_txt_path) else '' ) - click.echo('Copying agent source code complete.') + click.echo('Copying agent source code completed.') # create Dockerfile click.echo('Creating Dockerfile...') @@ -425,7 +425,7 @@ def to_agent_engine( 'async_stream': ['async_stream_query'], 'stream': ['stream_query', 'streaming_agent_run_with_events'], }, - sys_paths=[temp_folder[1:]], + sys_paths=[temp_folder], ) agent_config = dict( agent_engine=agent_engine, @@ -443,3 +443,231 @@ def to_agent_engine( finally: click.echo(f'Cleaning up the temp folder: {temp_folder}') shutil.rmtree(temp_folder) + + +def to_gke( + *, + agent_folder: str, + project: Optional[str], + region: Optional[str], + cluster_name: str, + service_name: str, + app_name: str, + temp_folder: str, + port: int, + trace_to_cloud: bool, + with_ui: bool, + log_level: str, + verbosity: str, + adk_version: 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, + a2a: bool = False, +): + """Deploys an agent to Google Kubernetes Engine(GKE). + + Args: + agent_folder: The folder (absolute path) containing the agent source code. + project: Google Cloud project id. + region: Google Cloud region. + cluster_name: The name of the GKE cluster. + service_name: The service name in GKE. + app_name: The name of the app, by default, it's basename of `agent_folder`. + temp_folder: The local directory to use as a temporary workspace for preparing deployment artifacts. The tool populates this folder with a copy of the agent's source code and auto-generates necessary files like a Dockerfile and deployment.yaml. + port: The port of the ADK api server. + trace_to_cloud: Whether to enable Cloud Trace. + with_ui: Whether to deploy with UI. + verbosity: The verbosity level of the CLI. + adk_version: The ADK version to use in GKE. + allow_origins: The list of allowed origins for the ADK api server. + 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. + """ + click.secho( + '\nšŸš€ Starting ADK Agent Deployment to GKE...', fg='cyan', bold=True + ) + click.echo('--------------------------------------------------') + # Resolve project early to show the user which one is being used + project = _resolve_project(project) + click.echo(f' Project: {project}') + click.echo(f' Region: {region}') + click.echo(f' Cluster: {cluster_name}') + click.echo('--------------------------------------------------\n') + + app_name = app_name or os.path.basename(agent_folder) + + click.secho('STEP 1: Preparing build environment...', bold=True) + click.echo(f' - Using temporary directory: {temp_folder}') + + # remove temp_folder if exists + if os.path.exists(temp_folder): + click.echo(' - Removing existing temporary directory...') + shutil.rmtree(temp_folder) + + try: + # copy agent source code + click.echo(' - Copying agent source code...') + agent_src_path = os.path.join(temp_folder, 'agents', app_name) + shutil.copytree(agent_folder, agent_src_path) + requirements_txt_path = os.path.join(agent_src_path, 'requirements.txt') + install_agent_deps = ( + f'RUN pip install -r "/app/agents/{app_name}/requirements.txt"' + if os.path.exists(requirements_txt_path) + else '' + ) + click.secho('āœ… Environment prepared.', fg='green') + + allow_origins_option = ( + f'--allow_origins={",".join(allow_origins)}' if allow_origins else '' + ) + + # create Dockerfile + click.secho('\nSTEP 2: Generating deployment files...', bold=True) + click.echo(' - Creating Dockerfile...') + host_option = '--host=0.0.0.0' if adk_version > '0.5.0' else '' + dockerfile_content = _DOCKERFILE_TEMPLATE.format( + gcp_project_id=project, + gcp_region=region, + app_name=app_name, + port=port, + command='web' if with_ui else 'api_server', + install_agent_deps=install_agent_deps, + service_option=_get_service_option_by_adk_version( + adk_version, + session_service_uri, + artifact_service_uri, + memory_service_uri, + ), + trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '', + allow_origins_option=allow_origins_option, + adk_version=adk_version, + host_option=host_option, + a2a_option='--a2a' if a2a else '', + ) + dockerfile_path = os.path.join(temp_folder, 'Dockerfile') + os.makedirs(temp_folder, exist_ok=True) + with open(dockerfile_path, 'w', encoding='utf-8') as f: + f.write( + dockerfile_content, + ) + click.secho(f'āœ… Dockerfile generated: {dockerfile_path}', fg='green') + + # Build and push the Docker image + click.secho( + '\nSTEP 3: Building container image with Cloud Build...', bold=True + ) + click.echo( + ' (This may take a few minutes. Raw logs from gcloud will be shown' + ' below.)' + ) + project = _resolve_project(project) + image_name = f'gcr.io/{project}/{service_name}' + subprocess.run( + [ + 'gcloud', + 'builds', + 'submit', + '--tag', + image_name, + '--verbosity', + log_level.lower() if log_level else verbosity, + temp_folder, + ], + check=True, + ) + click.secho('āœ… Container image built and pushed successfully.', fg='green') + + # Create a Kubernetes deployment + click.echo(' - Creating Kubernetes deployment.yaml...') + deployment_yaml = f""" +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {service_name} + labels: + app.kubernetes.io/name: adk-agent + app.kubernetes.io/version: {adk_version} + app.kubernetes.io/instance: {service_name} + app.kubernetes.io/managed-by: adk-cli +spec: + replicas: 1 + selector: + matchLabels: + app: {service_name} + template: + metadata: + labels: + app: {service_name} + app.kubernetes.io/name: adk-agent + app.kubernetes.io/version: {adk_version} + app.kubernetes.io/instance: {service_name} + app.kubernetes.io/managed-by: adk-cli + spec: + containers: + - name: {service_name} + image: {image_name} + ports: + - containerPort: {port} +--- +apiVersion: v1 +kind: Service +metadata: + name: {service_name} +spec: + type: LoadBalancer + selector: + app: {service_name} + ports: + - port: 80 + targetPort: {port} +""" + deployment_yaml_path = os.path.join(temp_folder, 'deployment.yaml') + with open(deployment_yaml_path, 'w', encoding='utf-8') as f: + f.write(deployment_yaml) + click.secho( + f'āœ… Kubernetes deployment manifest generated: {deployment_yaml_path}', + fg='green', + ) + + # Apply the deployment + click.secho('\nSTEP 4: Applying deployment to GKE cluster...', bold=True) + click.echo(' - Getting cluster credentials...') + subprocess.run( + [ + 'gcloud', + 'container', + 'clusters', + 'get-credentials', + cluster_name, + '--region', + region, + '--project', + project, + ], + check=True, + ) + click.echo(' - Applying Kubernetes manifest...') + result = subprocess.run( + ['kubectl', 'apply', '-f', temp_folder], + check=True, + capture_output=True, # <-- Add this + text=True, # <-- Add this + ) + + # 2. Print the captured output line by line + click.secho( + ' - The following resources were applied to the cluster:', fg='green' + ) + for line in result.stdout.strip().split('\n'): + click.echo(f' - {line}') + + finally: + click.secho('\nSTEP 5: Cleaning up...', bold=True) + click.echo(f' - Removing temporary directory: {temp_folder}') + shutil.rmtree(temp_folder) + click.secho( + '\nšŸŽ‰ Deployment to GKE finished successfully!', fg='cyan', bold=True + ) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index e0d0c19d01..4124be228e 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -33,6 +33,10 @@ from . import cli_deploy from .. import version from ..evaluation.constants import MISSING_EVAL_DEPENDENCIES_MESSAGE +from ..evaluation.gcs_eval_set_results_manager import GcsEvalSetResultsManager +from ..evaluation.gcs_eval_sets_manager import GcsEvalSetsManager +from ..evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager +from ..sessions.in_memory_session_service import InMemorySessionService from .cli import run_cli from .fast_api import get_fast_api_app from .utils import envs @@ -273,7 +277,7 @@ def cli_run( exists=True, dir_okay=True, file_okay=False, resolve_path=True ), ) -@click.argument("eval_set_file_path_or_id", nargs=-1) +@click.argument("eval_set_file_path", nargs=-1) @click.option("--config_file_path", help="Optional. The path to config file.") @click.option( "--print_detailed_results", @@ -293,7 +297,7 @@ def cli_run( ) def cli_eval( agent_module_file_path: str, - eval_set_file_path_or_id: list[str], + eval_set_file_path: list[str], config_file_path: str, print_detailed_results: bool, eval_storage_uri: Optional[str] = None, @@ -303,51 +307,20 @@ def cli_eval( AGENT_MODULE_FILE_PATH: The path to the __init__.py file that contains a module by the name "agent". "agent" module contains a root_agent. - EVAL_SET_FILE_PATH_OR_ID: You can specify one or more eval set file paths or - eval set id. + EVAL_SET_FILE_PATH: You can specify one or more eval set file paths. - Mixing of eval set file paths with eval set ids is not allowed. - - *Eval Set File Path* For each file, all evals will be run by default. If you want to run only specific evals from a eval set, first create a comma separated list of eval names and then add that as a suffix to the eval set file name, demarcated by a `:`. - For example, we have `sample_eval_set_file.json` file that has following the - eval cases: - sample_eval_set_file.json: - |....... eval_1 - |....... eval_2 - |....... eval_3 - |....... eval_4 - |....... eval_5 + For example, sample_eval_set_file.json:eval_1,eval_2,eval_3 This will only run eval_1, eval_2 and eval_3 from sample_eval_set_file.json. - *Eval Set Id* - For each eval set, all evals will be run by default. - - If you want to run only specific evals from a eval set, first create a comma - separated list of eval names and then add that as a suffix to the eval set - file name, demarcated by a `:`. - - For example, we have `sample_eval_set_id` that has following the eval cases: - sample_eval_set_id: - |....... eval_1 - |....... eval_2 - |....... eval_3 - |....... eval_4 - |....... eval_5 - - If we did: - sample_eval_set_id:eval_1,eval_2,eval_3 - - This will only run eval_1, eval_2 and eval_3 from sample_eval_set_id. - CONFIG_FILE_PATH: The path to config file. PRINT_DETAILED_RESULTS: Prints detailed results on the console. @@ -355,23 +328,17 @@ def cli_eval( envs.load_dotenv_for_agent(agent_module_file_path, ".") try: - from ..evaluation.base_eval_service import InferenceConfig - from ..evaluation.base_eval_service import InferenceRequest - from ..evaluation.eval_metrics import EvalMetric - from ..evaluation.eval_result import EvalCaseResult - from ..evaluation.evaluator import EvalStatus - from ..evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager - from ..evaluation.local_eval_service import LocalEvalService - from ..evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager from ..evaluation.local_eval_sets_manager import load_eval_set_from_file - from ..evaluation.local_eval_sets_manager import LocalEvalSetsManager - from .cli_eval import _collect_eval_results - from .cli_eval import _collect_inferences + from .cli_eval import EvalCaseResult + from .cli_eval import EvalMetric + from .cli_eval import EvalStatus from .cli_eval import get_evaluation_criteria_or_default from .cli_eval import get_root_agent from .cli_eval import parse_and_get_evals_to_run - except ModuleNotFoundError as mnf: - raise click.ClickException(MISSING_EVAL_DEPENDENCIES_MESSAGE) from mnf + from .cli_eval import run_evals + from .cli_eval import try_get_reset_func + except ModuleNotFoundError: + raise click.ClickException(MISSING_EVAL_DEPENDENCIES_MESSAGE) evaluation_criteria = get_evaluation_criteria_or_default(config_file_path) eval_metrics = [] @@ -383,103 +350,80 @@ def cli_eval( print(f"Using evaluation criteria: {evaluation_criteria}") root_agent = get_root_agent(agent_module_file_path) - app_name = os.path.basename(agent_module_file_path) - agents_dir = os.path.dirname(agent_module_file_path) - eval_sets_manager = None - eval_set_results_manager = None + reset_func = try_get_reset_func(agent_module_file_path) + gcs_eval_sets_manager = None + eval_set_results_manager = None if eval_storage_uri: gcs_eval_managers = evals.create_gcs_eval_managers_from_uri( eval_storage_uri ) - eval_sets_manager = gcs_eval_managers.eval_sets_manager + gcs_eval_sets_manager = gcs_eval_managers.eval_sets_manager eval_set_results_manager = gcs_eval_managers.eval_set_results_manager else: - eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=agents_dir) - - inference_requests = [] - eval_set_file_or_id_to_evals = parse_and_get_evals_to_run( - eval_set_file_path_or_id - ) - - # Check if the first entry is a file that exists, if it does then we assume - # rest of the entries are also files. We enforce this assumption in the if - # block. - if eval_set_file_or_id_to_evals and os.path.exists( - list(eval_set_file_or_id_to_evals.keys())[0] - ): - eval_sets_manager = InMemoryEvalSetsManager() - - # Read the eval_set files and get the cases. - for ( - eval_set_file_path, - eval_case_ids, - ) in eval_set_file_or_id_to_evals.items(): - try: - eval_set = load_eval_set_from_file( - eval_set_file_path, eval_set_file_path - ) - except FileNotFoundError as fne: - raise click.ClickException( - f"`{eval_set_file_path}` should be a valid eval set file." - ) from fne - - eval_sets_manager.create_eval_set( - app_name=app_name, eval_set_id=eval_set.eval_set_id + eval_set_results_manager = LocalEvalSetResultsManager( + agents_dir=os.path.dirname(agent_module_file_path) + ) + eval_set_file_path_to_evals = parse_and_get_evals_to_run(eval_set_file_path) + eval_set_id_to_eval_cases = {} + + # Read the eval_set files and get the cases. + for eval_set_file_path, eval_case_ids in eval_set_file_path_to_evals.items(): + if gcs_eval_sets_manager: + eval_set = gcs_eval_sets_manager._load_eval_set_from_blob( + eval_set_file_path ) - for eval_case in eval_set.eval_cases: - eval_sets_manager.add_eval_case( - app_name=app_name, - eval_set_id=eval_set.eval_set_id, - eval_case=eval_case, + if not eval_set: + raise click.ClickException( + f"Eval set {eval_set_file_path} not found in GCS." ) - inference_requests.append( - InferenceRequest( - app_name=app_name, - eval_set_id=eval_set.eval_set_id, - eval_case_ids=eval_case_ids, - inference_config=InferenceConfig(), - ) - ) - else: - # We assume that what we have are eval set ids instead. - eval_sets_manager = ( - eval_sets_manager - if eval_storage_uri - else LocalEvalSetsManager(agents_dir=agents_dir) - ) - - for eval_set_id_key, eval_case_ids in eval_set_file_or_id_to_evals.items(): - inference_requests.append( - InferenceRequest( - app_name=app_name, - eval_set_id=eval_set_id_key, - eval_case_ids=eval_case_ids, - inference_config=InferenceConfig(), - ) + else: + eval_set = load_eval_set_from_file(eval_set_file_path, eval_set_file_path) + eval_cases = eval_set.eval_cases + + if eval_case_ids: + # There are eval_ids that we should select. + eval_cases = [ + e for e in eval_set.eval_cases if e.eval_id in eval_case_ids + ] + + eval_set_id_to_eval_cases[eval_set.eval_set_id] = eval_cases + + async def _collect_eval_results() -> list[EvalCaseResult]: + session_service = InMemorySessionService() + eval_case_results = [] + async for eval_case_result in run_evals( + eval_set_id_to_eval_cases, + root_agent, + reset_func, + eval_metrics, + session_service=session_service, + ): + eval_case_result.session_details = await session_service.get_session( + app_name=os.path.basename(agent_module_file_path), + user_id=eval_case_result.user_id, + session_id=eval_case_result.session_id, ) + eval_case_results.append(eval_case_result) + return eval_case_results try: - eval_service = LocalEvalService( - root_agent=root_agent, - eval_sets_manager=eval_sets_manager, - eval_set_results_manager=eval_set_results_manager, - ) - - inference_results = asyncio.run( - _collect_inferences( - inference_requests=inference_requests, eval_service=eval_service - ) - ) - eval_results = asyncio.run( - _collect_eval_results( - inference_results=inference_results, - eval_service=eval_service, - eval_metrics=eval_metrics, - ) + eval_results = asyncio.run(_collect_eval_results()) + except ModuleNotFoundError: + raise click.ClickException(MISSING_EVAL_DEPENDENCIES_MESSAGE) + + # Write eval set results. + eval_set_id_to_eval_results = collections.defaultdict(list) + for eval_case_result in eval_results: + eval_set_id = eval_case_result.eval_set_id + eval_set_id_to_eval_results[eval_set_id].append(eval_case_result) + + for eval_set_id, eval_case_results in eval_set_id_to_eval_results.items(): + eval_set_results_manager.save_eval_set_result( + app_name=os.path.basename(agent_module_file_path), + eval_set_id=eval_set_id, + eval_case_results=eval_case_results, ) - except ModuleNotFoundError as mnf: - raise click.ClickException(MISSING_EVAL_DEPENDENCIES_MESSAGE) from mnf print("*********************************************************************") eval_run_summary = {} @@ -535,15 +479,6 @@ def decorator(func): ), default=None, ) - @click.option( - "--eval_storage_uri", - type=str, - help=( - "Optional. The evals storage URI to store agent evals," - " supported URIs: gs://." - ), - default=None, - ) @click.option( "--memory_service_uri", type=str, @@ -605,6 +540,13 @@ def fast_api_common_options(): """Decorator to add common fast api options to click commands.""" def decorator(func): + @click.option( + "--host", + type=str, + help="Optional. The binding host of the server", + default="127.0.0.1", + show_default=True, + ) @click.option( "--port", type=int, @@ -678,13 +620,6 @@ def wrapper(ctx, *args, **kwargs): @main.command("web") -@click.option( - "--host", - type=str, - help="Optional. The binding host of the server", - default="127.0.0.1", - show_default=True, -) @fast_api_common_options() @adk_services_options() @deprecated_adk_services_options() @@ -719,7 +654,7 @@ def cli_web( Example: - adk web --port=[port] path/to/agents_dir + adk web --session_service_uri=[uri] --port=[port] path/to/agents_dir """ logs.setup_adk_logger(getattr(logging, log_level.upper())) @@ -774,16 +709,6 @@ async def _lifespan(app: FastAPI): @main.command("api_server") -@click.option( - "--host", - type=str, - help="Optional. The binding host of the server", - default="127.0.0.1", - show_default=True, -) -@fast_api_common_options() -@adk_services_options() -@deprecated_adk_services_options() # The directory of agents, where each sub-directory is a single agent. # By default, it is the current working directory @click.argument( @@ -793,6 +718,9 @@ async def _lifespan(app: FastAPI): ), default=os.getcwd(), ) +@fast_api_common_options() +@adk_services_options() +@deprecated_adk_services_options() def cli_api_server( agents_dir: str, eval_storage_uri: Optional[str] = None, @@ -817,7 +745,7 @@ def cli_api_server( Example: - adk api_server --port=[port] path/to/agents_dir + adk api_server --session_service_uri=[uri] --port=[port] path/to/agents_dir """ logs.setup_adk_logger(getattr(logging, log_level.upper())) @@ -881,7 +809,19 @@ def cli_api_server( " of the AGENT source code)." ), ) -@fast_api_common_options() +@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, @@ -892,11 +832,6 @@ def cli_api_server( " only)" ), ) -@click.option( - "--verbosity", - type=LOG_LEVELS, - help="Deprecated. Use --log_level instead.", -) @click.option( "--temp_folder", type=str, @@ -910,6 +845,17 @@ def cli_api_server( " (default: a timestamped folder in the system temp directory)." ), ) +@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, @@ -922,12 +868,6 @@ def cli_api_server( ) @adk_services_options() @deprecated_adk_services_options() -@click.argument( - "agent", - type=click.Path( - exists=True, dir_okay=True, file_okay=False, resolve_path=True - ), -) def cli_deploy_cloud_run( agent: str, project: Optional[str], @@ -938,11 +878,9 @@ def cli_deploy_cloud_run( port: int, trace_to_cloud: bool, with_ui: bool, + verbosity: str, adk_version: str, log_level: Optional[str] = None, - verbosity: str = "WARNING", - reload: bool = True, - allow_origins: Optional[list[str]] = None, session_service_uri: Optional[str] = None, artifact_service_uri: Optional[str] = None, memory_service_uri: Optional[str] = None, @@ -973,7 +911,6 @@ def cli_deploy_cloud_run( 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, @@ -1120,8 +1057,7 @@ def cli_deploy_agent_engine( Example: adk deploy agent_engine --project=[project] --region=[region] - --staging_bucket=[staging_bucket] --display_name=[app_name] - path/to/my_agent + --staging_bucket=[staging_bucket] --display_name=[app_name] path/to/my_agent """ try: cli_deploy.to_agent_engine( @@ -1141,3 +1077,319 @@ def cli_deploy_agent_engine( ) except Exception as e: click.secho(f"Deploy failed: {e}", fg="red", err=True) + + +@deploy.command("gke") +@click.option( + "--project", + type=str, + help=( + "Required. Google Cloud project to deploy the agent. When absent," + " default project from gcloud config is used." + ), +) +@click.option( + "--region", + type=str, + help=( + "Required. Google Cloud region to deploy the agent. When absent," + " gcloud run deploy will prompt later." + ), +) +@click.option( + "--cluster_name", + type=str, + help="Required. The name of the GKE cluster.", +) +@click.option( + "--service_name", + type=str, + default="adk-default-service-name", + help=( + "Optional. The service name to use in GKE (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 GKE.", +) +@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( # This is the crucial missing piece + "--verbosity", + type=LOG_LEVELS, + help="Deprecated. Use --log_level instead.", +) +@click.option( + "--log_level", + type=LOG_LEVELS, + default="INFO", + help="Optional. Set the logging level", +) +@click.option( + "--temp_folder", + type=str, + default=os.path.join( + tempfile.gettempdir(), + "gke_deploy_src", + datetime.now().strftime("%Y%m%d_%H%M%S"), + ), + help=( + "Optional. Temp folder for the generated GKE source files" + " (default: a timestamped folder in the system temp directory)." + ), +) +@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 GKE deployment. (default: the" + " version in the dev environment)" + ), +) +@adk_services_options() +@deprecated_adk_services_options() +def cli_deploy_gke( + agent: str, + project: Optional[str], + region: Optional[str], + cluster_name: str, + service_name: str, + app_name: str, + temp_folder: str, + port: int, + trace_to_cloud: bool, + with_ui: bool, + verbosity: str, + adk_version: str, + log_level: Optional[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 +): + """Deploys an agent to GKE. + + AGENT: The path to the agent source code folder. + + Example: + + adk deploy gke --project=[project] --region=[region] --cluster_name=[cluster_name] 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.to_gke( + agent_folder=agent, + project=project, + region=region, + cluster_name=cluster_name, + service_name=service_name, + app_name=app_name, + temp_folder=temp_folder, + port=port, + trace_to_cloud=trace_to_cloud, + with_ui=with_ui, + verbosity=verbosity, + log_level=log_level, + adk_version=adk_version, + session_service_uri=session_service_uri, + artifact_service_uri=artifact_service_uri, + memory_service_uri=memory_service_uri, + ) + except Exception as e: + click.secho(f"Deploy failed: {e}", fg="red", err=True) + + +@deploy.command("gke") +@click.option( + "--project", + type=str, + help=( + "Required. Google Cloud project to deploy the agent. When absent," + " default project from gcloud config is used." + ), +) +@click.option( + "--region", + type=str, + help=( + "Required. Google Cloud region to deploy the agent. When absent," + " gcloud run deploy will prompt later." + ), +) +@click.option( + "--cluster_name", + type=str, + help="Required. The name of the GKE cluster.", +) +@click.option( + "--service_name", + type=str, + default="adk-default-service-name", + help=( + "Optional. The service name to use in GKE (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 GKE.", +) +@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( # This is the crucial missing piece + "--verbosity", + type=LOG_LEVELS, + help="Deprecated. Use --log_level instead.", +) +@click.option( + "--log_level", + type=LOG_LEVELS, + default="INFO", + help="Optional. Set the logging level", +) +@click.option( + "--temp_folder", + type=str, + default=os.path.join( + tempfile.gettempdir(), + "gke_deploy_src", + datetime.now().strftime("%Y%m%d_%H%M%S"), + ), + help=( + "Optional. Temp folder for the generated GKE source files" + " (default: a timestamped folder in the system temp directory)." + ), +) +@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 GKE deployment. (default: the" + " version in the dev environment)" + ), +) +@adk_services_options() +@deprecated_adk_services_options() +def cli_deploy_gke( + agent: str, + project: Optional[str], + region: Optional[str], + cluster_name: str, + service_name: str, + app_name: str, + temp_folder: str, + port: int, + trace_to_cloud: bool, + with_ui: bool, + verbosity: str, + adk_version: str, + log_level: Optional[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 +): + """Deploys an agent to GKE. + + AGENT: The path to the agent source code folder. + + Example: + + adk deploy gke --project=[project] --region=[region] --cluster_name=[cluster_name] 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.to_gke( + agent_folder=agent, + project=project, + region=region, + cluster_name=cluster_name, + service_name=service_name, + app_name=app_name, + temp_folder=temp_folder, + port=port, + trace_to_cloud=trace_to_cloud, + with_ui=with_ui, + verbosity=verbosity, + log_level=log_level, + adk_version=adk_version, + session_service_uri=session_service_uri, + artifact_service_uri=artifact_service_uri, + memory_service_uri=memory_service_uri, + ) + except Exception as e: + click.secho(f"Deploy failed: {e}", fg="red", err=True) diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index d3b2a538c3..958a387915 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -17,22 +17,26 @@ from __future__ import annotations +import importlib from pathlib import Path import shutil import subprocess +import sys import tempfile import types from typing import Any from typing import Callable from typing import Dict +from typing import Generator from typing import List from typing import Tuple from unittest import mock import click -import google.adk.cli.cli_deploy as cli_deploy import pytest +import src.google.adk.cli.cli_deploy as cli_deploy + # Helpers class _Recorder: @@ -44,30 +48,92 @@ def __init__(self) -> None: def __call__(self, *args: Any, **kwargs: Any) -> None: self.calls.append((args, kwargs)) + def get_last_call_args(self) -> Tuple[Any, ...]: + """Returns the positional arguments of the last call.""" + if not self.calls: + raise IndexError("No calls have been recorded.") + return self.calls[-1][0] + + def get_last_call_kwargs(self) -> Dict[str, Any]: + """Returns the keyword arguments of the last call.""" + if not self.calls: + raise IndexError("No calls have been recorded.") + return self.calls[-1][1] + # Fixtures @pytest.fixture(autouse=True) def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None: """Suppress click.echo to keep test output clean.""" monkeypatch.setattr(click, "echo", lambda *a, **k: None) + monkeypatch.setattr(click, "secho", lambda *a, **k: None) + + +@pytest.fixture(autouse=True) +def reload_cli_deploy(): + """Reload cli_deploy before each test.""" + importlib.reload(cli_deploy) + yield # This allows the test to run after the module has been reloaded. @pytest.fixture() -def agent_dir(tmp_path: Path) -> Callable[[bool], Path]: - """Return a factory that creates a dummy agent directory tree.""" +def agent_dir(tmp_path: Path) -> Callable[[bool, bool], Path]: + """ + Return a factory that creates a dummy agent directory tree. - def _factory(include_requirements: bool) -> Path: + Args: + tmp_path: The temporary path fixture provided by pytest. + + Returns: + A factory function that takes two booleans: + - include_requirements: Whether to include a `requirements.txt` file. + - include_env: Whether to include a `.env` file. + """ + + def _factory(include_requirements: bool, include_env: bool) -> Path: base = tmp_path / "agent" base.mkdir() (base / "agent.py").write_text("# dummy agent") (base / "__init__.py").touch() if include_requirements: (base / "requirements.txt").write_text("pytest\n") + if include_env: + (base / ".env").write_text('TEST_VAR="test_value"\n') return base return _factory +@pytest.fixture +def mock_vertex_ai( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[mock.MagicMock, None, None]: + """Mocks the entire vertexai module and its sub-modules.""" + mock_vertexai = mock.MagicMock() + mock_agent_engines = mock.MagicMock() + mock_vertexai.agent_engines = mock_agent_engines + mock_vertexai.init = mock.MagicMock() + mock_agent_engines.create = mock.MagicMock() + mock_agent_engines.ModuleAgent = mock.MagicMock( + return_value="mock-agent-engine-object" + ) + + sys.modules["vertexai"] = mock_vertexai + sys.modules["vertexai.agent_engines"] = mock_agent_engines + + # Also mock dotenv + mock_dotenv = mock.MagicMock() + mock_dotenv.dotenv_values = mock.MagicMock(return_value={"FILE_VAR": "value"}) + sys.modules["dotenv"] = mock_dotenv + + yield mock_vertexai + + # Cleanup: remove mocks from sys.modules + del sys.modules["vertexai"] + del sys.modules["vertexai.agent_engines"] + del sys.modules["dotenv"] + + # _resolve_project def test_resolve_project_with_option() -> None: """It should return the explicit project value untouched.""" @@ -87,97 +153,193 @@ def test_resolve_project_from_gcloud(monkeypatch: pytest.MonkeyPatch) -> None: mocked_echo.assert_called_once() -# _get_service_option_by_adk_version -def test_get_service_option_by_adk_version() -> None: - """It should return the explicit project value untouched.""" - assert cli_deploy._get_service_option_by_adk_version( - adk_version="1.3.0", - session_uri="sqlite://", - artifact_uri="gs://bucket", - memory_uri="rag://", - ) == ( - "--session_service_uri=sqlite:// " - "--artifact_service_uri=gs://bucket " - "--memory_service_uri=rag://" - ) - - assert ( - cli_deploy._get_service_option_by_adk_version( - adk_version="1.2.0", - session_uri="sqlite://", - artifact_uri="gs://bucket", - memory_uri="rag://", - ) - == "--session_db_url=sqlite:// --artifact_storage_uri=gs://bucket" +def test_resolve_project_from_gcloud_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """It should raise an exception if the gcloud command fails.""" + monkeypatch.setattr( + subprocess, + "run", + mock.Mock(side_effect=subprocess.CalledProcessError(1, "cmd", "err")), ) + with pytest.raises(subprocess.CalledProcessError): + cli_deploy._resolve_project(None) + + +@pytest.mark.parametrize( + "adk_version, session_uri, artifact_uri, memory_uri, expected", + [ + ( + "1.3.0", + "sqlite://s", + "gs://a", + "rag://m", + ( + "--session_service_uri=sqlite://s --artifact_service_uri=gs://a" + " --memory_service_uri=rag://m" + ), + ), + ( + "1.2.5", + "sqlite://s", + "gs://a", + "rag://m", + "--session_db_url=sqlite://s --artifact_storage_uri=gs://a", + ), + ( + "0.5.0", + "sqlite://s", + "gs://a", + "rag://m", + "--session_db_url=sqlite://s", + ), + ( + "1.3.0", + "sqlite://s", + None, + None, + "--session_service_uri=sqlite://s ", + ), + ( + "1.3.0", + None, + "gs://a", + "rag://m", + " --artifact_service_uri=gs://a --memory_service_uri=rag://m", + ), + ("1.2.0", None, "gs://a", None, " --artifact_storage_uri=gs://a"), + ], +) +# _get_service_option_by_adk_version +def test_get_service_option_by_adk_version( + adk_version: str, + session_uri: str | None, + artifact_uri: str | None, + memory_uri: str | None, + expected: str, +) -> None: + """It should return the correct service URI flags for a given ADK version.""" assert ( cli_deploy._get_service_option_by_adk_version( - adk_version="0.5.0", - session_uri="sqlite://", - artifact_uri="gs://bucket", - memory_uri="rag://", + adk_version=adk_version, + session_uri=session_uri, + artifact_uri=artifact_uri, + memory_uri=memory_uri, ) - == "--session_db_url=sqlite://" + == expected ) -# to_cloud_run @pytest.mark.parametrize("include_requirements", [True, False]) +@pytest.mark.parametrize("with_ui", [True, False]) def test_to_cloud_run_happy_path( monkeypatch: pytest.MonkeyPatch, - agent_dir: Callable[[bool], Path], + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, include_requirements: bool, + with_ui: bool, ) -> None: """ - End-to-end execution test for `to_cloud_run` covering both presence and - absence of *requirements.txt*. - """ - tmp_dir = Path(tempfile.mkdtemp()) - src_dir = agent_dir(include_requirements) + End-to-end execution test for `to_cloud_run`. - copy_recorder = _Recorder() + This test verifies that for a given configuration: + 1. The agent source files are correctly copied to a temporary build context. + 2. A valid Dockerfile is generated with the correct parameters. + 3. The `gcloud run deploy` command is constructed with the correct arguments. + """ + src_dir = agent_dir(include_requirements, False) run_recorder = _Recorder() - # Cache the ORIGINAL copytree before patching - original_copytree = cli_deploy.shutil.copytree - - def _recording_copytree(*args: Any, **kwargs: Any): - copy_recorder(*args, **kwargs) - return original_copytree(*args, **kwargs) - - monkeypatch.setattr(cli_deploy.shutil, "copytree", _recording_copytree) - # Skip actual cleanup so that we can inspect generated files later. - monkeypatch.setattr(cli_deploy.shutil, "rmtree", lambda *_a, **_k: None) monkeypatch.setattr(subprocess, "run", run_recorder) + # Mock rmtree to prevent actual deletion during test run but record calls + rmtree_recorder = _Recorder() + monkeypatch.setattr(shutil, "rmtree", rmtree_recorder) + # Execute the function under test cli_deploy.to_cloud_run( agent_folder=str(src_dir), project="proj", region="asia-northeast1", service_name="svc", - app_name="app", - temp_folder=str(tmp_dir), + app_name="agent", + temp_folder=str(tmp_path), port=8080, trace_to_cloud=True, - with_ui=True, - verbosity="info", + with_ui=with_ui, log_level="info", + verbosity="info", + allow_origins=["http://localhost:3000", "https://my-app.com"], session_service_uri="sqlite://", artifact_service_uri="gs://bucket", memory_service_uri="rag://", - adk_version="0.0.5", + adk_version="1.3.0", ) - # Assertions + # 1. Assert that source files were copied correctly + agent_dest_path = tmp_path / "agents" / "agent" + assert (agent_dest_path / "agent.py").is_file() + assert (agent_dest_path / "__init__.py").is_file() assert ( - len(copy_recorder.calls) == 1 - ), "Agent sources must be copied exactly once." - assert run_recorder.calls, "gcloud command should be executed at least once." - assert (tmp_dir / "Dockerfile").exists(), "Dockerfile must be generated." + agent_dest_path / "requirements.txt" + ).is_file() == include_requirements - # Manual cleanup because we disabled rmtree in the monkeypatch. - shutil.rmtree(tmp_dir, ignore_errors=True) + # 2. Assert that the Dockerfile was generated correctly + dockerfile_path = tmp_path / "Dockerfile" + assert dockerfile_path.is_file() + dockerfile_content = dockerfile_path.read_text() + + expected_command = "web" if with_ui else "api_server" + assert f"CMD adk {expected_command} --port=8080" in dockerfile_content + assert "FROM python:3.11-slim" in dockerfile_content + assert ( + '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 + + if include_requirements: + assert ( + 'RUN pip install -r "/app/agents/agent/requirements.txt"' + in dockerfile_content + ) + else: + assert "RUN pip install -r" not in dockerfile_content + + assert ( + "--allow_origins=http://localhost:3000,https://my-app.com" + in dockerfile_content + ) + + # 3. Assert that the gcloud command was constructed correctly + assert len(run_recorder.calls) == 1 + gcloud_args = run_recorder.get_last_call_args()[0] + + expected_gcloud_command = [ + "gcloud", + "run", + "deploy", + "svc", + "--source", + str(tmp_path), + "--project", + "proj", + "--region", + "asia-northeast1", + "--port", + "8080", + "--verbosity", + "info", + "--labels", + "created-by=adk", + ] + assert gcloud_args == expected_gcloud_command + + # 4. Assert cleanup was performed + assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path) def test_to_cloud_run_cleans_temp_dir( @@ -186,7 +348,7 @@ def test_to_cloud_run_cleans_temp_dir( ) -> None: """`to_cloud_run` should always delete the temporary folder on exit.""" tmp_dir = Path(tempfile.mkdtemp()) - src_dir = agent_dir(False) + src_dir = agent_dir(False, False) deleted: Dict[str, Path] = {} @@ -206,8 +368,8 @@ def _fake_rmtree(path: str | Path, *a: Any, **k: Any) -> None: port=8080, trace_to_cloud=False, with_ui=False, - verbosity="info", log_level="info", + verbosity="info", adk_version="1.0.0", session_service_uri=None, artifact_service_uri=None, @@ -215,3 +377,264 @@ def _fake_rmtree(path: str | Path, *a: Any, **k: Any) -> None: ) assert deleted["path"] == tmp_dir + + +def test_to_cloud_run_cleans_temp_dir_on_failure( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], +) -> None: + """`to_cloud_run` should always delete the temporary folder on exit, even if gcloud fails.""" + tmp_dir = Path(tempfile.mkdtemp()) + src_dir = agent_dir(False, False) + + rmtree_recorder = _Recorder() + monkeypatch.setattr(shutil, "rmtree", rmtree_recorder) + # Make the gcloud command fail + monkeypatch.setattr( + subprocess, + "run", + mock.Mock(side_effect=subprocess.CalledProcessError(1, "gcloud")), + ) + + with pytest.raises(subprocess.CalledProcessError): + cli_deploy.to_cloud_run( + agent_folder=str(src_dir), + project="proj", + region="us-central1", + service_name="svc", + app_name="app", + temp_folder=str(tmp_dir), + port=8080, + trace_to_cloud=False, + with_ui=False, + log_level="info", + verbosity="info", + adk_version="1.0.0", + session_service_uri=None, + artifact_service_uri=None, + memory_service_uri=None, + ) + + # Check that rmtree was called on the temp folder in the finally block + assert rmtree_recorder.calls, "shutil.rmtree should have been called" + assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir) + + +@pytest.mark.usefixtures("mock_vertex_ai") +@pytest.mark.parametrize("has_reqs", [True, False]) +@pytest.mark.parametrize("has_env", [True, False]) +def test_to_agent_engine_happy_path( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, + has_reqs: bool, + has_env: bool, +) -> None: + """ + Tests the happy path for the `to_agent_engine` function. + + Verifies: + 1. Source files are copied. + 2. `adk_app.py` is created correctly. + 3. `requirements.txt` is handled (created if not present). + 4. `.env` file is read if present. + 5. `vertexai.init` and `agent_engines.create` are called with the correct args. + 6. Cleanup is performed. + """ + src_dir = agent_dir(has_reqs, has_env) + temp_folder = tmp_path / "build" + app_name = src_dir.name + rmtree_recorder = _Recorder() + + monkeypatch.setattr(shutil, "rmtree", rmtree_recorder) + + # Execute + cli_deploy.to_agent_engine( + agent_folder=str(src_dir), + temp_folder=str(temp_folder), + adk_app="my_adk_app", + staging_bucket="gs://my-staging-bucket", + agent_engine_name="", + trace_to_cloud=True, + project="my-gcp-project", + region="us-central1", + display_name="My Test Agent", + description="A test agent.", + ) + + # 1. Verify file operations + assert (temp_folder / app_name / "agent.py").is_file() + assert (temp_folder / app_name / "__init__.py").is_file() + + # 2. Verify adk_app.py creation + adk_app_path = temp_folder / "my_adk_app.py" + assert adk_app_path.is_file() + content = adk_app_path.read_text() + assert f"from {app_name}.agent import root_agent" in content + assert "adk_app = AdkApp(" in content + assert "enable_tracing=True" in content + + # 3. Verify requirements handling + reqs_path = temp_folder / app_name / "requirements.txt" + assert reqs_path.is_file() + if not has_reqs: + # It should have been created with the default content + assert "google-cloud-aiplatform[adk,agent_engines]" in reqs_path.read_text() + + # 4. Verify Vertex AI SDK calls + vertexai = sys.modules["vertexai"] + vertexai.init.assert_called_once_with( + project="my-gcp-project", + location="us-central1", + staging_bucket="gs://my-staging-bucket", + ) + + # 5. Verify env var handling + dotenv = sys.modules["dotenv"] + if has_env: + dotenv.dotenv_values.assert_called_once() + expected_env_vars = {"FILE_VAR": "value"} + else: + dotenv.dotenv_values.assert_not_called() + expected_env_vars = None + + # 6. Verify agent_engines.create call + vertexai.agent_engines.create.assert_called_once() + create_kwargs = vertexai.agent_engines.create.call_args.kwargs + assert create_kwargs["agent_engine"] == "mock-agent-engine-object" + assert create_kwargs["display_name"] == "My Test Agent" + assert create_kwargs["description"] == "A test agent." + assert create_kwargs["requirements"] == str(reqs_path) + assert create_kwargs["extra_packages"] == [str(temp_folder)] + assert create_kwargs["env_vars"] == expected_env_vars + + # 7. Verify cleanup + assert str(rmtree_recorder.get_last_call_args()[0]) == str(temp_folder) + + +@pytest.mark.parametrize("include_requirements", [True, False]) +def test_to_gke_happy_path( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, + include_requirements: bool, +) -> None: + """ + Tests the happy path for the `to_gke` function. + + Verifies: + 1. Source files are copied and Dockerfile is created. + 2. `gcloud builds submit` is called to build the image. + 3. `deployment.yaml` is created with the correct content. + 4. `gcloud container get-credentials` and `kubectl apply` are called. + 5. Cleanup is performed. + """ + src_dir = agent_dir(include_requirements, False) + run_recorder = _Recorder() + rmtree_recorder = _Recorder() + + def mock_subprocess_run(*args, **kwargs): + # We still use the recorder to check which commands were called + run_recorder(*args, **kwargs) + + # The command is the first positional argument, e.g., ['kubectl', 'apply', ...] + command_list = args[0] + + # Check if this is the 'kubectl apply' call + if command_list and command_list[0:2] == ["kubectl", "apply"]: + # If it is, return a fake process object with a .stdout attribute + # This mimics the real output from kubectl. + fake_stdout = "deployment.apps/gke-svc created\nservice/gke-svc created" + return types.SimpleNamespace(stdout=fake_stdout) + + # For all other subprocess.run calls (like 'gcloud builds submit'), + # we don't need a return value, so the default None is fine. + return None + + monkeypatch.setattr(subprocess, "run", mock_subprocess_run) + monkeypatch.setattr(shutil, "rmtree", rmtree_recorder) + + # Execute + cli_deploy.to_gke( + agent_folder=str(src_dir), + project="gke-proj", + region="us-east1", + cluster_name="my-gke-cluster", + service_name="gke-svc", + app_name="agent", + temp_folder=str(tmp_path), + port=9090, + trace_to_cloud=False, + with_ui=True, + log_level="debug", + verbosity="debug", + adk_version="1.2.0", + allow_origins=["http://localhost:3000", "https://my-app.com"], + session_service_uri="sqlite:///", + artifact_service_uri="gs://gke-bucket", + ) + + # 1. Verify Dockerfile (basic check) + dockerfile_path = tmp_path / "Dockerfile" + assert dockerfile_path.is_file() + dockerfile_content = dockerfile_path.read_text() + assert "CMD adk web --port=9090" in dockerfile_content + assert "RUN pip install google-adk==1.2.0" in dockerfile_content + + # 2. Verify command executions by checking each recorded call + assert len(run_recorder.calls) == 3, "Expected 3 subprocess calls" + + # Call 1: gcloud builds submit + build_args = run_recorder.calls[0][0][0] + expected_build_args = [ + "gcloud", + "builds", + "submit", + "--tag", + "gcr.io/gke-proj/gke-svc", + "--verbosity", + "debug", + str(tmp_path), + ] + assert build_args == expected_build_args + + # Call 2: gcloud container clusters get-credentials + creds_args = run_recorder.calls[1][0][0] + expected_creds_args = [ + "gcloud", + "container", + "clusters", + "get-credentials", + "my-gke-cluster", + "--region", + "us-east1", + "--project", + "gke-proj", + ] + assert creds_args == expected_creds_args + + assert ( + "--allow_origins=http://localhost:3000,https://my-app.com" + in dockerfile_content + ) + + # Call 3: kubectl apply + apply_args = run_recorder.calls[2][0][0] + expected_apply_args = ["kubectl", "apply", "-f", str(tmp_path)] + assert apply_args == expected_apply_args + + # 3. Verify deployment.yaml content + deployment_yaml_path = tmp_path / "deployment.yaml" + assert deployment_yaml_path.is_file() + yaml_content = deployment_yaml_path.read_text() + + assert "kind: Deployment" in yaml_content + assert "kind: Service" in yaml_content + assert "name: gke-svc" in yaml_content + assert "image: gcr.io/gke-proj/gke-svc" in yaml_content + assert f"containerPort: 9090" in yaml_content + assert f"targetPort: 9090" in yaml_content + assert "type: LoadBalancer" in yaml_content + + # 4. Verify cleanup + assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path) diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 2c03ca5391..396e72d81d 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -23,44 +23,16 @@ from typing import Any from typing import Dict from typing import List +from typing import Optional from typing import Tuple -from unittest import mock import click from click.testing import CliRunner -from google.adk.agents.base_agent import BaseAgent -from google.adk.cli import cli_tools_click -from google.adk.evaluation.eval_case import EvalCase -from google.adk.evaluation.eval_set import EvalSet -from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager -from google.adk.evaluation.local_eval_sets_manager import LocalEvalSetsManager +import google.adk.evaluation.local_eval_sets_manager as managerModule from pydantic import BaseModel import pytest - -class DummyAgent(BaseAgent): - - def __init__(self, name): - super().__init__(name=name) - self.sub_agents = [] - - -root_agent = DummyAgent(name="dummy_agent") - - -@pytest.fixture -def mock_load_eval_set_from_file(): - with mock.patch( - "google.adk.evaluation.local_eval_sets_manager.load_eval_set_from_file" - ) as mock_func: - yield mock_func - - -@pytest.fixture -def mock_get_root_agent(): - with mock.patch("google.adk.cli.cli_eval.get_root_agent") as mock_func: - mock_func.return_value = root_agent - yield mock_func +from src.google.adk.cli import cli_tools_click # Helpers @@ -78,13 +50,14 @@ def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401 def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None: """Suppress click output during tests.""" monkeypatch.setattr(click, "echo", lambda *a, **k: None) - monkeypatch.setattr(click, "secho", lambda *a, **k: None) + # Keep secho for error messages + # monkeypatch.setattr(click, "secho", lambda *a, **k: None) # validate_exclusive def test_validate_exclusive_allows_single() -> None: """Providing exactly one exclusive option should pass.""" - ctx = click.Context(cli_tools_click.main) + ctx = click.Context(cli_tools_click.cli_run) param = SimpleNamespace(name="replay") assert ( cli_tools_click.validate_exclusive(ctx, param, "file.json") == "file.json" @@ -93,7 +66,7 @@ def test_validate_exclusive_allows_single() -> None: def test_validate_exclusive_blocks_multiple() -> None: """Providing two exclusive options should raise UsageError.""" - ctx = click.Context(cli_tools_click.main) + ctx = click.Context(cli_tools_click.cli_run) param1 = SimpleNamespace(name="replay") param2 = SimpleNamespace(name="resume") @@ -184,10 +157,6 @@ def _boom(*_a: Any, **_k: Any) -> None: # noqa: D401 monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", _boom) - # intercept click.secho(error=True) output - captured: List[str] = [] - monkeypatch.setattr(click, "secho", lambda msg, **__: captured.append(msg)) - agent_dir = tmp_path / "agent3" agent_dir.mkdir() runner = CliRunner() @@ -196,7 +165,73 @@ def _boom(*_a: Any, **_k: Any) -> None: # noqa: D401 ) assert result.exit_code == 0 - assert any("Deploy failed: boom" in m for m in captured) + assert "Deploy failed: boom" in result.output + + +# cli deploy agent_engine +def test_cli_deploy_agent_engine_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Successful path should call cli_deploy.to_agent_engine.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click.cli_deploy, "to_agent_engine", rec) + + agent_dir = tmp_path / "agent_ae" + agent_dir.mkdir() + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + [ + "deploy", + "agent_engine", + "--project", + "test-proj", + "--region", + "us-central1", + "--staging_bucket", + "gs://mybucket", + str(agent_dir), + ], + ) + assert result.exit_code == 0 + assert rec.calls, "cli_deploy.to_agent_engine must be invoked" + called_kwargs = rec.calls[0][1] + assert called_kwargs.get("project") == "test-proj" + assert called_kwargs.get("region") == "us-central1" + assert called_kwargs.get("staging_bucket") == "gs://mybucket" + + +# cli deploy gke +def test_cli_deploy_gke_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Successful path should call cli_deploy.to_gke.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click.cli_deploy, "to_gke", rec) + + agent_dir = tmp_path / "agent_gke" + agent_dir.mkdir() + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + [ + "deploy", + "gke", + "--project", + "test-proj", + "--region", + "us-central1", + "--cluster_name", + "my-cluster", + str(agent_dir), + ], + ) + assert result.exit_code == 0 + assert rec.calls, "cli_deploy.to_gke must be invoked" + called_kwargs = rec.calls[0][1] + assert called_kwargs.get("project") == "test-proj" + assert called_kwargs.get("region") == "us-central1" + assert called_kwargs.get("cluster_name") == "my-cluster" # cli eval @@ -204,16 +239,30 @@ def test_cli_eval_missing_deps_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """If cli_eval sub-module is missing, command should raise ClickException.""" - # Ensure .cli_eval is not importable orig_import = builtins.__import__ - def _fake_import(name: str, *a: Any, **k: Any): - if name.endswith(".cli_eval") or name == "google.adk.cli.cli_eval": - raise ModuleNotFoundError() - return orig_import(name, *a, **k) + def _fake_import(name: str, globals=None, locals=None, fromlist=(), level=0): + if name == "google.adk.cli.cli_eval" or (level > 0 and "cli_eval" in name): + raise ModuleNotFoundError(f"Simulating missing {name}") + return orig_import(name, globals, locals, fromlist, level) monkeypatch.setattr(builtins, "__import__", _fake_import) + agent_dir = tmp_path / "agent_missing_deps" + agent_dir.mkdir() + (agent_dir / "__init__.py").touch() + eval_file = tmp_path / "dummy.json" + eval_file.touch() + + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + ["eval", str(agent_dir), str(eval_file)], + ) + assert result.exit_code != 0 + assert isinstance(result.exception, SystemExit) + assert cli_tools_click.MISSING_EVAL_DEPENDENCIES_MESSAGE in result.output + # cli web & api_server (uvicorn patched) @pytest.fixture() @@ -235,18 +284,18 @@ def run(self) -> None: monkeypatch.setattr( cli_tools_click.uvicorn, "Server", lambda *_a, **_k: _DummyServer() ) - monkeypatch.setattr( - cli_tools_click, "get_fast_api_app", lambda **_k: object() - ) return rec def test_cli_web_invokes_uvicorn( - tmp_path: Path, _patch_uvicorn: _Recorder + tmp_path: Path, _patch_uvicorn: _Recorder, monkeypatch: pytest.MonkeyPatch ) -> None: """`adk web` should configure and start uvicorn.Server.run.""" agents_dir = tmp_path / "agents" agents_dir.mkdir() + monkeypatch.setattr( + cli_tools_click, "get_fast_api_app", lambda **_k: object() + ) runner = CliRunner() result = runner.invoke(cli_tools_click.main, ["web", str(agents_dir)]) assert result.exit_code == 0 @@ -254,84 +303,76 @@ def test_cli_web_invokes_uvicorn( def test_cli_api_server_invokes_uvicorn( - tmp_path: Path, _patch_uvicorn: _Recorder + tmp_path: Path, _patch_uvicorn: _Recorder, monkeypatch: pytest.MonkeyPatch ) -> None: """`adk api_server` should configure and start uvicorn.Server.run.""" agents_dir = tmp_path / "agents_api" agents_dir.mkdir() + monkeypatch.setattr( + cli_tools_click, "get_fast_api_app", lambda **_k: object() + ) runner = CliRunner() result = runner.invoke(cli_tools_click.main, ["api_server", str(agents_dir)]) assert result.exit_code == 0 assert _patch_uvicorn.calls, "uvicorn.Server.run must be called" -def test_cli_eval_with_eval_set_file_path( - mock_load_eval_set_from_file, - mock_get_root_agent, - tmp_path, -): - agent_path = tmp_path / "my_agent" - agent_path.mkdir() - (agent_path / "__init__.py").touch() +def test_cli_web_passes_service_uris( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, _patch_uvicorn: _Recorder +) -> None: + """`adk web` should pass service URIs to get_fast_api_app.""" + agents_dir = tmp_path / "agents" + agents_dir.mkdir() - eval_set_file = tmp_path / "my_evals.json" - eval_set_file.write_text("{}") + mock_get_app = _Recorder() + monkeypatch.setattr(cli_tools_click, "get_fast_api_app", mock_get_app) - mock_load_eval_set_from_file.return_value = EvalSet( - eval_set_id="my_evals", - eval_cases=[EvalCase(eval_id="case1", conversation=[])], + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + [ + "web", + str(agents_dir), + "--session_service_uri", + "sqlite:///test.db", + "--artifact_service_uri", + "gs://mybucket", + "--memory_service_uri", + "rag://mycorpus", + ], ) + assert result.exit_code == 0 + assert mock_get_app.calls + called_kwargs = mock_get_app.calls[0][1] + assert called_kwargs.get("session_service_uri") == "sqlite:///test.db" + assert called_kwargs.get("artifact_service_uri") == "gs://mybucket" + assert called_kwargs.get("memory_service_uri") == "rag://mycorpus" - result = CliRunner().invoke( - cli_tools_click.cli_eval, - [str(agent_path), str(eval_set_file)], - ) - assert result.exit_code == 0 - # Assert that we wrote eval set results - eval_set_results_manager = LocalEvalSetResultsManager( - agents_dir=str(tmp_path) - ) - eval_set_results = eval_set_results_manager.list_eval_set_results( - app_name="my_agent" - ) - assert len(eval_set_results) == 1 - - -def test_cli_eval_with_eval_set_id( - mock_get_root_agent, - tmp_path, -): - app_name = "test_app" - eval_set_id = "test_eval_set_id" - agent_path = tmp_path / app_name - agent_path.mkdir() - (agent_path / "__init__.py").touch() - - eval_sets_manager = LocalEvalSetsManager(agents_dir=str(tmp_path)) - eval_sets_manager.create_eval_set(app_name=app_name, eval_set_id=eval_set_id) - eval_sets_manager.add_eval_case( - app_name=app_name, - eval_set_id=eval_set_id, - eval_case=EvalCase(eval_id="case1", conversation=[]), - ) - eval_sets_manager.add_eval_case( - app_name=app_name, - eval_set_id=eval_set_id, - eval_case=EvalCase(eval_id="case2", conversation=[]), - ) +def test_cli_web_passes_deprecated_uris( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, _patch_uvicorn: _Recorder +) -> None: + """`adk web` should use deprecated URIs if new ones are not provided.""" + agents_dir = tmp_path / "agents" + agents_dir.mkdir() - result = CliRunner().invoke( - cli_tools_click.cli_eval, - [str(agent_path), "test_eval_set_id:case1,case2"], - ) + mock_get_app = _Recorder() + monkeypatch.setattr(cli_tools_click, "get_fast_api_app", mock_get_app) - assert result.exit_code == 0 - # Assert that we wrote eval set results - eval_set_results_manager = LocalEvalSetResultsManager( - agents_dir=str(tmp_path) - ) - eval_set_results = eval_set_results_manager.list_eval_set_results( - app_name=app_name + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + [ + "web", + str(agents_dir), + "--session_db_url", + "sqlite:///deprecated.db", + "--artifact_storage_uri", + "gs://deprecated", + ], ) - assert len(eval_set_results) == 2 + assert result.exit_code == 0 + assert mock_get_app.calls + called_kwargs = mock_get_app.calls[0][1] + assert called_kwargs.get("session_service_uri") == "sqlite:///deprecated.db" + assert called_kwargs.get("artifact_service_uri") == "gs://deprecated"