feat: convert build_container.sh to Python

Conversion from build_container.sh (by cursor/claud-4-sonnet)
o Create new build_container.py
o Maintain backward compatibility

Related: #2870

Signed-off-by: Derek Higgins <derekh@redhat.com>
This commit is contained in:
Derek Higgins 2025-07-24 09:16:43 +01:00
parent cd8715d327
commit 58435f7579

View file

@ -0,0 +1,382 @@
#!/usr/bin/env python3
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the terms described in the LICENSE file in
# the root directory of this source tree.
import argparse
import json
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import yaml
class Colors:
RED = "\033[0;31m"
NC = "\033[0m" # No Color
def is_command_available(command: str) -> bool:
"""Check if a command is available in the system."""
return shutil.which(command) is not None
def get_python_cmd() -> str:
"""Get the appropriate Python command."""
if is_command_available("python"):
return "python"
elif is_command_available("python3"):
return "python3"
else:
print("Error: Neither python nor python3 is installed. Please install Python to continue.", file=sys.stderr)
sys.exit(1)
class ContainerBuilder:
def __init__(self):
# Environment variables with defaults
self.llama_stack_dir = os.getenv("LLAMA_STACK_DIR", "")
self.llama_stack_client_dir = os.getenv("LLAMA_STACK_CLIENT_DIR", "")
self.test_pypi_version = os.getenv("TEST_PYPI_VERSION", "")
self.pypi_version = os.getenv("PYPI_VERSION", "")
self.build_platform = os.getenv("BUILD_PLATFORM", "")
self.uv_http_timeout = os.getenv("UV_HTTP_TIMEOUT", "500")
self.use_copy_not_mount = os.getenv("USE_COPY_NOT_MOUNT", "")
self.mount_cache = os.getenv("MOUNT_CACHE", "--mount=type=cache,id=llama-stack-cache,target=/root/.cache")
self.container_binary = os.getenv("CONTAINER_BINARY", "docker")
self.container_opts = os.getenv("CONTAINER_OPTS", "--progress=plain")
# Constants
self.run_config_path = "/app/run.yaml"
self.build_context_dir = os.getcwd()
self.stack_mount = "/app/llama-stack-source"
self.client_mount = "/app/llama-stack-client-source"
# Temporary directory and Containerfile
self.temp_dir = tempfile.mkdtemp()
self.containerfile_path = os.path.join(self.temp_dir, "Containerfile")
def cleanup(self):
"""Clean up temporary files."""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
# Clean up copied files in build context
run_yaml_path = os.path.join(self.build_context_dir, "run.yaml")
if os.path.exists(run_yaml_path):
os.remove(run_yaml_path)
providers_dir = os.path.join(self.build_context_dir, "providers.d")
if os.path.exists(providers_dir):
shutil.rmtree(providers_dir)
def add_to_container(self, content: str):
"""Add content to the Containerfile."""
with open(self.containerfile_path, "a") as f:
f.write(content + "\n")
def validate_args(self, args):
"""Validate command line arguments."""
if not is_command_available(self.container_binary):
print(
f"{Colors.RED}Error: {self.container_binary} command not found. "
f"Is {self.container_binary} installed and in your PATH?{Colors.NC}",
file=sys.stderr,
)
sys.exit(1)
def generate_base_image_setup(self, container_base: str):
"""Generate the base image setup commands."""
if "registry.access.redhat.com/ubi9" in container_base:
self.add_to_container(f"""FROM {container_base}
WORKDIR /app
# We install the Python 3.12 dev headers and build tools so that any
# C-extension wheels (e.g. polyleven, faiss-cpu) can compile successfully.
RUN dnf -y update && dnf install -y iputils git net-tools wget \\
vim-minimal python3.12 python3.12-pip python3.12-wheel \\
python3.12-setuptools python3.12-devel gcc make && \\
ln -s /bin/pip3.12 /bin/pip && ln -s /bin/python3.12 /bin/python && dnf clean all
ENV UV_SYSTEM_PYTHON=1
RUN pip install uv""")
else:
self.add_to_container(f"""FROM {container_base}
WORKDIR /app
RUN apt-get update && apt-get install -y \\
iputils-ping net-tools iproute2 dnsutils telnet \\
curl wget telnet git\\
procps psmisc lsof \\
traceroute \\
bubblewrap \\
gcc \\
&& rm -rf /var/lib/apt/lists/*
ENV UV_SYSTEM_PYTHON=1
RUN pip install uv""")
def add_pip_dependencies(self, pip_dependencies: str, special_pip_deps: str):
"""Add pip dependencies to the container."""
# Set link mode to copy
self.add_to_container("ENV UV_LINK_MODE=copy")
# Add regular pip dependencies
if pip_dependencies:
pip_args = shlex.split(pip_dependencies)
quoted_deps = " ".join(shlex.quote(dep) for dep in pip_args)
self.add_to_container(f"RUN {self.mount_cache} uv pip install {quoted_deps}")
# Add special pip dependencies
if special_pip_deps:
parts = special_pip_deps.split("#")
for part in parts:
if part.strip():
pip_args = shlex.split(part.strip())
quoted_deps = " ".join(shlex.quote(dep) for dep in pip_args)
self.add_to_container(f"RUN {self.mount_cache} uv pip install {quoted_deps}")
def handle_run_config(self, run_config: str):
"""Handle run configuration file."""
if not run_config:
return
# Copy the run config to the build context
run_yaml_dest = os.path.join(self.build_context_dir, "run.yaml")
shutil.copy2(run_config, run_yaml_dest)
# Parse the run.yaml configuration for external providers
try:
with open(run_config) as f:
config = yaml.safe_load(f)
external_providers_dir = config.get("external_providers_dir", "")
if external_providers_dir:
# Expand environment variables in path
external_providers_dir = os.path.expandvars(external_providers_dir)
if os.path.isdir(external_providers_dir):
print(f"Copying external providers directory: {external_providers_dir}")
providers_dest = os.path.join(self.build_context_dir, "providers.d")
shutil.copytree(external_providers_dir, providers_dest, dirs_exist_ok=True)
self.add_to_container("COPY providers.d /.llama/providers.d")
# Update the run.yaml file to change external_providers_dir
with open(run_yaml_dest) as f:
content = f.read()
# Replace external_providers_dir line
content = re.sub(
r"external_providers_dir:.*", "external_providers_dir: /.llama/providers.d", content
)
with open(run_yaml_dest, "w") as f:
f.write(content)
except Exception as e:
print(f"Warning: Could not parse run.yaml: {e}", file=sys.stderr)
# Copy run config into docker image
self.add_to_container(f"COPY run.yaml {self.run_config_path}")
def install_local_package(self, directory: str, mount_point: str, name: str):
"""Install a local package in the container."""
if not os.path.isdir(directory):
print(
f"{Colors.RED}Warning: {name} is set but directory does not exist: {directory}{Colors.NC}",
file=sys.stderr,
)
sys.exit(1)
if self.use_copy_not_mount == "true":
self.add_to_container(f"COPY {directory} {mount_point}")
self.add_to_container(f"RUN {self.mount_cache} uv pip install -e {mount_point}")
def install_llama_stack(self):
"""Install llama-stack package."""
if self.llama_stack_client_dir:
self.install_local_package(self.llama_stack_client_dir, self.client_mount, "LLAMA_STACK_CLIENT_DIR")
if self.llama_stack_dir:
self.install_local_package(self.llama_stack_dir, self.stack_mount, "LLAMA_STACK_DIR")
else:
if self.test_pypi_version:
# Install damaged packages first for test-pypi
self.add_to_container(f"RUN {self.mount_cache} uv pip install fastapi libcst")
self.add_to_container(f"""RUN {self.mount_cache} uv pip install --extra-index-url https://test.pypi.org/simple/ \\
--index-strategy unsafe-best-match \\
llama-stack=={self.test_pypi_version}""")
else:
if self.pypi_version:
spec_version = f"llama-stack=={self.pypi_version}"
else:
spec_version = "llama-stack"
self.add_to_container(f"RUN {self.mount_cache} uv pip install {spec_version}")
def add_entrypoint(self, template_or_config: str, run_config: str):
"""Add the container entrypoint."""
# Remove uv after installation
self.add_to_container("RUN pip uninstall -y uv")
# Set entrypoint based on configuration
if run_config:
self.add_to_container(
f'ENTRYPOINT ["python", "-m", "llama_stack.distribution.server.server", "--config", "{self.run_config_path}"]'
)
elif not template_or_config.endswith(".yaml"):
self.add_to_container(
f'ENTRYPOINT ["python", "-m", "llama_stack.distribution.server.server", "--template", "{template_or_config}"]'
)
# Add generic container setup
self.add_to_container("""
RUN mkdir -p /.llama /.cache && chmod -R g+rw /app /.llama /.cache""")
def get_version_tag(self):
"""Get the version tag for the image."""
if self.pypi_version:
return self.pypi_version
elif self.test_pypi_version:
return f"test-{self.test_pypi_version}"
elif self.llama_stack_dir or self.llama_stack_client_dir:
return "dev"
else:
try:
url = "https://pypi.org/pypi/llama-stack/json"
with urllib.request.urlopen(url) as response:
data = json.loads(response.read())
return data["info"]["version"]
except Exception:
return "latest"
def build_container(self, image_name: str) -> tuple[list[str], str]:
"""Build the container and return CLI arguments."""
cli_args = shlex.split(self.container_opts)
# Add volume mounts if not using copy mode
if self.use_copy_not_mount != "true":
if self.llama_stack_dir:
abs_path = os.path.abspath(self.llama_stack_dir)
cli_args.extend(["-v", f"{abs_path}:{self.stack_mount}"])
if self.llama_stack_client_dir:
abs_path = os.path.abspath(self.llama_stack_client_dir)
cli_args.extend(["-v", f"{abs_path}:{self.client_mount}"])
# Handle SELinux if available
try:
if is_command_available("selinuxenabled"):
result = subprocess.run(["selinuxenabled"], capture_output=True)
if result.returncode == 0:
cli_args.extend(["--security-opt", "label=disable"])
except Exception:
pass
# Set platform
arch = platform.machine()
if self.build_platform:
cli_args.extend(["--platform", self.build_platform])
elif arch in ["arm64", "aarch64"]:
cli_args.extend(["--platform", "linux/arm64"])
elif arch == "x86_64":
cli_args.extend(["--platform", "linux/amd64"])
else:
print(f"Unsupported architecture: {arch}")
sys.exit(1)
# Create image tag
version_tag = self.get_version_tag()
image_tag = f"{image_name}:{version_tag}"
return cli_args, image_tag
def run_build(self, cli_args: list[str], image_tag: str):
"""Execute the container build command."""
print(f"PWD: {os.getcwd()}")
print(f"Containerfile: {self.containerfile_path}")
# Print Containerfile content
print(f"Containerfile created successfully in {self.containerfile_path}\n")
with open(self.containerfile_path) as f:
print(f.read())
print()
# Build the container
cmd = [
self.container_binary,
"build",
*cli_args,
"-t",
image_tag,
"-f",
self.containerfile_path,
self.build_context_dir,
]
print("Running command:")
print(" ".join(shlex.quote(arg) for arg in cmd))
try:
subprocess.run(cmd, check=True)
print("Success!")
except subprocess.CalledProcessError as e:
print(f"Build failed with exit code {e.returncode}")
sys.exit(e.returncode)
def main():
parser = argparse.ArgumentParser(
description="Build container images for llama-stack", formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("template_or_config", help="Template name or path to config file")
parser.add_argument("image_name", help="Name for the container image")
parser.add_argument("container_base", help="Base container image")
parser.add_argument("pip_dependencies", help="Pip dependencies to install")
parser.add_argument("run_config", nargs="?", default="", help="Optional path to run.yaml config file")
parser.add_argument("special_pip_deps", nargs="?", default="", help="Optional special pip dependencies")
args = parser.parse_args()
# Handle the complex argument parsing logic from the bash script
# If we have 5+ args and the 5th arg doesn't end with .yaml, it's special_pip_deps
if len(sys.argv) >= 6:
if not sys.argv[5].endswith(".yaml"):
args.special_pip_deps = args.run_config
args.run_config = ""
builder = ContainerBuilder()
try:
builder.validate_args(args)
# Generate Containerfile
builder.generate_base_image_setup(args.container_base)
builder.add_pip_dependencies(args.pip_dependencies, args.special_pip_deps)
builder.handle_run_config(args.run_config)
builder.install_llama_stack()
builder.add_entrypoint(args.template_or_config, args.run_config)
# Build container
cli_args, image_tag = builder.build_container(args.image_name)
builder.run_build(cli_args, image_tag)
finally:
builder.cleanup()
if __name__ == "__main__":
main()