#!/usr/bin/env python3 # /// script # requires-python = ">=3.9" # dependencies = [] # /// """ Build Ray Docker images locally using raymake. """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path from ci.build.build_common import ( BuildError, detect_host_arch, find_ray_root, get_git_commit, log, parse_file, ) DEFAULT_ARCHITECTURE = "x86_64" RAYCI_REGISTRY = "cr.ray.io/rayproject" # Maps image type to its base name (YAML config key and Docker Hub repo). _BASE_TYPE_MAP: dict[str, str] = { "ray": "ray", "ray-extra": "ray", "ray-llm": "ray-llm", "ray-llm-extra": "ray-llm", } def _load_ray_images() -> dict: path = find_ray_root() / "ray-images.json" if not path.exists(): raise BuildError(f"Missing {path}") return json.loads(path.read_text()) def _build_image_type_config() -> dict[str, dict]: """Build IMAGE_TYPE_CONFIG from ray-images.yaml.""" raw = _load_ray_images() config: dict[str, dict] = {} for image_type, yaml_key in _BASE_TYPE_MAP.items(): yaml_cfg = raw[yaml_key] defaults = yaml_cfg["defaults"] platforms = yaml_cfg["platforms"] config[image_type] = { "python_versions": yaml_cfg["python"], "platforms": platforms, "architectures": yaml_cfg["architectures"], "default_python": defaults["python"], "default_platform": "cpu" if "cpu" in platforms else defaults["gpu_platform"], } return config IMAGE_TYPE_CONFIG: dict[str, dict] = _build_image_type_config() class RayImageError(Exception): """Raised when a RayImage field combination is invalid.""" @dataclass(frozen=True) class RayImage: """Immutable identity of a Ray Docker image variant.""" image_type: str python_version: str platform: str architecture: str = DEFAULT_ARCHITECTURE @property def wanda_image_name(self) -> str: """Wanda output image name (without registry prefix).""" if self.platform == "cpu": return f"{self.image_type}-py{self.python_version}-cpu{self.arch_suffix}" return f"{self.image_type}-py{self.python_version}-{self.platform}{self.arch_suffix}" @property def arch_suffix(self) -> str: if self.architecture == DEFAULT_ARCHITECTURE: return "" return f"-{self.architecture}" @property def repo(self) -> str: return _BASE_TYPE_MAP[self.image_type] @property def variation_suffix(self) -> str: if self.image_type.endswith("-extra"): return "-extra" return "" @property def platform_class(self) -> str: """Returns 'cpu', 'cuda', 'tpu', etc.""" if self.platform == "cpu": return "cpu" if self.platform == "tpu": return "tpu" if self.platform.startswith("cu"): return "cuda" raise RayImageError(f"Could not determine platform class for {self.platform!r}") def get_wanda_spec_path(self) -> str: """Returns the path to the wanda spec file for this image.""" key: WandaSpecKey = (self.image_type, self.platform_class) try: return WANDA_SPEC_PATHS[key] except KeyError: raise RayImageError( f"No wanda spec for image_type={self.image_type!r}, " f"platform={self.platform!r}" ) from None def validate(self) -> None: if self.image_type not in IMAGE_TYPE_CONFIG: valid = ", ".join(IMAGE_TYPE_CONFIG) raise RayImageError( f"Unknown image type {self.image_type!r}. Valid types: {valid}" ) cfg = IMAGE_TYPE_CONFIG[self.image_type] if self.python_version not in cfg["python_versions"]: raise RayImageError( f"Invalid python version {self.python_version} " f"for {self.image_type}. " f"Valid versions: {', '.join(cfg['python_versions'])}" ) if self.platform not in cfg["platforms"]: raise RayImageError( f"Invalid platform {self.platform} " f"for {self.image_type}. " f"Valid platforms: {', '.join(cfg['platforms'])}" ) if self.architecture not in cfg["architectures"]: raise RayImageError( f"Invalid architecture {self.architecture} " f"for {self.image_type}. " f"Valid architectures: {', '.join(cfg['architectures'])}" ) # (image_type, platform_class) where platform_class is "cpu" or "cuda". WandaSpecKey = tuple[str, str] WANDA_SPEC_PATHS: dict[WandaSpecKey, str] = { ("ray", "cpu"): "ci/docker/ray-image-cpu.wanda.yaml", ("ray", "cuda"): "ci/docker/ray-image-cuda.wanda.yaml", ("ray", "tpu"): "ci/docker/ray-image-tpu.wanda.yaml", ("ray-extra", "cpu"): "ci/docker/ray-extra-image-cpu.wanda.yaml", ("ray-extra", "cuda"): "ci/docker/ray-extra-image-cuda.wanda.yaml", ("ray-llm", "cuda"): "ci/docker/ray-llm-image-cuda.wanda.yaml", ("ray-llm-extra", "cuda"): "ci/docker/ray-llm-extra-image-cuda.wanda.yaml", } SUPPORTED_IMAGE_TYPES: list[str] = list(dict.fromkeys(t for t, _ in WANDA_SPEC_PATHS)) REGISTRY_PREFIX = "cr.ray.io/rayproject/" @dataclass(frozen=True) class ImageBuildConfig: ray_image: RayImage ray_root: Path raymake_version: str manylinux_version: str ray_version: str commit: str wanda_spec_path: str wanda_image_tag: str nightly_alias: str | None build_env: dict[str, str] @classmethod def from_args( cls, image_type: str, python_version: str, image_platform: str, *, no_java: bool = False, no_dashboard: bool = False, ) -> ImageBuildConfig: ray_image = RayImage( image_type=image_type, python_version=python_version, platform=image_platform, architecture=cls._detect_host_arch(), ) try: ray_image.validate() ray_image.get_wanda_spec_path() except RayImageError as e: raise BuildError(str(e)) from e root = find_ray_root() manylinux_version = parse_file( root / "rayci.env", r'MANYLINUX_VERSION=["\']?([^"\'\s]+)' ) ray_version = parse_file(root / "rayci.env", r'RAY_VERSION=["\']?([^"\'\s]+)') commit = get_git_commit(root) cfg = IMAGE_TYPE_CONFIG[image_type] nightly_alias = ( f"{REGISTRY_PREFIX}{ray_image.repo}:nightly{ray_image.variation_suffix}" if ( ray_image.python_version == cfg["default_python"] and ray_image.platform == cfg["default_platform"] and ray_image.architecture == DEFAULT_ARCHITECTURE ) else None ) java_image = ( "scratch" if no_java else f"{RAYCI_REGISTRY}/ray-java-build{ray_image.arch_suffix}" ) dashboard_image = ( "scratch" if no_dashboard else f"{RAYCI_REGISTRY}/ray-dashboard{ray_image.arch_suffix}" ) build_env: dict[str, str] = { "PYTHON_VERSION": ray_image.python_version, "MANYLINUX_VERSION": manylinux_version, "HOSTTYPE": ray_image.architecture, "ARCH_SUFFIX": ray_image.arch_suffix, "JDK_SUFFIX": "" if no_java else "-jdk", "BUILDKITE_COMMIT": commit, "RAY_VERSION": ray_version, "IS_LOCAL_BUILD": "true", "IMAGE_TYPE": ray_image.repo, "RAY_JAVA_IMAGE": java_image, "RAY_DASHBOARD_IMAGE": dashboard_image, } if ray_image.platform.startswith("cu"): build_env["CUDA_VERSION"] = ray_image.platform.removeprefix("cu") return cls( ray_image=ray_image, ray_root=root, raymake_version=(root / ".rayciversion").read_text().strip(), manylinux_version=manylinux_version, ray_version=ray_version, commit=commit, wanda_spec_path=ray_image.get_wanda_spec_path(), wanda_image_tag=f"{REGISTRY_PREFIX}{ray_image.wanda_image_name}", nightly_alias=nightly_alias, build_env=build_env, ) @staticmethod def _detect_host_arch() -> str: return detect_host_arch() class ImageBuilder: def __init__(self, config: ImageBuildConfig): self.config = config def build(self) -> str: """Build the image and return the primary image tag.""" if not shutil.which("raymake"): raise BuildError("raymake not found. Run via ./build-image.sh") log.info("Build configuration:") summary = { "Image Type": self.config.ray_image.image_type, "Python": self.config.ray_image.python_version, "Platform": self.config.ray_image.platform, "Arch": self.config.ray_image.architecture, "Commit": self.config.commit, "Raymake": self.config.raymake_version, "Ray Version": self.config.ray_version, "Wanda Spec": self.config.wanda_spec_path, } print("-" * 50) for k, v in summary.items(): print(f"{k:<12}: {v}") print("-" * 50) cmd = [ "raymake", str(self.config.wanda_spec_path), ] log.info(f"Running raymake: {self.config.wanda_spec_path}") log.info(f"Build environment: {self.config.build_env}") proc = subprocess.Popen( cmd, cwd=self.config.ray_root, env={**os.environ, **self.config.build_env}, ) try: if proc.wait() != 0: raise BuildError("raymake failed") except KeyboardInterrupt: try: proc.wait(timeout=2) except subprocess.TimeoutExpired: proc.kill() raise image_tag = self.config.wanda_image_tag log.info(f"Built image: {image_tag}") alias = self.config.nightly_alias if alias: self._docker_tag(image_tag, alias) log.info(f"Tagged alias: {alias}") return image_tag @staticmethod def _docker_tag(source: str, dest: str) -> None: result = subprocess.run( ["docker", "tag", source, dest], capture_output=True, text=True, ) if result.returncode != 0: raise BuildError(f"docker tag failed: {result.stderr.strip()}") def _image_type_table() -> str: """Build an ASCII table of all image types from IMAGE_TYPE_CONFIG.""" def _tag_default(items, default): return [f"{v} (default)" if v == default else v for v in items] def _wrap(items, width): """Join items with ', ' and wrap into lines that fit in *width*.""" lines: list[str] = [] cur = "" for item in items: entry = item if not cur else f", {item}" if cur and len(cur) + len(entry) > width: lines.append(cur) cur = item else: cur += entry if cur: lines.append(cur) return lines headers = ("IMAGE TYPE", "PYTHON VERSIONS (-p)", "PLATFORMS (--platform)") plat_width = 52 # wrap platforms to this width def _build_rows(type_list): rows: list[tuple[str, str, list[str]]] = [] for name in type_list: cfg = IMAGE_TYPE_CONFIG[name] py_cell = ", ".join( _tag_default(cfg["python_versions"], cfg["default_python"]) ) plat_lines = _wrap( _tag_default(cfg["platforms"], cfg["default_platform"]), plat_width, ) rows.append((name, py_cell, plat_lines)) return rows rows = _build_rows(SUPPORTED_IMAGE_TYPES) # Compute column widths. widths = [len(h) for h in headers] for label, py_cell, plat_lines in rows: widths[0] = max(widths[0], len(label)) widths[1] = max(widths[1], len(py_cell)) widths[2] = max(widths[2], *(len(l) for l in plat_lines)) sep = " " def fmt(cells): return sep.join(c.ljust(widths[i]) for i, c in enumerate(cells)) lines = [fmt(headers), sep.join("-" * w for w in widths)] for label, py_cell, plat_lines in rows: lines.append(fmt((label, py_cell, plat_lines[0]))) for cont in plat_lines[1:]: lines.append(fmt(("", "", cont))) return "\n".join(lines) def _nightly_table() -> str: """Build a table mapping build commands to nightly docker run commands.""" headers = ("BUILD", "RUN") rows = [] for name in SUPPORTED_IMAGE_TYPES: cfg = IMAGE_TYPE_CONFIG[name] img = RayImage(name, cfg["default_python"], cfg["default_platform"]) tag = f"{REGISTRY_PREFIX}{img.repo}:nightly{img.variation_suffix}" rows.append((f"./build-image.sh {name}", f"docker run -it {tag}")) widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): widths[i] = max(widths[i], len(cell)) sep = " " def fmt(cells): return sep.join(c.ljust(widths[i]) for i, c in enumerate(cells)) lines = [fmt(headers), sep.join("-" * w for w in widths)] lines.extend(fmt(row) for row in rows) return "\n".join(lines) def _build_examples() -> str: """Generate non-default example commands from IMAGE_TYPE_CONFIG.""" ray_cfg = IMAGE_TYPE_CONFIG["ray"] ray_py = ray_cfg["default_python"] ray_plat = ray_cfg["default_platform"] alt_py = next((v for v in ray_cfg["python_versions"] if v != ray_py), None) alt_plat = next((p for p in ray_cfg["platforms"] if p != ray_plat), None) examples: list[tuple[str, str]] = [] if alt_py: examples.append( (f"ray -p {alt_py}", f"ray {ray_plat} py{alt_py}"), ) if alt_plat: examples.append( (f"ray --platform {alt_plat}", f"ray {alt_plat} py{ray_py}"), ) if alt_py and alt_plat: examples.append( (f"ray -p {alt_py} --platform {alt_plat}", f"ray {alt_plat} py{alt_py}"), ) if not examples: return "" cmd_prefix = " ./build-image.sh " cmds = [cmd_prefix + args for args, _ in examples] max_cmd = max(len(c) for c in cmds) lines = [] for cmd, (_, comment) in zip(cmds, examples): lines.append(f"{cmd:<{max_cmd}} # {comment}") return "\n".join(lines) def main(): epilog_parts = [ "Supported image types:", _image_type_table(), "", "Nightly images (built with defaults):", _nightly_table(), "", "Examples:", _build_examples(), ] parser = argparse.ArgumentParser( prog="./build-image.sh", description="Build Ray Docker images locally using raymake.", epilog="\n".join(epilog_parts), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "image_type", choices=SUPPORTED_IMAGE_TYPES, metavar="IMAGE_TYPE", help=f"Image type: {', '.join(SUPPORTED_IMAGE_TYPES)}", ) parser.add_argument( "-p", "--python-version", default=None, metavar="VERSION", help="Python version (default depends on image type; see table below)", ) parser.add_argument( "--platform", default=None, metavar="PLATFORM", help="Target platform (default depends on image type; see table below)", ) parser.add_argument( "--list-platforms", action="store_true", help="List valid platforms for the given image type and exit", ) parser.add_argument( "--no-java", action="store_true", default=False, help="Build wheel without Java JARs", ) parser.add_argument( "--no-dashboard", action="store_true", default=False, help="Build wheel without the dashboard", ) if len(sys.argv) == 1: parser.print_help() sys.exit(0) args = parser.parse_args() cfg = IMAGE_TYPE_CONFIG[args.image_type] if args.list_platforms: print(f"Valid platforms for {args.image_type}:") for p in cfg["platforms"]: marker = " (default)" if p == cfg["default_platform"] else "" print(f" {p}{marker}") sys.exit(0) python_version = args.python_version or cfg["default_python"] platform_choice = args.platform or cfg["default_platform"] try: config = ImageBuildConfig.from_args( args.image_type, python_version, platform_choice, no_java=args.no_java, no_dashboard=args.no_dashboard, ) builder = ImageBuilder(config) image_tag = builder.build() print() log.info("Success!") print(f" docker run -it {image_tag}") alias = config.nightly_alias if alias: print(f" docker run -it {alias}") except BuildError as e: log.error(e) sys.exit(1) except KeyboardInterrupt: sys.exit(130) if __name__ == "__main__": main()