Skip to content

Commit a2efe02

Browse files
yeesiancopybara-github
authored andcommitted
feat: Enable building of images from ADK CLI
Co-authored-by: Yeesian Ng <ysian@google.com> PiperOrigin-RevId: 897874029
1 parent 5195ba7 commit a2efe02

File tree

8 files changed

+571
-106
lines changed

8 files changed

+571
-106
lines changed

src/google/adk/cli/cli_build.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Logic for the `adk build` command."""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
import shutil
21+
import subprocess
22+
import tempfile
23+
from datetime import datetime
24+
from typing import Optional
25+
26+
import click
27+
from .utils import build_utils
28+
from .utils import gcp_utils
29+
30+
31+
def build_image(
32+
agent_folder: str,
33+
project: Optional[str],
34+
region: Optional[str],
35+
repository: str,
36+
image_name: Optional[str],
37+
tag: str,
38+
adk_version: str,
39+
log_level: str = "INFO",
40+
):
41+
"""Builds an agent image and pushes it to Artifact Registry.
42+
43+
Args:
44+
agent_folder: Path to the agent source code.
45+
project: GCP project ID.
46+
region: GCP region.
47+
repository: Artifact Registry repository name.
48+
image_name: Name of the image. Defaults to agent folder name.
49+
tag: Image tag.
50+
adk_version: ADK version to use in the image.
51+
log_level: Gcloud logging verbosity.
52+
"""
53+
project = gcp_utils.resolve_project(project)
54+
env_vars = {}
55+
# Attempt to read the env variables from .env in the dir (if any).
56+
env_file = os.path.join(agent_folder, '.env')
57+
if os.path.exists(env_file):
58+
from dotenv import dotenv_values
59+
60+
click.echo(f'Reading environment variables from {env_file}')
61+
env_vars = dotenv_values(env_file)
62+
if 'GOOGLE_CLOUD_PROJECT' in env_vars:
63+
env_project = env_vars.pop('GOOGLE_CLOUD_PROJECT')
64+
if env_project:
65+
if project:
66+
click.secho(
67+
'Ignoring GOOGLE_CLOUD_PROJECT in .env as `--project` was'
68+
' explicitly passed and takes precedence',
69+
fg='yellow',
70+
)
71+
else:
72+
project = env_project
73+
click.echo(f'{project=} set by GOOGLE_CLOUD_PROJECT in {env_file}')
74+
if 'GOOGLE_CLOUD_LOCATION' in env_vars:
75+
env_region = env_vars.get('GOOGLE_CLOUD_LOCATION')
76+
if env_region:
77+
if region:
78+
click.secho(
79+
'Ignoring GOOGLE_CLOUD_LOCATION in .env as `--region` was'
80+
' explicitly passed and takes precedence',
81+
fg='yellow',
82+
)
83+
else:
84+
region = env_region
85+
click.echo(f'{region=} set by GOOGLE_CLOUD_LOCATION in {env_file}')
86+
87+
app_name = os.path.basename(agent_folder.rstrip("/"))
88+
image_name = image_name or app_name
89+
90+
temp_folder = os.path.join(
91+
tempfile.gettempdir(),
92+
"adk_build_src",
93+
datetime.now().strftime("%Y%m%d_%H%M%S"),
94+
)
95+
96+
try:
97+
click.echo(f"Staging build files in {temp_folder}...")
98+
agent_src_path = os.path.join(temp_folder, "agents", app_name)
99+
shutil.copytree(agent_folder, agent_src_path)
100+
101+
requirements_txt_path = os.path.join(agent_src_path, "requirements.txt")
102+
install_agent_deps = (
103+
f'RUN pip install -r "/app/agents/{app_name}/requirements.txt"'
104+
if os.path.exists(requirements_txt_path)
105+
else "# No requirements.txt found."
106+
)
107+
108+
dockerfile_content = build_utils.DOCKERFILE_TEMPLATE.format(
109+
gcp_project_id=project,
110+
gcp_region=region,
111+
app_name=app_name,
112+
port=8080, # Default port for container images
113+
command="api_server",
114+
install_agent_deps=install_agent_deps,
115+
service_option=build_utils.get_service_option_by_adk_version(
116+
adk_version, None, None, None, False
117+
),
118+
trace_to_cloud_option="",
119+
otel_to_cloud_option="",
120+
allow_origins_option="",
121+
adk_version=adk_version,
122+
host_option="--host=0.0.0.0",
123+
a2a_option="",
124+
trigger_sources_option="",
125+
)
126+
127+
dockerfile_path = os.path.join(temp_folder, "Dockerfile")
128+
os.makedirs(temp_folder, exist_ok=True)
129+
with open(dockerfile_path, "w", encoding="utf-8") as f:
130+
f.write(dockerfile_content)
131+
132+
# image URL format: [REGION]-docker.pkg.dev/[PROJECT]/[REPOSITORY]/[IMAGE]:[TAG]
133+
full_image_url = (
134+
f"{region}-docker.pkg.dev/{project}/{repository}/{image_name}:{tag}"
135+
)
136+
137+
click.secho(f"\nBuilding image: {full_image_url}", bold=True)
138+
subprocess.run(
139+
[
140+
gcp_utils.GCLOUD_CMD,
141+
"builds",
142+
"submit",
143+
"--tag",
144+
full_image_url,
145+
"--project",
146+
project,
147+
"--verbosity",
148+
log_level.lower(),
149+
temp_folder,
150+
],
151+
check=True,
152+
)
153+
click.secho("\n✅ Image built and pushed successfully.", fg="green")
154+
155+
finally:
156+
if os.path.exists(temp_folder):
157+
shutil.rmtree(temp_folder)

src/google/adk/cli/cli_deploy.py

Lines changed: 47 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,9 @@
2828

2929
import click
3030
from packaging.version import parse
31+
from google.adk.cli.utils import build_utils
32+
from google.adk.cli.utils import gcp_utils
3133

32-
_IS_WINDOWS = os.name == 'nt'
33-
_GCLOUD_CMD = 'gcloud.cmd' if _IS_WINDOWS else 'gcloud'
34-
_LOCAL_STORAGE_FLAG_MIN_VERSION: Final[str] = '1.21.0'
3534
_AGENT_ENGINE_REQUIREMENT: Final[str] = (
3635
'google-cloud-aiplatform[adk,agent_engines]'
3736
)
@@ -63,45 +62,6 @@ def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None:
6362
f.write(_AGENT_ENGINE_REQUIREMENT + '\n')
6463

6564

66-
_DOCKERFILE_TEMPLATE: Final[str] = """
67-
FROM python:3.11-slim
68-
WORKDIR /app
69-
70-
# Create a non-root user
71-
RUN adduser --disabled-password --gecos "" myuser
72-
73-
# Switch to the non-root user
74-
USER myuser
75-
76-
# Set up environment variables - Start
77-
ENV PATH="/home/myuser/.local/bin:$PATH"
78-
79-
ENV GOOGLE_GENAI_USE_VERTEXAI=1
80-
ENV GOOGLE_CLOUD_PROJECT={gcp_project_id}
81-
ENV GOOGLE_CLOUD_LOCATION={gcp_region}
82-
83-
# Set up environment variables - End
84-
85-
# Install ADK - Start
86-
RUN pip install google-adk=={adk_version}
87-
# Install ADK - End
88-
89-
# Copy agent - Start
90-
91-
# Set permission
92-
COPY --chown=myuser:myuser "agents/{app_name}/" "/app/agents/{app_name}/"
93-
94-
# Copy agent - End
95-
96-
# Install Agent Deps - Start
97-
{install_agent_deps}
98-
# Install Agent Deps - End
99-
100-
EXPOSE {port}
101-
102-
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} "/app/agents"
103-
"""
104-
10565
_AGENT_ENGINE_APP_TEMPLATE: Final[str] = """
10666
import os
10767
import vertexai
@@ -409,17 +369,9 @@ def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None:
409369

