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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion stackbox/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,13 +480,45 @@ def init(
click.echo(f"\n❌ Failed to enroll node: {e}", err=True)
sys.exit(1)

# TODO: Phase 9 (Issue #11): Configure Tempest
# Phase 9: Generate Tempest configuration
click.echo("\n⚙️ Generating Tempest configuration...")
try:
config_gen = config.ConfigGenerator()

# Generate tempest.conf
tempest_conf = config_dir_path / "config" / "tempest" / "tempest.conf"
config_gen.generate_tempest_conf(
output_path=tempest_conf,
debug=verbose,
ironic_api_url="http://ironic-api:6385",
driver="redfish",
deploy_interface="direct",
)
click.echo(f" ✅ Generated: {tempest_conf.relative_to(Path.cwd())}")

# Generate accounts.yaml
accounts_file = config_dir_path / "config" / "tempest" / "accounts.yaml"
config_gen.generate_tempest_accounts(output_path=accounts_file)
click.echo(f" ✅ Generated: {accounts_file.relative_to(Path.cwd())}")

# Validate configuration
if config_gen.validate_tempest_config(tempest_conf):
click.echo(" ✅ Configuration validated")
else:
click.echo(" ⚠️ Configuration validation failed", err=True)

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

# TODO: Phase 10 (Issue #12): Install Tempest and ironic-tempest-plugin

click.echo("\n✅ Initialization complete!")
click.echo(" Ironic API: http://localhost:6385")
click.echo(f" Virtual node: {node_name}")
click.echo(" BMC endpoint: http://localhost:8000/redfish/v1/")
click.echo(f" Enrolled in Ironic: {enrolled_node['uuid']}")
click.echo(f" Tempest config: {tempest_conf.relative_to(Path.cwd())}")

def rebuild(self, service: str, no_cache: bool) -> None:
"""Rebuild a service after code changes."""
Expand Down
84 changes: 84 additions & 0 deletions stackbox/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,87 @@ def generate_sushy_conf(

output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(config_content)

def generate_tempest_conf(
self,
output_path: Path,
debug: bool = True,
ironic_api_url: str = "http://ironic-api:6385",
driver: str = "redfish",
deploy_interface: str = "direct",
accounts_file: str = "/etc/tempest/accounts.yaml",
) -> None:
"""Generate tempest.conf from template.

Args:
output_path: Where to write tempest.conf
debug: Enable debug logging
ironic_api_url: Ironic API endpoint URL
driver: Hardware type (default: redfish)
deploy_interface: Deploy interface (default: direct)
accounts_file: Path to accounts.yaml (in container)
"""
template = self.env.get_template("tempest/tempest.conf.j2")

config_content = template.render(
debug=debug,
ironic_api_url=ironic_api_url,
driver=driver,
deploy_interface=deploy_interface,
accounts_file=accounts_file,
min_microversion="1.1",
max_microversion="1.80",
)

output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(config_content)

def generate_tempest_accounts(
self,
output_path: Path,
admin_username: str = "admin",
admin_tenant: str = "admin",
admin_password: str = "password",
) -> None:
"""Generate accounts.yaml for Tempest.

Args:
output_path: Where to write accounts.yaml
admin_username: Admin account username
admin_tenant: Admin tenant/project name
admin_password: Admin account password (not validated in noauth)
"""
template = self.env.get_template("tempest/accounts.yaml.j2")

accounts_content = template.render(
admin_username=admin_username,
admin_tenant=admin_tenant,
admin_password=admin_password,
)

output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(accounts_content)

def validate_tempest_config(self, config_path: Path) -> bool:
"""Validate that Tempest configuration has required sections.

Args:
config_path: Path to tempest.conf file

Returns:
True if all required sections present, False otherwise
"""
if not config_path.exists():
return False

content = config_path.read_text()

required_sections = [
"[DEFAULT]",
"[baremetal]",
"[service_available]",
"[auth]",
"[identity]",
]

return all(section in content for section in required_sections)
8 changes: 4 additions & 4 deletions stackbox/templates/ironic/ironic.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ port = {{ api_port }}

[conductor]
automated_clean = False
enabled_hardware_types = ipmi,fake-hardware
enabled_boot_interfaces = pxe,fake
enabled_hardware_types = ipmi,redfish,fake-hardware
enabled_boot_interfaces = pxe,redfish-virtual-media,fake
enabled_deploy_interfaces = direct,fake
enabled_inspect_interfaces = no-inspect
enabled_management_interfaces = ipmitool,fake
enabled_management_interfaces = ipmitool,redfish,fake
enabled_network_interfaces = flat,noop
enabled_power_interfaces = ipmitool,fake
enabled_power_interfaces = ipmitool,redfish,fake
enabled_raid_interfaces = no-raid
enabled_storage_interfaces = noop
enabled_vendor_interfaces = no-vendor
Expand Down
10 changes: 10 additions & 0 deletions stackbox/templates/tempest/accounts.yaml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Tempest test accounts for noauth mode
# These credentials are not validated, but Tempest framework requires this file

- username: {{ admin_username|default('admin') }}
tenant_name: {{ admin_tenant|default('admin') }}
password: {{ admin_password|default('password') }}
project_name: {{ admin_tenant|default('admin') }}
domain_name: Default
types:
- admin
77 changes: 77 additions & 0 deletions stackbox/templates/tempest/tempest.conf.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
[DEFAULT]
# Logging configuration
log_file = tempest.log
debug = {{ debug|default(true) }}
use_stderr = false

[auth]
# Authentication - noauth mode doesn't validate these, but Tempest requires them
use_dynamic_credentials = false
test_accounts_file = {{ accounts_file|default('/etc/tempest/accounts.yaml') }}

[identity]
# Keystone endpoints - not used in noauth, but required by Tempest framework
uri = http://localhost:5000/v3
uri_v3 = http://localhost:5000/v3
auth_version = v3
region = RegionOne

[service_available]
# Which OpenStack services are available
ironic = true
ironic_inspector = false
nova = false
neutron = false
glance = false
swift = false

[baremetal]
# Ironic API configuration
catalog_type = baremetal
endpoint_type = public

# Driver configuration - must match enrolled nodes
driver = {{ driver|default('redfish') }}
deploy_interface = {{ deploy_interface|default('direct') }}

# API versioning
min_microversion = {{ min_microversion|default('1.1') }}
max_microversion = {{ max_microversion|default('1.80') }}

# Timeouts (seconds) - generous for slower test environments
active_timeout = 900
association_timeout = 30
deploy_timeout = 900
power_timeout = 90
unprovision_timeout = 300
deploywait_timeout = 900

# Deployment configuration
partition_netboot = false
whole_disk_image = true
use_provision_network = false

# Node resource class
resource_class = baremetal

[baremetal_introspection]
# Introspection not available in basic setup
enabled = false

[validation]
# Connectivity validation settings
connect_method = fixed
run_validation = false
image_ssh_user = tc

[compute]
# Not using Nova, but Tempest framework requires this section
image_ref = ipa-test-image
image_ref_alt = ipa-test-image
flavor_ref = baremetal
flavor_ref_alt = baremetal

[scenario]
# Image locations for scenario tests
img_dir = /var/lib/ironic/httpboot
img_file = tinyipa.gz
132 changes: 132 additions & 0 deletions tests/unit/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,135 @@ def test_generate_sushy_conf_uses_tcp_not_socket(self, tmp_path: Path) -> None:
# Should NOT use Unix socket
assert "qemu+unix" not in content
assert "libvirt-sock" not in content


class TestTempestConfigGeneration:
"""Tests for Tempest configuration generation."""

def test_generate_tempest_conf_creates_file(self, tmp_path: Path) -> None:
"""Test that tempest.conf file is created."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

gen.generate_tempest_conf(output_path=output_file)

assert output_file.exists()

def test_generate_tempest_conf_has_required_sections(self, tmp_path: Path) -> None:
"""Test that generated config has all required sections."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

gen.generate_tempest_conf(output_path=output_file)

content = output_file.read_text()
required_sections = [
"[DEFAULT]",
"[baremetal]",
"[service_available]",
"[auth]",
"[identity]",
"[baremetal_introspection]",
"[validation]",
"[compute]",
"[scenario]",
]

for section in required_sections:
assert section in content, f"Missing section: {section}"

def test_generate_tempest_conf_sets_ironic_true(self, tmp_path: Path) -> None:
"""Test that service_available.ironic = true."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

gen.generate_tempest_conf(output_path=output_file)

content = output_file.read_text()
assert "ironic = true" in content

def test_generate_tempest_conf_sets_driver(self, tmp_path: Path) -> None:
"""Test that driver is set correctly."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

gen.generate_tempest_conf(output_path=output_file)

content = output_file.read_text()
assert "driver = redfish" in content

def test_generate_tempest_conf_custom_params(self, tmp_path: Path) -> None:
"""Test that custom parameters override defaults."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

gen.generate_tempest_conf(output_path=output_file, driver="ipmi", debug=False)

content = output_file.read_text()
assert "driver = ipmi" in content
assert "debug = false" in content.lower()

def test_generate_tempest_accounts_creates_file(self, tmp_path: Path) -> None:
"""Test that accounts.yaml file is created."""
gen = ConfigGenerator()
output_file = tmp_path / "accounts.yaml"

gen.generate_tempest_accounts(output_path=output_file)

assert output_file.exists()

def test_generate_tempest_accounts_valid_yaml(self, tmp_path: Path) -> None:
"""Test that generated accounts file is valid YAML."""
import yaml

gen = ConfigGenerator()
output_file = tmp_path / "accounts.yaml"

gen.generate_tempest_accounts(output_path=output_file)

content = output_file.read_text()
data = yaml.safe_load(content)

assert isinstance(data, list)
assert len(data) == 1
assert data[0]["username"] == "admin"
assert data[0]["tenant_name"] == "admin"

def test_validate_tempest_config_valid(self, tmp_path: Path) -> None:
"""Test validation passes for valid config."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

gen.generate_tempest_conf(output_path=output_file)

assert gen.validate_tempest_config(output_file) is True

def test_validate_tempest_config_missing_file(self, tmp_path: Path) -> None:
"""Test validation fails for missing file."""
gen = ConfigGenerator()
nonexistent_file = tmp_path / "nonexistent.conf"

assert gen.validate_tempest_config(nonexistent_file) is False

def test_validate_tempest_config_missing_sections(self, tmp_path: Path) -> None:
"""Test validation fails when required sections are missing."""
gen = ConfigGenerator()
output_file = tmp_path / "tempest.conf"

# Create incomplete config
output_file.write_text("[DEFAULT]\nlog_file = test.log\n")

assert gen.validate_tempest_config(output_file) is False

def test_generate_tempest_conf_creates_parent_dirs(self, tmp_path: Path) -> None:
"""Test that parent directories are created if they don't exist."""
gen = ConfigGenerator()
output_file = tmp_path / "nested" / "path" / "tempest.conf"

# Ensure directory doesn't exist
assert not output_file.parent.exists()

gen.generate_tempest_conf(output_path=output_file)

assert output_file.parent.exists()
assert output_file.exists()
Loading