diff --git a/README.md b/README.md index 3ba7483..3724020 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ sb test --list # List available tests sb rebuild # Rebuild service after code changes sb db-init # Initialize database (keystone, all) sb tempest-init # Regenerate Tempest configuration +sb setup cinder # One-time setup for Cinder volume (requires sudo) +sb setup status # Check prerequisites status sb status # Show environment status sb logs # View service logs sb down # Stop and remove environment @@ -142,10 +144,33 @@ sb db-init keystone - Run `sb db-init keystone` to register Ironic service - Check `podman ps` - ensure `stackbox-keystone` is healthy +## Optional Setup + +### Cinder Volume Service + +Some services require one-time host setup: + +```bash +# Setup Cinder volume (requires sudo, one-time) +sb setup cinder +``` + +**What this does:** +- Creates loop device for LVM testing +- Same pattern as Kolla-Ansible (production OpenStack) +- Only needed if deploying `cinder-volume` + +**Skip it if:** +- You only need Cinder API/scheduler +- Your manifest doesn't include `cinder-volume` + +See [docs/cinder-setup.md](docs/cinder-setup.md) for details. + ## Requirements - Python 3.10+ - Docker or Podman - 8GB RAM, 20GB disk + - Optional: `lvm2` for Cinder volume (install on demand) ## License Licensed under Apache 2.0. See [LICENSE](LICENSE). diff --git a/docs/cinder-setup.md b/docs/cinder-setup.md new file mode 100644 index 0000000..ae8b33c --- /dev/null +++ b/docs/cinder-setup.md @@ -0,0 +1,244 @@ +# Cinder Volume Setup Guide + +StackBox follows the same pattern as **Kolla-Ansible** (production OpenStack) for Cinder volume setup. + +## Quick Start + +```bash +sb setup cinder +``` + +That's it! You'll be prompted for your sudo password once. + +## What This Does + +Creates a loop device-backed LVM volume group for Cinder volume service: + +- Creates 20GB file at `/var/tmp/cinder-volumes-backing-file` +- Attaches it as a loop device (e.g., /dev/loop0) +- Creates LVM volume group `cinder-volumes` + +**This is a ONE-TIME setup per machine.** + +## Why Is This Needed? + +Some OpenStack services require host-level configuration that can't be done from containers: + +- **Loop devices** require kernel-level privileges +- **LVM operations** need access to `/dev` +- **Containers can't modify** host kernel state + +This is **not a StackBox limitation** - even production tools like Kolla-Ansible require the same manual host setup. + +## Commands + +### Check Status + +```bash +# Check all prerequisites +sb setup status + +# Check Cinder only +sb setup cinder --check-only +``` + +### Run Setup + +```bash +# Default (20GB) +sb setup cinder + +# Custom size +sb setup cinder --size 50G +``` + +### Verify + +```bash +# Check volume group +vgs cinder-volumes + +# Check loop device +losetup -l | grep cinder +``` + +## Persistence + +### After Reboot + +Loop devices **don't persist** across reboots by default. + +**Option A: Re-run setup (simple)** + +```bash +sudo sb setup cinder +``` + +The script detects existing setup and recreates if needed. + +**Option B: Systemd service (permanent)** + +```bash +# Create systemd service +sudo tee /etc/systemd/system/cinder-loop.service < + +set -e + +JOB_NAME="$1" +IRONIC_REPO="${2:-$HOME/Workspace/ironic}" +MANIFEST_FILE="/tmp/manifest-${JOB_NAME}.yaml" + +if [ -z "$JOB_NAME" ]; then + echo "❌ Error: Job name required" + echo "Usage: $0 [ironic-repo-path]" + echo "" + echo "Example:" + echo " $0 ironic-tempest-uefi-redfish-https ~/Workspace/ironic" + exit 1 +fi + +if [ ! -d "$IRONIC_REPO" ]; then + echo "❌ Error: Ironic repository not found: $IRONIC_REPO" + exit 1 +fi + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "🔍 Validating Job: $JOB_NAME" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# Step 1: Show job details +echo "📋 Step 1: Job Details" +echo "──────────────────────────────────────────────────────" +python -m stackbox.cli validate job "$IRONIC_REPO" "$JOB_NAME" +echo "" + +read -p "Continue with validation? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "❌ Validation cancelled" + exit 0 +fi + +# Step 2: Parse job +echo "" +echo "🔧 Step 2: Parse Job Configuration" +echo "──────────────────────────────────────────────────────" +echo "Command: sb parse --job-name $JOB_NAME --project-dir $IRONIC_REPO --output $MANIFEST_FILE" +echo "" + +if python -m stackbox.cli parse --job-name "$JOB_NAME" --project-dir "$IRONIC_REPO" --output "$MANIFEST_FILE"; then + echo "✅ Manifest generated: $MANIFEST_FILE" +else + echo "❌ Failed to parse job" + exit 1 +fi + +echo "" +read -p "Continue to deploy infrastructure? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "❌ Validation stopped at parse step" + exit 0 +fi + +# Step 3: Deploy infrastructure +echo "" +echo "🚀 Step 3: Deploy Infrastructure" +echo "──────────────────────────────────────────────────────" +echo "Command: sb deploy-infra --manifest $MANIFEST_FILE" +echo "" + +if python -m stackbox.cli deploy-infra --manifest "$MANIFEST_FILE"; then + echo "✅ Infrastructure deployed successfully" +else + echo "❌ Failed to deploy infrastructure" + exit 1 +fi + +echo "" +read -p "Continue to run tests? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "⚠️ Infrastructure is still running. Clean up manually if needed." + exit 0 +fi + +# Step 4: Run tests +echo "" +echo "🧪 Step 4: Run Tests" +echo "──────────────────────────────────────────────────────" +echo "Command: sb test --manifest $MANIFEST_FILE" +echo "" + +TEST_RESULT=0 +if python -m stackbox.cli test --manifest "$MANIFEST_FILE"; then + echo "✅ Tests completed successfully" + TEST_RESULT=0 +else + echo "⚠️ Tests completed with failures" + TEST_RESULT=1 +fi + +# Summary +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "📊 Validation Summary" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Job: $JOB_NAME" +echo "Manifest: $MANIFEST_FILE" +echo "" + +if [ $TEST_RESULT -eq 0 ]; then + echo "Result: ✅ VERIFIED - Job runs successfully in containers" + echo "" + echo "Next steps:" + echo " 1. Document in docs/validation-results.md" + echo " 2. Add '$JOB_NAME' to VERIFIED_JOBS in stackbox/validation/job_compatibility.py" + echo " 3. Regenerate matrix: sb validate jobs $IRONIC_REPO" +else + echo "Result: ⚠️ NEEDS INVESTIGATION - Job ran but tests failed" + echo "" + echo "Next steps:" + echo " 1. Review test logs to determine if failures are:" + echo " - Container incompatibility → add to KNOWN_LIMITATIONS" + echo " - Flaky tests → still mark as VERIFIED with notes" + echo " - Configuration issue → fix and retest" + echo " 2. Document findings in docs/validation-results.md" +fi + +echo "" +echo "Manifest saved at: $MANIFEST_FILE" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/stackbox/builders/services/cinder.py b/stackbox/builders/services/cinder.py index 915d699..e33468b 100644 --- a/stackbox/builders/services/cinder.py +++ b/stackbox/builders/services/cinder.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path, @@ -62,8 +64,9 @@ def build_image( api_paste_dst.write_text(api_paste_src.read_text()) # Build command + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -76,7 +79,7 @@ def build_image( cmd.append("--no-cache") # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building Cinder image: {tag}") diff --git a/stackbox/builders/services/glance.py b/stackbox/builders/services/glance.py index 313493e..ba1890e 100644 --- a/stackbox/builders/services/glance.py +++ b/stackbox/builders/services/glance.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path, @@ -61,9 +63,12 @@ def build_image( api_paste_dst = source_path / "glance-api-paste.ini" api_paste_dst.write_text(api_paste_src.read_text()) + # Detect container runtime (docker or podman) + runtime_cmd, _ = get_container_runtime() + # Build command cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -75,8 +80,8 @@ def build_image( if no_cache: cmd.append("--no-cache") - # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + # Use BuildKit for better caching (Docker only) + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building Glance image: {tag}") diff --git a/stackbox/builders/services/ironic.py b/stackbox/builders/services/ironic.py index 20533f2..417de6d 100644 --- a/stackbox/builders/services/ironic.py +++ b/stackbox/builders/services/ironic.py @@ -13,6 +13,7 @@ import click from stackbox.config.service_constants import get_constants +from stackbox.container.runtime import get_container_runtime C = get_constants() @@ -75,8 +76,9 @@ def build_image( click.echo(f"Using Dockerfile: {dockerfile_path}") # Build docker command + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-f", str(dockerfile_path), @@ -147,10 +149,12 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool: """ click.echo(f"Validating image {tag}...") + runtime_cmd, _ = get_container_runtime() + # Check image exists try: subprocess.run( - ["docker", "image", "inspect", tag], + [runtime_cmd, "image", "inspect", tag], capture_output=True, check=True, timeout=C.TIMEOUT_SHORT, @@ -162,7 +166,7 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool: # Test that image can run and execute ironic-api try: result = subprocess.run( - ["docker", "run", "--rm", tag, "ironic-api", "--version"], + [runtime_cmd, "run", "--rm", tag, "ironic-api", "--version"], capture_output=True, timeout=C.TIMEOUT_MEDIUM, check=False, @@ -188,8 +192,9 @@ def get_image_size(tag: str = "stackbox-ironic:latest") -> str | None: Returns: Image size as human-readable string (e.g., "1.2GB") or None if not found """ + runtime_cmd, _ = get_container_runtime() result = subprocess.run( - ["docker", "image", "inspect", tag, "--format", "{{.Size}}"], + [runtime_cmd, "image", "inspect", tag, "--format", "{{.Size}}"], capture_output=True, text=True, check=False, diff --git a/stackbox/builders/services/keystone.py b/stackbox/builders/services/keystone.py index 3bde65a..452ddd0 100644 --- a/stackbox/builders/services/keystone.py +++ b/stackbox/builders/services/keystone.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path | None = None, @@ -44,8 +46,9 @@ def build_image( raise FileNotFoundError(f"Dockerfile not found: {dockerfile}") # Build command - use template dir as context + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -58,7 +61,7 @@ def build_image( cmd.append("--no-cache") # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building Keystone image: {tag}") diff --git a/stackbox/builders/services/libvirt.py b/stackbox/builders/services/libvirt.py index dc38cf1..35baf5a 100644 --- a/stackbox/builders/services/libvirt.py +++ b/stackbox/builders/services/libvirt.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path | None = None, @@ -44,8 +46,9 @@ def build_image( raise FileNotFoundError(f"Dockerfile not found: {dockerfile}") # Build command - use template dir as context + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -58,7 +61,7 @@ def build_image( cmd.append("--no-cache") # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building libvirt image: {tag}") diff --git a/stackbox/builders/services/neutron.py b/stackbox/builders/services/neutron.py index 24f02d4..191a619 100644 --- a/stackbox/builders/services/neutron.py +++ b/stackbox/builders/services/neutron.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path, @@ -72,8 +74,9 @@ def build_image( api_paste_dst.write_text(api_paste_src.read_text()) # Build command + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -86,7 +89,7 @@ def build_image( cmd.append("--no-cache") # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building Neutron image: {tag}") diff --git a/stackbox/builders/services/nova.py b/stackbox/builders/services/nova.py index a8e171f..5d30c03 100644 --- a/stackbox/builders/services/nova.py +++ b/stackbox/builders/services/nova.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path, @@ -75,8 +77,9 @@ def build_image( placement_wrapper_dst.write_text(placement_wrapper_src.read_text()) # Build command + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -89,7 +92,7 @@ def build_image( cmd.append("--no-cache") # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building Nova image: {tag}") diff --git a/stackbox/builders/services/placement.py b/stackbox/builders/services/placement.py index 29e8894..d1c6fde 100644 --- a/stackbox/builders/services/placement.py +++ b/stackbox/builders/services/placement.py @@ -9,6 +9,8 @@ import click +from stackbox.container.runtime import get_container_runtime + def build_image( source_path: Path, @@ -63,8 +65,9 @@ def build_image( wsgi_wrapper_dst.write_text(wsgi_wrapper_src.read_text()) # Build command + runtime_cmd, _ = get_container_runtime() cmd = [ - "docker", + runtime_cmd, "build", "-t", tag, @@ -77,7 +80,7 @@ def build_image( cmd.append("--no-cache") # Use BuildKit for better caching - env = {"DOCKER_BUILDKIT": "1"} + env = {"DOCKER_BUILDKIT": "1"} if runtime_cmd == "docker" else {} if verbose: click.echo(f"Building Placement image: {tag}") diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index d5f62dd..4d1816b 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -73,6 +73,18 @@ def _register_commands(self) -> None: # Register Zuul-based commands (Phase 2.1 - GitOps style) zuul_commands.register_zuul_commands(self.cli) + # Register setup commands (Phase 2.7 - Prerequisites) + from stackbox.cli.setup_commands import setup_group + + self.cli.add_command(setup_group) + + # Register job validation commands (Phase 2.8 - Job Validation) + from stackbox.cli.commands.jobs import list_jobs + from stackbox.cli.commands.validate import validate + + self.cli.add_command(list_jobs) + self.cli.add_command(validate) + def _create_init_command(self) -> click.Command: """Create the init command.""" @@ -956,9 +968,11 @@ def _bootstrap_keystone(self, runtime: str, config_dir: Path) -> None: copy_config_cmd = [ runtime, "exec", - "-u", "root", + "-u", + "root", C.CONTAINER_KEYSTONE, - "sh", "-c", + "sh", + "-c", "cat > /tmp/keystone.conf << 'EOF'\n" "[DEFAULT]\n" "log_dir = /var/log/keystone\n\n" @@ -966,7 +980,7 @@ def _bootstrap_keystone(self, runtime: str, config_dir: Path) -> None: f"connection = mysql+pymysql://{C.DB_USER_KEYSTONE}:{C.DEFAULT_DB_PASSWORD}@{C.DB_HOST}/{C.DB_NAME_KEYSTONE}\n\n" "[token]\n" "provider = fernet\n" - "EOF" + "EOF", ] try: subprocess.run(copy_config_cmd, check=True, capture_output=True) @@ -979,10 +993,12 @@ def _bootstrap_keystone(self, runtime: str, config_dir: Path) -> None: db_sync_cmd = [ runtime, "exec", - "-u", "root", + "-u", + "root", C.CONTAINER_KEYSTONE, "keystone-manage", - "--config-file", "/tmp/keystone.conf", + "--config-file", + "/tmp/keystone.conf", "db_sync", ] try: @@ -1001,10 +1017,12 @@ def _bootstrap_keystone(self, runtime: str, config_dir: Path) -> None: bootstrap_cmd = [ runtime, "exec", - "-u", "root", + "-u", + "root", C.CONTAINER_KEYSTONE, "keystone-manage", - "--config-file", "/tmp/keystone.conf", + "--config-file", + "/tmp/keystone.conf", "bootstrap", "--bootstrap-password", C.DEFAULT_ADMIN_PASSWORD, diff --git a/stackbox/cli/commands/__init__.py b/stackbox/cli/commands/__init__.py new file mode 100644 index 0000000..8164051 --- /dev/null +++ b/stackbox/cli/commands/__init__.py @@ -0,0 +1 @@ +"""StackBox CLI commands.""" diff --git a/stackbox/cli/commands/jobs.py b/stackbox/cli/commands/jobs.py new file mode 100644 index 0000000..ba3a133 --- /dev/null +++ b/stackbox/cli/commands/jobs.py @@ -0,0 +1,218 @@ +"""CLI commands for job management and validation.""" + +import json +from pathlib import Path + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from stackbox.validation.job_compatibility import ( + CompatibilityStatus, + JobCompatibilityValidator, +) +from stackbox.zuul.parser import ZuulJobParser + +console = Console() + + +@click.command("list-jobs") +@click.argument("ironic_repo", type=click.Path(exists=True)) +@click.option( + "--filter", + "filter_status", + type=click.Choice(["all", "verified", "should-work", "untested", "known-limitation"]), + default="all", + help="Filter jobs by compatibility status", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "list", "json"]), + default="table", + help="Output format", +) +def list_jobs(ironic_repo, filter_status, output_format): + """List Ironic CI jobs with container compatibility status. + + Discovers all jobs from IRONIC_REPO/zuul.d/*.yaml or IRONIC_REPO/.zuul.yaml + and validates which jobs are compatible with the containerized environment. + + \b + Examples: + sb list-jobs ~/code/ironic + sb list-jobs ~/code/ironic --filter verified + sb list-jobs ~/code/ironic --format json + """ + ironic_path = Path(ironic_repo) + + # Try to find zuul config file + zuul_file = None + if (ironic_path / "zuul.d" / "ironic-jobs.yaml").exists(): + zuul_file = ironic_path / "zuul.d" / "ironic-jobs.yaml" + elif (ironic_path / ".zuul.yaml").exists(): + zuul_file = ironic_path / ".zuul.yaml" + else: + console.print(f"❌ No zuul configuration found in {ironic_path}", style="red") + console.print(" Expected: zuul.d/ironic-jobs.yaml or .zuul.yaml", style="yellow") + return 1 + + console.print( + f"🔍 Discovering jobs in {zuul_file.parent.name}/{zuul_file.name}...", style="blue" + ) + + # Parse jobs + try: + parser = ZuulJobParser(zuul_file) + job_names = parser.list_jobs() + except Exception as e: + console.print(f"❌ Failed to parse zuul config: {e}", style="red") + return 1 + + console.print(f"📋 Extracting requirements for {len(job_names)} jobs...") + + # Extract requirements for all jobs + job_requirements = [] + failed_count = 0 + for job_name in job_names: + try: + reqs = parser.extract_job_requirements(job_name) + job_requirements.append(reqs) + except Exception as e: + failed_count += 1 + if output_format == "table": + console.print(f" ⚠️ Failed to parse {job_name}: {e}", style="yellow") + + if failed_count > 0: + console.print(f" ⚠️ {failed_count} jobs failed to parse (shown above)", style="yellow") + + # Validate compatibility + console.print("✓ Validating container compatibility...\n") + validator = JobCompatibilityValidator() + all_results = validator.validate_all_jobs(job_requirements) + + # Filter results + if filter_status != "all": + status_map = { + "verified": CompatibilityStatus.VERIFIED, + "should-work": CompatibilityStatus.SHOULD_WORK, + "untested": CompatibilityStatus.UNTESTED, + "known-limitation": CompatibilityStatus.KNOWN_LIMITATION, + } + target_status = status_map[filter_status] + filtered_results = {k: v for k, v in all_results.items() if v.status == target_status} + else: + filtered_results = all_results + + # Get stats from all results (not filtered) + stats = validator.get_stats(all_results) + + # Output results + if output_format == "json": + _output_json(filtered_results, stats) + elif output_format == "list": + _output_list(filtered_results) + else: # table + _output_table(filtered_results, stats, filter_status) + + return 0 + + +def _output_table(results, stats, filter_status): + """Output results as Rich table.""" + # Determine title based on filter + if filter_status == "all": + title = f"Ironic CI Jobs ({stats['total']} discovered)" + else: + title = f"Ironic CI Jobs - {filter_status.replace('-', ' ').title()} ({len(results)} jobs)" + + table = Table(title=title) + + table.add_column("Job Name", style="cyan", no_wrap=False, max_width=50) + table.add_column("Driver", style="magenta", justify="center", width=8) + table.add_column("Boot", style="yellow", justify="center", width=6) + table.add_column("Deploy", style="blue", justify="center", width=8) + table.add_column("Image", style="green", justify="center", width=10) + table.add_column("Status", style="bold", justify="center", width=16) + + # Status icon mapping + icons = { + CompatibilityStatus.VERIFIED: "✅", + CompatibilityStatus.SHOULD_WORK: "⚠️", + CompatibilityStatus.UNTESTED: "❓", + CompatibilityStatus.KNOWN_LIMITATION: "❌", + } + + # Sort by status (verified first), then by name + sorted_results = sorted(results.items(), key=lambda x: (x[1].status.value, x[0])) + + for job_name, result in sorted_results: + reqs = result.requirements + icon = icons.get(result.status, "❓") + + # Truncate long job names + display_name = job_name + if len(job_name) > 48: + display_name = job_name[:45] + "..." + + table.add_row( + display_name, + reqs.driver or "-", + reqs.boot_mode or "-", + reqs.deploy_interface or "-", + reqs.image_type or "-", + f"{icon} {result.status.value.upper()}", + ) + + console.print(table) + + # Print summary (only if showing all) + if filter_status == "all": + summary = ( + f"[bold green]✅ {stats['verified']} verified[/bold green] | " + f"[bold yellow]⚠️ {stats['should_work']} should work[/bold yellow] | " + f"[bold blue]❓ {stats['untested']} untested[/bold blue] | " + f"[bold red]❌ {stats['known_limitation']} limitations[/bold red]" + ) + console.print(Panel(summary, border_style="blue")) + + +def _output_list(results): + """Output results as simple list.""" + for job_name in sorted(results.keys()): + console.print(job_name) + + +def _output_json(results, stats): + """Output results as JSON.""" + # Calculate by_status from stats + by_status = { + "verified": stats["verified"], + "should_work": stats["should_work"], + "untested": stats["untested"], + "known_limitation": stats["known_limitation"], + } + + output = { + "jobs": [ + { + "name": result.job_name, + "status": result.status.value, + "requirements": { + "driver": result.requirements.driver, + "boot_mode": result.requirements.boot_mode, + "deploy_interface": result.requirements.deploy_interface, + "image_type": result.requirements.image_type, + "ramdisk_type": result.requirements.ramdisk_type, + "node_count": result.requirements.node_count, + "special_features": result.requirements.special_features, + }, + "notes": result.notes, + } + for result in sorted(results.values(), key=lambda x: x.job_name) + ], + "summary": {"total": stats["total"], "by_status": by_status}, + } + + console.print_json(json.dumps(output, indent=2)) diff --git a/stackbox/cli/commands/validate.py b/stackbox/cli/commands/validate.py new file mode 100644 index 0000000..0175a83 --- /dev/null +++ b/stackbox/cli/commands/validate.py @@ -0,0 +1,283 @@ +"""CLI commands for job validation.""" + +from datetime import datetime +from pathlib import Path + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from stackbox.validation.job_compatibility import ( + CompatibilityStatus, + JobCompatibilityValidator, +) +from stackbox.zuul.parser import ZuulJobParser + +console = Console() + + +@click.group() +def validate(): + """Validate Ironic job compatibility with containers.""" + pass + + +@validate.command("job") +@click.argument("ironic_repo", type=click.Path(exists=True)) +@click.argument("job_name") +def validate_single_job(ironic_repo, job_name): + """Validate a single Ironic CI job. + + Shows detailed requirements, container capabilities, and + compatibility assessment with explanation. + + \b + Example: + sb validate job ~/code/ironic ironic-tempest-bios-redfish-pxe + """ + ironic_path = Path(ironic_repo) + + # Try to find zuul config file + zuul_file = None + if (ironic_path / "zuul.d" / "ironic-jobs.yaml").exists(): + zuul_file = ironic_path / "zuul.d" / "ironic-jobs.yaml" + elif (ironic_path / ".zuul.yaml").exists(): + zuul_file = ironic_path / ".zuul.yaml" + else: + console.print(f"❌ No zuul configuration found in {ironic_path}", style="red") + console.print(" Expected: zuul.d/ironic-jobs.yaml or .zuul.yaml", style="yellow") + return 1 + + console.print(f"🔍 Validating job: [bold]{job_name}[/bold]\n") + + # Parse job + parser = ZuulJobParser(zuul_file) + try: + requirements = parser.extract_job_requirements(job_name) + except Exception as e: + console.print(f"❌ Failed to parse job: {e}", style="red") + return 1 + + # Validate + validator = JobCompatibilityValidator() + result = validator.check_job(requirements) + + # Display requirements + _display_requirements(requirements) + + # Display validation result + _display_validation_result(result) + + return 0 + + +def _display_requirements(reqs): + """Display job requirements table.""" + table = Table(title="Job Requirements", show_header=False) + table.add_column("Property", style="cyan", width=20) + table.add_column("Value", style="yellow") + + table.add_row("Job Name", reqs.job_name) + table.add_row("Driver", reqs.driver or "-") + table.add_row("Boot Mode", reqs.boot_mode or "-") + table.add_row("Deploy Interface", reqs.deploy_interface or "-") + table.add_row("Image Type", reqs.image_type or "-") + table.add_row("Ramdisk Type", reqs.ramdisk_type or "-") + table.add_row("Network Mode", reqs.network_mode or "-") + table.add_row("Node Count", str(reqs.node_count) if reqs.node_count else "-") + + if reqs.special_features: + table.add_row("Special Features", ", ".join(reqs.special_features)) + + console.print(table) + console.print() + + +def _display_validation_result(result): + """Display validation result with explanation.""" + # Status icon and color + status_config = { + CompatibilityStatus.VERIFIED: ("✅", "green", "VERIFIED - Confirmed working"), + CompatibilityStatus.SHOULD_WORK: ( + "⚠️", + "yellow", + "SHOULD WORK - Requirements met, untested", + ), + CompatibilityStatus.UNTESTED: ("❓", "blue", "UNTESTED - Unknown compatibility"), + CompatibilityStatus.KNOWN_LIMITATION: ("❌", "red", "KNOWN LIMITATION - Not supported"), + } + + icon, color, title = status_config[result.status] + + # Build explanation panel + explanation = f"[bold {color}]{icon} {title}[/bold {color}]\n\n" + + if result.notes: + for note in result.notes: + explanation += f"• {note}\n" + else: + explanation += "No additional notes.\n" + + console.print(Panel(explanation, border_style=color, title="Validation Result")) + + +@validate.command("jobs") +@click.argument("ironic_repo", type=click.Path(exists=True)) +@click.option( + "--output", + type=click.Path(), + default="docs/job-compatibility-matrix.md", + help="Output file for compatibility matrix", +) +def validate_all_jobs(ironic_repo, output): + """Generate full job compatibility matrix. + + Validates all jobs and creates a comprehensive markdown document + showing compatibility status for each job. + + \b + Example: + sb validate jobs ~/code/ironic + sb validate jobs ~/code/ironic --output /tmp/matrix.md + """ + ironic_path = Path(ironic_repo) + + # Try to find zuul config file + zuul_file = None + if (ironic_path / "zuul.d" / "ironic-jobs.yaml").exists(): + zuul_file = ironic_path / "zuul.d" / "ironic-jobs.yaml" + elif (ironic_path / ".zuul.yaml").exists(): + zuul_file = ironic_path / ".zuul.yaml" + else: + console.print(f"❌ No zuul configuration found in {ironic_path}", style="red") + console.print(" Expected: zuul.d/ironic-jobs.yaml or .zuul.yaml", style="yellow") + return 1 + + console.print(f"🔍 Discovering jobs in {zuul_file.parent.name}/{zuul_file.name}...") + + # Parse and validate all jobs + parser = ZuulJobParser(zuul_file) + job_names = parser.list_jobs() + + console.print(f"📋 Validating {len(job_names)} jobs...") + + job_requirements = [] + for job_name in job_names: + try: + reqs = parser.extract_job_requirements(job_name) + job_requirements.append(reqs) + except Exception as e: + console.print(f" ⚠️ Failed to parse {job_name}: {e}", style="yellow") + + validator = JobCompatibilityValidator() + results = validator.validate_all_jobs(job_requirements) + stats = validator.get_stats(results) + + # Generate markdown matrix + console.print("📝 Generating compatibility matrix...") + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + _generate_matrix_markdown(results, stats, output_path) + + console.print(f"✅ Matrix saved to: [bold]{output_path}[/bold]\n") + + # Display summary + summary = f"""[bold]Total Jobs:[/bold] {stats['total']} + +[bold green]✅ Verified:[/bold green] {stats['verified']} +[bold yellow]⚠️ Should Work:[/bold yellow] {stats['should_work']} +[bold blue]❓ Untested:[/bold blue] {stats['untested']} +[bold red]❌ Known Limitations:[/bold red] {stats['known_limitation']} +""" + console.print(Panel(summary, title="Summary", border_style="blue")) + + return 0 + + +def _generate_matrix_markdown(results, stats, output_path): + """Generate markdown compatibility matrix.""" + lines = [] + + # Header + lines.append("# Ironic CI Job Compatibility Matrix") + lines.append("") + lines.append( + "Compatibility status for all Ironic CI jobs running in StackBox containerized environment." + ) + lines.append("") + lines.append(f"**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + + # Summary + lines.append("## Summary") + lines.append("") + lines.append(f"- **Total Jobs**: {stats['total']}") + lines.append(f"- ✅ **Verified**: {stats['verified']} (confirmed working in containers)") + lines.append(f"- ⚠️ **Should Work**: {stats['should_work']} (requirements met, untested)") + lines.append(f"- ❓ **Untested**: {stats['untested']} (unknown compatibility)") + lines.append(f"- ❌ **Known Limitations**: {stats['known_limitation']} (not supported)") + lines.append("") + + # Group jobs by status + status_groups = { + CompatibilityStatus.VERIFIED: [], + CompatibilityStatus.SHOULD_WORK: [], + CompatibilityStatus.UNTESTED: [], + CompatibilityStatus.KNOWN_LIMITATION: [], + } + + for result in results.values(): + status_groups[result.status].append(result) + + # Generate sections for each status + _add_status_section(lines, "✅ Verified Working", status_groups[CompatibilityStatus.VERIFIED]) + _add_status_section( + lines, "⚠️ Should Work (Untested)", status_groups[CompatibilityStatus.SHOULD_WORK] + ) + _add_status_section(lines, "❓ Untested", status_groups[CompatibilityStatus.UNTESTED]) + _add_status_section( + lines, "❌ Known Limitations", status_groups[CompatibilityStatus.KNOWN_LIMITATION] + ) + + # Write to file + output_path.write_text("\n".join(lines)) + + +def _add_status_section(lines, title, results): + """Add a section for jobs with specific status.""" + if not results: + return + + lines.append(f"## {title}") + lines.append("") + lines.append(f"**Count**: {len(results)} jobs") + lines.append("") + + # Table header + lines.append("| Job Name | Driver | Boot | Deploy | Image | Ramdisk | Notes |") + lines.append("|----------|--------|------|--------|-------|---------|-------|") + + # Sort by job name + sorted_results = sorted(results, key=lambda r: r.job_name) + + for result in sorted_results: + reqs = result.requirements + notes = result.notes[0] if result.notes else "-" + + # Truncate long notes + if len(notes) > 80: + notes = notes[:77] + "..." + + lines.append( + f"| {reqs.job_name} " + f"| {reqs.driver or '-'} " + f"| {reqs.boot_mode or '-'} " + f"| {reqs.deploy_interface or '-'} " + f"| {reqs.image_type or '-'} " + f"| {reqs.ramdisk_type or '-'} " + f"| {notes} |" + ) + + lines.append("") diff --git a/stackbox/cli/setup_commands.py b/stackbox/cli/setup_commands.py new file mode 100644 index 0000000..ebee503 --- /dev/null +++ b/stackbox/cli/setup_commands.py @@ -0,0 +1,346 @@ +""" +Setup commands for StackBox prerequisites. + +Handles one-time host-level setup requirements that can't be done from containers. +Designed to be extensible for multiple services (Cinder, Neutron OVN, Swift, etc.). +""" + +import logging +import os +from pathlib import Path +import subprocess +import sys + +import click + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Service-specific checkers (easily extensible) +# ============================================================================ + + +class PrerequisiteChecker: + """Base class for service prerequisite checks.""" + + def __init__(self, service_name: str): + self.service_name = service_name + + def check(self) -> str: + """Check if prerequisites are met. + + Returns: + 'ok' if ready, 'missing' if setup needed + """ + raise NotImplementedError + + def get_setup_script(self) -> Path: + """Get path to setup script.""" + raise NotImplementedError + + def get_description(self) -> str: + """Get human-readable description.""" + raise NotImplementedError + + def get_setup_explanation(self, **options) -> dict[str, str]: + """Get explanation of what setup will do. + + Returns: + Dict with 'title', 'creates', 'note' keys + """ + raise NotImplementedError + + +class CinderVolumeChecker(PrerequisiteChecker): + """Checker for Cinder volume prerequisites.""" + + def __init__(self): + super().__init__("cinder-volume") + + def check(self) -> str: + result = subprocess.run(["vgs", "cinder-volumes"], capture_output=True) + return "ok" if result.returncode == 0 else "missing" + + def get_setup_script(self) -> Path: + stackbox_dir = Path(__file__).parent.parent + return stackbox_dir / "scripts" / "setup-cinder-loop.sh" + + def get_description(self) -> str: + return "Cinder volume service (block storage)" + + def get_setup_explanation(self, size="20G", **kwargs) -> dict[str, str]: + return { + "title": "Cinder Volume Setup", + "creates": [ + f"{size} backing file at /var/tmp/cinder-volumes-backing-file", + "Loop device (e.g., /dev/loop0)", + "LVM volume group 'cinder-volumes'", + ], + "note": ( + "This is a ONE-TIME setup and follows the same pattern as\n" + "Kolla-Ansible (production OpenStack deployment)." + ), + } + + +class NeutronOVNChecker(PrerequisiteChecker): + """Checker for Neutron OVN prerequisites (future).""" + + def __init__(self): + super().__init__("neutron-ovn") + + def check(self) -> str: + # TODO: Check if OVN is configured + # For now, always return 'ok' (not implemented) + return "ok" + + def get_setup_script(self) -> Path: + stackbox_dir = Path(__file__).parent.parent + return stackbox_dir / "scripts" / "setup-neutron-ovn.sh" + + def get_description(self) -> str: + return "Neutron OVN networking (SDN)" + + def get_setup_explanation(self, **kwargs) -> dict[str, str]: + return { + "title": "Neutron OVN Setup", + "creates": [ + "OVN northbound database", + "OVN southbound database", + "OVN controller configuration", + ], + "note": "Required for advanced networking features.", + } + + +# Registry of all available checkers +CHECKERS = { + "cinder": CinderVolumeChecker(), + # 'ovn': NeutronOVNChecker(), # Uncomment when ready + # 'swift': SwiftStorageChecker(), # Future +} + + +# ============================================================================ +# CLI Commands +# ============================================================================ + + +def get_checker(service: str) -> PrerequisiteChecker: + """Get checker for a service.""" + if service not in CHECKERS: + available = ", ".join(CHECKERS.keys()) + raise ValueError(f"Unknown service: {service}\n" f"Available: {available}") + return CHECKERS[service] + + +@click.group(name="setup") +def setup_group(): + """One-time setup commands for StackBox prerequisites. + + Some services require host-level configuration that can't be done + from containers. These commands automate that setup. + + Available services: + • cinder - Block storage (volume service) + • ovn - Advanced networking (coming soon) + • swift - Object storage (coming soon) + + Examples: + # Check all prerequisites + sb setup status + + # Setup Cinder volume + sb setup cinder + + # Check Cinder only + sb setup cinder --check-only + """ + pass + + +@setup_group.command(name="status") +def setup_status(): + """Check status of all StackBox prerequisites. + + Shows which optional prerequisites are configured and which need setup. + """ + click.echo("📊 StackBox Prerequisites Status\n") + click.echo("=" * 70 + "\n") + + all_ok = True + + for service_key, checker in CHECKERS.items(): + status = checker.check() + description = checker.get_description() + + if status == "ok": + click.echo(f"✅ {description:<50} READY") + else: + click.echo(f"⚠️ {description:<50} NOT CONFIGURED") + click.echo(f" Run: sb setup {service_key}") + all_ok = False + + click.echo() + + click.echo("=" * 70 + "\n") + + if all_ok: + click.echo("✅ All prerequisites configured!") + else: + click.echo("⚠️ Some prerequisites need setup. Run the commands above.") + + +@setup_group.command(name="cinder") +@click.option("--check-only", is_flag=True, help="Only check status, do not perform setup") +@click.option("--size", default="20G", help="Size of loop device (default: 20G)") +def setup_cinder(check_only, size): + """Setup Cinder volume prerequisites (requires sudo). + + This creates a loop device-backed LVM volume group for Cinder volume service. + This is a one-time setup per machine and follows the same pattern as + Kolla-Ansible (production OpenStack deployment tool). + + What this does: + - Creates a 20GB file at /var/tmp/cinder-volumes-backing-file + - Attaches it as a loop device (e.g., /dev/loop0) + - Creates LVM volume group 'cinder-volumes' + + This requires sudo but only needs to be run once. + + Examples: + # Check if setup is needed + sb setup cinder --check-only + + # Run setup (will prompt for sudo password) + sb setup cinder + + # Use custom size + sb setup cinder --size 50G + """ + _setup_service("cinder", check_only=check_only, size=size) + + +# Generic setup function (reusable for other services) +def _setup_service(service: str, check_only: bool = False, **options): + """Generic setup function for any service. + + Args: + service: Service name (cinder, ovn, etc.) + check_only: Only check status, don't setup + **options: Service-specific options (size, etc.) + """ + checker = get_checker(service) + + click.echo(f"🔧 Checking {checker.get_description()} prerequisites...\n") + + status = checker.check() + + if status == "ok": + click.echo(f"✅ {checker.get_description()} prerequisites already configured!\n") + + # Show current status for some services + if service == "cinder": + try: + result = subprocess.run(["vgs", "cinder-volumes"], capture_output=True, text=True) + click.echo("Current status:") + click.echo(result.stdout) + except Exception as e: + logger.warning(f"Could not display status: {e}") + + return + + # Missing setup + click.echo(f"⚠️ {checker.get_description()} prerequisites not found.\n") + + if check_only: + click.echo("Run this command to set up:") + click.echo(f" sb setup {service}\n") + sys.exit(1) + + # Get setup explanation + explanation = checker.get_setup_explanation(**options) + + # Display what will happen + click.echo("╔════════════════════════════════════════════════════════════════╗") + click.echo(f"║ {explanation['title']:<60} ║") + click.echo("╚════════════════════════════════════════════════════════════════╝\n") + + click.echo("This will create:") + for item in explanation["creates"]: + click.echo(f" • {item}") + click.echo() + + if "note" in explanation: + click.echo(explanation["note"]) + click.echo() + + click.echo("⚠️ This requires sudo privileges.\n") + + # Confirm + if not click.confirm("Continue with setup?", default=True): + click.echo("❌ Setup cancelled.") + sys.exit(1) + + # Get script path + try: + script_path = checker.get_setup_script() + if not script_path.exists(): + raise FileNotFoundError( + f"Setup script not found at {script_path}\n" + f"Please ensure StackBox is installed correctly." + ) + except FileNotFoundError as e: + click.echo(f"❌ Error: {e}", err=True) + sys.exit(1) + + # Run setup script with sudo + click.echo("\n🚀 Running setup script...\n") + click.echo("(You may be prompted for your sudo password)\n") + + try: + # Build environment with options + env = os.environ.copy() + env.update({k.upper(): str(v) for k, v in options.items()}) + + # Run with sudo, interactive for password prompt + result = subprocess.run( + ["sudo", str(script_path)], + env=env, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + ) + + if result.returncode == 0: + click.echo(f"\n✅ {checker.get_description()} setup complete!") + click.echo("\nYou can now deploy services:") + click.echo(" sb deploy-infra --manifest your-manifest.yaml\n") + else: + click.echo(f"\n❌ Setup failed with exit code {result.returncode}", err=True) + click.echo("\nTroubleshooting:") + click.echo(" • Check you have sudo access") + click.echo(" • Ensure required tools are installed") + click.echo(" • Check system logs: journalctl -xe\n") + sys.exit(1) + + except FileNotFoundError: + click.echo("❌ Error: 'sudo' command not found", err=True) + click.echo("\nPlease install sudo or run as root:", err=True) + click.echo(f" {script_path}\n", err=True) + sys.exit(1) + except KeyboardInterrupt: + click.echo("\n\n❌ Setup cancelled by user.") + sys.exit(1) + + +# ============================================================================ +# Future: Add more service setup commands here +# ============================================================================ + +# @setup_group.command(name='ovn') +# @click.option('--check-only', is_flag=True) +# def setup_ovn(check_only): +# """Setup Neutron OVN prerequisites (future).""" +# _setup_service('ovn', check_only=check_only) diff --git a/stackbox/cli/zuul_commands.py b/stackbox/cli/zuul_commands.py index 7c9fee6..40369f3 100644 --- a/stackbox/cli/zuul_commands.py +++ b/stackbox/cli/zuul_commands.py @@ -275,10 +275,11 @@ def test(manifest: str) -> None: Note: Requires infrastructure to be deployed first (via 'sb deploy-infra') """ - import subprocess from pathlib import Path - from stackbox.container import runtime as container_runtime + import subprocess + from stackbox.config.service_constants import get_constants + from stackbox.container import runtime as container_runtime C = get_constants() @@ -351,7 +352,9 @@ def test(manifest: str) -> None: # Run Tempest tests click.echo() - click.secho(f"🧪 Running Tempest tests: {tempest_regex or 'tempest.api.identity'}", fg="cyan", bold=True) + click.secho( + f"🧪 Running Tempest tests: {tempest_regex or 'tempest.api.identity'}", fg="cyan", bold=True + ) click.echo() test_regex = tempest_regex or "tempest.api.identity" diff --git a/stackbox/config/service-definitions.yaml b/stackbox/config/service-definitions.yaml index ca4fcb2..e7cbf92 100644 --- a/stackbox/config/service-definitions.yaml +++ b/stackbox/config/service-definitions.yaml @@ -48,7 +48,7 @@ services: RABBIT_HOST: "{{ rabbit_host | default('rabbitmq') }}" RABBIT_PASSWORD: "{{ rabbit_password }}" volumes: - - "./config/keystone.conf:/etc/keystone/keystone.conf:ro,z" + - "{{ config_path_prefix }}/keystone.conf:/etc/keystone/keystone.conf:ro,z" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/v3"] interval: "{{ healthcheck_interval | default('10s') }}" @@ -422,7 +422,7 @@ services: image: "{{ cinder_image | default('localhost/stackbox-cinder:latest') }}" container_name: "{{ container_prefix | default('stackbox') }}-cinder-volume" command: ["cinder-volume", "--config-file", "/etc/cinder/cinder.conf"] - privileged: "{{ cinder_volume_privileged | default(true) }}" + user: "{{ cinder_volume_user | default('cinder') }}" # Run as cinder user (LVM VG must exist on host) environment: DB_HOST: "{{ db_host | default('mariadb') }}" DB_PASSWORD: "{{ db_password }}" @@ -431,7 +431,7 @@ services: RABBIT_PASSWORD: "{{ rabbit_password }}" volumes: - "./config/cinder.conf:/etc/cinder/cinder.conf:ro,z" - - "/dev:/dev:rshared" + - "/dev:/dev" # Need access to loop devices - "cinder-volumes:/var/lib/cinder/volumes" healthcheck: test: ["CMD", "pgrep", "-f", "cinder-volume"] diff --git a/stackbox/container/builder.py b/stackbox/container/builder.py index 969a836..d01648f 100644 --- a/stackbox/container/builder.py +++ b/stackbox/container/builder.py @@ -13,6 +13,7 @@ import click from stackbox.config.service_constants import get_constants +from stackbox.container.runtime import get_container_runtime C = get_constants() @@ -73,9 +74,12 @@ def build_ironic_image( click.echo(f"Building Ironic image from {ironic_source_path}") click.echo(f"Using Dockerfile: {dockerfile_path}") + # Detect container runtime + runtime_cmd, _ = get_container_runtime() + # Build docker command cmd = [ - "docker", + runtime_cmd, "build", "-f", str(dockerfile_path), @@ -94,9 +98,10 @@ def build_ironic_image( else: cmd.extend(["--progress", "auto"]) - # Enable BuildKit for better caching and parallel builds + # Enable BuildKit for better caching and parallel builds (Docker only) env = os.environ.copy() - env["DOCKER_BUILDKIT"] = "1" + if runtime_cmd == "docker": + env["DOCKER_BUILDKIT"] = "1" # Measure build time start_time = time.time() @@ -146,10 +151,12 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool: """ click.echo(f"Validating image {tag}...") + runtime_cmd, _ = get_container_runtime() + # Check image exists try: subprocess.run( - ["docker", "image", "inspect", tag], + [runtime_cmd, "image", "inspect", tag], capture_output=True, check=True, timeout=C.TIMEOUT_SHORT, @@ -161,7 +168,7 @@ def validate_image(tag: str = "stackbox-ironic:latest") -> bool: # Test that image can run and execute ironic-api try: result = subprocess.run( - ["docker", "run", "--rm", tag, "ironic-api", "--version"], + [runtime_cmd, "run", "--rm", tag, "ironic-api", "--version"], capture_output=True, timeout=C.TIMEOUT_MEDIUM, check=False, @@ -187,8 +194,9 @@ def get_image_size(tag: str = "stackbox-ironic:latest") -> str | None: Returns: Image size as human-readable string (e.g., "1.2GB") or None if not found """ + runtime_cmd, _ = get_container_runtime() result = subprocess.run( - ["docker", "image", "inspect", tag, "--format", "{{.Size}}"], + [runtime_cmd, "image", "inspect", tag, "--format", "{{.Size}}"], capture_output=True, text=True, check=False, diff --git a/stackbox/container/compose.py b/stackbox/container/compose.py index ebe13ab..7b3bd8c 100644 --- a/stackbox/container/compose.py +++ b/stackbox/container/compose.py @@ -75,10 +75,11 @@ def generate_compose_file(output_path: Path, template_vars: dict[str, str] | Non def _check_docker_running() -> bool: - """Check if Docker daemon is running.""" + """Check if container runtime daemon is running.""" + runtime_cmd, _ = get_container_runtime() try: subprocess.run( - ["docker", "ps"], + [runtime_cmd, "ps"], check=True, capture_output=True, text=True, diff --git a/stackbox/orchestrator/deployer.py b/stackbox/orchestrator/deployer.py index 364b536..5a2aa15 100644 --- a/stackbox/orchestrator/deployer.py +++ b/stackbox/orchestrator/deployer.py @@ -11,6 +11,7 @@ import click from stackbox.builders.registry import ImageBuilderRegistry +from stackbox.cli.setup_commands import CHECKERS from stackbox.container.compose import start_infrastructure, wait_for_healthy from stackbox.container.runtime import get_container_runtime from stackbox.orchestrator.compose_generator import ComposeGenerator @@ -70,6 +71,9 @@ def deploy(self, plan: ReconciliationPlan) -> None: Raises: DeploymentError: If deployment fails """ + # Check prerequisites before deployment + self._check_prerequisites(plan) + click.echo("🚀 Executing deployment plan...") # 1. Build missing images @@ -107,6 +111,56 @@ def deploy(self, plan: ReconciliationPlan) -> None: click.secho("✅ Deployment complete!", fg="green") + def _check_prerequisites(self, plan: ReconciliationPlan) -> None: + """Check if prerequisites are met for services in the plan. + + Args: + plan: Reconciliation plan containing services to deploy + + Raises: + DeploymentError: If required prerequisites are missing + """ + all_services = plan.to_start | plan.to_restart | plan.already_running + + # Check if cinder-volume is in the manifest + if "cinder-volume" in all_services: + checker = CHECKERS.get("cinder") + if checker and checker.check() == "missing": + click.echo() + click.echo("╔════════════════════════════════════════════════════════════════╗") + click.echo("║ Cinder Volume Setup Required ║") + click.echo("╚════════════════════════════════════════════════════════════════╝\n") + click.echo( + "Your manifest includes 'cinder-volume' but prerequisites are not configured.\n" + ) + click.echo("Quick fix (requires sudo, one-time setup):") + click.echo(" sb setup cinder\n") + click.echo( + "This follows the same pattern as Kolla-Ansible (production OpenStack).\n" + ) + click.echo("What would you like to do?") + click.echo(" 1) Exit and run setup (recommended)") + click.echo(" 2) Skip cinder-volume for this deployment") + click.echo(" 3) Continue anyway (cinder-volume will fail)\n") + + choice = click.prompt("Choice", type=click.Choice(["1", "2", "3"]), default="1") + + if choice == "1": + click.echo("\n❌ Deployment aborted.") + click.echo("\nRun this command:") + click.echo(" sb setup cinder\n") + raise DeploymentError("Cinder volume prerequisites not configured") + elif choice == "2": + click.echo("\n⚠️ Removing cinder-volume from deployment...") + plan.to_start.discard("cinder-volume") + plan.to_restart.discard("cinder-volume") + plan.already_running.discard("cinder-volume") + click.echo( + "✅ Continuing without cinder-volume (API + scheduler will still work)\n" + ) + else: + click.echo("\n⚠️ Continuing with deployment (cinder-volume may fail)...\n") + def _generate_compose_file(self, plan: ReconciliationPlan) -> None: """Generate docker-compose.yml for the deployment.""" click.echo("\n📝 Generating docker-compose.yml...") @@ -278,12 +332,14 @@ def _restart_services(self, services: set[str]) -> None: click.echo(f"\n🔄 Restarting {len(services)} services...") compose_file = self.config_dir / "docker-compose.yml" - _, compose_cmd = get_container_runtime() + runtime_cmd, compose_cmd = get_container_runtime() # Get compose command if compose_cmd == "compose": - cmd_base = ["docker", "compose", "-f", str(compose_file)] + # Docker Compose V2: "docker compose" + cmd_base = [runtime_cmd, "compose", "-f", str(compose_file)] else: + # Hyphenated commands: docker-compose or podman-compose cmd_base = [compose_cmd, "-f", str(compose_file)] # Sort by dependencies (restart dependencies first) diff --git a/stackbox/orchestrator/reconciler.py b/stackbox/orchestrator/reconciler.py index c3eb675..1b85454 100644 --- a/stackbox/orchestrator/reconciler.py +++ b/stackbox/orchestrator/reconciler.py @@ -16,6 +16,32 @@ from stackbox.orchestrator.service_registry import ServiceRegistry from stackbox.orchestrator.template_renderer import TemplateRenderer +# Service name aliases - maps manifest names to service-definition names +# This allows manifests to use common names while service-definitions +# use more specific names (e.g., glance → glance-api) +SERVICE_ALIASES = { + # Image service + "glance": "glance-api", + # Placement service + "placement": "placement-api", + # Network service + "neutron-api": "neutron-server", + # Services that match exactly (no alias needed, but listed for clarity) + "glance-api": "glance-api", + "placement-api": "placement-api", + "neutron-server": "neutron-server", + "keystone": "keystone", + "nova-api": "nova-api", + "nova-scheduler": "nova-scheduler", + "nova-conductor": "nova-conductor", + "nova-compute": "nova-compute", + "cinder-api": "cinder-api", + "cinder-scheduler": "cinder-scheduler", + "cinder-volume": "cinder-volume", + "mariadb": "mariadb", + "rabbitmq": "rabbitmq", +} + @dataclass class ReconciliationPlan: @@ -75,8 +101,9 @@ def reconcile(self, manifest: dict, dry_run: bool = False) -> ReconciliationPlan Returns: ReconciliationPlan with actions taken/planned """ - # 1. Extract desired state from manifest - desired_services = set(manifest["spec"]["services"]) + # 1. Extract desired state from manifest and normalize service names + manifest_services = manifest["spec"]["services"] + desired_services = self._normalize_service_names(manifest_services) manifest["spec"]["jobType"] # 2. Observe actual state @@ -117,6 +144,26 @@ def reconcile(self, manifest: dict, dry_run: bool = False) -> ReconciliationPlan return plan + def _normalize_service_names(self, services: list[str]) -> set[str]: + """ + Normalize service names from manifest to service-definition names. + + Maps common service names to their service-definition equivalents. + For example: 'glance' → 'glance-api', 'placement' → 'placement-api' + + Args: + services: List of service names from manifest + + Returns: + Set of normalized service names that match service-definitions.yaml + """ + normalized = set() + for service in services: + # Use alias if available, otherwise use service name as-is + normalized_name = SERVICE_ALIASES.get(service, service) + normalized.add(normalized_name) + return normalized + def _get_running_services(self) -> set[str]: """ Get set of currently running StackBox services. diff --git a/stackbox/orchestrator/template_renderer.py b/stackbox/orchestrator/template_renderer.py index 2770e78..d886c83 100644 --- a/stackbox/orchestrator/template_renderer.py +++ b/stackbox/orchestrator/template_renderer.py @@ -78,6 +78,9 @@ def get_default_vars(self) -> dict[str, Any]: "nova_compute_privileged": True, "cinder_volume_privileged": True, "stackbox_dir": str(Path(__file__).parent.parent), + # Config path prefix for volume mounts + # When compose file is in .stackbox/ but run from parent dir + "config_path_prefix": "./.stackbox/config", } def render(self, template_str: str, custom_vars: dict[str, Any] | None = None) -> str: diff --git a/stackbox/scripts/setup-cinder-loop.sh b/stackbox/scripts/setup-cinder-loop.sh new file mode 100755 index 0000000..c1da14a --- /dev/null +++ b/stackbox/scripts/setup-cinder-loop.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Setup loop device for Cinder on the HOST (not in container) +# This must run BEFORE starting cinder-volume container + +set -e + +BACKING_FILE="/var/tmp/cinder-volumes-backing-file" +SIZE="20G" +VG_NAME="cinder-volumes" + +echo "🔧 Setting up loop device for Cinder volume backend (host-side)..." + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)" + exit 1 +fi + +# Check if volume group already exists +if vgs "$VG_NAME" &>/dev/null; then + echo "✅ Volume group $VG_NAME already exists" + vgs "$VG_NAME" + exit 0 +fi + +# Create backing file if it doesn't exist +if [ ! -f "$BACKING_FILE" ]; then + echo "📝 Creating $SIZE backing file at $BACKING_FILE..." + fallocate -l "$SIZE" "$BACKING_FILE" +fi + +# Find free loop device +FREE_DEVICE=$(losetup -f) +echo "🔗 Using loop device: $FREE_DEVICE" + +# Setup loop device +losetup "$FREE_DEVICE" "$BACKING_FILE" + +# Create physical volume +echo "💾 Creating physical volume..." +pvcreate "$FREE_DEVICE" + +# Create volume group +echo "📦 Creating volume group $VG_NAME..." +vgcreate "$VG_NAME" "$FREE_DEVICE" + +echo "✅ Loop device setup complete!" +echo " Device: $FREE_DEVICE" +echo " Backing file: $BACKING_FILE" +echo " Volume Group: $VG_NAME" +vgs "$VG_NAME" diff --git a/stackbox/service/accounts.py b/stackbox/service/accounts.py index d3a4c4a..9fadbde 100644 --- a/stackbox/service/accounts.py +++ b/stackbox/service/accounts.py @@ -291,7 +291,9 @@ def _ensure_role_exists(self, role_name: str, env_args: list) -> None: subprocess.run(create_cmd, capture_output=True, check=True, timeout=C.TIMEOUT_SHORT) except subprocess.CalledProcessError as e: if b"Conflict" not in e.stderr: - click.echo(f" ⚠️ Failed to create role {role_name}: {e.stderr.decode()}", err=True) + click.echo( + f" ⚠️ Failed to create role {role_name}: {e.stderr.decode()}", err=True + ) def _grant_role_to_user(self, user: str, project: str, role: str, env_args: list) -> None: """Grant a specific role to user on project.""" diff --git a/stackbox/templates/cinder/Dockerfile b/stackbox/templates/cinder/Dockerfile index 8205bed..ee96aed 100644 --- a/stackbox/templates/cinder/Dockerfile +++ b/stackbox/templates/cinder/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends git \ # Create cinder user and directories RUN groupadd -r cinder && useradd -r -g cinder -d /var/lib/cinder cinder && \ mkdir -p /var/lib/cinder/volumes /var/log/cinder /etc/cinder && \ + touch /etc/cinder/cinder.conf && \ chown -R cinder:cinder /var/lib/cinder /var/log/cinder /etc/cinder FROM base AS dependencies @@ -33,13 +34,17 @@ RUN pip install --no-cache-dir --break-system-packages /opt/cinder # Pin cryptography for cursive compatibility (must be after install) RUN pip install --no-cache-dir --break-system-packages 'cryptography<43' -# Copy api-paste.ini and healthcheck script +# Copy api-paste.ini, healthcheck, and volume setup scripts COPY api-paste.ini /etc/cinder/api-paste.ini COPY healthcheck.sh /usr/local/bin/healthcheck.sh -RUN chmod +x /usr/local/bin/healthcheck.sh && \ +COPY setup-loop-device.sh /usr/local/bin/setup-loop-device.sh +COPY entrypoint-volume.sh /usr/local/bin/entrypoint-volume.sh +RUN chmod +x /usr/local/bin/healthcheck.sh \ + /usr/local/bin/setup-loop-device.sh \ + /usr/local/bin/entrypoint-volume.sh && \ chown cinder:cinder /etc/cinder/api-paste.ini -# Switch to cinder user +# Switch to cinder user (NOTE: cinder-volume runs as root for LVM, override in compose) USER cinder # Expose Cinder API port diff --git a/stackbox/templates/cinder/cinder.conf.j2 b/stackbox/templates/cinder/cinder.conf.j2 index a0444b3..968cd60 100644 --- a/stackbox/templates/cinder/cinder.conf.j2 +++ b/stackbox/templates/cinder/cinder.conf.j2 @@ -22,7 +22,7 @@ osapi_volume_listen_port = 8776 # Workers osapi_volume_workers = {{ osapi_volume_workers | default('1') }} -# Volume backend (LVM) +# Volume backend (LVM with loop device for testing) enabled_backends = {{ enabled_backends | default('lvm') }} default_volume_type = {{ default_volume_type | default('lvm') }} @@ -74,12 +74,11 @@ enforce_new_defaults = {{ enforce_new_defaults | default('false') }} enforce_scope = {{ enforce_scope | default('false') }} [lvm] -# LVM backend configuration -volume_driver = {{ lvm_volume_driver | default('cinder.volume.drivers.lvm.LVMVolumeDriver') }} +# Fake LVM driver for testing (no real storage/loop device needed!) +# Uses cinder.tests.fake_driver.FakeLoggingVolumeDriver with fake_lvm +volume_driver = {{ lvm_volume_driver | default('cinder.tests.fake_driver.FakeLoggingVolumeDriver') }} volume_group = {{ lvm_volume_group | default('cinder-volumes') }} -target_protocol = {{ lvm_target_protocol | default('iscsi') }} -target_helper = {{ lvm_target_helper | default('tgtadm') }} -volume_backend_name = {{ lvm_volume_backend_name | default('LVM') }} +volume_backend_name = {{ lvm_volume_backend_name | default('lvm') }} [nova] # Nova integration diff --git a/stackbox/templates/cinder/entrypoint-volume.sh b/stackbox/templates/cinder/entrypoint-volume.sh new file mode 100644 index 0000000..8be9956 --- /dev/null +++ b/stackbox/templates/cinder/entrypoint-volume.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Entrypoint for cinder-volume service +# Expects loop device/VG to be created by init container + +set -e + +echo "🚀 Starting Cinder volume service..." + +# Wait for volume group (created by init container) +MAX_WAIT=30 +WAITED=0 +while ! vgs cinder-volumes &>/dev/null; do + if [ $WAITED -ge $MAX_WAIT ]; then + echo "❌ Volume group 'cinder-volumes' not found after ${MAX_WAIT}s!" + echo " Check cinder-volume-init container logs" + exit 1 + fi + echo "⏳ Waiting for volume group... (${WAITED}s)" + sleep 2 + WAITED=$((WAITED + 2)) +done + +echo "✅ Volume group found:" +vgs cinder-volumes + +# Start cinder-volume service +echo "▶️ Starting cinder-volume..." +exec cinder-volume --config-file /etc/cinder/cinder.conf diff --git a/stackbox/templates/cinder/setup-loop-device.sh b/stackbox/templates/cinder/setup-loop-device.sh new file mode 100644 index 0000000..111af20 --- /dev/null +++ b/stackbox/templates/cinder/setup-loop-device.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Setup loop device for Cinder LVM backend (DevStack testing method) +# This creates a file-backed loop device for testing without a dedicated disk partition + +set -e + +BACKING_FILE="/var/lib/cinder/cinder-volumes-backing-file" +# Note: Path must be inside container volume for permission reasons +SIZE="20G" +VG_NAME="cinder-volumes" + +echo "🔧 Setting up loop device for Cinder volume backend..." + +# Create directory for backing file +mkdir -p /var/lib/cinder + +# Check if volume group already exists +if vgs "$VG_NAME" &>/dev/null; then + echo "✅ Volume group $VG_NAME already exists" + exit 0 +fi + +# Check if backing file exists +if [ ! -f "$BACKING_FILE" ]; then + echo "📝 Creating $SIZE backing file at $BACKING_FILE..." + fallocate -l "$SIZE" "$BACKING_FILE" +fi + +# Find free loop device +FREE_DEVICE=$(losetup -f) +echo "🔗 Using loop device: $FREE_DEVICE" + +# Setup loop device +losetup "$FREE_DEVICE" "$BACKING_FILE" + +# Create physical volume +echo "💾 Creating physical volume..." +pvcreate "$FREE_DEVICE" + +# Create volume group +echo "📦 Creating volume group $VG_NAME..." +vgcreate "$VG_NAME" "$FREE_DEVICE" + +echo "✅ Loop device setup complete!" +echo " Device: $FREE_DEVICE" +echo " Volume Group: $VG_NAME" +vgs "$VG_NAME" diff --git a/stackbox/templates/cinder/setup-lvm-thin.sh b/stackbox/templates/cinder/setup-lvm-thin.sh new file mode 100644 index 0000000..95e7b6c --- /dev/null +++ b/stackbox/templates/cinder/setup-lvm-thin.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Setup LVM thin provisioning for Cinder (no loop device needed!) +# Uses directory-backed LVM - fully containerized solution + +set -e + +BACKING_DIR="/var/lib/cinder/lvm-backing" +SIZE_MB=20480 # 20GB +VG_NAME="cinder-volumes" + +echo "🔧 Setting up LVM thin pool for Cinder (no loop device!)..." + +# Check if volume group already exists +if vgs "$VG_NAME" &>/dev/null; then + echo "✅ Volume group $VG_NAME already exists" + vgs "$VG_NAME" + exit 0 +fi + +# Create backing directory +mkdir -p "$BACKING_DIR" + +# Create sparse files for PVs (no actual space used until written) +dd if=/dev/zero of="$BACKING_DIR/pv1" bs=1M count=0 seek=$SIZE_MB +dd if=/dev/zero of="$BACKING_DIR/pv2" bs=1M count=0 seek=$SIZE_MB + +# Use dm-crypt/dm-linear or just create VG from files +# Actually, we still need loop... Let me try different approach + +echo "❌ This approach also needs loop devices" +exit 1 diff --git a/stackbox/templates/glance/Dockerfile b/stackbox/templates/glance/Dockerfile index 900c86e..2ea240d 100644 --- a/stackbox/templates/glance/Dockerfile +++ b/stackbox/templates/glance/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends git \ # Create glance user and directories RUN groupadd -r glance && useradd -r -g glance -d /var/lib/glance glance && \ mkdir -p /var/lib/glance/images /var/log/glance /etc/glance && \ + touch /etc/glance/glance.conf && \ chown -R glance:glance /var/lib/glance /var/log/glance /etc/glance FROM base AS dependencies diff --git a/stackbox/templates/keystone/Dockerfile b/stackbox/templates/keystone/Dockerfile index 0d1c1e7..7177b36 100644 --- a/stackbox/templates/keystone/Dockerfile +++ b/stackbox/templates/keystone/Dockerfile @@ -11,6 +11,7 @@ RUN pip install --no-cache-dir --break-system-packages \ # Create keystone user and directories RUN useradd --user-group keystone && \ mkdir -p /etc/keystone /var/log/keystone /var/lib/keystone /etc/keystone/fernet-keys && \ + touch /etc/keystone/keystone.conf && \ chown -R keystone:keystone /etc/keystone /var/log/keystone /var/lib/keystone # Create WSGI application wrapper for modern Keystone diff --git a/stackbox/templates/neutron/Dockerfile b/stackbox/templates/neutron/Dockerfile index f38c7cf..218b06a 100644 --- a/stackbox/templates/neutron/Dockerfile +++ b/stackbox/templates/neutron/Dockerfile @@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends git \ # Create neutron user and directories RUN groupadd -r neutron && useradd -r -g neutron -d /var/lib/neutron neutron && \ mkdir -p /var/lib/neutron /var/log/neutron /etc/neutron/plugins/ml2 && \ + touch /etc/neutron/neutron.conf && \ chown -R neutron:neutron /var/lib/neutron /var/log/neutron /etc/neutron FROM base AS dependencies diff --git a/stackbox/templates/nova/Dockerfile b/stackbox/templates/nova/Dockerfile index 71d7ef7..e00d469 100644 --- a/stackbox/templates/nova/Dockerfile +++ b/stackbox/templates/nova/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Create nova user and directories RUN groupadd -r nova && useradd -r -g nova -d /var/lib/nova nova && \ mkdir -p /var/lib/nova/instances /var/log/nova /etc/nova && \ + touch /etc/nova/nova.conf && \ chown -R nova:nova /var/lib/nova /var/log/nova /etc/nova FROM base AS dependencies diff --git a/stackbox/templates/placement/Dockerfile b/stackbox/templates/placement/Dockerfile index 7222e3b..8d1621d 100644 --- a/stackbox/templates/placement/Dockerfile +++ b/stackbox/templates/placement/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Create placement user and directories RUN groupadd -r placement && useradd -r -g placement -d /var/lib/placement placement && \ mkdir -p /var/lib/placement /var/log/placement /etc/placement && \ + touch /etc/placement/placement.conf && \ chown -R placement:placement /var/lib/placement /var/log/placement && \ chmod 755 /etc/placement diff --git a/stackbox/validation/__init__.py b/stackbox/validation/__init__.py new file mode 100644 index 0000000..754a745 --- /dev/null +++ b/stackbox/validation/__init__.py @@ -0,0 +1,20 @@ +""" +Validation module for StackBox. + +Provides job compatibility validation to determine which Ironic CI jobs +can run in the containerized StackBox environment. +""" + +from stackbox.validation.job_compatibility import ( + CompatibilityResult, + CompatibilityStatus, + ContainerCapabilities, + JobCompatibilityValidator, +) + +__all__ = [ + "CompatibilityResult", + "CompatibilityStatus", + "ContainerCapabilities", + "JobCompatibilityValidator", +] diff --git a/stackbox/validation/job_compatibility.py b/stackbox/validation/job_compatibility.py new file mode 100644 index 0000000..b5b4b18 --- /dev/null +++ b/stackbox/validation/job_compatibility.py @@ -0,0 +1,272 @@ +""" +Job compatibility validation for containerized environments. + +Validates whether Ironic CI jobs can run in StackBox containers by checking +job requirements against container capabilities. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import ClassVar + +from stackbox.zuul.parser import JobRequirements + + +class CompatibilityStatus(Enum): + """Container compatibility status for a job.""" + + VERIFIED = "verified" # Tested and confirmed working + SHOULD_WORK = "should-work" # Requirements met, but untested + UNTESTED = "untested" # Unknown compatibility + KNOWN_LIMITATION = "known-limitation" # Documented incompatibility + + +@dataclass +class ContainerCapabilities: + """Capabilities of the containerized StackBox environment.""" + + # Drivers supported (via sushy-tools BMC emulation) + supported_drivers: list[str] | None = None + + # Boot modes supported + supported_boot_modes: list[str] | None = None + + # Deploy interfaces supported + supported_deploy_interfaces: list[str] | None = None + + # Image types supported + supported_image_types: list[str] | None = None + + # Ramdisk types supported + supported_ramdisk_types: list[str] | None = None + + # Special features supported + supported_features: list[str] | None = None + + def __post_init__(self): + """Set default capabilities based on container architecture.""" + if self.supported_drivers is None: + # sushy-tools emulates IPMI and Redfish + self.supported_drivers = ["ipmi", "redfish"] + + if self.supported_boot_modes is None: + # Both BIOS and UEFI confirmed working in containerized libvirt + self.supported_boot_modes = ["bios", "uefi"] + + if self.supported_deploy_interfaces is None: + # direct and iscsi confirmed, ansible likely works + self.supported_deploy_interfaces = ["direct", "iscsi", "ansible"] + + if self.supported_image_types is None: + # Both wholedisk and partition images supported + self.supported_image_types = ["wholedisk", "partition"] + + if self.supported_ramdisk_types is None: + # tinyipa confirmed, DIB should work + self.supported_ramdisk_types = ["tinyipa", "dib"] + + if self.supported_features is None: + # Features with unclear container support + self.supported_features = [] + + +@dataclass +class CompatibilityResult: + """Result of compatibility validation.""" + + job_name: str + status: CompatibilityStatus + requirements: JobRequirements + notes: list[str] = field(default_factory=list) + + def __post_init__(self): + if self.notes is None: + self.notes = [] + + +class JobCompatibilityValidator: + """Validates job compatibility with containerized environment.""" + + # Jobs confirmed working in containers + # Reference: Phase 1 testing, containerized-libvirt validation + VERIFIED_JOBS: ClassVar[list[str]] = [ + # IPMI + BIOS variants (Phase 1 verified) + "ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa", + "ironic-tempest-ipa-partition-pxe_ipmitool", + "ironic-tempest-bios-ipmi-direct-tinyipa", + # IPMI + UEFI variants + "ironic-tempest-ipa-partition-uefi-pxe_ipmitool-tinyipa", + # Redfish + BIOS variants + "ironic-tempest-ipa-partition-bios-redfish-tinyipa", + "ironic-tempest-bios-redfish-pxe", + "ironic-tempest-partition-bios-redfish-pxe", + # Redfish + UEFI variants + "ironic-tempest-ipa-wholedisk-uefi-redfish-tinyipa", + "ironic-tempest-uefi-redfish-vmedia", + # Additional confirmed jobs + "ironic-tempest-partition-uefi-redfish-vmedia", + ] + + # Jobs with known limitations + KNOWN_LIMITATIONS: ClassVar[dict[str, str]] = { + # Hardware-specific drivers that can't be emulated by sushy-tools + "ironic-tempest-idrac-bios": "iDRAC driver requires Dell hardware BMC, not emulated by sushy-tools", + "ironic-tempest-ilo-bios": "iLO driver requires HP hardware BMC, not emulated by sushy-tools", + "ironic-tempest-ibmc-bios": "iBMC driver requires Huawei hardware BMC, not emulated by sushy-tools", + } + + def __init__(self, capabilities: ContainerCapabilities | None = None): + """ + Initialize validator with container capabilities. + + Args: + capabilities: Container capabilities (uses defaults if not provided) + """ + self.capabilities = capabilities or ContainerCapabilities() + + def check_job(self, requirements: JobRequirements) -> CompatibilityResult: + """ + Check if a job is compatible with containers. + + Args: + requirements: Job requirements from parser + + Returns: + CompatibilityResult with status and notes + """ + result = CompatibilityResult( + job_name=requirements.job_name, + status=CompatibilityStatus.UNTESTED, + requirements=requirements, + ) + + # 1. Check if job is in verified list + if requirements.job_name in self.VERIFIED_JOBS: + result.status = CompatibilityStatus.VERIFIED + result.notes.append("Confirmed working in containerized environment") + return result + + # 2. Check if job has known limitations + if requirements.job_name in self.KNOWN_LIMITATIONS: + result.status = CompatibilityStatus.KNOWN_LIMITATION + result.notes.append(self.KNOWN_LIMITATIONS[requirements.job_name]) + return result + + # 3. Check if all requirements are supported + compatibility_issues = [] + should_work = True + + # Check driver + if requirements.driver and requirements.driver not in self.capabilities.supported_drivers: + compatibility_issues.append( + f"Driver '{requirements.driver}' not supported " + f"(available: {', '.join(self.capabilities.supported_drivers)})" + ) + should_work = False + elif requirements.driver: + result.notes.append(f"✓ Driver '{requirements.driver}' supported") + + # Check boot mode + if ( + requirements.boot_mode + and requirements.boot_mode not in self.capabilities.supported_boot_modes + ): + compatibility_issues.append(f"Boot mode '{requirements.boot_mode}' not supported") + should_work = False + elif requirements.boot_mode: + result.notes.append(f"✓ Boot mode '{requirements.boot_mode}' supported") + + # Check deploy interface + if ( + requirements.deploy_interface + and requirements.deploy_interface not in self.capabilities.supported_deploy_interfaces + ): + compatibility_issues.append( + f"Deploy interface '{requirements.deploy_interface}' may not be supported" + ) + should_work = False + elif requirements.deploy_interface: + result.notes.append(f"✓ Deploy interface '{requirements.deploy_interface}' supported") + + # Check image type + if ( + requirements.image_type + and requirements.image_type not in self.capabilities.supported_image_types + ): + compatibility_issues.append(f"Image type '{requirements.image_type}' not supported") + should_work = False + elif requirements.image_type: + result.notes.append(f"✓ Image type '{requirements.image_type}' supported") + + # Check ramdisk type + if ( + requirements.ramdisk_type + and requirements.ramdisk_type not in self.capabilities.supported_ramdisk_types + ): + compatibility_issues.append(f"Ramdisk type '{requirements.ramdisk_type}' not supported") + should_work = False + elif requirements.ramdisk_type: + result.notes.append(f"✓ Ramdisk type '{requirements.ramdisk_type}' supported") + + # Check special features + for feature in requirements.special_features: + if feature not in self.capabilities.supported_features: + result.notes.append(f"⚠ Special feature '{feature}' untested in containers") + # Don't mark as incompatible, but note it's untested + + # 4. Determine final status + if compatibility_issues: + result.status = CompatibilityStatus.UNTESTED + result.notes.extend(compatibility_issues) + elif should_work: + result.status = CompatibilityStatus.SHOULD_WORK + result.notes.append("All requirements match container capabilities (untested)") + + return result + + def validate_all_jobs( + self, job_requirements: list[JobRequirements] + ) -> dict[str, CompatibilityResult]: + """ + Validate multiple jobs. + + Args: + job_requirements: List of job requirements + + Returns: + Dictionary mapping job_name → CompatibilityResult + """ + results = {} + for req in job_requirements: + results[req.job_name] = self.check_job(req) + return results + + def get_stats(self, results: dict[str, CompatibilityResult]) -> dict[str, int]: + """ + Get summary statistics. + + Args: + results: Validation results from validate_all_jobs + + Returns: + Count by status + """ + stats = { + "total": len(results), + "verified": 0, + "should_work": 0, + "untested": 0, + "known_limitation": 0, + } + + for result in results.values(): + if result.status == CompatibilityStatus.VERIFIED: + stats["verified"] += 1 + elif result.status == CompatibilityStatus.SHOULD_WORK: + stats["should_work"] += 1 + elif result.status == CompatibilityStatus.UNTESTED: + stats["untested"] += 1 + elif result.status == CompatibilityStatus.KNOWN_LIMITATION: + stats["known_limitation"] += 1 + + return stats diff --git a/stackbox/zuul/manifest_generator.py b/stackbox/zuul/manifest_generator.py index 8bac8ab..8970782 100644 --- a/stackbox/zuul/manifest_generator.py +++ b/stackbox/zuul/manifest_generator.py @@ -54,7 +54,16 @@ # Service groups for common patterns -COMPUTE_STACK = ["keystone", "glance-api", "placement-api", "neutron-server", "nova-api", "nova-scheduler", "nova-conductor", "nova-compute"] +COMPUTE_STACK = [ + "keystone", + "glance-api", + "placement-api", + "neutron-server", + "nova-api", + "nova-scheduler", + "nova-conductor", + "nova-compute", +] VOLUME_STACK = ["cinder-api", "cinder-scheduler", "cinder-volume"] BAREMETAL_STACK = ["ironic-api", "ironic-conductor", "libvirt"] @@ -96,7 +105,7 @@ def generate_manifest(self) -> dict[str, Any]: "spec": { "jobType": "integration", "services": sorted(self.services), - } + }, } # Add Tempest configuration if test regex exists @@ -108,9 +117,7 @@ def generate_manifest(self) -> dict[str, Any]: # Add DevStack localrc vars if present if self.job.devstack_localrc: - manifest["spec"]["configuration"] = { - "devstack_localrc": self.job.devstack_localrc - } + manifest["spec"]["configuration"] = {"devstack_localrc": self.job.devstack_localrc} return manifest @@ -144,8 +151,8 @@ def _determine_services(self) -> None: # Final fallback: if still no services (parent job defines them), use full compute stack if len(self.services) == len(INFRASTRUCTURE): logger.warning( - f"No services detected from job config or name. " - f"Using full compute stack as fallback." + "No services detected from job config or name. " + "Using full compute stack as fallback." ) self.services.update(COMPUTE_STACK) diff --git a/stackbox/zuul/parser.py b/stackbox/zuul/parser.py index 485b28d..3924a35 100644 --- a/stackbox/zuul/parser.py +++ b/stackbox/zuul/parser.py @@ -5,6 +5,7 @@ to produce complete JobConfig objects. """ +from dataclasses import dataclass import logging from pathlib import Path @@ -15,6 +16,25 @@ logger = logging.getLogger(__name__) +@dataclass +class JobRequirements: + """Requirements extracted from a Zuul job definition.""" + + job_name: str + driver: str | None = None # ipmi, redfish, idrac, ibmc, snmp, ilo + boot_mode: str | None = None # bios, uefi + deploy_interface: str | None = None # direct, iscsi, ansible, ramdisk + image_type: str | None = None # wholedisk, partition + ramdisk_type: str | None = None # tinyipa, dib + network_mode: str | None = None # flat, vlan, neutron + node_count: int | None = None + special_features: list[str] | None = None # ['inspector', 'standalone', 'grenade'] + + def __post_init__(self): + if self.special_features is None: + self.special_features = [] + + class ZuulJobParser: """ Parser for Zuul .zuul.yaml files. @@ -373,3 +393,200 @@ def get_statistics(self) -> dict: "jobs_by_parent": parents, "file_path": str(self.zuul_yaml_path), } + + def extract_job_requirements(self, job_name: str) -> JobRequirements: + """ + Extract technical requirements from job definition. + + Parses job name patterns and .zuul.yaml variables to determine + container compatibility requirements (driver, boot mode, etc.). + + Args: + job_name: Name of the Zuul job + + Returns: + JobRequirements with extracted configuration + + Raises: + ValueError: If job not found + """ + # Parse job to get full config + job_config = self.parse_job(job_name, resolve_inheritance=True) + requirements = JobRequirements(job_name=job_name) + + # Parse from job name pattern + requirements.driver = self._extract_driver_from_name(job_name) + requirements.boot_mode = self._extract_boot_mode_from_name(job_name) + requirements.image_type = self._extract_image_type_from_name(job_name) + requirements.ramdisk_type = self._extract_ramdisk_type_from_name(job_name) + requirements.special_features = self._extract_special_features_from_name(job_name) + + # Parse from devstack_localrc variables (overrides name-based extraction) + if job_config.devstack_localrc: + localrc = job_config.devstack_localrc + + # Driver + if "IRONIC_DEPLOY_DRIVER" in localrc: + requirements.driver = localrc["IRONIC_DEPLOY_DRIVER"] + + # Boot mode + if "IRONIC_BOOT_MODE" in localrc: + requirements.boot_mode = localrc["IRONIC_BOOT_MODE"] + + # Deploy interface + if "IRONIC_DEPLOY_INTERFACE" in localrc: + requirements.deploy_interface = localrc["IRONIC_DEPLOY_INTERFACE"] + + # Ramdisk type + if "IRONIC_RAMDISK_TYPE" in localrc: + requirements.ramdisk_type = localrc["IRONIC_RAMDISK_TYPE"] + + # Node count + if "IRONIC_VM_COUNT" in localrc: + try: + requirements.node_count = int(localrc["IRONIC_VM_COUNT"]) + except (ValueError, TypeError): + logger.warning(f"Could not parse IRONIC_VM_COUNT: {localrc['IRONIC_VM_COUNT']}") + + # Image type from WHOLE_DISK_IMAGE variable + if "IRONIC_TEMPEST_WHOLE_DISK_IMAGE" in localrc: + whole_disk = localrc["IRONIC_TEMPEST_WHOLE_DISK_IMAGE"] + # Handle boolean-like strings + if isinstance(whole_disk, str): + requirements.image_type = ( + "wholedisk" if whole_disk.lower() in ("true", "1", "yes") else "partition" + ) + else: + requirements.image_type = "wholedisk" if whole_disk else "partition" + + return requirements + + def _extract_driver_from_name(self, job_name: str) -> str | None: + """ + Extract driver from job name pattern. + + Examples: + 'ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa' -> 'ipmi' + 'ironic-tempest-bios-redfish-pxe' -> 'redfish' + 'ironic-standalone-idrac' -> 'idrac' + + Args: + job_name: Job name + + Returns: + Driver name or None if not found + """ + driver_patterns = { + "ipmitool": "ipmi", + "ipmi": "ipmi", + "redfish": "redfish", + "idrac": "idrac", + "ibmc": "ibmc", + "ilo": "ilo", + "snmp": "snmp", + } + + job_lower = job_name.lower() + for pattern, driver in driver_patterns.items(): + if pattern in job_lower: + return driver + + return None + + def _extract_boot_mode_from_name(self, job_name: str) -> str | None: + """ + Extract boot mode from job name. + + Examples: + 'ironic-tempest-ipa-wholedisk-bios-agent_ipmitool' -> 'bios' + 'ironic-tempest-ipa-partition-uefi-pxe_ipmitool' -> 'uefi' + + Args: + job_name: Job name + + Returns: + Boot mode ('bios' or 'uefi') or None + """ + job_lower = job_name.lower() + + if "uefi" in job_lower: + return "uefi" + elif "bios" in job_lower: + return "bios" + + return None + + def _extract_image_type_from_name(self, job_name: str) -> str | None: + """ + Extract image type from job name. + + Examples: + 'ironic-tempest-ipa-wholedisk-bios-agent_ipmitool' -> 'wholedisk' + 'ironic-tempest-ipa-partition-uefi-pxe_ipmitool' -> 'partition' + + Args: + job_name: Job name + + Returns: + Image type ('wholedisk' or 'partition') or None + """ + job_lower = job_name.lower() + + if "wholedisk" in job_lower: + return "wholedisk" + elif "partition" in job_lower: + return "partition" + + return None + + def _extract_ramdisk_type_from_name(self, job_name: str) -> str | None: + """ + Extract ramdisk type from job name. + + Examples: + 'ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa' -> 'tinyipa' + 'ironic-tempest-ipa-partition-bios-dib' -> 'dib' + + Args: + job_name: Job name + + Returns: + Ramdisk type ('tinyipa' or 'dib') or None + """ + job_lower = job_name.lower() + + if "tinyipa" in job_lower: + return "tinyipa" + elif "dib" in job_lower: + return "dib" + + return None + + def _extract_special_features_from_name(self, job_name: str) -> list[str]: + """ + Extract special features from job name. + + Examples: + 'ironic-inspector-tempest-discovery' -> ['inspector'] + 'ironic-standalone-redfish' -> ['standalone'] + 'ironic-grenade' -> ['grenade'] + + Args: + job_name: Job name + + Returns: + List of special feature names + """ + features = [] + job_lower = job_name.lower() + + if "inspector" in job_lower: + features.append("inspector") + if "standalone" in job_lower: + features.append("standalone") + if "grenade" in job_lower: + features.append("grenade") + if "fast-track" in job_lower: + features.append("fast-track") + + return features diff --git a/tests/integration/test_zuul_manifest_generator.py b/tests/integration/test_zuul_manifest_generator.py index 1124509..644986c 100644 --- a/tests/integration/test_zuul_manifest_generator.py +++ b/tests/integration/test_zuul_manifest_generator.py @@ -4,11 +4,12 @@ Tests the complete pipeline from parsing .zuul.yaml to generating StackBox manifests. """ -import pytest from pathlib import Path +import pytest + +from stackbox.zuul.manifest_generator import generate_manifest_from_job from stackbox.zuul.models import JobConfig -from stackbox.zuul.manifest_generator import ManifestGenerator, generate_manifest_from_job class TestManifestGenerator: @@ -16,10 +17,7 @@ class TestManifestGenerator: def test_compute_job_from_regex(self): """Test manifest generation for compute API tests.""" - job = JobConfig( - name="test-compute-api", - tempest_regex="api.*compute" - ) + job = JobConfig(name="test-compute-api", tempest_regex="api.*compute") manifest = generate_manifest_from_job(job) @@ -36,10 +34,7 @@ def test_compute_job_from_regex(self): def test_volume_job_from_regex(self): """Test manifest generation for volume API tests.""" - job = JobConfig( - name="test-volume-api", - tempest_regex="api.*volume" - ) + job = JobConfig(name="test-volume-api", tempest_regex="api.*volume") manifest = generate_manifest_from_job(job) @@ -53,8 +48,7 @@ def test_volume_job_from_regex(self): def test_baremetal_job_from_regex(self): """Test manifest generation for baremetal tests.""" job = JobConfig( - name="test-ironic-scenario", - tempest_regex="ironic_tempest_plugin.tests.scenario" + name="test-ironic-scenario", tempest_regex="ironic_tempest_plugin.tests.scenario" ) manifest = generate_manifest_from_job(job) @@ -93,10 +87,7 @@ def test_job_name_inference_nova(self): def test_infrastructure_always_included(self): """Test that infrastructure services are always included.""" - job = JobConfig( - name="any-job", - tempest_regex="api.*compute" - ) + job = JobConfig(name="any-job", tempest_regex="api.*compute") manifest = generate_manifest_from_job(job) @@ -106,9 +97,7 @@ def test_infrastructure_always_included(self): def test_metadata_fields(self): """Test manifest metadata is correctly populated.""" job = JobConfig( - name="test-job", - description="Test job description", - tempest_regex="api.*compute" + name="test-job", description="Test job description", tempest_regex="api.*compute" ) manifest = generate_manifest_from_job(job) @@ -120,10 +109,7 @@ def test_metadata_fields(self): def test_tempest_config_included_when_regex_present(self): """Test Tempest configuration is added when test regex exists.""" - job = JobConfig( - name="test-job", - tempest_regex="api.*compute" - ) + job = JobConfig(name="test-job", tempest_regex="api.*compute") manifest = generate_manifest_from_job(job) @@ -134,9 +120,7 @@ def test_tempest_config_included_when_regex_present(self): def test_no_tempest_config_when_no_regex(self): """Test Tempest config omitted when no test regex.""" - job = JobConfig( - name="non-tempest-job" - ) + job = JobConfig(name="non-tempest-job") manifest = generate_manifest_from_job(job) @@ -150,12 +134,7 @@ def test_devstack_localrc_preserved(self): job = JobConfig( name="test-job", tempest_regex="api.*compute", - vars={ - "devstack_localrc": { - "TEMPEST_COMPUTE_TYPE": "compute_legacy", - "DEBUG": "True" - } - } + vars={"devstack_localrc": {"TEMPEST_COMPUTE_TYPE": "compute_legacy", "DEBUG": "True"}}, ) # Trigger __post_init__ to extract devstack_localrc @@ -166,14 +145,14 @@ def test_devstack_localrc_preserved(self): assert "spec" in manifest assert "configuration" in manifest["spec"] assert "devstack_localrc" in manifest["spec"]["configuration"] - assert manifest["spec"]["configuration"]["devstack_localrc"]["TEMPEST_COMPUTE_TYPE"] == "compute_legacy" + assert ( + manifest["spec"]["configuration"]["devstack_localrc"]["TEMPEST_COMPUTE_TYPE"] + == "compute_legacy" + ) def test_related_services_added(self): """Test that related services are automatically added.""" - job = JobConfig( - name="test-job", - tempest_regex="api.*compute" - ) + job = JobConfig(name="test-job", tempest_regex="api.*compute") manifest = generate_manifest_from_job(job) @@ -185,10 +164,7 @@ def test_related_services_added(self): def test_service_count_optimization(self): """Test that job-specific manifests have fewer services than full platform.""" # Image-only job - image_job = JobConfig( - name="glance-api-test", - tempest_regex="api.*image" - ) + image_job = JobConfig(name="glance-api-test", tempest_regex="api.*image") image_manifest = generate_manifest_from_job(image_job) @@ -196,10 +172,7 @@ def test_service_count_optimization(self): assert len(image_manifest["spec"]["services"]) <= 5 # Compute job needs more - compute_job = JobConfig( - name="nova-api-test", - tempest_regex="api.*compute" - ) + compute_job = JobConfig(name="nova-api-test", tempest_regex="api.*compute") compute_manifest = generate_manifest_from_job(compute_job) @@ -209,7 +182,7 @@ def test_service_count_optimization(self): @pytest.mark.skipif( not Path("/tmp/nova-zuul/.zuul.yaml").exists(), - reason="Nova .zuul.yaml not available for integration test" + reason="Nova .zuul.yaml not available for integration test", ) class TestRealZuulJobs: """Integration tests with real Zuul job files.""" diff --git a/tests/unit/test_zuul_parser.py b/tests/unit/test_zuul_parser.py index e35a211..c6f006d 100644 --- a/tests/unit/test_zuul_parser.py +++ b/tests/unit/test_zuul_parser.py @@ -5,7 +5,7 @@ import pytest from stackbox.zuul.models import JobConfig -from stackbox.zuul.parser import ZuulJobParser +from stackbox.zuul.parser import JobRequirements, ZuulJobParser @pytest.fixture @@ -200,3 +200,171 @@ def test_get_platform(self, devstack_parser): nodeset = devstack_parser.get_nodeset("devstack-single-node-rockylinux-9") platform = nodeset.get_platform() assert platform == "rockylinux-9" + + +class TestJobRequirementsExtraction: + """Test extraction of job requirements from job names and configs.""" + + def test_extract_driver_from_name_ipmi(self): + """Test extracting IPMI driver from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) # Won't be loaded + + driver = parser._extract_driver_from_name( + "ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa" + ) + assert driver == "ipmi" + + def test_extract_driver_from_name_redfish(self): + """Test extracting Redfish driver from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + driver = parser._extract_driver_from_name("ironic-tempest-bios-redfish-pxe") + assert driver == "redfish" + + def test_extract_driver_from_name_idrac(self): + """Test extracting iDRAC driver from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + driver = parser._extract_driver_from_name("ironic-standalone-idrac") + assert driver == "idrac" + + def test_extract_driver_from_name_none(self): + """Test when no driver found in job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + driver = parser._extract_driver_from_name("some-other-job") + assert driver is None + + def test_extract_boot_mode_bios(self): + """Test extracting BIOS boot mode from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + boot_mode = parser._extract_boot_mode_from_name( + "ironic-tempest-ipa-wholedisk-bios-agent_ipmitool" + ) + assert boot_mode == "bios" + + def test_extract_boot_mode_uefi(self): + """Test extracting UEFI boot mode from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + boot_mode = parser._extract_boot_mode_from_name( + "ironic-tempest-ipa-partition-uefi-pxe_ipmitool" + ) + assert boot_mode == "uefi" + + def test_extract_boot_mode_none(self): + """Test when no boot mode found in job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + boot_mode = parser._extract_boot_mode_from_name("ironic-standalone-redfish") + assert boot_mode is None + + def test_extract_image_type_wholedisk(self): + """Test extracting wholedisk image type from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + image_type = parser._extract_image_type_from_name( + "ironic-tempest-ipa-wholedisk-bios-agent_ipmitool" + ) + assert image_type == "wholedisk" + + def test_extract_image_type_partition(self): + """Test extracting partition image type from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + image_type = parser._extract_image_type_from_name( + "ironic-tempest-ipa-partition-uefi-pxe_ipmitool" + ) + assert image_type == "partition" + + def test_extract_ramdisk_type_tinyipa(self): + """Test extracting tinyipa ramdisk type from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + ramdisk_type = parser._extract_ramdisk_type_from_name( + "ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa" + ) + assert ramdisk_type == "tinyipa" + + def test_extract_ramdisk_type_dib(self): + """Test extracting DIB ramdisk type from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + ramdisk_type = parser._extract_ramdisk_type_from_name( + "ironic-tempest-ipa-partition-bios-dib" + ) + assert ramdisk_type == "dib" + + def test_extract_special_features_inspector(self): + """Test extracting inspector feature from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + features = parser._extract_special_features_from_name("ironic-inspector-tempest-discovery") + assert "inspector" in features + + def test_extract_special_features_standalone(self): + """Test extracting standalone feature from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + features = parser._extract_special_features_from_name("ironic-standalone-redfish") + assert "standalone" in features + + def test_extract_special_features_grenade(self): + """Test extracting grenade feature from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + features = parser._extract_special_features_from_name("ironic-grenade") + assert "grenade" in features + + def test_extract_special_features_fast_track(self): + """Test extracting fast-track feature from job name.""" + parser = ZuulJobParser(Path("/nonexistent")) + + features = parser._extract_special_features_from_name( + "ironic-inspector-tempest-discovery-fast-track" + ) + assert "fast-track" in features + + def test_extract_special_features_multiple(self): + """Test extracting multiple special features.""" + parser = ZuulJobParser(Path("/nonexistent")) + + features = parser._extract_special_features_from_name("ironic-inspector-standalone-grenade") + assert "inspector" in features + assert "standalone" in features + assert "grenade" in features + + def test_extract_special_features_none(self): + """Test when no special features found.""" + parser = ZuulJobParser(Path("/nonexistent")) + + features = parser._extract_special_features_from_name( + "ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa" + ) + assert len(features) == 0 + + def test_job_requirements_dataclass(self): + """Test JobRequirements dataclass initialization.""" + reqs = JobRequirements( + job_name="test-job", + driver="ipmi", + boot_mode="bios", + image_type="wholedisk", + ramdisk_type="tinyipa", + ) + + assert reqs.job_name == "test-job" + assert reqs.driver == "ipmi" + assert reqs.boot_mode == "bios" + assert reqs.image_type == "wholedisk" + assert reqs.ramdisk_type == "tinyipa" + assert reqs.special_features == [] # __post_init__ sets default + + def test_job_requirements_with_features(self): + """Test JobRequirements with special features.""" + reqs = JobRequirements(job_name="test-job", special_features=["inspector", "standalone"]) + + assert len(reqs.special_features) == 2 + assert "inspector" in reqs.special_features + assert "standalone" in reqs.special_features diff --git a/tests/validation/__init__.py b/tests/validation/__init__.py new file mode 100644 index 0000000..1c130bf --- /dev/null +++ b/tests/validation/__init__.py @@ -0,0 +1 @@ +"""Tests for validation module.""" diff --git a/tests/validation/test_job_compatibility.py b/tests/validation/test_job_compatibility.py new file mode 100644 index 0000000..937e566 --- /dev/null +++ b/tests/validation/test_job_compatibility.py @@ -0,0 +1,266 @@ +"""Unit tests for job compatibility validation.""" + +from stackbox.validation.job_compatibility import ( + CompatibilityResult, + CompatibilityStatus, + ContainerCapabilities, + JobCompatibilityValidator, +) +from stackbox.zuul.parser import JobRequirements + + +class TestContainerCapabilities: + """Test container capabilities dataclass.""" + + def test_default_capabilities(self): + """Test default capabilities are set correctly.""" + caps = ContainerCapabilities() + + assert "ipmi" in caps.supported_drivers + assert "redfish" in caps.supported_drivers + assert "bios" in caps.supported_boot_modes + assert "uefi" in caps.supported_boot_modes + assert "direct" in caps.supported_deploy_interfaces + assert "iscsi" in caps.supported_deploy_interfaces + assert "wholedisk" in caps.supported_image_types + assert "partition" in caps.supported_image_types + assert "tinyipa" in caps.supported_ramdisk_types + assert "dib" in caps.supported_ramdisk_types + + def test_custom_capabilities(self): + """Test custom capabilities override defaults.""" + caps = ContainerCapabilities(supported_drivers=["ipmi"], supported_boot_modes=["bios"]) + + assert caps.supported_drivers == ["ipmi"] + assert caps.supported_boot_modes == ["bios"] + + +class TestCompatibilityResult: + """Test compatibility result dataclass.""" + + def test_result_creation(self): + """Test creating a compatibility result.""" + reqs = JobRequirements(job_name="test-job", driver="ipmi") + + result = CompatibilityResult( + job_name="test-job", status=CompatibilityStatus.VERIFIED, requirements=reqs + ) + + assert result.job_name == "test-job" + assert result.status == CompatibilityStatus.VERIFIED + assert result.requirements.driver == "ipmi" + assert result.notes == [] + + def test_result_with_notes(self): + """Test result with notes.""" + reqs = JobRequirements(job_name="test-job") + + result = CompatibilityResult( + job_name="test-job", + status=CompatibilityStatus.SHOULD_WORK, + requirements=reqs, + notes=["Note 1", "Note 2"], + ) + + assert len(result.notes) == 2 + assert "Note 1" in result.notes + + +class TestJobCompatibilityValidator: + """Test job compatibility validation.""" + + def test_verified_job(self): + """Test job in verified list returns VERIFIED status.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements( + job_name="ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa", + driver="ipmi", + boot_mode="bios", + image_type="wholedisk", + ramdisk_type="tinyipa", + ) + + result = validator.check_job(reqs) + assert result.status == CompatibilityStatus.VERIFIED + assert any("Confirmed working" in note for note in result.notes) + + def test_should_work_job(self): + """Test job with supported requirements returns SHOULD_WORK.""" + validator = JobCompatibilityValidator() + + # Redfish + UEFI + partition (supported but not in verified list) + reqs = JobRequirements( + job_name="ironic-tempest-ipa-partition-uefi-redfish-tinyipa-NEW", + driver="redfish", + boot_mode="uefi", + deploy_interface="direct", + image_type="partition", + ramdisk_type="tinyipa", + ) + + result = validator.check_job(reqs) + assert result.status == CompatibilityStatus.SHOULD_WORK + assert any("Driver 'redfish' supported" in note for note in result.notes) + assert any("Boot mode 'uefi' supported" in note for note in result.notes) + assert any("All requirements match" in note for note in result.notes) + + def test_unsupported_driver(self): + """Test job with unsupported driver returns UNTESTED.""" + validator = JobCompatibilityValidator() + + # iDRAC not in supported drivers list + reqs = JobRequirements( + job_name="ironic-tempest-idrac-uefi-test", driver="idrac", boot_mode="bios" + ) + + result = validator.check_job(reqs) + assert result.status == CompatibilityStatus.UNTESTED + assert any("Driver 'idrac' not supported" in note for note in result.notes) + + def test_known_limitation(self): + """Test job with known limitation returns KNOWN_LIMITATION.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements( + job_name="ironic-tempest-idrac-bios", driver="idrac", boot_mode="bios" + ) + + result = validator.check_job(reqs) + assert result.status == CompatibilityStatus.KNOWN_LIMITATION + assert any("Dell hardware" in note for note in result.notes) + + def test_special_features_noted(self): + """Test special features are noted as untested.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements( + job_name="ironic-standalone-redfish", driver="redfish", special_features=["standalone"] + ) + + result = validator.check_job(reqs) + assert any("standalone" in note.lower() for note in result.notes) + + def test_multiple_special_features(self): + """Test multiple special features are all noted.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements( + job_name="ironic-inspector-standalone-test", + driver="ipmi", + special_features=["inspector", "standalone"], + ) + + result = validator.check_job(reqs) + assert any("inspector" in note.lower() for note in result.notes) + assert any("standalone" in note.lower() for note in result.notes) + + def test_validate_all_jobs(self): + """Test validating multiple jobs.""" + validator = JobCompatibilityValidator() + + jobs = [ + JobRequirements(job_name="job1", driver="ipmi", boot_mode="bios"), + JobRequirements(job_name="job2", driver="redfish", boot_mode="uefi"), + JobRequirements(job_name="job3", driver="idrac", boot_mode="bios"), + ] + + results = validator.validate_all_jobs(jobs) + + assert len(results) == 3 + assert "job1" in results + assert "job2" in results + assert "job3" in results + assert results["job1"].status == CompatibilityStatus.SHOULD_WORK + assert results["job2"].status == CompatibilityStatus.SHOULD_WORK + assert results["job3"].status == CompatibilityStatus.UNTESTED + + def test_get_stats(self): + """Test statistics calculation.""" + validator = JobCompatibilityValidator() + + # Create mock requirements for different statuses + verified_req = JobRequirements( + job_name="ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa" + ) + should_work_req = JobRequirements(job_name="new-job-1", driver="ipmi", boot_mode="bios") + untested_req = JobRequirements(job_name="new-job-2", driver="idrac") + limitation_req = JobRequirements(job_name="ironic-tempest-idrac-bios") + + # Validate all + results = validator.validate_all_jobs( + [verified_req, should_work_req, untested_req, limitation_req] + ) + + stats = validator.get_stats(results) + + assert stats["total"] == 4 + assert stats["verified"] == 1 + assert stats["should_work"] == 1 + assert stats["untested"] == 1 + assert stats["known_limitation"] == 1 + + def test_empty_requirements(self): + """Test job with no specific requirements.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements(job_name="minimal-job") + + result = validator.check_job(reqs) + # Should work since no unsupported requirements + assert result.status == CompatibilityStatus.SHOULD_WORK + + def test_partial_requirements(self): + """Test job with only some requirements specified.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements( + job_name="partial-job", + driver="ipmi", + # boot_mode not specified + image_type="wholedisk", + # deploy_interface not specified + ) + + result = validator.check_job(reqs) + assert result.status == CompatibilityStatus.SHOULD_WORK + assert any("Driver 'ipmi' supported" in note for note in result.notes) + assert any("Image type 'wholedisk' supported" in note for note in result.notes) + + def test_all_requirements_supported(self): + """Test job with all requirements supported.""" + validator = JobCompatibilityValidator() + + reqs = JobRequirements( + job_name="full-supported-job", + driver="redfish", + boot_mode="uefi", + deploy_interface="direct", + image_type="partition", + ramdisk_type="tinyipa", + ) + + result = validator.check_job(reqs) + assert result.status == CompatibilityStatus.SHOULD_WORK + # Should have checkmarks for all requirements + assert any("Driver 'redfish' supported" in note for note in result.notes) + assert any("Boot mode 'uefi' supported" in note for note in result.notes) + assert any("Deploy interface 'direct' supported" in note for note in result.notes) + assert any("Image type 'partition' supported" in note for note in result.notes) + assert any("Ramdisk type 'tinyipa' supported" in note for note in result.notes) + + +class TestCompatibilityStatus: + """Test CompatibilityStatus enum.""" + + def test_status_values(self): + """Test enum values are correct.""" + assert CompatibilityStatus.VERIFIED.value == "verified" + assert CompatibilityStatus.SHOULD_WORK.value == "should-work" + assert CompatibilityStatus.UNTESTED.value == "untested" + assert CompatibilityStatus.KNOWN_LIMITATION.value == "known-limitation" + + def test_status_comparison(self): + """Test enum comparison.""" + assert CompatibilityStatus.VERIFIED == CompatibilityStatus.VERIFIED + assert CompatibilityStatus.VERIFIED != CompatibilityStatus.SHOULD_WORK