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