# -*- coding: utf-8 -*-
# pylint: disable=too-many-statements,too-many-branches
import argparse
import logging
import os
import socket
import subprocess
import time
import requests
from .enums import SandboxType
from .registry import SandboxRegistry
from .utils import dynamic_import, get_platform
from .box.mobile.box.host_checker import (
check_mobile_sandbox_host_readiness,
HostPrerequisiteError,
)
logger = logging.getLogger(__name__)
DOCKER_PLATFORMS = [
"linux/amd64",
"linux/arm64",
]
REDROID_DIGESTS = {
"linux/amd64": (
"sha256:d1ca0815eb68139a43d25a835e"
"374559e9d18f5d5cea1a4288d4657c0074fb8d"
),
"linux/arm64": (
"sha256:f070231146ba5043bdb225a1f5"
"1c77ef0765c1157131b26cb827078bf536c922"
),
}
INTERNAL_REDROID_TAG = "agentscope/redroid:internal"
[docs]
def find_free_port(start_port, end_port):
for port in range(start_port, end_port + 1):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex(("localhost", port)) != 0:
return port
logger.error(
f"No free ports available in the range {start_port}-{end_port}",
)
raise RuntimeError(
f"No free ports available in the range {start_port}-{end_port}",
)
[docs]
def check_health(url, secret_token, timeout=120, interval=5):
headers = {"Authorization": f"Bearer {secret_token}"}
spent_time = 0
while spent_time < timeout:
logger.info(
f"Attempting to connect to {url} (Elapsed time: {spent_time} "
f"seconds)...",
)
try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
print(f"Health check successful for {url}")
return True
except requests.exceptions.RequestException:
pass
logger.info(
f"Health check failed for {url}. Retrying in {interval} "
f"seconds...",
)
time.sleep(interval)
spent_time += interval
logger.error(f"Health check failed for {url} after {timeout} seconds.")
return False
[docs]
def prepare_redroid_image(platform_choice, redroid_tar_path):
"""
Pulls and saves the redroid image to a tarball in the build context.
Returns:
bool: True on success, False on failure.
"""
if platform_choice not in REDROID_DIGESTS:
raise ValueError(
f"Unsupported platform for Redroid: {platform_choice}",
)
redroid_digest = REDROID_DIGESTS[platform_choice]
image_with_digest = f"redroid/redroid@{redroid_digest}"
logger.info(f"Preparing Redroid image for platform {platform_choice}...")
logger.info(f"Pulling {image_with_digest}...")
try:
subprocess.run(
["docker", "pull", image_with_digest],
check=True,
capture_output=True,
text=True,
)
logger.info(f"Tagging image with internal tag: {INTERNAL_REDROID_TAG}")
subprocess.run(
["docker", "tag", image_with_digest, INTERNAL_REDROID_TAG],
check=True,
capture_output=True,
text=True,
)
logger.info(
f"Saving image '{INTERNAL_REDROID_TAG}' to {redroid_tar_path}...",
)
subprocess.run(
["docker", "save", "-o", redroid_tar_path, INTERNAL_REDROID_TAG],
check=True,
capture_output=True,
text=True,
)
logger.info(f"Cleaning up local tag: {INTERNAL_REDROID_TAG}")
subprocess.run(
["docker", "rmi", INTERNAL_REDROID_TAG],
check=False,
)
logger.info("Redroid image prepared successfully.")
return True
except subprocess.CalledProcessError as e:
error_msg = e.stderr if getattr(e, "stderr", None) else str(e)
logger.error(f"Failed to prepare Redroid image: {error_msg}")
return False
[docs]
def build_image(
build_type,
dockerfile_path=None,
platform_choice="linux/amd64",
):
assert platform_choice in DOCKER_PLATFORMS, (
f"Invalid platform: {platform_choice}. Valid options:"
f" {DOCKER_PLATFORMS}"
)
auto_build = os.getenv("AUTO_BUILD", "false").lower() == "true"
buildx_enable = platform_choice != get_platform()
if dockerfile_path is None:
dockerfile_path = (
f"src/agentscope_runtime/sandbox/box/{build_type}/Dockerfile"
)
logger.info(f"Building {build_type} with `{dockerfile_path}`...")
# Initialize and update Git submodule
logger.info("Initializing and updating Git submodule...")
subprocess.run(
["git", "submodule", "update", "--init", "--recursive"],
check=True,
)
# Add platform tag
image_name = SandboxRegistry.get_image_by_type(build_type)
logger.info(f"Building Docker image {image_name}...")
# Check if image exists
result = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True,
)
images = result.stdout.splitlines()
# Check if the image already exists
if image_name in images or f"{image_name}dev" in images:
if auto_build:
choice = "y"
else:
choice = input(
f"Image {image_name}dev|{image_name} already exists. Do "
f"you want to overwrite it? (y/N): ",
)
if choice.lower() != "y":
logger.info("Exiting without overwriting the existing image.")
return
if not os.path.exists(dockerfile_path):
raise FileNotFoundError(
f"Dockerfile not found at {dockerfile_path}. Are you trying to "
f"build custom images?",
)
redroid_tar_path = None
try:
if build_type == "mobile":
try:
check_mobile_sandbox_host_readiness()
except HostPrerequisiteError as e:
logger.error(e)
logger.error(
"Build process aborted due to host environment issue.",
)
return
except Exception as e:
logger.error(
f"An unexpected error occurred during host check: {e}",
)
return
redroid_tar_path = os.path.join(
os.path.dirname(__file__),
"box",
build_type,
"redroid.tar",
)
if not prepare_redroid_image(platform_choice, redroid_tar_path):
raise RuntimeError(
"Failed to prepare Redroid image. Build aborted.",
)
secret_token = "secret_token123"
# Build Docker image
if not buildx_enable:
subprocess.run(
[
"docker",
"build",
"-f",
dockerfile_path,
"-t",
f"{image_name}dev",
".",
],
check=True,
)
else:
subprocess.run(
[
"docker",
"buildx",
"build",
"--platform",
platform_choice,
"-f",
dockerfile_path,
"-t",
f"{image_name}dev",
"--load",
".",
],
check=True,
)
logger.info(f"Docker image {image_name}dev built successfully.")
if buildx_enable:
logger.warning(
"Cross-platform build detected; "
"skipping health checks and tagging the final image directly.",
)
subprocess.run(
["docker", "tag", f"{image_name}dev", image_name],
check=True,
)
logger.info(f"Docker image {image_name} tagged successfully.")
else:
logger.info(f"Start to build image {image_name}.")
# Run the container with port mapping and environment variable
free_port = find_free_port(8080, 8090)
run_command = [
"docker",
"run",
"-d",
"-p",
f"{free_port}:80",
"-e",
f"SECRET_TOKEN={secret_token}",
]
if build_type == "mobile":
run_command.extend(["-e", "BUILT_BY_SCRIPT=true"])
run_command.append("--privileged")
run_command.append(f"{image_name}dev")
result = subprocess.run(
run_command,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
logger.error(
"Failed to start Docker container for image %s: %s",
f"{image_name}dev",
(result.stderr or "").strip()
or (result.stdout or "").strip(),
)
raise RuntimeError(
"Failed to start Docker container for "
f"image {image_name}dev",
)
container_id = (result.stdout or "").strip()
if not container_id:
logger.error(
"Docker run command did not return a container ID "
"for image %s.",
f"{image_name}dev",
)
raise RuntimeError(
"Docker run did not return a container ID "
f"for image {image_name}dev",
)
logger.info(
f"Running container {container_id} on port {free_port}",
)
# Check health endpoints
fastapi_health_url = (
f"http://localhost:{free_port}/fastapi/healthz"
)
fastapi_healthy = check_health(fastapi_health_url, secret_token)
if fastapi_healthy:
logger.info("Health checks passed.")
subprocess.run(
["docker", "commit", container_id, f"{image_name}"],
check=True,
)
logger.info(
f"Docker image {image_name} committed successfully.",
)
subprocess.run(["docker", "stop", container_id], check=True)
subprocess.run(["docker", "rm", container_id], check=True)
else:
logger.error("Health checks failed.")
subprocess.run(["docker", "stop", container_id], check=True)
subprocess.run(["docker", "rm", container_id], check=True)
if auto_build:
choice = "y"
else:
choice = input(
f"Do you want to delete the dev image {image_name}dev? ("
f"y/N): ",
)
if choice.lower() == "y":
subprocess.run(
["docker", "rmi", "-f", f"{image_name}dev"],
check=True,
)
logger.info(f"Dev image {image_name}dev deleted.")
else:
logger.info(f"Dev image {image_name}dev retained.")
finally:
if redroid_tar_path and os.path.exists(redroid_tar_path):
logger.info(f"Cleaning up temporary file: {redroid_tar_path}")
os.remove(redroid_tar_path)
[docs]
def main():
parser = argparse.ArgumentParser(
description="Build different types of Docker images.",
)
parser.add_argument(
"build_type",
nargs="?",
default="base",
help="Specify the build type to execute.",
)
parser.add_argument(
"--dockerfile_path",
default=None,
help="Specify the path for the Dockerfile.",
)
parser.add_argument(
"--extension",
action="append",
help="Path to a Python file or module name to load as an extension",
)
parser.add_argument(
"--platform",
default=get_platform(),
choices=DOCKER_PLATFORMS,
help="Specify target platform for Docker image (default: current "
f"system platform: {get_platform()})",
)
args = parser.parse_args()
if args.extension:
for ext in args.extension:
logger.info(f"Loading extension: {ext}")
mod = dynamic_import(ext)
logger.info(f"Extension loaded: {mod.__name__}")
if args.build_type == "all":
# Only build the built-in images
for build_type in [x.value for x in SandboxType.get_builtin_members()]:
build_image(build_type)
else:
assert args.build_type in [
x.value for x in SandboxType
], f"Invalid build type: {args.build_type}"
if args.build_type not in [
x.value for x in SandboxType.get_builtin_members()
]:
assert (
args.dockerfile_path is not None
), "Dockerfile path is required for custom images"
build_image(
args.build_type,
args.dockerfile_path,
platform_choice=args.platform,
)
if __name__ == "__main__":
main()