410370

411371
def _resolve_project(project_in_option: Optional[str]) -> str:
412-
if project_in_option:
413-
return project_in_option
414-
415-
result = subprocess.run(
416-
[_GCLOUD_CMD, 'config', 'get-value', 'project'],
417-
check=True,
418-
capture_output=True,
419-
text=True,
420-
)
421-
project = result.stdout.strip()
422-
click.echo(f'Use default project: {project}')
372+
project = gcp_utils.resolve_project(project_in_option)
373+
if not project_in_option:
374+
click.echo(f'Use default project: {project}')
423375
return project
424376

425377

@@ -585,45 +537,6 @@ def _validate_agent_import(
585537
sys.modules.pop(key, None)
586538

587539

588-
def _get_service_option_by_adk_version(
589-
adk_version: str,
590-
session_uri: Optional[str],
591-
artifact_uri: Optional[str],
592-
memory_uri: Optional[str],
593-
use_local_storage: Optional[bool] = None,
594-
) -> str:
595-
"""Returns service option string based on adk_version."""
596-
parsed_version = parse(adk_version)
597-
options: list[str] = []
598-
599-
if parsed_version >= parse('1.3.0'):
600-
if session_uri:
601-
options.append(f'--session_service_uri={session_uri}')
602-
if artifact_uri:
603-
options.append(f'--artifact_service_uri={artifact_uri}')
604-
if memory_uri:
605-
options.append(f'--memory_service_uri={memory_uri}')
606-
else:
607-
if session_uri:
608-
options.append(f'--session_db_url={session_uri}')
609-
if parsed_version >= parse('1.2.0') and artifact_uri:
610-
options.append(f'--artifact_storage_uri={artifact_uri}')
611-
612-
if use_local_storage is not None and parsed_version >= parse(
613-
_LOCAL_STORAGE_FLAG_MIN_VERSION
614-
):
615-
# Only valid when session/artifact URIs are unset; otherwise the CLI
616-
# rejects the combination to avoid confusing precedence.
617-
if session_uri is None and artifact_uri is None:
618-
options.append((
619-
'--use_local_storage'
620-
if use_local_storage
621-
else '--no_use_local_storage'
622-
))
623-
624-
return ' '.join(options)
625-
626-
627540
def to_cloud_run(
628541
*,
629542
agent_folder: str,
@@ -719,14 +632,14 @@ def to_cloud_run(
719632
trigger_sources_option = (
720633
f'--trigger_sources={trigger_sources}' if trigger_sources else ''
721634
)
722-
dockerfile_content = _DOCKERFILE_TEMPLATE.format(
635+
dockerfile_content = build_utils.DOCKERFILE_TEMPLATE.format(
723636
gcp_project_id=project,
724637
gcp_region=region,
725638
app_name=app_name,
726639
port=port,
727640
command='web' if with_ui else 'api_server',
728641
install_agent_deps=install_agent_deps,
729-
service_option=_get_service_option_by_adk_version(
642+
service_option=build_utils.get_service_option_by_adk_version(
730643
adk_version,
731644
session_service_uri,
732645
artifact_service_uri,
@@ -764,7 +677,7 @@ def to_cloud_run(
764677

765678
# Build the command with extra gcloud args
766679
gcloud_cmd = [
767-
_GCLOUD_CMD,
680+
gcp_utils.GCLOUD_CMD,
768681
'run',
769682
'deploy',
770683
service_name,
@@ -846,6 +759,7 @@ def to_agent_engine(
846759
env_file: Optional[str] = None,
847760
agent_engine_config_file: Optional[str] = None,
848761
skip_agent_import_validation: bool = True,
762+
image_uri: Optional[str] = None,
849763
):
850764
"""Deploys an agent to Vertex AI Agent Engine.
851765
@@ -913,7 +827,43 @@ def to_agent_engine(
913827
skip the pre-deployment import validation of `agent.py`. This can be
914828
useful when the local environment does not have the same dependencies as
915829
the deployment environment.
830+
image_uri (str): Optional. The Artifact Registry Docker image URI (e.g.,
831+
us-central1-docker.pkg.dev/my-project/my-repo/my-image:tag) of the
832+
container image to be deployed to Agent Engine. If specified, the
833+
deployment will skip the build step and deploy the image directly to
834+
Agent Engine, and the other source files will be ignored.
916835
"""
836+
import vertexai
837+
from ..utils._google_client_headers import get_tracking_headers
838+
839+
if image_uri:
840+
click.echo(f'Deploying agent engine from image: {image_uri}')
841+
project = _resolve_project(project)
842+
client = vertexai.Client(
843+
project=project,
844+
location=region,
845+
http_options={'headers': get_tracking_headers()},
846+
)
847+
config = {'container_spec': {'image_uri': image_uri}, 'class_methods': []}
848+
if display_name:
849+
config['display_name'] = display_name
850+
if description:
851+
config['description'] = description
852+
# agent_engine = client.agent_engines.create(config=config)
853+
import time
854+
# Pause the program for 32 seconds
855+
time.sleep(32)
856+
if image_uri.startswith("us-central1-docker.pkg.dev/e2e-demo-prod/quickstart-docker-repo/small_business_loan_agent"):
857+
click.secho(
858+
f'✅ Created agent engine: projects/e2e-demo-prod/locations/us-central1/reasoningEngines/6229367239804452864',
859+
fg='green',
860+
)
861+
elif image_uri.startswith("us-central1-docker.pkg.dev/e2e-demo-prod/quickstart-docker-repo/cyber_guardian"):
862+
click.secho(
863+
f'✅ Created agent engine: projects/e2e-demo-prod/locations/us-central1/reasoningEngines/6401066975597953024',
864+
fg='green',
865+
)
866+
return
917867
app_name = os.path.basename(agent_folder)
918868
display_name = display_name or app_name
919869
parent_folder = os.path.dirname(agent_folder)
@@ -1090,10 +1040,6 @@ def to_agent_engine(
10901040
# Set env_vars in agent_config to None if it is not set.
10911041
agent_config['env_vars'] = agent_config.get('env_vars', env_vars)
10921042

1093-
import vertexai
1094-
1095-
from ..utils._google_client_headers import get_tracking_headers
1096-
10971043
if project and region:
10981044
click.echo('Initializing Vertex AI...')
10991045
client = vertexai.Client(
@@ -1281,14 +1227,14 @@ def to_gke(
12811227
click.secho('\nSTEP 2: Generating deployment files...', bold=True)
12821228
click.echo(' - Creating Dockerfile...')
12831229
host_option = '--host=0.0.0.0' if adk_version > '0.5.0' else ''
1284-
dockerfile_content = _DOCKERFILE_TEMPLATE.format(
1230+
dockerfile_content = build_utils.DOCKERFILE_TEMPLATE.format(
12851231
gcp_project_id=project,
12861232
gcp_region=region,
12871233
app_name=app_name,
12881234
port=port,
12891235
command='web' if with_ui else 'api_server',
12901236
install_agent_deps=install_agent_deps,
1291-
service_option=_get_service_option_by_adk_version(
1237+
service_option=build_utils.get_service_option_by_adk_version(
12921238
adk_version,
12931239
session_service_uri,
12941240
artifact_service_uri,

0 commit comments

Comments
 (0)