Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions stackbox/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""StackBox CLI entry point - class-based architecture."""

from pathlib import Path
import sys

import click

from stackbox import __version__
from stackbox.core import builder


class StackBoxCLI:
Expand Down Expand Up @@ -68,16 +72,30 @@ def _create_init_command(self) -> click.Command:
type=click.Path(),
help="Directory to store StackBox configuration",
)
def init_cmd(job_name: str, ironic_repo: str, config_dir: str) -> None:
@click.option(
"--no-cache",
is_flag=True,
help="Build images without using cache",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Show verbose build output",
)
def init_cmd(
job_name: str, ironic_repo: str, config_dir: str, no_cache: bool, verbose: bool
) -> None:
"""Initialize environment for a CI job.

Sets up infrastructure containers, builds Ironic, creates test nodes,
and configures Tempest.

Example:
sb init ironic-basic --ironic-repo ~/code/ironic
sb init ironic-basic --ironic-repo ~/code/ironic --verbose
"""
self.init(job_name, ironic_repo, config_dir)
self.init(job_name, ironic_repo, config_dir, no_cache, verbose)

return init_cmd

Expand Down Expand Up @@ -183,12 +201,40 @@ def down_cmd(volumes: bool) -> None:
# Command Implementation Methods
# ========================================================================

def init(self, job_name: str, ironic_repo: str, config_dir: str) -> None:
def init(
self, job_name: str, ironic_repo: str, config_dir: str, no_cache: bool, verbose: bool
) -> None:
"""Initialize environment for a CI job."""
click.echo(f"Initializing {job_name}...")
click.echo(f" Ironic repo: {ironic_repo}")
click.echo(f" Config dir: {config_dir}")
click.echo("\n⚠️ Not implemented yet (Issue #9)")
click.echo(f"🚀 Initializing {job_name}\n")

# Phase 1: Build Ironic image
click.echo("📦 Building Ironic image...")
try:
builder.build_ironic_image(
Path(ironic_repo).expanduser(),
tag="stackbox-ironic:latest",
no_cache=no_cache,
verbose=verbose,
)

# Validate image
if not builder.validate_image("stackbox-ironic:latest"):
raise RuntimeError("Image validation failed")

# Show image info
size = builder.get_image_size("stackbox-ironic:latest")
if size:
click.echo(f"Image size: {size}")

except Exception as e:
click.echo(f"\n❌ Failed to build Ironic image: {e}", err=True)
sys.exit(1)

# TODO: Phase 2 (Issue #6): Start containers
# TODO: Phase 3 (Issue #7): Run migrations
# TODO: Phase 4 (Issue #11): Configure Tempest

click.echo("\n✅ Initialization complete!")

def rebuild(self, service: str, no_cache: bool) -> None:
"""Rebuild a service after code changes."""
Expand Down
94 changes: 83 additions & 11 deletions stackbox/core/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
import subprocess
import time

import click


def build_ironic_image(
ironic_source_path: Path,
tag: str = "stackbox-ironic:latest",
no_cache: bool = False,
verbose: bool = False,
timeout: int = 600,
) -> float:
"""Build Ironic Docker image from local source.
Expand All @@ -22,25 +25,38 @@ def build_ironic_image(
ironic_source_path: Path to local Ironic repository
tag: Docker image tag (default: stackbox-ironic:latest)
no_cache: Force rebuild without using cache
verbose: Show verbose build output
timeout: Build timeout in seconds (default: 600)

Returns:
Build time in seconds

Raises:
RuntimeError: If source path doesn't exist or build fails
subprocess.TimeoutExpired: If build exceeds timeout
FileNotFoundError: If requirements.txt not found
"""
# Validate source path exists
ironic_source_path = Path(ironic_source_path).expanduser().resolve()
if not ironic_source_path.exists():
raise RuntimeError(f"Ironic source path not found: {ironic_source_path}")

# Validate requirements.txt exists
requirements_file = ironic_source_path / "requirements.txt"
if not requirements_file.exists():
raise FileNotFoundError(
f"requirements.txt not found in {ironic_source_path}\n"
"Is this a valid Ironic repository?"
)

# Find Dockerfile in templates
dockerfile_path = Path(__file__).parent.parent / "templates" / "ironic" / "Dockerfile"

if not dockerfile_path.exists():
raise RuntimeError(f"Dockerfile not found: {dockerfile_path}")

click.echo(f"Building Ironic image from {ironic_source_path}")
click.echo(f"Using Dockerfile: {dockerfile_path}")

# Build docker command
cmd = [
"docker",
Expand All @@ -54,6 +70,13 @@ def build_ironic_image(

if no_cache:
cmd.append("--no-cache")
click.echo("Building without cache...")

# Add progress mode based on verbose flag
if verbose:
cmd.extend(["--progress", "plain"])
else:
cmd.extend(["--progress", "auto"])

# Enable BuildKit for better caching and parallel builds
env = os.environ.copy()
Expand All @@ -63,22 +86,31 @@ def build_ironic_image(
start_time = time.time()

try:
subprocess.run(
result = subprocess.run(
cmd,
env=env,
capture_output=True,
capture_output=not verbose,
text=True,
check=True,
check=False,
timeout=timeout,
)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Docker build failed:\n{e.stderr}") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"Build timeout after {timeout}s") from e

elapsed = time.time() - start_time
elapsed = time.time() - start_time

if result.returncode != 0:
click.echo("\n❌ Build failed!", err=True)
if not verbose and result.stderr:
click.echo(result.stderr, err=True)
raise RuntimeError(f"Docker build failed with exit code {result.returncode}")

return elapsed
click.echo(f"\n✅ Build completed in {elapsed:.1f}s")

return elapsed

except FileNotFoundError as e:
raise RuntimeError("Docker command not found. Is Docker installed and in PATH?") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"Build timeout after {timeout}s") from e


def validate_image(tag: str = "stackbox-ironic:latest") -> bool:
Expand All @@ -90,6 +122,8 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool:
Returns:
True if image exists and is functional
"""
click.echo(f"Validating image {tag}...")

# Check image exists
try:
subprocess.run(
Expand All @@ -99,6 +133,7 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool:
timeout=10,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
click.echo("❌ Image not found", err=True)
return False

# Test that image can run and execute ironic-api
Expand All @@ -107,7 +142,44 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool:
["docker", "run", "--rm", tag, "ironic-api", "--version"],
capture_output=True,
timeout=30,
check=False,
)
return result.returncode == 0

if result.returncode == 0:
click.echo("✅ Image validated successfully")
return True
else:
click.echo("❌ Image validation failed", err=True)
return False
except subprocess.TimeoutExpired:
click.echo("❌ Image validation timed out", err=True)
return False


def get_image_size(tag: str = "stackbox-ironic:latest") -> str | None:
"""Get size of built Docker image.

Args:
tag: Docker image tag

Returns:
Image size as human-readable string (e.g., "1.2GB") or None if not found
"""
result = subprocess.run(
["docker", "image", "inspect", tag, "--format", "{{.Size}}"],
capture_output=True,
text=True,
check=False,
timeout=10,
)

if result.returncode == 0:
# Convert bytes to human-readable
size_bytes = float(result.stdout.strip())
for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024:
return f"{size_bytes:.1f}{unit}"
size_bytes /= 1024
return f"{size_bytes:.1f}TB"

return None
Loading
Loading