|
| 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) |
0 commit comments