diff --git a/charms/tempest-k8s/charmcraft.yaml b/charms/tempest-k8s/charmcraft.yaml index 4cb8a968..6e0ad547 100644 --- a/charms/tempest-k8s/charmcraft.yaml +++ b/charms/tempest-k8s/charmcraft.yaml @@ -47,6 +47,13 @@ config: "*/30 * * * *" every 30 minutes "5 2 * * *" at 2:05am every day "5 2 * * mon" at 2:05am every Monday + roles: + type: string + default: storage,compute,control + description: | + A comma-separated list of Sunbeam node roles to be used for enabling + tempest tests. Removing a role from the list will disable tempest tests + relating to that role. Valid roles are: storage, compute, control. actions: validate: diff --git a/charms/tempest-k8s/src/charm.py b/charms/tempest-k8s/src/charm.py index 1d96be6e..785f9386 100755 --- a/charms/tempest-k8s/src/charm.py +++ b/charms/tempest-k8s/src/charm.py @@ -19,6 +19,8 @@ This charm provide Tempest as part of an OpenStack deployment """ +import hashlib +import json import logging import os from typing import ( @@ -71,6 +73,7 @@ from utils.constants import ( ) from utils.overrides import ( get_compute_overrides, + get_role_based_overrides, get_swift_overrides, ) from utils.types import ( @@ -112,6 +115,7 @@ class TempestOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): """Run the constructor.""" # config for openstack, used by tempest super().__init__(framework) + self._state.set_default(config_rebuild_hash="") self.framework.observe( self.on.validate_action, self._on_validate_action ) @@ -247,8 +251,9 @@ class TempestOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): ( get_swift_overrides(), get_compute_overrides(), + get_role_based_overrides(self.config["roles"]), ) - ) + ).strip() def _get_environment_for_tempest( self, variant: TempestEnvVariant @@ -369,6 +374,11 @@ class TempestOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): f"invalid schedule config: {schedule.err}" ) + # Only trigger rebuild if config options change + updated_hash = self._current_config_hash() + if updated_hash != self._state.config_rebuild_hash: + self.set_tempest_ready(False) + self.status.set(MaintenanceStatus("tempest init in progress")) self.init_tempest() @@ -391,6 +401,7 @@ class TempestOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): for relation in self.model.relations[LOKI_RELATION_NAME]: self.logging.interface._handle_alert_rules(relation) + self._state.config_rebuild_hash = updated_hash self.status.set(ActiveStatus("")) logger.info("Finished configuring the tempest environment") @@ -460,6 +471,19 @@ class TempestOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): # display neatly to the user. This will also end up in the action output results.stdout print("\n".join(lists)) + def _get_relevant_tempest_config_values(self) -> dict: + """Return values that should trigger a rebuild.""" + return { + "roles": self.config.get("roles"), + "region": self.config.get("region"), + } + + def _current_config_hash(self) -> str: + data = self._get_relevant_tempest_config_values() + blob = json.dumps(data, sort_keys=True).encode() + logger.info(f"Hashing config data: {blob}") + return hashlib.sha256(blob).hexdigest() + if __name__ == "__main__": # pragma: nocover ops.main(TempestOperatorCharm) diff --git a/charms/tempest-k8s/src/templates/tempest-init.j2 b/charms/tempest-k8s/src/templates/tempest-init.j2 index 77c3efa8..c9fcaa72 100644 --- a/charms/tempest-k8s/src/templates/tempest-init.j2 +++ b/charms/tempest-k8s/src/templates/tempest-init.j2 @@ -14,7 +14,7 @@ rm -rf "${TEMPEST_HOME}/.tempest" tempest init --name "$TEMPEST_WORKSPACE" "$TEMPEST_WORKSPACE_PATH" -discover-tempest-config --out "$TEMPEST_CONF" --create $TEMPEST_CONFIG_OVERRIDES +discover-tempest-config --out "$TEMPEST_CONF" --create ${TEMPEST_CONFIG_OVERRIDES:+$TEMPEST_CONFIG_OVERRIDES} tempest account-generator -r "$TEMPEST_ACCOUNTS_COUNT" -c "$TEMPEST_CONF" "$TEMPEST_TEST_ACCOUNTS" diff --git a/charms/tempest-k8s/src/templates/tempest-run-wrapper.j2 b/charms/tempest-k8s/src/templates/tempest-run-wrapper.j2 index 2a8e0847..c880d0c1 100644 --- a/charms/tempest-k8s/src/templates/tempest-run-wrapper.j2 +++ b/charms/tempest-k8s/src/templates/tempest-run-wrapper.j2 @@ -20,8 +20,7 @@ rm -rf /tmp/tempest-lock/ TMP_FILE="$(mktemp)" echo ":: discover-tempest-config" >> "$TMP_FILE" -if discover-tempest-config --test-accounts "$TEMPEST_TEST_ACCOUNTS" --out "$TEMPEST_CONF" >> "$TMP_FILE" 2>&1; then - echo ":: tempest run" >> "$TMP_FILE" +if discover-tempest-config --test-accounts "$TEMPEST_TEST_ACCOUNTS" --out "$TEMPEST_CONF" ${TEMPEST_CONFIG_OVERRIDES:+$TEMPEST_CONFIG_OVERRIDES} >> "$TMP_FILE" 2>&1; then tempest run --exclude-list "$TEMPEST_EXCLUDE_LIST" --workspace "$TEMPEST_WORKSPACE" -w "$TEMPEST_CONCURRENCY" "$@" >> "$TMP_FILE" 2>&1 python3 "$TEMPEST_HOME/cleanup.py" quick else diff --git a/charms/tempest-k8s/src/utils/overrides.py b/charms/tempest-k8s/src/utils/overrides.py index 3007f2bb..51f2c322 100644 --- a/charms/tempest-k8s/src/utils/overrides.py +++ b/charms/tempest-k8s/src/utils/overrides.py @@ -13,6 +13,18 @@ # limitations under the License. """Tempest configuration overrides.""" +import logging +from typing import ( + List, + Set, +) + +from ops_sunbeam.guard import ( + BlockedExceptionError, +) + +logger = logging.getLogger(__name__) + def get_swift_overrides() -> str: """Return swift configuration override. @@ -34,3 +46,57 @@ def get_compute_overrides() -> str: "compute-feature-enabled.cold_migration false", # lp:2082056 ) ) + + +def get_role_based_overrides(config_roles: str) -> str: + """Generate tempest.conf overrides based on the configured roles. + + :param configured_roles: A set of role strings, e.g., {"compute", "storage"}. + :return: A string of space-separated key-value pairs for overrides. + """ + configured_roles = _parse_roles_config(config_roles) + overrides: List[str] = [] + + if "storage" not in configured_roles: + logger.info("Storage role not configured, disabling cinder tests.") + overrides.extend(["service_available.cinder", "false"]) + + if "compute" not in configured_roles: + logger.info("Compute role not configured, disabling nova tests.") + overrides.extend(["service_available.nova", "false"]) + + return " ".join(overrides) + + +def _parse_roles_config(config_roles: str) -> Set[str]: + """Parses the 'roles' config string from the charm's configuration. + + :param config_roles: The charm's config object (self.config in the charm). + :return: A set of lower-case role strings. + """ + if not config_roles.strip(): + error_message = ( + "Config option 'roles' must contain at least one role " + "(compute, control, storage)." + ) + logger.error(error_message) + raise BlockedExceptionError(error_message) + + parsed_user_roles = { + role.strip().lower() + for role in config_roles.split(",") + if role.strip() + } + + valid_roles = {"compute", "control", "storage"} + + invalid_user_roles = parsed_user_roles - valid_roles + if invalid_user_roles: + error_message = ( + f"Invalid roles specified in 'roles' config: {', '.join(invalid_user_roles)}. " + f"Valid roles are: {', '.join(valid_roles)}." + ) + logger.error(error_message) + raise BlockedExceptionError(error_message) + + return parsed_user_roles diff --git a/charms/tempest-k8s/tests/unit/test_tempest_charm.py b/charms/tempest-k8s/tests/unit/test_tempest_charm.py index fda0fc63..18b268e8 100644 --- a/charms/tempest-k8s/tests/unit/test_tempest_charm.py +++ b/charms/tempest-k8s/tests/unit/test_tempest_charm.py @@ -29,6 +29,9 @@ import charm import ops_sunbeam.test_utils as test_utils import utils import yaml +from ops_sunbeam.guard import ( + BlockedExceptionError, +) from utils import ( overrides, ) @@ -489,7 +492,7 @@ class TestTempestOperatorCharm(test_utils.CharmTestCase): self.harness.charm.set_tempest_ready.assert_has_calls( [call(False), call(False)] ) - self.assertEqual(self.harness.charm.set_tempest_ready.call_count, 2) + self.assertEqual(self.harness.charm.set_tempest_ready.call_count, 3) self.assertIn( "tempest init failed", self.harness.charm.status.message() ) @@ -659,3 +662,127 @@ class TestTempestOperatorCharm(test_utils.CharmTestCase): self.harness.remove_relation(rel_id) self.harness.charm.logging.interface._promtail_config.return_value = {} self.assertEqual(self.harness.charm.logging.ready, False) + + def _check_override_in_string( + self, + key: str, + value: str, + actual_string: str, + should_be_present: bool = True, + ): + """Checks if a specific 'key value' pair is in the override string.""" + expected_pair = f"{key} {value}" + if should_be_present: + self.assertIn( + expected_pair, + actual_string, + f"Expected '{expected_pair}' to be in '{actual_string}'", + ) + else: + self.assertNotIn( + expected_pair, + actual_string, + f"Expected '{expected_pair}' NOT to be in '{actual_string}'", + ) + + def test_roles_default_overrides(self): + """Verify role-based overrides when roles config is default (all roles).""" + self.add_identity_ops_relation(self.harness) + test_utils.set_all_pebbles_ready(self.harness) + + self.harness.update_config({"roles": "compute,control,storage"}) + + env = self.harness.charm._get_environment_for_tempest( + TempestEnvVariant.ADHOC + ) + actual_combined_overrides = env.get("TEMPEST_CONFIG_OVERRIDES", "") + + self._check_override_in_string( + "service_available.cinder", + "false", + actual_combined_overrides, + should_be_present=False, + ) + self._check_override_in_string( + "service_available.nova", + "false", + actual_combined_overrides, + should_be_present=False, + ) + + def test_roles_compute_only_overrides(self): + """Verify role-based overrides when roles = 'compute'.""" + self.add_identity_ops_relation(self.harness) + test_utils.set_all_pebbles_ready(self.harness) + + self.harness.update_config({"roles": "compute"}) + env = self.harness.charm._get_environment_for_tempest( + TempestEnvVariant.ADHOC + ) + actual_combined_overrides = env.get("TEMPEST_CONFIG_OVERRIDES", "") + + self._check_override_in_string( + "service_available.cinder", + "false", + actual_combined_overrides, + should_be_present=True, + ) + self._check_override_in_string( + "service_available.nova", + "false", + actual_combined_overrides, + should_be_present=False, + ) + + def test_roles_storage_only_overrides(self): + """Verify role-based overrides when roles = 'storage'.""" + self.add_identity_ops_relation(self.harness) + test_utils.set_all_pebbles_ready(self.harness) + + self.harness.update_config({"roles": "storage"}) + env = self.harness.charm._get_environment_for_tempest( + TempestEnvVariant.ADHOC + ) + actual_combined_overrides = env.get("TEMPEST_CONFIG_OVERRIDES", "") + + self._check_override_in_string( + "service_available.cinder", + "false", + actual_combined_overrides, + should_be_present=False, + ) + self._check_override_in_string( + "service_available.nova", + "false", + actual_combined_overrides, + should_be_present=True, + ) + + def test_roles_blank_raises(self): + """Blank 'roles' config should raise a BlockedExceptionError.""" + with self.assertRaises(BlockedExceptionError): + overrides._parse_roles_config("") + + def test_current_config_hash_changes(self): + """Hash is stable for unchanged config and updates when roles/region change.""" + initial_roles = "compute,control" + initial_region = "RegionOne" + self.harness.update_config( + {"roles": initial_roles, "region": initial_region} + ) + hash_1 = self.harness.charm._current_config_hash() + self.harness.update_config( + {"roles": initial_roles, "region": initial_region} + ) + hash_2 = self.harness.charm._current_config_hash() + self.assertEqual(hash_1, hash_2) + + self.harness.update_config({"roles": "compute"}) + hash_3 = self.harness.charm._current_config_hash() + self.assertNotEqual(hash_1, hash_3) + + self.harness.update_config( + {"roles": initial_roles, "region": "RegionTwo"} + ) + hash_4 = self.harness.charm._current_config_hash() + self.assertNotEqual(hash_1, hash_4)