diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index d630cc5b88..fe8d309b70 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -749,6 +749,52 @@ def _get_ignore_patterns_func( return shutil.ignore_patterns(*patterns) +def _stage_extra_packages( + requested_packages: list[tuple[str, str]], temp_folder: str +) -> list[str]: + """Copies additional packages into a deployment build context.""" + staged_packages = [] + for package, base_dir in requested_packages: + package_source = ( + package if os.path.isabs(package) else os.path.join(base_dir, package) + ) + package_source = os.path.abspath(package_source) + if not os.path.exists(package_source): + raise click.ClickException(f'extra_packages path not found: {package}') + + basename = os.path.basename(os.path.normpath(package_source)) + destination = os.path.join(temp_folder, basename) + # The Dockerfile may not have been written yet, so reserve its name. + if os.path.exists(destination) or basename == 'Dockerfile': + raise click.ClickException( + f'extra_packages entry has a conflicting name: {basename}' + ) + if os.path.isdir(package_source): + shutil.copytree(package_source, destination, dirs_exist_ok=True) + else: + shutil.copy2(package_source, destination) + staged_packages.append(basename) + + return staged_packages + + +def _extra_packages_dockerfile_copy( + staged_packages: list[str], temp_folder: str +) -> str: + """Builds Dockerfile instructions for staged extra packages.""" + if not staged_packages: + return '' + + copy_lines = [ + f'COPY --chown=myuser:myuser "{basename}/" "/app/{basename}/"' + if os.path.isdir(os.path.join(temp_folder, basename)) + else f'COPY --chown=myuser:myuser "{basename}" "/app/{basename}"' + for basename in staged_packages + ] + copy_lines.append('ENV PYTHONPATH="/app:$PYTHONPATH"') + return '\n'.join(copy_lines) + + def to_cloud_run( *, agent_folder: str, @@ -773,6 +819,7 @@ def to_cloud_run( trigger_sources: Optional[str] = None, extra_gcloud_args: Optional[tuple[str, ...]] = None, with_cloud_run_sandbox: bool = False, + extra_packages: Optional[list[str]] = None, ) -> None: """Deploys an agent to Google Cloud Run. @@ -811,6 +858,8 @@ def to_cloud_run( use_local_storage: Whether to use local .adk storage in the container. with_cloud_run_sandbox: Whether to enable the Cloud Run sandbox for code execution. + extra_packages: Additional local files or directories to stage alongside + the agent and make importable in the deployed image. """ app_name = app_name or os.path.basename(agent_folder) if parse(adk_version) >= parse('1.3.0') and not use_local_storage: @@ -830,6 +879,10 @@ def to_cloud_run( agent_src_path = os.path.join(temp_folder, 'agents', app_name) ignore_func = _get_ignore_patterns_func(agent_folder) shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) + staged_extra_packages = _stage_extra_packages( + [(package, os.getcwd()) for package in extra_packages or []], + temp_folder, + ) 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"' @@ -871,7 +924,9 @@ def to_cloud_run( trigger_sources_option=trigger_sources_option, gemini_enterprise_option='', express_mode_option='', - extra_packages_copy='', + extra_packages_copy=_extra_packages_dockerfile_copy( + staged_extra_packages, temp_folder + ), ) dockerfile_path = os.path.join(temp_folder, 'Dockerfile') os.makedirs(temp_folder, exist_ok=True) @@ -1179,24 +1234,9 @@ def to_agent_engine( requested_extra_packages = [ (pkg, original_cwd) for pkg in extra_packages or [] ] + [(pkg, agent_folder_abs) for pkg in config_extra_packages] - staged_extra_packages = [] - for pkg, base_dir in requested_extra_packages: - pkg_src = pkg if os.path.isabs(pkg) else os.path.join(base_dir, pkg) - pkg_src = os.path.abspath(pkg_src) - if not os.path.exists(pkg_src): - raise click.ClickException(f'extra_packages path not found: {pkg}') - base = os.path.basename(os.path.normpath(pkg_src)) - dst = os.path.join(temp_folder_path, base) - # The Dockerfile is written after this loop, so it is not on disk yet. - if os.path.exists(dst) or base == 'Dockerfile': - raise click.ClickException( - f'extra_packages entry has a conflicting name: {base}' - ) - if os.path.isdir(pkg_src): - shutil.copytree(pkg_src, dst, dirs_exist_ok=True) - else: - shutil.copy2(pkg_src, dst) - staged_extra_packages.append(base) + staged_extra_packages = _stage_extra_packages( + requested_extra_packages, temp_folder_path + ) requirements_txt_path = os.path.join(agent_src_path, 'requirements.txt') if requirements_file: @@ -1349,16 +1389,9 @@ def create_dockerfile_for_agent_engine(resource_name: str) -> None: trigger_sources_option = ( f'--trigger_sources={trigger_sources}' if trigger_sources else '' ) - extra_packages_copy = '' - if staged_extra_packages: - copy_lines = [ - f'COPY --chown=myuser:myuser "{base}/" "/app/{base}/"' - if os.path.isdir(os.path.join(temp_folder_path, base)) - else f'COPY --chown=myuser:myuser "{base}" "/app/{base}"' - for base in staged_extra_packages - ] - copy_lines.append('ENV PYTHONPATH="/app:$PYTHONPATH"') - extra_packages_copy = '\n'.join(copy_lines) + extra_packages_copy = _extra_packages_dockerfile_copy( + staged_extra_packages, temp_folder_path + ) agent_engine_uri = f'agentengine://{resource_name}' dockerfile_content = _DOCKERFILE_TEMPLATE.format( gcp_project_id=project, @@ -1461,6 +1494,7 @@ def to_gke( service_type: Literal[ 'ClusterIP', 'NodePort', 'LoadBalancer' ] = 'ClusterIP', + extra_packages: Optional[list[str]] = None, ) -> None: """Deploys an agent to Google Kubernetes Engine(GKE). @@ -1489,6 +1523,8 @@ def to_gke( memory_service_uri: The URI of the memory service. use_local_storage: Whether to use local .adk storage in the container. service_type: The Kubernetes Service type (default: ClusterIP). + extra_packages: Additional local files or directories to stage alongside + the agent and make importable in the deployed image. """ click.secho( '\nšŸš€ Starting ADK Agent Deployment to GKE...', fg='cyan', bold=True @@ -1520,6 +1556,10 @@ def to_gke( agent_src_path = os.path.join(temp_folder, 'agents', app_name) ignore_func = _get_ignore_patterns_func(agent_folder) shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) + staged_extra_packages = _stage_extra_packages( + [(package, os.getcwd()) for package in extra_packages or []], + temp_folder, + ) 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"' @@ -1561,7 +1601,9 @@ def to_gke( ), gemini_enterprise_option='', express_mode_option='', - extra_packages_copy='', + extra_packages_copy=_extra_packages_dockerfile_copy( + staged_extra_packages, temp_folder + ), ) dockerfile_path = os.path.join(temp_folder, 'Dockerfile') os.makedirs(temp_folder, exist_ok=True) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 49fb819388..b17b7f48c3 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -2362,6 +2362,17 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]: " execution. Requires the 'gcloud beta run deploy' release track." ), ) +@click.option( + "--extra_packages", + multiple=True, + type=str, + default=(), + help=( + "Optional. Additional local package paths (a file or directory) to" + " stage and deploy alongside the agent, and make importable in the" + " deployed image. Repeatable." + ), +) # Kept as raw str (not parsed to list) — interpolated directly into Dockerfile CMD. @click.option( "--trigger_sources", @@ -2407,6 +2418,7 @@ def cli_deploy_cloud_run( a2a: bool = False, trigger_sources: str | None = None, with_cloud_run_sandbox: bool = False, + extra_packages: tuple[str, ...] = (), ): """Deploys an agent to Cloud Run. @@ -2451,6 +2463,7 @@ def cli_deploy_cloud_run( use_local_storage=use_local_storage, a2a=a2a, trigger_sources=trigger_sources, + extra_packages=list(extra_packages), extra_gcloud_args=tuple(gcloud_args), ) except Exception as e: @@ -2926,6 +2939,17 @@ def cli_deploy_agent_engine( " version in the dev environment)" ), ) +@click.option( + "--extra_packages", + multiple=True, + type=str, + default=(), + help=( + "Optional. Additional local package paths (a file or directory) to" + " stage and deploy alongside the agent, and make importable in the" + " deployed image. Repeatable." + ), +) # Kept as raw str (not parsed to list) — interpolated directly into Dockerfile CMD. @click.option( "--trigger_sources", @@ -2964,6 +2988,7 @@ def cli_deploy_gke( memory_service_uri: str | None = None, use_local_storage: bool = False, trigger_sources: str | None = None, + extra_packages: tuple[str, ...] = (), ): """Deploys an agent to GKE. @@ -2998,6 +3023,7 @@ def cli_deploy_gke( memory_service_uri=memory_service_uri, use_local_storage=use_local_storage, trigger_sources=trigger_sources, + extra_packages=list(extra_packages), ) 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 de163ee8be..11d8cca4e6 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -456,6 +456,51 @@ def mock_subprocess_run(*args, **kwargs): assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path) +def test_to_gke_stages_extra_packages( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool, bool], Path], + tmp_path: Path, +) -> None: + src_dir = agent_dir(False, False) + extra_package = tmp_path / "shared_package" + extra_package.mkdir() + (extra_package / "helpers.py").write_text("VALUE = 1\n") + deployment_dir = tmp_path / "deployment" + + def mock_subprocess_run(*args, **kwargs): + if args[0][:2] == ["kubectl", "apply"]: + return types.SimpleNamespace(stdout="deployment created") + return None + + monkeypatch.setattr(subprocess, "run", mock_subprocess_run) + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + + cli_deploy.to_gke( + agent_folder=str(src_dir), + project="gke-proj", + region="us-east1", + cluster_name="cluster", + service_name="svc", + app_name="agent", + temp_folder=str(deployment_dir), + port=8080, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + log_level="info", + adk_version="2.7.1", + extra_packages=[str(extra_package)], + ) + + assert (deployment_dir / "shared_package" / "helpers.py").is_file() + dockerfile = (deployment_dir / "Dockerfile").read_text() + assert ( + 'COPY --chown=myuser:myuser "shared_package/" "/app/shared_package/"' + in dockerfile + ) + assert 'ENV PYTHONPATH="/app:$PYTHONPATH"' in dockerfile + + def test_to_gke_uses_gcloud_cmd_on_windows( monkeypatch: pytest.MonkeyPatch, agent_dir: Callable[[bool, bool], Path], @@ -1195,7 +1240,6 @@ def test_removes_directory_tree(self, tmp_path: Path) -> None: def test_removes_readonly_files(self, tmp_path: Path) -> None: """It should remove a tree containing read-only files.""" - import os import stat d = tmp_path / "ro_dir" 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 35ebd636ab..544541ec0b 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 @@ -196,6 +196,46 @@ def test_to_cloud_run_happy_path( assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path) +def test_to_cloud_run_stages_extra_packages( + monkeypatch: pytest.MonkeyPatch, + agent_dir: AgentDirFixture, + tmp_path: Path, +) -> None: + src_dir = agent_dir(include_requirements=False, include_env=False) + extra_package = tmp_path / "shared_package" + extra_package.mkdir() + (extra_package / "helpers.py").write_text("VALUE = 1\n") + deployment_dir = tmp_path / "deployment" + + monkeypatch.setattr(subprocess, "run", _Recorder()) + monkeypatch.setattr(shutil, "rmtree", _Recorder()) + + cli_deploy.to_cloud_run( + agent_folder=str(src_dir), + project="proj", + region="us-central1", + service_name="svc", + app_name="agent", + temp_folder=str(deployment_dir), + port=8080, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + log_level="info", + verbosity="info", + adk_version="2.7.1", + extra_packages=[str(extra_package)], + ) + + assert (deployment_dir / "shared_package" / "helpers.py").is_file() + dockerfile = (deployment_dir / "Dockerfile").read_text() + assert ( + 'COPY --chown=myuser:myuser "shared_package/" "/app/shared_package/"' + in dockerfile + ) + assert 'ENV PYTHONPATH="/app:$PYTHONPATH"' in dockerfile + + def test_to_cloud_run_cleans_temp_dir( monkeypatch: pytest.MonkeyPatch, agent_dir: AgentDirFixture, diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index dddda4471f..d0e5b8b4c5 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -1203,6 +1203,29 @@ def test_cli_deploy_cloud_run_success( assert rec.calls, "cli_deploy.to_cloud_run must be invoked" +def test_cli_deploy_cloud_run_forwards_extra_packages( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + rec = _Recorder() + monkeypatch.setattr("google.adk.cli.cli_deploy.to_cloud_run", rec) + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + + result = CliRunner().invoke( + cli_tools_click.main, + [ + "deploy", + "cloud_run", + "--extra_packages=shared_a", + "--extra_packages=shared_b", + str(agent_dir), + ], + ) + + assert result.exit_code == 0 + assert rec.calls[0][1]["extra_packages"] == ["shared_a", "shared_b"] + + def test_cli_deploy_cloud_run_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1508,6 +1531,30 @@ def test_cli_deploy_gke_success( assert called_kwargs.get("cluster_name") == "my-cluster" +def test_cli_deploy_gke_forwards_extra_packages( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + rec = _Recorder() + monkeypatch.setattr("google.adk.cli.cli_deploy.to_gke", rec) + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + + result = CliRunner().invoke( + cli_tools_click.main, + [ + "deploy", + "gke", + "--cluster_name=cluster", + "--extra_packages=shared_a", + "--extra_packages=shared_b", + str(agent_dir), + ], + ) + + assert result.exit_code == 0 + assert rec.calls[0][1]["extra_packages"] == ["shared_a", "shared_b"] + + # cli eval def test_cli_eval_missing_deps_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch