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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
]

Expand Down
113 changes: 113 additions & 0 deletions stackbox/core/builder.py
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions stackbox/templates/ironic/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
15 changes: 15 additions & 0 deletions stackbox/templates/ironic/healthcheck.sh
Original file line number Diff line number Diff line change
@@ -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
142 changes: 142 additions & 0 deletions tests/unit/core/test_builder.py
Original file line number Diff line number Diff line change
@@ -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
Loading