From 43c9ab27a8c35ba7f6241f1725cadd8cb35ecf97 Mon Sep 17 00:00:00 2001 From: Yanks Yoon <37652070+yanksyoon@users.noreply.github.com> Date: Thu, 29 Aug 2024 15:41:13 +0800 Subject: [PATCH] image-builder openstack creds relation (#357) --- src/charm.py | 17 +++++++++ src/charm_state.py | 64 ++++++++++++++++++++++++++++------ tests/unit/test_charm.py | 59 +++++++++++++++++++++++++++---- tests/unit/test_charm_state.py | 25 ++++++++++--- 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/src/charm.py b/src/charm.py index 057982ef2..a61586b9b 100755 --- a/src/charm.py +++ b/src/charm.py @@ -1163,6 +1163,23 @@ def _on_debug_ssh_relation_changed(self, _: ops.RelationChangedEvent) -> None: state.runner_config.virtual_machine_resources, ) + @catch_charm_errors + def _on_image_relation_joined(self, _: ops.RelationJoinedEvent) -> None: + """Handle image relation joined event.""" + state = self._setup_state() + + if state.instance_type != InstanceType.OPENSTACK: + self.unit.status = BlockedStatus( + "Openstack mode not enabled. Please remove the image integration." + ) + return + + clouds_yaml = state.charm_config.openstack_clouds_yaml + cloud = list(clouds_yaml["clouds"].keys())[0] + auth_map = clouds_yaml["clouds"][cloud]["auth"] + for relation in self.model.relations[IMAGE_INTEGRATION_NAME]: + relation.data[self.model.unit].update(auth_map) + @catch_charm_errors def _on_image_relation_changed(self, _: ops.RelationChangedEvent) -> None: """Handle image relation changed event.""" diff --git a/src/charm_state.py b/src/charm_state.py index 186609806..492f4b21e 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -14,7 +14,7 @@ import re from enum import Enum from pathlib import Path -from typing import NamedTuple, Optional, cast +from typing import NamedTuple, Optional, TypedDict, cast from urllib.parse import urlsplit import yaml @@ -27,6 +27,7 @@ IPvAnyAddress, MongoDsn, ValidationError, + create_model_from_typeddict, validator, ) @@ -346,6 +347,48 @@ def from_charm(cls, charm: CharmBase) -> "RepoPolicyComplianceConfig": return cls(url=url, token=token) # type: ignore +class _OpenStackAuth(TypedDict): + """The OpenStack cloud connection authentication info. + + Attributes: + auth_url: The OpenStack authentication URL (keystone). + password: The OpenStack project user's password. + project_domain_name: The project domain in which the project belongs to. + project_name: The OpenStack project to connect to. + user_domain_name: The user domain in which the user belongs to. + username: The user to authenticate as. + """ + + auth_url: str + password: str + project_domain_name: str + project_name: str + user_domain_name: str + username: str + + +class _OpenStackCloud(TypedDict): + """The OpenStack cloud connection info. + + See https://docs.openstack.org/python-openstackclient/pike/configuration/index.html. + + Attributes: + auth: The connection authentication info. + """ + + auth: _OpenStackAuth + + +class OpenStackCloudsYAML(TypedDict): + """The OpenStack clouds YAML dict mapping. + + Attributes: + clouds: The map of cloud name to cloud connection info. + """ + + clouds: dict[str, _OpenStackCloud] + + class CharmConfig(BaseModel): """General charm configuration. @@ -366,7 +409,7 @@ class CharmConfig(BaseModel): denylist: list[FirewallEntry] dockerhub_mirror: AnyHttpsUrl | None labels: tuple[str, ...] - openstack_clouds_yaml: dict[str, dict] | None + openstack_clouds_yaml: OpenStackCloudsYAML | None path: GithubPath reconcile_interval: int repo_policy_compliance: RepoPolicyComplianceConfig | None @@ -421,7 +464,7 @@ def _parse_dockerhub_mirror(cls, charm: CharmBase) -> str | None: return dockerhub_mirror @classmethod - def _parse_openstack_clouds_config(cls, charm: CharmBase) -> dict | None: + def _parse_openstack_clouds_config(cls, charm: CharmBase) -> OpenStackCloudsYAML | None: """Parse and validate openstack clouds yaml config value. Args: @@ -440,16 +483,17 @@ def _parse_openstack_clouds_config(cls, charm: CharmBase) -> dict | None: return None try: - openstack_clouds_yaml = yaml.safe_load(cast(str, openstack_clouds_yaml_str)) - except yaml.YAMLError as exc: + openstack_clouds_yaml: OpenStackCloudsYAML = yaml.safe_load( + cast(str, openstack_clouds_yaml_str) + ) + # use Pydantic to validate TypedDict. + create_model_from_typeddict(OpenStackCloudsYAML)(**openstack_clouds_yaml) + except (yaml.YAMLError, TypeError) as exc: logger.error(f"Invalid {OPENSTACK_CLOUDS_YAML_CONFIG_NAME} config: %s.", exc) raise CharmConfigInvalidError( f"Invalid {OPENSTACK_CLOUDS_YAML_CONFIG_NAME} config. Invalid yaml." ) from exc - if (config_type := type(openstack_clouds_yaml)) is not dict: - raise CharmConfigInvalidError( - f"Invalid openstack config format, expected dict, got {config_type}" - ) + try: openstack_cloud.initialize(openstack_clouds_yaml) except OpenStackInvalidConfigError as exc: @@ -458,7 +502,7 @@ def _parse_openstack_clouds_config(cls, charm: CharmBase) -> dict | None: "Invalid openstack config. Not able to initialize openstack integration." ) from exc - return cast(dict, openstack_clouds_yaml) + return openstack_clouds_yaml @validator("reconcile_interval") @classmethod diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index a7276f078..8b19a7797 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -18,6 +18,7 @@ from charm import GithubRunnerCharm, catch_action_errors, catch_charm_errors from charm_state import ( GROUP_CONFIG_NAME, + IMAGE_INTEGRATION_NAME, OPENSTACK_CLOUDS_YAML_CONFIG_NAME, PATH_CONFIG_NAME, RECONCILE_INTERVAL_CONFIG_NAME, @@ -30,6 +31,7 @@ GithubOrg, GithubRepo, InstanceType, + OpenStackCloudsYAML, OpenstackImage, ProxyConfig, VirtualMachineResources, @@ -874,11 +876,18 @@ def test_openstack_image_ready_status( assert is_ready == expected_value -def test__on_image_relation_changed_lxd(): +@pytest.mark.parametrize( + "hook", + [ + pytest.param("_on_image_relation_changed", id="image relation changed"), + pytest.param("_on_image_relation_joined", id="image relation joined"), + ], +) +def test__on_image_relation_hooks_not_openstack(hook: str): """ - arrange: given a charm with LXD instance type. - act: when _on_image_relation_changed is called. - assert: nothing happens. + arrange: given a hook that is for OpenStack mode but the image relation exists. + act: when the hook is triggered. + assert: the charm falls into BlockedStatus. """ harness = Harness(GithubRunnerCharm) harness.begin() @@ -886,10 +895,11 @@ def test__on_image_relation_changed_lxd(): state_mock.instance_type = InstanceType.LOCAL_LXD harness.charm._setup_state = MagicMock(return_value=state_mock) - harness.charm._on_image_relation_changed(MagicMock()) + getattr(harness.charm, hook)(MagicMock()) - # the unit is in maintenance status since nothing has happened. - assert harness.charm.unit.status.name == BlockedStatus.name + assert harness.charm.unit.status == BlockedStatus( + "Openstack mode not enabled. Please remove the image integration." + ) def test__on_image_relation_image_not_ready(): @@ -933,3 +943,38 @@ def test__on_image_relation_image_ready(): assert harness.charm.unit.status.name == ActiveStatus.name runner_manager_mock.flush.assert_called_once() runner_manager_mock.reconcile.assert_called_once() + + +def test__on_image_relation_joined(): + """ + arrange: given an OpenStack mode charm. + act: when _on_image_relation_joined is fired. + assert: the relation data is populated with openstack creds. + """ + harness = Harness(GithubRunnerCharm) + relation_id = harness.add_relation(IMAGE_INTEGRATION_NAME, "image-builder") + harness.add_relation_unit(relation_id, "image-builder/0") + harness.begin() + state_mock = MagicMock() + state_mock.instance_type = InstanceType.OPENSTACK + state_mock.charm_config.openstack_clouds_yaml = OpenStackCloudsYAML( + clouds={ + "test-cloud": { + "auth": ( + test_auth_data := { + "auth_url": "http://test-auth.url", + "password": secrets.token_hex(16), + "project_domain_name": "Default", + "project_name": "test-project-name", + "user_domain_name": "Default", + "username": "test-user-name", + } + ) + } + } + ) + harness.charm._setup_state = MagicMock(return_value=state_mock) + + harness.charm._on_image_relation_joined(MagicMock()) + + assert harness.get_relation_data(relation_id, harness.charm.unit) == test_auth_data diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index 2025c0e76..8479782a8 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock import pytest +import yaml from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires from pydantic import BaseModel from pydantic.error_wrappers import ValidationError @@ -472,7 +473,25 @@ def test_charm_config_from_charm_valid(): RECONCILE_INTERVAL_CONFIG_NAME: "5", DENYLIST_CONFIG_NAME: "192.168.1.1,192.168.1.2", DOCKERHUB_MIRROR_CONFIG_NAME: "https://example.com", - OPENSTACK_CLOUDS_YAML_CONFIG_NAME: "clouds: { openstack: { auth: { username: 'admin' }}}", + # "clouds: { openstack: { auth: { username: 'admin' }}}" + OPENSTACK_CLOUDS_YAML_CONFIG_NAME: yaml.safe_dump( + ( + test_openstack_config := { + "clouds": { + "openstack": { + "auth": { + "auth_url": "https://project-keystone.url/", + "password": secrets.token_hex(16), + "project_domain_name": "Default", + "project_name": "test-project-name", + "user_domain_name": "Default", + "username": "test-user-name", + } + } + } + } + ) + ), LABELS_CONFIG_NAME: "label1,label2,label3", TOKEN_CONFIG_NAME: "abc123", } @@ -486,9 +505,7 @@ def test_charm_config_from_charm_valid(): FirewallEntry(ip_range="192.168.1.2"), ] assert result.dockerhub_mirror == "https://example.com" - assert result.openstack_clouds_yaml == { - "clouds": {"openstack": {"auth": {"username": "admin"}}} - } + assert result.openstack_clouds_yaml == test_openstack_config assert result.labels == ("label1", "label2", "label3") assert result.token == "abc123"