diff --git a/charmcraft.yaml b/charmcraft.yaml index 8e4bb07..558f9e9 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -20,13 +20,12 @@ requires: credentials: interface: keystone-admin -# config: -# options: -# snap_channel: -# default: "latest/stable" -# type: string -# description: | -# If install_method is set to "snap" this option controls channel name. +config: + options: + ssl_ca: + type: string + description: | + Custom SSL CA for keystone if required. # links: diff --git a/src/charm.py b/src/charm.py index 114ded0..2a5e820 100755 --- a/src/charm.py +++ b/src/charm.py @@ -14,10 +14,15 @@ import ops import yaml +from ops import CollectStatusEvent, RelationChangedEvent +from ops.model import ActiveStatus, BlockedStatus logger = logging.getLogger(__name__) CLOUDS_YAML = "/etc/openstack/clouds.yaml" +# used to match entry in clouds.yaml with openstack-exporter config +CLOUD_NAME = "openstack" +SUPPORTED_API_VERSION = 3 class OpenstackExporterOperatorCharm(ops.CharmBase): @@ -27,53 +32,95 @@ def __init__(self, *args: tuple[Any]) -> None: """Initialize the charm.""" super().__init__(*args) self.framework.observe(self.on.credentials_relation_changed, self._on_credentials_changed) + self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status) - def _on_credentials_changed(self, event: TODO) -> None: + def _on_credentials_changed(self, event: RelationChangedEvent) -> None: """Handle updates to credentials from keystone.""" - # keystone charm sets relation data like so: - # relation_data = { - # 'service_hostname': resolve_address(ADMIN), - # 'service_port': config('service-port'), - # 'service_username': config('admin-user'), - # 'service_tenant_name': config('admin-role'), - # 'service_region': config('region'), - # 'service_protocol': 'https' if https() else 'http', - # 'api_version': get_api_version(), - # } - # if relation_data['api_version'] > 2: - # relation_data['service_user_domain_name'] = ADMIN_DOMAIN - # relation_data['service_project_domain_name'] = ADMIN_DOMAIN - # relation_data['service_project_name'] = ADMIN_PROJECT - # relation_data['service_password'] = get_admin_passwd() - # relation_set(relation_id=relation_id, **relation_data) - + # TODO: test and verify this is the data we want logger.info(f"{event.relation.data = }") - data = event.relation.data - - # TODO build the data dictionary from the above, - # also figure out how to handle differences between api version 2 and 3 + data = event.relation.data[event.unit] - # TODO: this is pseudocode - if all_data_available: - self.update_clouds_yaml(data) - self.restart_services() - else: + try: + self._validate_keystone_credentials(data) + except ValueError: + # stop services, etc. because it's not ready self.stop_services() - def update_clouds_yaml(creds: dict[str, str]) -> None: - data = { + self.update_clouds_yaml(data) + self.restart_services() + + def _validate_keystone_credentials(self, data: dict[str, str]) -> None: + """Validate credentials from keystone. + + Raise an exception with an informative message if not valid. + """ + if data.get("api_version") != SUPPORTED_API_VERSION: + raise ValueError( + f"Keystone is using an unsupported api version (expected: 3, found: {data.get('api_version')})" + ) + + if not all( + data[x] + for x in [ + "service_protocol", + "service_hostname", + "service_port", + "service_username", + "service_password", + "service_project_name", + "service_project_domain_name", + "service_user_domain_name", + ] + # I guess service_region is optional + ): + raise ValueError("Not all values are available from keystone yet.") + + def update_clouds_yaml(self, data: dict[str, str]) -> None: + """Build a clouds.yaml given the keystone credentials data. + + We only handle api version 3, + since v2 was removed a long time ago (Queens release) + https://docs.openstack.org/keystone/latest/contributor/http-api.html + """ + auth_url = "{protocol}://{hostname}:{port}/v3".format( + protocol=data["service_protocol"], + hostname=data["service_hostname"], + port=data["service_port"], + ) + contents = { "clouds": { - "openstack": { # the name here must be passed to --cloud option of openstack-exporter - # TODO: populate from creds - # see https://docs.openstack.org/python-openstackclient/latest/configuration/index.html for more info - # https://docs.openstack.org/python-openstackclient/latest/configuration/index.html + CLOUD_NAME: { # the name here must be passed to --cloud option of openstack-exporter + "region_name": data["service_region"], + "identity_api_version": "3", + "identity_interface": "internal", + "auth": { + "username": data["service_username"], + "password": data["service_password"], + "project_name": data["service_project_name"], + "project_domain_name": data["service_project_domain_name"], + "user_domain_name": data["service_user_domain_name"], + "auth_url": auth_url, + }, + "verify": data["service_protocol"] == "https", + "cacert": self.config["ssl_ca"], } } } os.makedirs(os.path.dirname(CLOUDS_YAML), exist_ok=True) with open(CLOUDS_YAML, "w") as f: - yaml.dump(data, f) + yaml.dump(contents, f) + + def _on_collect_unit_status(self, event: CollectStatusEvent) -> None: + # TODO: add blocked status if keystone is not related + + keystone_data: dict[str, str] = {} # TODO: collect from relation data + try: + self._validate_keystone_credentials(keystone_data) + except ValueError as e: + event.add_status(BlockedStatus(str(e))) + + event.add_status(ActiveStatus()) if __name__ == "__main__": # pragma: nocover