openenv/coding_env
21
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""Build Docker images for OpenEnv environments."""8 9from __future__ import annotations10 11import shutil12import subprocess13import sys14import tempfile15from pathlib import Path16from typing import Annotated17 18import typer19 20from .._cli_utils import console21 22app = typer.Typer(help="Build Docker images for OpenEnv environments")23 24 25def _detect_build_context(env_path: Path) -> tuple[str, Path, Path | None]:26 """27 Detect whether we're building a standalone or in-repo environment.28 29 Returns:30 tuple: (build_mode, build_context_path, repo_root)31 - build_mode: "standalone" or "in-repo"32 - build_context_path: Path to use as Docker build context33 - repo_root: Path to repo root (None for standalone)34 """35 # Ensure env_path is absolute for proper comparison36 env_path = env_path.absolute()37 38 # Check if we're in a git repository39 current = env_path40 repo_root = None41 42 # Walk up to find .git directory43 for parent in [current] + list(current.parents):44 if (parent / ".git").exists():45 repo_root = parent46 break47 48 if repo_root is None:49 # Not in a git repo = standalone50 return "standalone", env_path, None51 52 # Check if environment is under envs/ (in-repo pattern)53 try:54 rel_path = env_path.relative_to(repo_root)55 rel_str = str(rel_path)56 if (57 rel_str.startswith("envs/")58 or rel_str.startswith("envs\\")59 or rel_str.startswith("envs/")60 ):61 # In-repo environment62 return "in-repo", repo_root, repo_root63 except ValueError:64 pass65 66 # Otherwise, it's standalone (environment outside repo structure)67 return "standalone", env_path, None68 69 70def _prepare_standalone_build(env_path: Path, temp_dir: Path) -> Path:71 """72 Prepare a standalone environment for building.73 74 For standalone builds:75 1. Copy environment to temp directory76 2. Ensure pyproject.toml depends on openenv77 78 Returns:79 Path to the prepared build directory80 """81 console.print("[cyan]Preparing standalone build...[/cyan]")82 83 # Copy environment to temp directory84 build_dir = temp_dir / env_path.name85 shutil.copytree(env_path, build_dir, symlinks=True)86 87 console.print(f"[cyan]Copied environment to:[/cyan] {build_dir}")88 89 # Check if pyproject.toml has openenv dependency90 pyproject_path = build_dir / "pyproject.toml"91 if pyproject_path.exists():92 with open(pyproject_path, "rb") as f:93 try:94 import tomli95 96 pyproject = tomli.load(f)97 deps = pyproject.get("project", {}).get("dependencies", [])98 99 # Check if openenv dependency is declared100 has_openenv = any(dep.startswith("openenv") for dep in deps)101 102 if not has_openenv:103 console.print(104 "[yellow]Warning:[/yellow] pyproject.toml doesn't list the openenv dependency",105 )106 console.print(107 "[yellow]You may need to add:[/yellow] openenv>=0.2.0",108 )109 except ImportError:110 console.print(111 "[yellow]Warning:[/yellow] tomli not available, skipping dependency check",112 )113 114 return build_dir115 116 117def _prepare_inrepo_build(env_path: Path, repo_root: Path, temp_dir: Path) -> Path:118 """119 Prepare an in-repo environment for building.120 121 For in-repo builds:122 1. Create temp directory with environment and core123 2. Set up structure that matches expected layout124 125 Returns:126 Path to the prepared build directory127 """128 console.print("[cyan]Preparing in-repo build...[/cyan]")129 130 # Copy environment to temp directory131 build_dir = temp_dir / env_path.name132 shutil.copytree(env_path, build_dir, symlinks=True)133 134 # Copy OpenEnv package metadata + sources to temp directory.135 # Keep the src/ layout since pyproject.toml uses package-dir = {"" = "src"}.136 package_src = repo_root / "src" / "openenv"137 package_dest = build_dir / "openenv"138 if package_src.exists():139 package_dest.mkdir(parents=True, exist_ok=True)140 shutil.copytree(package_src, package_dest / "src" / "openenv", symlinks=True)141 142 for filename in ("pyproject.toml", "README.md"):143 src_file = repo_root / filename144 if src_file.exists():145 shutil.copy2(src_file, package_dest / filename)146 147 console.print(f"[cyan]Copied OpenEnv package to:[/cyan] {package_dest}")148 149 # Update pyproject.toml to reference local OpenEnv copy150 pyproject_path = build_dir / "pyproject.toml"151 if pyproject_path.exists():152 with open(pyproject_path, "rb") as f:153 try:154 import tomli155 156 pyproject = tomli.load(f)157 deps = pyproject.get("project", {}).get("dependencies", [])158 159 # Replace openenv/openenv-core with local reference160 new_deps = []161 for dep in deps:162 if (163 dep.startswith("openenv-core")164 or dep.startswith("openenv_core")165 or dep.startswith("openenv")166 ):167 # Skip - we'll use local core168 continue169 new_deps.append(dep)170 171 # Write back with local core reference172 pyproject["project"]["dependencies"] = new_deps + [173 "openenv-core @ file:///app/env/openenv"174 ]175 176 # Write updated pyproject.toml177 with open(pyproject_path, "wb") as out_f:178 import tomli_w179 180 tomli_w.dump(pyproject, out_f)181 182 console.print(183 "[cyan]Updated pyproject.toml to use local core[/cyan]"184 )185 186 # Remove old lockfile since dependencies changed187 lockfile = build_dir / "uv.lock"188 if lockfile.exists():189 lockfile.unlink()190 console.print("[cyan]Removed outdated uv.lock[/cyan]")191 192 except ImportError:193 console.print(194 "[yellow]Warning:[/yellow] tomli/tomli_w not available, using pyproject.toml as-is",195 )196 else:197 console.print(198 "[yellow]Warning:[/yellow] OpenEnv package not found, building without it"199 )200 201 console.print(f"[cyan]Build directory prepared:[/cyan] {build_dir}")202 return build_dir203 204 205def _run_command(206 cmd: list[str],207 cwd: Path | None = None,208 check: bool = True,209) -> subprocess.CompletedProcess:210 """Run a shell command and handle errors."""211 console.print(f"[bold cyan]Running:[/bold cyan] {' '.join(cmd)}")212 try:213 result = subprocess.run(214 cmd, cwd=cwd, check=check, capture_output=True, text=True215 )216 if result.stdout:217 console.print(result.stdout)218 if result.stderr:219 print(result.stderr, file=sys.stderr)220 return result221 except subprocess.CalledProcessError as e:222 print(f"Error running command: {e}", file=sys.stderr)223 if e.stdout:224 console.print(e.stdout)225 if e.stderr:226 print(e.stderr, file=sys.stderr)227 if check:228 raise typer.Exit(1) from e229 return e230 231 232def _build_docker_image(233 env_path: Path,234 tag: str | None = None,235 context_path: Path | None = None,236 dockerfile: Path | None = None,237 build_args: dict[str, str] | None = None,238 no_cache: bool = False,239) -> bool:240 """Build Docker image for the environment with smart context detection."""241 242 # Detect build context (standalone vs in-repo)243 build_mode, detected_context, repo_root = _detect_build_context(env_path)244 245 console.print(f"[bold cyan]Build mode detected:[/bold cyan] {build_mode}")246 247 # Use detected context unless explicitly overridden248 if context_path is None:249 context_path = detected_context250 251 # Create temporary build directory252 with tempfile.TemporaryDirectory() as temp_dir_str:253 temp_dir = Path(temp_dir_str)254 255 # Prepare build directory based on mode256 if build_mode == "standalone":257 build_dir = _prepare_standalone_build(env_path, temp_dir)258 else: # in-repo259 build_dir = _prepare_inrepo_build(env_path, repo_root, temp_dir)260 261 # Determine Dockerfile path262 if dockerfile is None:263 # Look for Dockerfile in server/ subdirectory264 dockerfile = build_dir / "server" / "Dockerfile"265 if not dockerfile.exists():266 # Fallback to root of build directory267 dockerfile = build_dir / "Dockerfile"268 269 if not dockerfile.exists():270 console.print(271 f"[bold red]Error:[/bold red] Dockerfile not found at {dockerfile}",272 )273 return False274 275 # Generate tag if not provided276 if tag is None:277 env_name = env_path.name278 if env_name.endswith("_env"):279 env_name = env_name[:-4]280 tag = f"openenv-{env_name}"281 282 console.print(f"[bold cyan]Building Docker image:[/bold cyan] {tag}")283 console.print(f"[bold cyan]Build context:[/bold cyan] {build_dir}")284 console.print(f"[bold cyan]Dockerfile:[/bold cyan] {dockerfile}")285 286 # Prepare build args287 if build_args is None:288 build_args = {}289 290 # Add build mode and env name to build args291 build_args["BUILD_MODE"] = build_mode292 build_args["ENV_NAME"] = env_path.name.replace("_env", "")293 294 # Build Docker command295 cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile)]296 297 if no_cache:298 cmd.append("--no-cache")299 300 for key, value in build_args.items():301 cmd.extend(["--build-arg", f"{key}={value}"])302 303 cmd.append(str(build_dir))304 305 result = _run_command(cmd, check=False)306 return result.returncode == 0307 308 309def _push_docker_image(tag: str, registry: str | None = None) -> bool:310 """Push Docker image to registry."""311 if registry:312 full_tag = f"{registry}/{tag}"313 console.print(f"[bold cyan]Tagging image as {full_tag}[/bold cyan]")314 _run_command(["docker", "tag", tag, full_tag])315 tag = full_tag316 317 console.print(f"[bold cyan]Pushing image:[/bold cyan] {tag}")318 result = _run_command(["docker", "push", tag], check=False)319 return result.returncode == 0320 321 322@app.command()323def build(324 env_path: Annotated[325 str | None,326 typer.Argument(327 help="Path to the environment directory (default: current directory)"328 ),329 ] = None,330 tag: Annotated[331 str | None,332 typer.Option(333 "--tag",334 "-t",335 help="Docker image tag (default: openenv-<env_name>)",336 ),337 ] = None,338 context: Annotated[339 str | None,340 typer.Option(341 "--context",342 "-c",343 help="Build context path (default: <env_path>/server)",344 ),345 ] = None,346 dockerfile: Annotated[347 str | None,348 typer.Option(349 "--dockerfile",350 "-f",351 help="Path to Dockerfile (default: <context>/Dockerfile)",352 ),353 ] = None,354 no_cache: Annotated[355 bool,356 typer.Option(357 "--no-cache",358 help="Build without using cache",359 ),360 ] = False,361 build_arg: Annotated[362 list[str] | None,363 typer.Option(364 "--build-arg",365 help="Build arguments (can be used multiple times, format: KEY=VALUE)",366 ),367 ] = None,368) -> None:369 """370 Build Docker images for OpenEnv environments.371 372 This command builds Docker images using the environment's pyproject.toml373 and uv for dependency management. Run from the environment root directory.374 375 Examples:376 # Build from environment root (recommended)377 $ cd my_env378 $ openenv build379 380 # Build with custom tag381 $ openenv build -t my-custom-tag382 383 # Build without cache384 $ openenv build --no-cache385 386 # Build with custom build arguments387 $ openenv build --build-arg VERSION=1.0 --build-arg ENV=prod388 389 # Build from different directory390 $ openenv build envs/echo_env391 """392 # Determine environment path (default to current directory)393 if env_path is None:394 env_path_obj = Path.cwd()395 else:396 env_path_obj = Path(env_path)397 398 # Validate environment path399 if not env_path_obj.exists():400 print(401 f"Error: Environment path does not exist: {env_path_obj}",402 file=sys.stderr,403 )404 raise typer.Exit(1)405 406 if not env_path_obj.is_dir():407 print(408 f"Error: Environment path is not a directory: {env_path_obj}",409 file=sys.stderr,410 )411 raise typer.Exit(1)412 413 # Check for openenv.yaml to confirm this is an environment directory414 openenv_yaml = env_path_obj / "openenv.yaml"415 if not openenv_yaml.exists():416 print(417 f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}",418 file=sys.stderr,419 )420 print(421 "Hint: Run this command from the environment root directory or specify the path",422 file=sys.stderr,423 )424 raise typer.Exit(1)425 426 console.print(f"[bold]Building Docker image for:[/bold] {env_path_obj.name}")427 console.print("=" * 60)428 429 # Parse build args430 build_args = {}431 if build_arg:432 for arg in build_arg:433 if "=" in arg:434 key, value = arg.split("=", 1)435 build_args[key] = value436 else:437 print(438 f"Warning: Invalid build arg format: {arg}",439 file=sys.stderr,440 )441 442 # Convert string paths to Path objects443 context_path_obj = Path(context) if context else None444 dockerfile_path_obj = Path(dockerfile) if dockerfile else None445 446 # Build Docker image447 success = _build_docker_image(448 env_path=env_path_obj,449 tag=tag,450 context_path=context_path_obj,451 dockerfile=dockerfile_path_obj,452 build_args=build_args if build_args else None,453 no_cache=no_cache,454 )455 456 if not success:457 print("✗ Docker build failed", file=sys.stderr)458 raise typer.Exit(1)459 460 console.print("[bold green]✓ Docker build successful[/bold green]")461 console.print("\n[bold green]Done![/bold green]")462 