diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index 6c32f7f..f44d3ea 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -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.""" diff --git a/stackbox/core/config.py b/stackbox/core/config.py index 286655a..3df5441 100644 --- a/stackbox/core/config.py +++ b/stackbox/core/config.py @@ -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) diff --git a/stackbox/templates/ironic/ironic.conf.j2 b/stackbox/templates/ironic/ironic.conf.j2 index 395fcd9..f9e217c 100644 --- a/stackbox/templates/ironic/ironic.conf.j2 +++ b/stackbox/templates/ironic/ironic.conf.j2 @@ -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 diff --git a/stackbox/templates/tempest/accounts.yaml.j2 b/stackbox/templates/tempest/accounts.yaml.j2 new file mode 100644 index 0000000..7311e00 --- /dev/null +++ b/stackbox/templates/tempest/accounts.yaml.j2 @@ -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 diff --git a/stackbox/templates/tempest/tempest.conf.j2 b/stackbox/templates/tempest/tempest.conf.j2 new file mode 100644 index 0000000..1af2ffd --- /dev/null +++ b/stackbox/templates/tempest/tempest.conf.j2 @@ -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 diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index d50a782..26993f4 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -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()