diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index d26b3c9660..2835706e51 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -15,6 +15,7 @@ import json import os +from pathlib import Path import shutil import subprocess from typing import Final @@ -43,7 +44,7 @@ # Set up environment variables - End # Install ADK - Start -RUN pip install google-adk=={adk_version} +{adk_install_instructions} # Install ADK - End # Copy agent - Start @@ -246,6 +247,10 @@ def to_cloud_run( ) click.echo('Copying agent source code completed.') + adk_install_instructions = ( + f'RUN pip install google-adk=={adk_version}' + ) + # create Dockerfile click.echo('Creating Dockerfile...') host_option = '--host=0.0.0.0' if adk_version > '0.5.0' else '' @@ -268,6 +273,7 @@ def to_cloud_run( ), trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '', allow_origins_option=allow_origins_option, + adk_install_instructions=adk_install_instructions, adk_version=adk_version, host_option=host_option, a2a_option=a2a_option, @@ -622,6 +628,8 @@ def to_gke( artifact_service_uri: Optional[str] = None, memory_service_uri: Optional[str] = None, a2a: bool = False, + editable: bool = False, + service_account_name: Optional[str] = None, ): """Deploys an agent to Google Kubernetes Engine(GKE). @@ -645,6 +653,7 @@ def to_gke( 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. + service_account_name: The name of the Kubernetes Service Account to use for the deployed agent pod. """ click.secho( '\nšŸš€ Starting ADK Agent Deployment to GKE...', fg='cyan', bold=True @@ -680,6 +689,22 @@ def to_gke( ) click.secho('āœ… Environment prepared.', fg='green') + adk_install_instructions = ( + f'RUN pip install "google-adk=={adk_version}"' + ) + if editable: + click.echo(' - Preparing local ADK source for editable install...') + # Find the project root to include pyproject.toml + adk_source_path = next(p for p in Path(__file__).resolve().parents if (p / 'pyproject.toml').is_file()) + temp_adk_source_dest = Path(temp_folder) / 'adk_local_src' + shutil.copytree(adk_source_path, temp_adk_source_dest) + adk_install_instructions = ( + '# Install ADK from local source \n' + 'COPY --chown=myuser:myuser adk_local_src/ /app/adk_local_src/\n' + 'RUN pip install --editable "/app/adk_local_src/[extensions]"' + ) + click.secho('āœ… Local ADK source prepared.', fg='green') + allow_origins_option = ( f'--allow_origins={",".join(allow_origins)}' if allow_origins else '' ) @@ -703,6 +728,7 @@ def to_gke( ), trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '', allow_origins_option=allow_origins_option, + adk_install_instructions=adk_install_instructions, adk_version=adk_version, host_option=host_option, a2a_option='--a2a' if a2a else '', @@ -742,6 +768,10 @@ def to_gke( # Create a Kubernetes deployment click.echo(' - Creating Kubernetes deployment.yaml...') + sa_yaml_block = '' + if service_account_name: + # The newline at the end is important for correct YAML formatting. + sa_yaml_block = f'serviceAccountName: {service_account_name}\n' deployment_yaml = f""" apiVersion: apps/v1 kind: Deployment @@ -766,6 +796,7 @@ def to_gke( app.kubernetes.io/instance: {service_name} app.kubernetes.io/managed-by: adk-cli spec: + {sa_yaml_block} containers: - name: {service_name} image: {image_name} @@ -813,8 +844,8 @@ def to_gke( result = subprocess.run( ['kubectl', 'apply', '-f', temp_folder], check=True, - capture_output=True, # <-- Add this - text=True, # <-- Add this + capture_output=True, + text=True, ) # 2. Print the captured output line by line diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index c45fdd37ea..a99f24add5 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -1285,6 +1285,11 @@ def cli_deploy_agent_engine( " of the AGENT source code)." ), ) +@click.option( + "--service-account-name", + default=None, + help="Optional. Name of the K8s Service Account for the pod.", +) @click.option( "--port", type=int, @@ -1351,6 +1356,7 @@ def cli_deploy_gke( cluster_name: str, service_name: str, app_name: str, + service_account_name: Optional[str], temp_folder: str, port: int, trace_to_cloud: bool, @@ -1377,6 +1383,7 @@ def cli_deploy_gke( cluster_name=cluster_name, service_name=service_name, app_name=app_name, + service_account_name=service_account_name, temp_folder=temp_folder, port=port, trace_to_cloud=trace_to_cloud, diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index b2a31f70f3..2b1ec5b3c5 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -388,6 +388,104 @@ def mock_subprocess_run(*args, **kwargs): assert f"containerPort: 9090" in yaml_content assert f"targetPort: 9090" in yaml_content assert "type: LoadBalancer" in yaml_content + assert "serviceAccountName:" not in yaml_content # 4. Verify cleanup assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path) + +def test_to_gke_with_service_account( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, +) -> None: + """ + Tests that `to_gke` correctly adds the serviceAccountName to the + deployment manifest when the parameter is provided. + """ + src_dir = agent_dir(False, False) + monkeypatch.setattr(subprocess, "run", lambda *a, **k: types.SimpleNamespace(stdout="")) + monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: None) + + # Execute with the new service_account_name parameter + 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=False, + log_level="debug", + adk_version="1.2.0", + service_account_name="my-test-sa", + ) + + deployment_yaml_path = tmp_path / "deployment.yaml" + assert deployment_yaml_path.is_file() + yaml_content = deployment_yaml_path.read_text() + + assert "serviceAccountName: my-test-sa" in yaml_content + +def test_to_gke_editable_mode( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, +) -> None: + """ + Tests that `to_gke` with `editable=True` generates the correct Dockerfile. + + Verifies: + 1. The local ADK source is copied (mocked). + 2. The Dockerfile contains `COPY` and `pip install --editable` commands. + 3. The Dockerfile does NOT contain the standard `pip install from pypi`. + """ + src_dir = agent_dir(False, False) + # Mock subprocess and cleanup functions + monkeypatch.setattr(subprocess, "run", lambda *a, **k: types.SimpleNamespace(stdout="")) + monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: None) + + # Mock the shutil.copytree to avoid actual file operations for the ADK source + copytree_recorder = _Recorder() + # The first call will be for the agent, the second for the ADK source + original_copytree = shutil.copytree + def mock_copytree(src, dst, **kwargs): + copytree_recorder(src, dst, **kwargs) + # We still need to copy the agent for the test to proceed + if "agent" in str(src): + original_copytree(src, dst, **kwargs) + + monkeypatch.setattr(shutil, "copytree", mock_copytree) + + # 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=False, + log_level="debug", + adk_version="1.2.0", + editable=True, # Test the new editable path + ) + + # 1. Verify that copytree was called for the ADK source + assert len(copytree_recorder.calls) == 2 + adk_source_copy_call = copytree_recorder.calls[1][0] + assert str(adk_source_copy_call[1]) == str(tmp_path / "adk_local_src") + + # 2. Verify Dockerfile content for editable mode + dockerfile_path = tmp_path / "Dockerfile" + assert dockerfile_path.is_file() + dockerfile_content = dockerfile_path.read_text() + + assert "COPY --chown=myuser:myuser adk_local_src/ /app/adk_local_src/" in dockerfile_content + assert 'RUN pip install --editable "/app/adk_local_src/[extensions]"' in dockerfile_content + assert "RUN pip install google-adk==" not in dockerfile_content