From 0610ca1abbffb9c4ee6934bd486c62b6e18aa14b Mon Sep 17 00:00:00 2001 From: Abhishek Bongale Date: Wed, 20 May 2026 10:33:32 +0100 Subject: [PATCH] Create Ironic Dockerfile - Created multi-stage Dockerfile for Ironic - Optimized for Docker layer caching - Builded from local source directory - Included all Ironic dependencies - Supported both API and Conductor services Closes: #4 Signed-off-by: Abhishek Bongale --- pyproject.toml | 2 + stackbox/core/builder.py | 113 ++++++++++++++++++ stackbox/templates/ironic/Dockerfile | 78 +++++++++++++ stackbox/templates/ironic/healthcheck.sh | 15 +++ tests/unit/core/test_builder.py | 142 +++++++++++++++++++++++ 5 files changed, 350 insertions(+) create mode 100644 stackbox/core/builder.py create mode 100644 stackbox/templates/ironic/Dockerfile create mode 100755 stackbox/templates/ironic/healthcheck.sh create mode 100644 tests/unit/core/test_builder.py diff --git a/pyproject.toml b/pyproject.toml index b8e098d..27d98c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,8 @@ stackbox = [ "templates/**/*.yml", "templates/**/*.yaml", "templates/**/*.conf", + "templates/ironic/Dockerfile", + "templates/ironic/healthcheck.sh", "py.typed", # PEP 561 - declare package is typed ] diff --git a/stackbox/core/builder.py b/stackbox/core/builder.py new file mode 100644 index 0000000..7c962aa --- /dev/null +++ b/stackbox/core/builder.py @@ -0,0 +1,113 @@ +"""Docker image builder for StackBox. + +Builds optimized Ironic Docker images from local source code with +aggressive layer caching for fast iteration cycles. +""" + +import os +from pathlib import Path +import subprocess +import time + + +def build_ironic_image( + ironic_source_path: Path, + tag: str = "stackbox-ironic:latest", + no_cache: bool = False, + timeout: int = 600, +) -> float: + """Build Ironic Docker image from local source. + + Args: + ironic_source_path: Path to local Ironic repository + tag: Docker image tag (default: stackbox-ironic:latest) + no_cache: Force rebuild without using cache + 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 + """ + # Validate source path exists + if not ironic_source_path.exists(): + raise RuntimeError(f"Ironic source path not found: {ironic_source_path}") + + # 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}") + + # Build docker command + cmd = [ + "docker", + "build", + "-f", + str(dockerfile_path), + "-t", + tag, + str(ironic_source_path), + ] + + if no_cache: + cmd.append("--no-cache") + + # Enable BuildKit for better caching and parallel builds + env = os.environ.copy() + env["DOCKER_BUILDKIT"] = "1" + + # Measure build time + start_time = time.time() + + try: + subprocess.run( + cmd, + env=env, + capture_output=True, + text=True, + check=True, + 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 + + return elapsed + + +def validate_image(tag: str = "stackbox-ironic:latest") -> bool: + """Validate that Docker image was built successfully. + + Args: + tag: Docker image tag to validate + + Returns: + True if image exists and is functional + """ + # Check image exists + try: + subprocess.run( + ["docker", "image", "inspect", tag], + capture_output=True, + check=True, + timeout=10, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + return False + + # Test that image can run and execute ironic-api + try: + result = subprocess.run( + ["docker", "run", "--rm", tag, "ironic-api", "--version"], + capture_output=True, + timeout=30, + ) + return result.returncode == 0 + except subprocess.TimeoutExpired: + return False diff --git a/stackbox/templates/ironic/Dockerfile b/stackbox/templates/ironic/Dockerfile new file mode 100644 index 0000000..bc368a9 --- /dev/null +++ b/stackbox/templates/ironic/Dockerfile @@ -0,0 +1,78 @@ +# Multi-stage build for Ironic +# Optimized for fast rebuilds with Docker layer caching + +# Stage 1: Base OS and system dependencies + +FROM ubuntu:22.04 AS base + +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 + +# Install system packages + +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + python3-dev \ + git \ + gcc \ + make \ + libvirt-daemon-system \ + libvirt-dev \ + qemu-kvm \ + ipmitool \ + iproute2 \ + iptables \ + dnsmasq \ + ipxe \ + tftpd-hpa \ + xinetd \ + parted \ + psmisc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Stage 2: Python dependencies (this layer rarely changes) +FROM base AS dependencies + +# Copy just requirements files first (for caching) +COPY requirements.txt /tmp/ironic-requirements.txt + +# Install Python dependencies +RUN pip3 install --no-cache-dir \ + -r /tmp/ironic-requirements.txt + +# Install common dependencies +RUN pip3 install --no-cache-dir \ + pymysql \ + python-openstackclient + +# Stage 3: Ironic code (this layer changes frequently) +FROM dependencies AS ironic + +# Copy Ironic source code +COPY . /tmp/ironic-source + +# Install Ironic +RUN pip3 install --no-cache-dir /tmp/ironic-source && \ + rm -rf /tmp/ironic-source + +# Create directories +RUN mkdir -p /var/lib/ironic \ + /var/log/ironic \ + /etc/ironic \ + /var/lib/ironic/httpboot \ + /var/lib/ironic/tftpboot + +# Health check script +COPY healthcheck.sh /usr/local/bin/healthcheck.sh +RUN chmod +x /usr/local/bin/healthcheck.sh + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD /usr/local/bin/healthcheck.sh + +# Expose Ironic API port +EXPOSE 6385 + +# Default command (can be overridden) +CMD ["ironic-api"] diff --git a/stackbox/templates/ironic/healthcheck.sh b/stackbox/templates/ironic/healthcheck.sh new file mode 100755 index 0000000..5645ffd --- /dev/null +++ b/stackbox/templates/ironic/healthcheck.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Health check for Ironic containers + +# Determine which service is running +if pgrep -f "ironic-api" > /dev/null; then + # Check API endpoint + curl -sf http://localhost:6385/ > /dev/null + exit $? +elif pgrep -f "ironic-conductor" > /dev/null; then + # Conductor is healthy if process is running + exit 0 +else + # No Ironic process found + exit 1 +fi diff --git a/tests/unit/core/test_builder.py b/tests/unit/core/test_builder.py new file mode 100644 index 0000000..feb05e2 --- /dev/null +++ b/tests/unit/core/test_builder.py @@ -0,0 +1,142 @@ +"""Tests for stackbox.core.builder module.""" + +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from stackbox.core.builder import build_ironic_image, validate_image + + +class TestBuildIronicImage: + """Tests for build_ironic_image function.""" + + @patch("subprocess.run") + @patch("time.time") + def test_build_calls_docker_correctly( + self, mock_time: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that docker build command is called with correct arguments.""" + # Setup mocks + mock_time.side_effect = [100.0, 105.0] # 5 second build + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + # Create temporary source directory + source_path = tmp_path / "ironic" + source_path.mkdir() + (source_path / "requirements.txt").write_text("eventlet\n") + + # Call function + build_time = build_ironic_image(source_path) + + # Verify docker build was called + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "docker" in call_args + assert "build" in call_args + assert "-t" in call_args + assert "stackbox-ironic:latest" in call_args + + # Verify BuildKit env was set + call_kwargs = mock_run.call_args[1] + assert call_kwargs["env"]["DOCKER_BUILDKIT"] == "1" + + # Verify build time measured + assert build_time == 5.0 + + @patch("subprocess.run") + def test_build_with_no_cache_flag(self, 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() + + build_ironic_image(source_path, no_cache=True) + + call_args = mock_run.call_args[0][0] + assert "--no-cache" in call_args + + @patch("subprocess.run") + def test_build_with_custom_tag(self, 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() + custom_tag = "my-registry/ironic:v1.2.3" + + build_ironic_image(source_path, tag=custom_tag) + + call_args = mock_run.call_args[0][0] + assert custom_tag in call_args + + def test_build_raises_if_source_missing(self, tmp_path: Path) -> None: + """Test that error is raised if source directory doesn't exist.""" + missing_source = tmp_path / "nonexistent" + + with pytest.raises(RuntimeError, match="source path not found"): + build_ironic_image(missing_source) + + @patch("subprocess.run") + def test_build_failure_error_handling(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that subprocess errors raise RuntimeError with stderr.""" + source_path = tmp_path / "ironic" + source_path.mkdir() + + error = subprocess.CalledProcessError(1, "docker build", stderr="error: invalid base image") + mock_run.side_effect = error + + with pytest.raises(RuntimeError, match="Docker build failed"): + build_ironic_image(source_path) + + @patch("subprocess.run") + def test_build_timeout_handling(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that subprocess timeout errors are handled.""" + source_path = tmp_path / "ironic" + source_path.mkdir() + + mock_run.side_effect = subprocess.TimeoutExpired("docker", 300) + + with pytest.raises(RuntimeError, match="timeout"): + build_ironic_image(source_path, timeout=300) + + +class TestValidateImage: + """Tests for validate_image validation function.""" + + @patch("subprocess.run") + def test_image_exists_check(self, 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 = [ + MagicMock(returncode=0), # inspect succeeds + MagicMock(returncode=0), # run succeeds + ] + + result = validate_image("test-tag:latest") + + assert result is True + assert mock_run.call_count == 2 + + @patch("subprocess.run") + def test_image_does_not_exist(self, mock_run: MagicMock) -> None: + """Test returns False when image doesn't exist.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "docker") + + result = validate_image("nonexistent:latest") + + assert result is False + + @patch("subprocess.run") + def test_image_exists_but_not_executable(self, mock_run: MagicMock) -> None: + """Test returns False when image exists but can't run.""" + mock_run.side_effect = [ + MagicMock(returncode=0), # inspect succeeds + MagicMock(returncode=1), # run fails + ] + + result = validate_image("broken-image:latest") + + assert result is False