# Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. """Build Docker images for OpenEnv environments.""" from __future__ import annotations import shutil import subprocess import tempfile import sys from pathlib import Path from typing import Annotated import typer from .._cli_utils import console app = typer.Typer(help="Build Docker images for OpenEnv environments") def _detect_build_context(env_path: Path) -> tuple[str, Path, Path | None]: """ Detect whether we're building a standalone or in-repo environment. Returns: tuple: (build_mode, build_context_path, repo_root) - build_mode: "standalone" or "in-repo" - build_context_path: Path to use as Docker build context - repo_root: Path to repo root (None for standalone) """ # Ensure env_path is absolute for proper comparison env_path = env_path.absolute() # Check if we're in a git repository current = env_path repo_root = None # Walk up to find .git directory for parent in [current] + list(current.parents): if (parent / ".git").exists(): repo_root = parent break if repo_root is None: # Not in a git repo = standalone return "standalone", env_path, None # Check if environment is under envs/ (in-repo pattern) try: rel_path = env_path.relative_to(repo_root) rel_str = str(rel_path) if ( rel_str.startswith("envs/") or rel_str.startswith("envs\\") or rel_str.startswith("envs/") ): # In-repo environment return "in-repo", repo_root, repo_root except ValueError: pass # Otherwise, it's standalone (environment outside repo structure) return "standalone", env_path, None def _prepare_standalone_build(env_path: Path, temp_dir: Path) -> Path: """ Prepare a standalone environment for building. For standalone builds: 1. Copy environment to temp directory 2. Ensure pyproject.toml depends on openenv Returns: Path to the prepared build directory """ console.print("[cyan]Preparing standalone build...[/cyan]") # Copy environment to temp directory build_dir = temp_dir / env_path.name shutil.copytree(env_path, build_dir, symlinks=True) console.print(f"[cyan]Copied environment to:[/cyan] {build_dir}") # Check if pyproject.toml has openenv dependency pyproject_path = build_dir / "pyproject.toml" if pyproject_path.exists(): with open(pyproject_path, "rb") as f: try: import tomli pyproject = tomli.load(f) deps = pyproject.get("project", {}).get("dependencies", []) # Check if openenv dependency is declared has_openenv = any(dep.startswith("openenv") for dep in deps) if not has_openenv: console.print( "[yellow]Warning:[/yellow] pyproject.toml doesn't list the openenv dependency", ) console.print( "[yellow]You may need to add:[/yellow] openenv>=0.2.0", ) except ImportError: console.print( "[yellow]Warning:[/yellow] tomli not available, skipping dependency check", ) return build_dir def _prepare_inrepo_build(env_path: Path, repo_root: Path, temp_dir: Path) -> Path: """ Prepare an in-repo environment for building. For in-repo builds: 1. Create temp directory with environment and core 2. Set up structure that matches expected layout Returns: Path to the prepared build directory """ console.print("[cyan]Preparing in-repo build...[/cyan]") # Copy environment to temp directory build_dir = temp_dir / env_path.name shutil.copytree(env_path, build_dir, symlinks=True) # Copy OpenEnv package to temp directory package_src = repo_root / "src" / "openenv" if package_src.exists(): package_dest = build_dir / "openenv" shutil.copytree(package_src, package_dest, symlinks=True) console.print(f"[cyan]Copied OpenEnv package to:[/cyan] {package_dest}") # Update pyproject.toml to reference local OpenEnv copy pyproject_path = build_dir / "pyproject.toml" if pyproject_path.exists(): with open(pyproject_path, "rb") as f: try: import tomli pyproject = tomli.load(f) deps = pyproject.get("project", {}).get("dependencies", []) # Replace openenv/openenv-core with local reference new_deps = [] for dep in deps: if ( dep.startswith("openenv-core") or dep.startswith("openenv_core") or dep.startswith("openenv") ): # Skip - we'll use local core continue new_deps.append(dep) # Write back with local core reference pyproject["project"]["dependencies"] = new_deps + [ "openenv @ file:///app/env/openenv" ] # Write updated pyproject.toml with open(pyproject_path, "wb") as out_f: import tomli_w tomli_w.dump(pyproject, out_f) console.print( "[cyan]Updated pyproject.toml to use local core[/cyan]" ) # Remove old lockfile since dependencies changed lockfile = build_dir / "uv.lock" if lockfile.exists(): lockfile.unlink() console.print("[cyan]Removed outdated uv.lock[/cyan]") except ImportError: console.print( "[yellow]Warning:[/yellow] tomli/tomli_w not available, using pyproject.toml as-is", ) else: console.print( "[yellow]Warning:[/yellow] OpenEnv package not found, building without it" ) console.print(f"[cyan]Build directory prepared:[/cyan] {build_dir}") return build_dir def _run_command( cmd: list[str], cwd: Path | None = None, check: bool = True, ) -> subprocess.CompletedProcess: """Run a shell command and handle errors.""" console.print(f"[bold cyan]Running:[/bold cyan] {' '.join(cmd)}") try: result = subprocess.run( cmd, cwd=cwd, check=check, capture_output=True, text=True ) if result.stdout: console.print(result.stdout) if result.stderr: print(result.stderr, file=sys.stderr) return result except subprocess.CalledProcessError as e: print(f"Error running command: {e}", file=sys.stderr) if e.stdout: console.print(e.stdout) if e.stderr: print(e.stderr, file=sys.stderr) if check: raise typer.Exit(1) from e return e def _build_docker_image( env_path: Path, tag: str | None = None, context_path: Path | None = None, dockerfile: Path | None = None, build_args: dict[str, str] | None = None, no_cache: bool = False, ) -> bool: """Build Docker image for the environment with smart context detection.""" # Detect build context (standalone vs in-repo) build_mode, detected_context, repo_root = _detect_build_context(env_path) console.print(f"[bold cyan]Build mode detected:[/bold cyan] {build_mode}") # Use detected context unless explicitly overridden if context_path is None: context_path = detected_context # Create temporary build directory with tempfile.TemporaryDirectory() as temp_dir_str: temp_dir = Path(temp_dir_str) # Prepare build directory based on mode if build_mode == "standalone": build_dir = _prepare_standalone_build(env_path, temp_dir) else: # in-repo build_dir = _prepare_inrepo_build(env_path, repo_root, temp_dir) # Determine Dockerfile path if dockerfile is None: # Look for Dockerfile in server/ subdirectory dockerfile = build_dir / "server" / "Dockerfile" if not dockerfile.exists(): # Fallback to root of build directory dockerfile = build_dir / "Dockerfile" if not dockerfile.exists(): console.print( f"[bold red]Error:[/bold red] Dockerfile not found at {dockerfile}", ) return False # Generate tag if not provided if tag is None: env_name = env_path.name if env_name.endswith("_env"): env_name = env_name[:-4] tag = f"openenv-{env_name}" console.print(f"[bold cyan]Building Docker image:[/bold cyan] {tag}") console.print(f"[bold cyan]Build context:[/bold cyan] {build_dir}") console.print(f"[bold cyan]Dockerfile:[/bold cyan] {dockerfile}") # Prepare build args if build_args is None: build_args = {} # Add build mode and env name to build args build_args["BUILD_MODE"] = build_mode build_args["ENV_NAME"] = env_path.name.replace("_env", "") # Build Docker command cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile)] if no_cache: cmd.append("--no-cache") for key, value in build_args.items(): cmd.extend(["--build-arg", f"{key}={value}"]) cmd.append(str(build_dir)) result = _run_command(cmd, check=False) return result.returncode == 0 def _push_docker_image(tag: str, registry: str | None = None) -> bool: """Push Docker image to registry.""" if registry: full_tag = f"{registry}/{tag}" console.print(f"[bold cyan]Tagging image as {full_tag}[/bold cyan]") _run_command(["docker", "tag", tag, full_tag]) tag = full_tag console.print(f"[bold cyan]Pushing image:[/bold cyan] {tag}") result = _run_command(["docker", "push", tag], check=False) return result.returncode == 0 @app.command() def build( env_path: Annotated[ str | None, typer.Argument( help="Path to the environment directory (default: current directory)" ), ] = None, tag: Annotated[ str | None, typer.Option( "--tag", "-t", help="Docker image tag (default: openenv-)", ), ] = None, context: Annotated[ str | None, typer.Option( "--context", "-c", help="Build context path (default: /server)", ), ] = None, dockerfile: Annotated[ str | None, typer.Option( "--dockerfile", "-f", help="Path to Dockerfile (default: /Dockerfile)", ), ] = None, no_cache: Annotated[ bool, typer.Option( "--no-cache", help="Build without using cache", ), ] = False, build_arg: Annotated[ list[str] | None, typer.Option( "--build-arg", help="Build arguments (can be used multiple times, format: KEY=VALUE)", ), ] = None, ) -> None: """ Build Docker images for OpenEnv environments. This command builds Docker images using the environment's pyproject.toml and uv for dependency management. Run from the environment root directory. Examples: # Build from environment root (recommended) $ cd my_env $ openenv build # Build with custom tag $ openenv build -t my-custom-tag # Build without cache $ openenv build --no-cache # Build with custom build arguments $ openenv build --build-arg VERSION=1.0 --build-arg ENV=prod # Build from different directory $ openenv build envs/echo_env """ # Determine environment path (default to current directory) if env_path is None: env_path_obj = Path.cwd() else: env_path_obj = Path(env_path) # Validate environment path if not env_path_obj.exists(): print( f"Error: Environment path does not exist: {env_path_obj}", file=sys.stderr, ) raise typer.Exit(1) if not env_path_obj.is_dir(): print( f"Error: Environment path is not a directory: {env_path_obj}", file=sys.stderr, ) raise typer.Exit(1) # Check for openenv.yaml to confirm this is an environment directory openenv_yaml = env_path_obj / "openenv.yaml" if not openenv_yaml.exists(): print( f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}", file=sys.stderr, ) print( "Hint: Run this command from the environment root directory or specify the path", file=sys.stderr, ) raise typer.Exit(1) console.print(f"[bold]Building Docker image for:[/bold] {env_path_obj.name}") console.print("=" * 60) # Parse build args build_args = {} if build_arg: for arg in build_arg: if "=" in arg: key, value = arg.split("=", 1) build_args[key] = value else: print( f"Warning: Invalid build arg format: {arg}", file=sys.stderr, ) # Convert string paths to Path objects context_path_obj = Path(context) if context else None dockerfile_path_obj = Path(dockerfile) if dockerfile else None # Build Docker image success = _build_docker_image( env_path=env_path_obj, tag=tag, context_path=context_path_obj, dockerfile=dockerfile_path_obj, build_args=build_args if build_args else None, no_cache=no_cache, ) if not success: print("Γ£ù Docker build failed", file=sys.stderr) raise typer.Exit(1) console.print("[bold green]Γ£ô Docker build successful[/bold green]") console.print("\n[bold green]Done![/bold green]")