diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index b2bb5ef..fcf002d 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -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: @@ -68,7 +72,20 @@ 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, @@ -76,8 +93,9 @@ def init_cmd(job_name: str, ironic_repo: str, config_dir: str) -> None: 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 @@ -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.""" diff --git a/stackbox/core/builder.py b/stackbox/core/builder.py index 7c962aa..8f8c44a 100644 --- a/stackbox/core/builder.py +++ b/stackbox/core/builder.py @@ -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. @@ -22,6 +25,7 @@ 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: @@ -29,18 +33,30 @@ def build_ironic_image( 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", @@ -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() @@ -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: @@ -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( @@ -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 @@ -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 diff --git a/tests/unit/core/test_builder.py b/tests/unit/core/test_builder.py index feb05e2..1a0cdf5 100644 --- a/tests/unit/core/test_builder.py +++ b/tests/unit/core/test_builder.py @@ -6,7 +6,7 @@ import pytest -from stackbox.core.builder import build_ironic_image, validate_image +from stackbox.core.builder import build_ironic_image, get_image_size, validate_image class TestBuildIronicImage: @@ -46,12 +46,16 @@ def test_build_calls_docker_correctly( assert build_time == 5.0 @patch("subprocess.run") - def test_build_with_no_cache_flag(self, mock_run: MagicMock, tmp_path: Path) -> None: + @patch("click.echo") + def test_build_with_no_cache_flag( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: """Test that --no-cache flag is added when requested.""" mock_run.return_value = MagicMock(returncode=0) source_path = tmp_path / "ironic" source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") build_ironic_image(source_path, no_cache=True) @@ -59,12 +63,16 @@ def test_build_with_no_cache_flag(self, mock_run: MagicMock, tmp_path: Path) -> assert "--no-cache" in call_args @patch("subprocess.run") - def test_build_with_custom_tag(self, mock_run: MagicMock, tmp_path: Path) -> None: + @patch("click.echo") + def test_build_with_custom_tag( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: """Test building with custom image tag.""" mock_run.return_value = MagicMock(returncode=0) source_path = tmp_path / "ironic" source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") custom_tag = "my-registry/ironic:v1.2.3" build_ironic_image(source_path, tag=custom_tag) @@ -80,13 +88,16 @@ def test_build_raises_if_source_missing(self, tmp_path: Path) -> None: build_ironic_image(missing_source) @patch("subprocess.run") - def test_build_failure_error_handling(self, mock_run: MagicMock, tmp_path: Path) -> None: + @patch("click.echo") + def test_build_failure_error_handling( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: """Test that subprocess errors raise RuntimeError with stderr.""" source_path = tmp_path / "ironic" source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") - error = subprocess.CalledProcessError(1, "docker build", stderr="error: invalid base image") - mock_run.side_effect = error + mock_run.return_value = MagicMock(returncode=1, stderr="error: invalid base image") with pytest.raises(RuntimeError, match="Docker build failed"): build_ironic_image(source_path) @@ -96,18 +107,70 @@ def test_build_timeout_handling(self, mock_run: MagicMock, tmp_path: Path) -> No """Test that subprocess timeout errors are handled.""" source_path = tmp_path / "ironic" source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") mock_run.side_effect = subprocess.TimeoutExpired("docker", 300) with pytest.raises(RuntimeError, match="timeout"): build_ironic_image(source_path, timeout=300) + @patch("subprocess.run") + @patch("click.echo") + def test_build_with_verbose_flag( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that verbose mode enables plain progress output.""" + mock_run.return_value = MagicMock(returncode=0) + + source_path = tmp_path / "ironic" + source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") + + build_ironic_image(source_path, verbose=True) + + call_args = mock_run.call_args[0][0] + assert "--progress" in call_args + assert "plain" in call_args + + # Verify capture_output is False for verbose + call_kwargs = mock_run.call_args[1] + assert call_kwargs["capture_output"] is False + + def test_build_raises_if_requirements_missing(self, tmp_path: Path) -> None: + """Test that error is raised if requirements.txt doesn't exist.""" + source_path = tmp_path / "ironic" + source_path.mkdir() + # Don't create requirements.txt + + with pytest.raises(FileNotFoundError, match=r"requirements\.txt not found"): + build_ironic_image(source_path) + + @patch("subprocess.run") + @patch("click.echo") + def test_build_shows_progress_messages( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that build shows progress messages to user.""" + mock_run.return_value = MagicMock(returncode=0) + + source_path = tmp_path / "ironic" + source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") + + build_ironic_image(source_path) + + # Check that click.echo was called with progress messages + echo_calls = [str(call) for call in mock_echo.call_args_list] + assert any("Building Ironic image" in call for call in echo_calls) + assert any("Build completed" in call for call in echo_calls) + class TestValidateImage: """Tests for validate_image validation function.""" @patch("subprocess.run") - def test_image_exists_check(self, mock_run: MagicMock) -> None: + @patch("click.echo") + def test_image_exists_check(self, mock_echo: MagicMock, mock_run: MagicMock) -> None: """Test that docker image inspect is called to verify image exists.""" # Mock both inspect and run commands mock_run.side_effect = [ @@ -120,8 +183,14 @@ def test_image_exists_check(self, mock_run: MagicMock) -> None: assert result is True assert mock_run.call_count == 2 + # Check that click.echo was called with success messages + echo_calls = [str(call) for call in mock_echo.call_args_list] + assert any("Validating" in call for call in echo_calls) + assert any("validated successfully" in call for call in echo_calls) + @patch("subprocess.run") - def test_image_does_not_exist(self, mock_run: MagicMock) -> None: + @patch("click.echo") + def test_image_does_not_exist(self, mock_echo: MagicMock, mock_run: MagicMock) -> None: """Test returns False when image doesn't exist.""" mock_run.side_effect = subprocess.CalledProcessError(1, "docker") @@ -129,8 +198,15 @@ def test_image_does_not_exist(self, mock_run: MagicMock) -> None: assert result is False + # Check error message was shown + echo_calls = [str(call) for call in mock_echo.call_args_list] + assert any("not found" in call for call in echo_calls) + @patch("subprocess.run") - def test_image_exists_but_not_executable(self, mock_run: MagicMock) -> None: + @patch("click.echo") + def test_image_exists_but_not_executable( + self, mock_echo: MagicMock, mock_run: MagicMock + ) -> None: """Test returns False when image exists but can't run.""" mock_run.side_effect = [ MagicMock(returncode=0), # inspect succeeds @@ -140,3 +216,49 @@ def test_image_exists_but_not_executable(self, mock_run: MagicMock) -> None: result = validate_image("broken-image:latest") assert result is False + + # Check error message was shown + echo_calls = [str(call) for call in mock_echo.call_args_list] + assert any("validation failed" in call for call in echo_calls) + + +class TestGetImageSize: + """Tests for get_image_size function.""" + + @patch("subprocess.run") + def test_returns_human_readable_size_bytes(self, mock_run: MagicMock) -> None: + """Test that image size is converted to bytes.""" + mock_run.return_value = MagicMock(returncode=0, stdout="512") + + size = get_image_size("test:latest") + + assert size == "512.0B" + + @patch("subprocess.run") + def test_returns_human_readable_size_mb(self, mock_run: MagicMock) -> None: + """Test that image size is converted to MB.""" + # 100 MB in bytes + mock_run.return_value = MagicMock(returncode=0, stdout="104857600") + + size = get_image_size("test:latest") + + assert size == "100.0MB" + + @patch("subprocess.run") + def test_returns_human_readable_size_gb(self, mock_run: MagicMock) -> None: + """Test that image size is converted to GB.""" + # 1.5 GB in bytes + mock_run.return_value = MagicMock(returncode=0, stdout="1610612736") + + size = get_image_size("test:latest") + + assert size == "1.5GB" + + @patch("subprocess.run") + def test_returns_none_if_image_not_found(self, mock_run: MagicMock) -> None: + """Test returns None when image doesn't exist.""" + mock_run.return_value = MagicMock(returncode=1) + + size = get_image_size("nonexistent:latest") + + assert size is None