Skip to content
Closed
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
118 changes: 104 additions & 14 deletions src/google/adk/cli/cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
from .deployers import DeployerFactory
from .deployers._dockerfile_template import _render_dockerfile
from .deployers._dockerfile_template import _render_install_agent_deps
from .deployers._dockerfile_template import _to_exec_form
from .deployers._dockerfile_template import _validate_app_name
from .deployers._dockerfile_template import _validate_dockerfile_literal
from .utils import _onboarding

logger = logging.getLogger('google_adk.' + __name__)
Expand Down Expand Up @@ -1054,6 +1056,23 @@ def _get_ignore_patterns_func(
return shutil.ignore_patterns(*patterns)


def _validate_extra_package_name(base: str) -> str:
"""Validates an extra_packages basename before writing it into a Dockerfile."""
_validate_dockerfile_literal('extra_packages', base)
if (
not base
or base in ('.', '..')
or '"' in base
or '\\' in base
or '/' in base
):
raise click.ClickException(
f'Invalid extra_packages entry {base!r}. The basename must not contain'
' quotes, backslashes, or path separators.'
)
return base


def _stage_extra_packages(
requested_extra_packages: Sequence[tuple[str, str]],
build_context: str,
Expand Down Expand Up @@ -1082,7 +1101,9 @@ def _stage_extra_packages(
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))
base = _validate_extra_package_name(
os.path.basename(os.path.normpath(pkg_src))
)
dst = os.path.join(build_context, base)
if os.path.exists(dst) or base in reserved_names:
raise click.ClickException(
Expand All @@ -1097,19 +1118,79 @@ def _stage_extra_packages(
return staged_extra_packages


_INSTALLABLE_ARCHIVE_SUFFIXES: Final[tuple[str, ...]] = (
'.whl',
'.tar.gz',
'.tgz',
'.zip',
)


def _get_extra_packages_copy(
staged_extra_packages: Sequence[str], build_context: str
) -> str:
"""Returns the Dockerfile lines that copy staged extra packages."""
"""Returns the Dockerfile lines that copy and install staged extra packages.

Each staged entry is handled one of two ways:

* An installable distribution (a file ending in one of
`_INSTALLABLE_ARCHIVE_SUFFIXES`, or a directory containing
`pyproject.toml` or `setup.py`) is copied to `/tmp/extra_packages/<base>`
and installed with `pip install`, so its modules land in site-packages
together with the dependencies it declares.
* Anything else (a plain module or a folder of modules) is copied to
`/app/<base>` and `/app` is added to PYTHONPATH.

Installable entries are deliberately kept out of `/app`: a flat-layout
package such as `my_pkg/pyproject.toml` + `my_pkg/my_pkg/__init__.py` copied
to `/app/my_pkg` would otherwise resolve as a PEP 420 namespace package
ahead of its own site-packages install. For the same reason PYTHONPATH is
only set when at least one plain entry is present.

Args:
staged_extra_packages: Basenames returned by `_stage_extra_packages`.
build_context: The folder the entries were staged into.

Returns:
The `COPY`, `ENV`, and `RUN` lines to splice into the Dockerfile, or an
empty string when nothing was staged.
"""
if not staged_extra_packages:
return ''
copy_lines = [
f'COPY --chown=myuser:myuser "{base}/" "/app/{base}/"'
if os.path.isdir(os.path.join(build_context, base))
else f'COPY --chown=myuser:myuser "{base}" "/app/{base}"'
for base in staged_extra_packages
]
copy_lines.append('ENV PYTHONPATH="/app:$PYTHONPATH"')
copy_lines: list[str] = []
installable_targets: list[str] = []
has_plain_packages = False
for base in staged_extra_packages:
_validate_extra_package_name(base)
staged_path = os.path.join(build_context, base)
if os.path.isdir(staged_path):
if os.path.isfile(
os.path.join(staged_path, 'pyproject.toml')
) or os.path.isfile(os.path.join(staged_path, 'setup.py')):
copy_lines.append(
f'COPY --chown=myuser:myuser "{base}/"'
f' "/tmp/extra_packages/{base}/"'
)
installable_targets.append(f'/tmp/extra_packages/{base}')
else:
copy_lines.append(
f'COPY --chown=myuser:myuser "{base}/" "/app/{base}/"'
)
has_plain_packages = True
elif base.endswith(_INSTALLABLE_ARCHIVE_SUFFIXES):
copy_lines.append(
f'COPY --chown=myuser:myuser "{base}" "/tmp/extra_packages/{base}"'
)
installable_targets.append(f'/tmp/extra_packages/{base}')
else:
copy_lines.append(f'COPY --chown=myuser:myuser "{base}" "/app/{base}"')
has_plain_packages = True
if has_plain_packages:
copy_lines.append('ENV PYTHONPATH="/app:$PYTHONPATH"')
if installable_targets:
copy_lines.append(
_to_exec_form('RUN', ['pip', 'install', *installable_targets])
)
return '\n'.join(copy_lines)


Expand Down Expand Up @@ -1182,8 +1263,11 @@ def run(
with_cloud_run_sandbox: Whether to enable the Cloud Run sandbox for code
execution.
extra_packages: Additional local file or directory paths to stage alongside
the agent and make importable in the image. A relative path is resolved
against the current working directory.
the agent and make importable in the image. A wheel, source archive, or
a directory containing `pyproject.toml` or `setup.py` is installed with
pip; any other entry is copied to `/app/<basename>` and `/app` is added
to PYTHONPATH. A relative path is resolved against the current working
directory.
"""
_validate_trigger_options(
trigger_sources, trigger_oidc_audience, trigger_oidc_service_accounts
Expand Down Expand Up @@ -1487,7 +1571,10 @@ def to_agent_engine(
deployment. If not specified, the version in the dev environment will be
used.
extra_packages (list[str]): Optional. Additional local file or directory
paths to stage alongside the agent and make importable in the image.
paths to stage alongside the agent and make importable in the image. A
wheel, source archive, or a directory containing `pyproject.toml` or
`setup.py` is installed with pip; any other entry is copied to
`/app/<basename>` and `/app` is added to PYTHONPATH.
worker_pool (str): Optional. Full Cloud Build private worker pool resource
name
(`projects/{project}/locations/{location}/workerPools/{pool}`).
Expand Down Expand Up @@ -1975,8 +2062,11 @@ def to_gke(
use_local_storage: Whether to use local .adk storage in the container.
service_type: The Kubernetes Service type (default: ClusterIP).
extra_packages: Additional local file or directory paths to stage alongside
the agent and make importable in the image. A relative path is resolved
against the current working directory.
the agent and make importable in the image. A wheel, source archive, or
a directory containing `pyproject.toml` or `setup.py` is installed with
pip; any other entry is copied to `/app/<basename>` and `/app` is added
to PYTHONPATH. A relative path is resolved against the current working
directory.
"""
_validate_trigger_options(
trigger_sources, trigger_oidc_audience, trigger_oidc_service_accounts
Expand Down
30 changes: 18 additions & 12 deletions src/google/adk/cli/cli_tools_click.py
Original file line number Diff line number Diff line change
Expand Up @@ -2667,10 +2667,12 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
help=(
"Optional. Additional local package paths (a file or directory) to"
" stage and deploy alongside the agent, and make importable in the"
" deployed image. Each entry is placed at `/app/<basename>` and `/app`"
" is added to PYTHONPATH, so a top-level name that matches an installed"
" dependency will shadow it at runtime; pick distinct names."
" Repeatable."
" deployed image. A wheel (.whl), source archive (.tar.gz, .tgz,"
" .zip), or a directory containing pyproject.toml or setup.py is"
" installed with pip. Any other entry is placed at `/app/<basename>`"
" and `/app` is added to PYTHONPATH, so a top-level name that matches"
" an installed dependency will shadow it at runtime; pick distinct"
" names. Repeatable."
),
)
@deploy_options
Expand Down Expand Up @@ -3086,10 +3088,12 @@ def cli_migrate_session(
help=(
"Optional. Additional local package paths (a file or directory) to"
" stage and deploy alongside the agent, and make importable in the"
" deployed image. Each entry is placed at `/app/<basename>` and `/app`"
" is added to PYTHONPATH, so a top-level name that matches an installed"
" dependency will shadow it at runtime; pick distinct names."
" Repeatable."
" deployed image. A wheel (.whl), source archive (.tar.gz, .tgz,"
" .zip), or a directory containing pyproject.toml or setup.py is"
" installed with pip. Any other entry is placed at `/app/<basename>`"
" and `/app` is added to PYTHONPATH, so a top-level name that matches"
" an installed dependency will shadow it at runtime; pick distinct"
" names. Repeatable."
),
)
@click.option(
Expand Down Expand Up @@ -3377,10 +3381,12 @@ def cli_deploy_agent_engine(
help=(
"Optional. Additional local package paths (a file or directory) to"
" stage and deploy alongside the agent, and make importable in the"
" deployed image. Each entry is placed at `/app/<basename>` and `/app`"
" is added to PYTHONPATH, so a top-level name that matches an installed"
" dependency will shadow it at runtime; pick distinct names."
" Repeatable."
" deployed image. A wheel (.whl), source archive (.tar.gz, .tgz,"
" .zip), or a directory containing pyproject.toml or setup.py is"
" installed with pip. Any other entry is placed at `/app/<basename>`"
" and `/app` is added to PYTHONPATH, so a top-level name that matches"
" an installed dependency will shadow it at runtime; pick distinct"
" names. Repeatable."
),
)
@adk_services_options(default_use_local_storage=False)
Expand Down
133 changes: 133 additions & 0 deletions tests/unittests/cli/utils/test_cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3053,3 +3053,136 @@ def test_cli_deploy_agent_engine_passes_service_account(
mock_to_agent_engine.assert_called_once()
_, kwargs = mock_to_agent_engine.call_args
assert kwargs["service_account"] == _VALID_SERVICE_ACCOUNT


def test_extra_packages_pip_installs_installable_packages_and_wheels(
monkeypatch: pytest.MonkeyPatch,
agent_dir: Callable[[bool, bool], Path],
tmp_path: Path,
) -> None:
"""Installable extra_packages (pyproject.toml, setup.py, .whl) are pip-installed."""
src_dir = agent_dir(False, False)
build_dir = tmp_path / "build"

schemas_dir = tmp_path / "libs" / "schemas"
(schemas_dir / "agent_schemas").mkdir(parents=True)
(schemas_dir / "pyproject.toml").write_text(
'[project]\nname = "agent-schemas"\nversion = "0.1.0"\n'
)
(schemas_dir / "agent_schemas" / "__init__.py").write_text("SCHEMA = {}\n")

wheel_file = tmp_path / "pkg.whl"
wheel_file.write_bytes(b"fake-wheel-bytes")

plain_dir = tmp_path / "common"
plain_dir.mkdir()
(plain_dir / "helper.py").write_text("VALUE = 1\n")

def mock_subprocess_run(*args, **kwargs):
del kwargs
if args[0][0:2] == ["kubectl", "apply"]:
return types.SimpleNamespace(stdout="service/gke-svc 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="my-gke-cluster",
service_name="gke-svc",
app_name="agent",
temp_folder=str(build_dir),
port=9090,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=False,
log_level="debug",
adk_version="1.2.0",
extra_packages=[str(schemas_dir), str(wheel_file), str(plain_dir)],
)

dockerfile_content = (build_dir / "Dockerfile").read_text()
assert (
'COPY --chown=myuser:myuser "schemas/" "/tmp/extra_packages/schemas/"'
in dockerfile_content
)
assert (
'COPY --chown=myuser:myuser "pkg.whl" "/tmp/extra_packages/pkg.whl"'
in dockerfile_content
)
assert (
'COPY --chown=myuser:myuser "common/" "/app/common/"'
in dockerfile_content
)
assert 'ENV PYTHONPATH="/app:$PYTHONPATH"' in dockerfile_content
assert (
'RUN ["pip", "install", "/tmp/extra_packages/schemas",'
' "/tmp/extra_packages/pkg.whl"]'
in dockerfile_content
)


def test_get_extra_packages_copy_pip_installs_setup_py_package(
tmp_path: Path,
) -> None:
"""A staged directory with setup.py is installed without setting PYTHONPATH."""
legacy_pkg = tmp_path / "legacy_pkg"
legacy_pkg.mkdir()
(legacy_pkg / "setup.py").write_text(
"from setuptools import setup; setup()\n"
)

rendered = cli_deploy._get_extra_packages_copy(["legacy_pkg"], str(tmp_path))

assert (
'COPY --chown=myuser:myuser "legacy_pkg/"'
' "/tmp/extra_packages/legacy_pkg/"'
in rendered
)
assert "PYTHONPATH" not in rendered
assert 'RUN ["pip", "install", "/tmp/extra_packages/legacy_pkg"]' in rendered


@pytest.mark.parametrize(
"archive_name",
["pkg-0.1.0.tar.gz", "pkg-0.1.0.tgz", "pkg-0.1.0.zip"],
)
def test_get_extra_packages_copy_pip_installs_source_archives(
tmp_path: Path,
archive_name: str,
) -> None:
"""Source distribution archives (.tar.gz, .tgz, .zip) are pip-installed."""
(tmp_path / archive_name).write_bytes(b"fake-archive-bytes")

rendered = cli_deploy._get_extra_packages_copy([archive_name], str(tmp_path))

assert (
f'COPY --chown=myuser:myuser "{archive_name}"'
f' "/tmp/extra_packages/{archive_name}"'
in rendered
)
assert "PYTHONPATH" not in rendered
assert (
f'RUN ["pip", "install", "/tmp/extra_packages/{archive_name}"]'
in rendered
)


@pytest.mark.parametrize(
"unsafe_base",
[
'pkg"\nRUN touch /tmp/pwned',
'pkg" "/tmp/evil',
"../outside",
],
)
def test_get_extra_packages_copy_rejects_unsafe_basename(
tmp_path: Path,
unsafe_base: str,
) -> None:
"""Unsafe extra_packages basenames cannot break out of Dockerfile COPY."""
with pytest.raises(click.ClickException):
cli_deploy._get_extra_packages_copy([unsafe_base], str(tmp_path))
Loading