From 8b978b646c50ba3452087937415d79196aa4efc1 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:11:09 +0000 Subject: [PATCH] fix(cli): Port deploy ignore-file handling to v1 `adk deploy cloud_run` and `adk deploy gke` copied the whole agent folder into the staging directory with no exclusions at all, so a `.env` holding a live API key was built into the container image and its registry layers even when the user's own `.gitignore` excluded it. `adk deploy agent_engine` filtered on `.ae_ignore` alone, and read it without stripping comments or normalizing trailing slashes. A single `_get_ignore_patterns_func` now unions the patterns from `.gitignore`, `.gcloudignore` and `.ae_ignore`, normalizes them for `fnmatch`, and feeds all three `copytree` calls. The local dev UI's `.adk` folder is excluded unconditionally, because the session database it holds grows large enough to fail a deployment. Nothing else is excluded on the user's behalf, so a `.git` directory is still copied unless an ignore file lists it. Ports commits ee79e712 ("respect ignore files in adk deploy commands", PR #4187) and dab351be ("Prevent adk deploy from uploading .adk/session.db") from main. The second of those writes `patterns = set('.adk/')`, which iterates the string into five one-character patterns, so `.adk` is not excluded on main either and a file named `d` is. This port writes `{'.adk'}`. Behaviour change: files matched by an ignore file are no longer deployed. A deployment that relied on a `.env` excluded by `.gitignore` reaching Cloud Run must now supply those values as environment variables or secrets on the target. --- src/google/adk/cli/cli_deploy.py | 50 ++- .../cli/utils/test_cli_deploy_ignore.py | 316 ++++++++++++++++++ 2 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 tests/unittests/cli/utils/test_cli_deploy_ignore.py diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 4350ae17ced..f95f42492ba 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -21,6 +21,8 @@ import subprocess import sys import traceback +from typing import Any +from typing import Callable from typing import Final from typing import Literal from typing import Optional @@ -620,6 +622,38 @@ def _get_service_option_by_adk_version( return ' '.join(options) +def _get_ignore_patterns_func( + agent_folder: str, +) -> Callable[[Any, list[str]], set[str]]: + """Returns a shutil.ignore_patterns function with combined patterns from .gitignore, .gcloudignore and .ae_ignore.""" + # The local dev UI writes its session database under .adk/, which can grow + # large enough to fail a deployment. + patterns = {'.adk'} + + for filename in ['.gitignore', '.gcloudignore', '.ae_ignore']: + filepath = os.path.join(agent_folder, filename) + if os.path.exists(filepath): + click.echo(f'Reading ignore patterns from {filename}...') + try: + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + # If it ends with /, remove it for fnmatch compatibility + if line.endswith('/'): + line = line[:-1] + # Strip leading / from root-anchored patterns; shutil.ignore_patterns + # matches basenames via fnmatch, so '/venv' would match nothing. + if line.startswith('/'): + line = line[1:] + if line: + patterns.add(line) + except Exception as e: + click.secho(f'Warning: Failed to read {filename}: {e}', fg='yellow') + + return shutil.ignore_patterns(*patterns) + + def to_cloud_run( *, agent_folder: str, @@ -696,7 +730,8 @@ def to_cloud_run( # 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) + 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"' @@ -948,18 +983,12 @@ def to_agent_engine( try: click.echo(f'Staging all files in: {agent_src_path}') - ignore_patterns = None - ae_ignore_path = os.path.join(agent_folder, '.ae_ignore') - if os.path.exists(ae_ignore_path): - click.echo(f'Ignoring files matching the patterns in {ae_ignore_path}') - with open(ae_ignore_path, 'r') as f: - patterns = [pattern.strip() for pattern in f.readlines()] - ignore_patterns = shutil.ignore_patterns(*patterns) + ignore_func = _get_ignore_patterns_func(agent_folder) click.echo('Copying agent source code...') shutil.copytree( agent_folder, agent_src_path, - ignore=ignore_patterns, + ignore=ignore_func, dirs_exist_ok=True, ) click.echo('Copying agent source code complete.') @@ -1254,7 +1283,8 @@ def to_gke( # 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) + 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"' diff --git a/tests/unittests/cli/utils/test_cli_deploy_ignore.py b/tests/unittests/cli/utils/test_cli_deploy_ignore.py new file mode 100644 index 00000000000..82cdc99de8e --- /dev/null +++ b/tests/unittests/cli/utils/test_cli_deploy_ignore.py @@ -0,0 +1,316 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for ignore file support in cli_deploy.""" + +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +import sys +import types +from typing import Any +from typing import Dict +from unittest import mock + +import click +from google.adk.cli import cli_deploy +import pytest + + +@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) + + +def _fake_vertexai_module() -> types.ModuleType: + """Returns a stand-in for the vertexai module that records nothing.""" + fake_vertexai = types.ModuleType("vertexai") + + class _FakeAgentEngines: + + def create(self, *, config: Dict[str, Any]) -> Any: + del config + return types.SimpleNamespace( + api_resource=types.SimpleNamespace( + name="projects/p/locations/l/reasoningEngines/e" + ) + ) + + def update(self, *, name: str, config: Dict[str, Any]) -> None: + del name + del config + + class _FakeVertexClient: + + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args + del kwargs + self.agent_engines = _FakeAgentEngines() + + fake_vertexai.Client = _FakeVertexClient + return fake_vertexai + + +def test_get_ignore_patterns_func_excludes_dot_adk_without_ignore_files( + tmp_path: Path, +) -> None: + """The .adk folder is excluded even when the agent has no ignore files.""" + ignore_func = cli_deploy._get_ignore_patterns_func(str(tmp_path)) + + names = [".adk", "agent.py", "a", "d", "k", ".", "/"] + ignored = ignore_func(str(tmp_path), names) + + assert ".adk" in ignored + # A set built from the string '.adk/' would ignore each character instead, + # so single-character names must survive. + assert ignored == {".adk"} + + +def test_get_ignore_patterns_func_combines_ignore_files(tmp_path: Path) -> None: + """Patterns from all three ignore files are combined and normalized.""" + (tmp_path / ".gitignore").write_text( + "# a comment\n\nignored_by_git.txt\n/rooted.txt\nbuild/\n" + ) + (tmp_path / ".gcloudignore").write_text("ignored_by_gcloud.txt\n") + (tmp_path / ".ae_ignore").write_text("ignored_by_ae.txt\n") + + ignore_func = cli_deploy._get_ignore_patterns_func(str(tmp_path)) + + names = [ + "agent.py", + "ignored_by_git.txt", + "rooted.txt", + "build", + "ignored_by_gcloud.txt", + "ignored_by_ae.txt", + ".adk", + ] + ignored = ignore_func(str(tmp_path), names) + + assert ignored == { + "ignored_by_git.txt", + "rooted.txt", + "build", + "ignored_by_gcloud.txt", + "ignored_by_ae.txt", + ".adk", + } + + +def test_get_ignore_patterns_func_warns_on_unreadable_ignore_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """An unreadable ignore file warns instead of aborting the deployment.""" + (tmp_path / ".gitignore").write_text("ignored.txt\n") + + def _raise(*_a: Any, **_k: Any) -> Any: + raise OSError("boom") + + monkeypatch.setattr("builtins.open", _raise) + + ignore_func = cli_deploy._get_ignore_patterns_func(str(tmp_path)) + + assert ignore_func(str(tmp_path), ["ignored.txt", ".adk"]) == {".adk"} + + +def test_to_cloud_run_respects_ignore_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """to_cloud_run respects .gitignore, .gcloudignore and the .adk folder.""" + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "agent.py").write_text("# agent") + (agent_dir / "__init__.py").write_text("") + (agent_dir / "ignored_by_git.txt").write_text("ignored") + (agent_dir / "ignored_by_gcloud.txt").write_text("ignored") + (agent_dir / "ignored_rooted.txt").write_text("ignored") + (agent_dir / "not_ignored.txt").write_text("keep") + (agent_dir / ".adk").mkdir() + (agent_dir / ".adk" / "session.db").write_text("db") + + # Use a root-anchored pattern (leading slash) to ensure it is honored. + (agent_dir / ".gitignore").write_text( + "ignored_by_git.txt\n/ignored_rooted.txt\n" + ) + (agent_dir / ".gcloudignore").write_text("ignored_by_gcloud.txt\n") + + temp_deploy_dir = tmp_path / "temp_deploy" + + # Mock subprocess.run to avoid actual gcloud call + monkeypatch.setattr(subprocess, "run", mock.Mock()) + # Mock shutil.rmtree to keep the temp folder for verification + original_rmtree = shutil.rmtree + monkeypatch.setattr( + shutil, + "rmtree", + lambda path, **kwargs: None + if "temp_deploy" in str(path) + else original_rmtree(path, **kwargs), + ) + + cli_deploy.to_cloud_run( + agent_folder=str(agent_dir), + project="proj", + region="us-central1", + service_name="svc", + app_name="app", + temp_folder=str(temp_deploy_dir), + port=8080, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + log_level="info", + verbosity="info", + adk_version="1.0.0", + ) + + agent_src_path = temp_deploy_dir / "agents" / "app" + + assert (agent_src_path / "agent.py").exists() + assert (agent_src_path / "not_ignored.txt").exists() + + # These should be ignored + assert not ( + agent_src_path / "ignored_by_git.txt" + ).exists(), "Should respect .gitignore" + assert not ( + agent_src_path / "ignored_by_gcloud.txt" + ).exists(), "Should respect .gcloudignore" + assert not ( + agent_src_path / "ignored_rooted.txt" + ).exists(), "Should respect root-anchored (leading slash) patterns" + assert not ( + agent_src_path / ".adk" + ).exists(), "Should exclude the local .adk folder" + + +def test_to_agent_engine_respects_multiple_ignore_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """to_agent_engine respects .gitignore, .gcloudignore and .ae_ignore.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + monkeypatch.chdir(project_dir) + + agent_dir = project_dir / "my_agent" + agent_dir.mkdir() + (agent_dir / "agent.py").write_text("root_agent = None") + (agent_dir / "__init__.py").write_text("from . import agent") + (agent_dir / "ignored_by_git.txt").write_text("ignored") + (agent_dir / "ignored_by_ae.txt").write_text("ignored") + (agent_dir / ".adk").mkdir() + (agent_dir / ".adk" / "session.db").write_text("db") + + (agent_dir / ".gitignore").write_text("ignored_by_git.txt\n") + (agent_dir / ".ae_ignore").write_text("ignored_by_ae.txt\n") + + monkeypatch.setitem(sys.modules, "vertexai", _fake_vertexai_module()) + # Mock shutil.rmtree to keep the temp folder for verification + original_rmtree = shutil.rmtree + + def mock_rmtree(path, **kwargs): + if "_tmp" in str(path): + return None + return original_rmtree(path, **kwargs) + + monkeypatch.setattr(shutil, "rmtree", mock_rmtree) + + cli_deploy.to_agent_engine( + agent_folder=str(agent_dir), + adk_app="adk_app", + project="my-gcp-project", + region="us-central1", + ) + + # Find the temp folder created by to_agent_engine + temp_folders = [ + d for d in project_dir.iterdir() if d.is_dir() and "_tmp" in d.name + ] + assert len(temp_folders) == 1 + agent_src_path = temp_folders[0] + + assert (agent_src_path / "agent.py").exists() + assert not ( + agent_src_path / "ignored_by_git.txt" + ).exists(), "Should respect .gitignore" + assert not ( + agent_src_path / "ignored_by_ae.txt" + ).exists(), "Should respect .ae_ignore" + assert not ( + agent_src_path / ".adk" + ).exists(), "Should exclude the local .adk folder" + + +def test_to_gke_respects_ignore_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """to_gke respects ignore files.""" + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "agent.py").write_text("# agent") + (agent_dir / "__init__.py").write_text("") + (agent_dir / "ignored.txt").write_text("ignored") + (agent_dir / ".gitignore").write_text("ignored.txt\n") + (agent_dir / ".adk").mkdir() + (agent_dir / ".adk" / "session.db").write_text("db") + + temp_deploy_dir = tmp_path / "temp_deploy" + + # Mock subprocess.run to avoid actual gcloud call + mock_run = mock.Mock() + mock_run.return_value.stdout = "deployment created" + monkeypatch.setattr(subprocess, "run", mock_run) + # Mock shutil.rmtree to keep the temp folder for verification + original_rmtree = shutil.rmtree + monkeypatch.setattr( + shutil, + "rmtree", + lambda path, **kwargs: None + if "temp_deploy" in str(path) + else original_rmtree(path, **kwargs), + ) + + cli_deploy.to_gke( + agent_folder=str(agent_dir), + project="proj", + region="us-central1", + cluster_name="cluster", + service_name="svc", + app_name="app", + temp_folder=str(temp_deploy_dir), + port=8080, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + log_level="info", + adk_version="1.0.0", + ) + + agent_src_path = temp_deploy_dir / "agents" / "app" + + assert (agent_src_path / "agent.py").exists() + assert not ( + agent_src_path / "ignored.txt" + ).exists(), "Should respect .gitignore" + assert not ( + agent_src_path / ".adk" + ).exists(), "Should exclude the local .adk folder"