Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 72 additions & 30 deletions src/google/adk/cli/cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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"'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"'
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/google/adk/cli/cli_tools_click.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)
46 changes: 45 additions & 1 deletion tests/unittests/cli/utils/test_cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions tests/unittests/cli/utils/test_cli_deploy_to_cloud_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading