diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index f0d709a4..db4172f9 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -665,6 +665,7 @@ def to_cloud_run( a2a: bool = False, trigger_sources: Optional[str] = None, extra_gcloud_args: Optional[tuple[str, ...]] = None, + with_cloud_run_sandbox: bool = False, ) -> None: """Deploys an agent to Google Cloud Run. @@ -701,6 +702,8 @@ def to_cloud_run( artifact_service_uri: The URI of the artifact service. memory_service_uri: The URI of the memory service. 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. """ app_name = app_name or os.path.basename(agent_folder) if parse(adk_version) >= parse('1.3.0') and not use_local_storage: @@ -780,14 +783,18 @@ def to_cloud_run( adk_managed_args = {'--source', '--project', '--port', '--verbosity'} if region: adk_managed_args.add('--region') + if with_cloud_run_sandbox: + adk_managed_args.add('--sandbox-launcher') # Validate that extra gcloud args don't conflict with ADK-managed args _validate_gcloud_extra_args(extra_gcloud_args, adk_managed_args) # Build the command with extra gcloud args - gcloud_cmd = [ - _GCLOUD_CMD, - 'beta', + gcloud_cmd = [_GCLOUD_CMD] + if with_cloud_run_sandbox: + # --sandbox-launcher is only supported on the beta release track. + gcloud_cmd.append('beta') + gcloud_cmd += [ 'run', 'deploy', service_name, @@ -800,8 +807,9 @@ def to_cloud_run( str(port), '--verbosity', log_level.lower() if log_level else verbosity, - '--sandbox-launcher', ] + if with_cloud_run_sandbox: + gcloud_cmd.append('--sandbox-launcher') # Handle labels specially - merge user labels with ADK label user_labels = [] diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 0779c223..e0a91a15 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -2315,6 +2315,16 @@ def cli_api_server( default=False, help="Optional. Whether to enable A2A endpoint.", ) +@click.option( + "--with_cloud_run_sandbox", + is_flag=True, + show_default=True, + default=False, + help=( + "Optional. Whether to enable the Cloud Run sandbox for code" + " execution. Requires the 'gcloud beta run deploy' release track." + ), +) # Kept as raw str (not parsed to list) — interpolated directly into Dockerfile CMD. @click.option( "--trigger_sources", @@ -2359,6 +2369,7 @@ def cli_deploy_cloud_run( use_local_storage: bool = False, a2a: bool = False, trigger_sources: str | None = None, + with_cloud_run_sandbox: bool = False, ): """Deploys an agent to Cloud Run. @@ -2383,6 +2394,7 @@ def cli_deploy_cloud_run( cli_deploy.to_cloud_run( agent_folder=agent, + with_cloud_run_sandbox=with_cloud_run_sandbox, project=project, region=region, service_name=service_name, 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 956f4240..35ebd636 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 @@ -175,7 +175,6 @@ def test_to_cloud_run_happy_path( expected_gcloud_command = [ cli_deploy._GCLOUD_CMD, - "beta", "run", "deploy", "svc", @@ -189,7 +188,6 @@ def test_to_cloud_run_happy_path( "8080", "--verbosity", "info", - "--sandbox-launcher", "--labels", "created-by=adk", ] @@ -276,6 +274,85 @@ def test_to_cloud_run_cleans_temp_dir_on_failure( assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir) +@pytest.mark.parametrize("with_cloud_run_sandbox", [True, False]) +def test_to_cloud_run_with_sandbox( + monkeypatch: pytest.MonkeyPatch, + agent_dir: AgentDirFixture, + tmp_path: Path, + with_cloud_run_sandbox: bool, +) -> None: + """Verify --sandbox-launcher and beta release track based on with_cloud_run_sandbox.""" + src_dir = agent_dir(include_requirements=False, include_env=False) + run_recorder = _Recorder() + + monkeypatch.setattr(subprocess, "run", run_recorder) + monkeypatch.setattr(shutil, "rmtree", lambda _x: None) + + cli_deploy.to_cloud_run( + agent_folder=str(src_dir), + project="proj", + region="us-central1", + service_name="svc", + app_name="app", + temp_folder=str(tmp_path), + port=8080, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + log_level="info", + verbosity="info", + adk_version="1.0.0", + with_cloud_run_sandbox=with_cloud_run_sandbox, + ) + + assert len(run_recorder.calls) == 1 + gcloud_cmd = run_recorder.get_last_call_args()[0] + + if with_cloud_run_sandbox: + # 'beta' is inserted right after the gcloud command + assert gcloud_cmd[1] == "beta" + assert gcloud_cmd[2] == "run" + assert "--sandbox-launcher" in gcloud_cmd + else: + assert gcloud_cmd[1] == "run" + assert "--sandbox-launcher" not in gcloud_cmd + assert "beta" not in gcloud_cmd + + +def test_to_cloud_run_sandbox_conflict( + monkeypatch: pytest.MonkeyPatch, + agent_dir: AgentDirFixture, + tmp_path: Path, +) -> None: + """Verify that --sandbox-launcher in extra_gcloud_args raises an error when with_cloud_run_sandbox is True.""" + src_dir = agent_dir(include_requirements=False, include_env=False) + run_recorder = _Recorder() + + monkeypatch.setattr(subprocess, "run", run_recorder) + monkeypatch.setattr(shutil, "rmtree", lambda _x: None) + + with pytest.raises(click.ClickException) as exc_info: + cli_deploy.to_cloud_run( + agent_folder=str(src_dir), + project="proj", + region="us-central1", + service_name="svc", + app_name="app", + temp_folder=str(tmp_path), + port=8080, + trace_to_cloud=False, + otel_to_cloud=False, + with_ui=False, + log_level="info", + verbosity="info", + adk_version="1.0.0", + with_cloud_run_sandbox=True, + extra_gcloud_args=("--sandbox-launcher",), + ) + + assert "conflicts with ADK's automatic configuration" in str(exc_info.value) + + # Label merging tests @pytest.mark.parametrize( "extra_gcloud_args, expected_labels", diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index d9e29911..31d5161e 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -1090,6 +1090,29 @@ def test_cli_deploy_cloud_run_allows_empty_gcloud_args( assert extra_args == () +@pytest.mark.parametrize("with_sandbox", [True, False]) +def test_cli_deploy_cloud_run_sandbox( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, with_sandbox: bool +) -> None: + """Verify --with_cloud_run_sandbox parameter gets forwarded to to_cloud_run.""" + rec = _Recorder() + monkeypatch.setattr("google.adk.cli.cli_deploy.to_cloud_run", rec) + + agent_dir = tmp_path / "agent_sandbox" + agent_dir.mkdir() + runner = CliRunner() + args = ["deploy", "cloud_run", str(agent_dir)] + if with_sandbox: + args.append("--with_cloud_run_sandbox") + result = runner.invoke( + cli_tools_click.main, + args, + ) + assert result.exit_code == 0 + assert rec.calls, "cli_deploy.to_cloud_run must be invoked" + assert rec.calls[0][1].get("with_cloud_run_sandbox") == with_sandbox + + def test_cli_deploy_cloud_run_interspersed_options( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: