From 13750de2a217b0601027b67d5b136f44afe06b42 Mon Sep 17 00:00:00 2001 From: Anusha Date: Fri, 18 Sep 2026 17:56:16 +0530 Subject: [PATCH] fix(cli): install agent deps before copying source in deploy Dockerfiles Copying the full agent tree first made every source change rebuild the pip layer. Copy requirements.txt on its own, install, then copy the rest so Cloud Run, Agent Engine, and GKE keep the dependency cache. Fixes #6923 --- src/google/adk/cli/cli_deploy.py | 19 ++++------ .../adk/cli/deployers/_dockerfile_template.py | 24 ++++++++++--- tests/unittests/cli/utils/test_cli_deploy.py | 36 +++++++++++++++++++ .../cli/utils/test_cli_deploy_to_cloud_run.py | 16 +++++++-- 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 80f92c4ea7..b6dbf66ff8 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -35,6 +35,7 @@ from ..version import __version__ from .deployers import DeployerFactory +from .deployers._dockerfile_template import _agent_deps_install_layer from .deployers._dockerfile_template import _DOCKERFILE_TEMPLATE from .utils import _onboarding @@ -801,10 +802,8 @@ def run( ignore_func = _get_ignore_patterns_func(agent_folder) shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) 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 '# No requirements.txt found.' + install_agent_deps = _agent_deps_install_layer( + app_name, os.path.exists(requirements_txt_path) ) click.echo('Copying agent source code completed.') @@ -1349,10 +1348,8 @@ def to_agent_engine( def create_dockerfile_for_agent_engine(resource_name: str) -> None: 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 '# No requirements.txt found.' + install_agent_deps = _agent_deps_install_layer( + app_name, os.path.exists(requirements_txt_path) ) trigger_sources_option = ( f'--trigger_sources={trigger_sources}' if trigger_sources else '' @@ -1566,10 +1563,8 @@ def to_gke( ignore_func = _get_ignore_patterns_func(agent_folder) shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) 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 '' + install_agent_deps = _agent_deps_install_layer( + app_name, os.path.exists(requirements_txt_path) ) click.secho('✅ Environment prepared.', fg='green') diff --git a/src/google/adk/cli/deployers/_dockerfile_template.py b/src/google/adk/cli/deployers/_dockerfile_template.py index ed91c63080..2bbaacb8a0 100644 --- a/src/google/adk/cli/deployers/_dockerfile_template.py +++ b/src/google/adk/cli/deployers/_dockerfile_template.py @@ -34,6 +34,10 @@ RUN python -c "import os, glob, google.adk.cli as cli; d = os.path.dirname(cli.__file__); [os.remove(f) for f in glob.glob(os.path.join(d, 'dev_server*'))]; [os.remove(f) for f in glob.glob(os.path.join(d, '__pycache__', 'dev_server*'))]" || true # Install ADK - End +# Install Agent Deps - Start +{install_agent_deps} +# Install Agent Deps - End + # Copy agent - Start # Set permission @@ -41,11 +45,23 @@ {extra_packages_copy} # 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} {otel_to_cloud_option} {allow_origins_option} {a2a_option} {trigger_sources_option} {trigger_oidc_audience_option} {trigger_oidc_service_accounts_option} {gemini_enterprise_option}{express_mode_option} "/app/agents" """ + + +def _agent_deps_install_layer(app_name: str, has_requirements: bool) -> str: + """Returns Dockerfile lines that install agent deps before copying source. + + Copying requirements.txt on its own keeps the pip layer cached when only + agent source changes. + """ + if not has_requirements: + return '# No requirements.txt found.' + dest = f'/app/agents/{app_name}/requirements.txt' + return ( + f'COPY --chown=myuser:myuser "agents/{app_name}/requirements.txt"' + f' "{dest}"\n' + f'RUN pip install -r "{dest}"' + ) diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index f8bc4c5ce9..4de183693f 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -33,6 +33,7 @@ import click from click.testing import CliRunner +from google.adk.cli.deployers._dockerfile_template import _agent_deps_install_layer import pytest import src.google.adk.cli.cli_deploy as cli_deploy @@ -99,6 +100,26 @@ def _factory(include_requirements: bool, include_env: bool) -> Path: return _factory +def test_agent_deps_install_layer_copies_requirements_before_pip() -> None: + """Agent requirements are copied as their own layer, then installed.""" + layer = _agent_deps_install_layer("my_app", True) + req_copy = ( + 'COPY --chown=myuser:myuser "agents/my_app/requirements.txt"' + ' "/app/agents/my_app/requirements.txt"' + ) + req_install = 'RUN pip install -r "/app/agents/my_app/requirements.txt"' + assert req_copy in layer + assert req_install in layer + assert layer.index(req_copy) < layer.index(req_install) + + +def test_agent_deps_install_layer_without_requirements() -> None: + """Missing requirements.txt is a no-op comment, not a pip install.""" + assert _agent_deps_install_layer("my_app", False) == ( + "# No requirements.txt found." + ) + + # _resolve_project def test_resolve_project_with_option() -> None: """It should return the explicit project value untouched.""" @@ -403,6 +424,21 @@ def mock_subprocess_run(*args, **kwargs): dockerfile_content = dockerfile_path.read_text() assert "CMD adk api_server --with_ui --port=9090" in dockerfile_content assert 'RUN pip install "google-adk[a2a]==1.2.0"' in dockerfile_content + agent_copy = 'COPY --chown=myuser:myuser "agents/agent/" "/app/agents/agent/"' + if include_requirements: + req_copy = ( + 'COPY --chown=myuser:myuser "agents/agent/requirements.txt"' + ' "/app/agents/agent/requirements.txt"' + ) + req_install = 'RUN pip install -r "/app/agents/agent/requirements.txt"' + assert dockerfile_content.index(req_copy) < dockerfile_content.index( + req_install + ) + assert dockerfile_content.index(req_install) < dockerfile_content.index( + agent_copy + ) + else: + assert "# No requirements.txt found." in dockerfile_content assert len(run_recorder.calls) == 3, "Expected 3 subprocess calls" 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 cb638b4b2e..ff8ae932b1 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 @@ -158,10 +158,20 @@ def test_to_cloud_run_happy_path( assert "--otel_to_cloud" in dockerfile_content # Check agent dependencies installation based on include_requirements + agent_copy = 'COPY --chown=myuser:myuser "agents/agent/" "/app/agents/agent/"' if include_requirements: - assert ( - 'RUN pip install -r "/app/agents/agent/requirements.txt"' - in dockerfile_content + req_copy = ( + 'COPY --chown=myuser:myuser "agents/agent/requirements.txt"' + ' "/app/agents/agent/requirements.txt"' + ) + req_install = 'RUN pip install -r "/app/agents/agent/requirements.txt"' + assert req_copy in dockerfile_content + assert req_install in dockerfile_content + assert dockerfile_content.index(req_copy) < dockerfile_content.index( + req_install + ) + assert dockerfile_content.index(req_install) < dockerfile_content.index( + agent_copy ) else: assert "# No requirements.txt found." in dockerfile_content