Skip to content

Commit

Permalink
image-builder openstack creds relation (#357)
Browse files Browse the repository at this point in the history
  • Loading branch information
yanksyoon authored Aug 29, 2024
1 parent 841f318 commit 43c9ab2
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 21 deletions.
17 changes: 17 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
64 changes: 54 additions & 10 deletions src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +27,7 @@
IPvAnyAddress,
MongoDsn,
ValidationError,
create_model_from_typeddict,
validator,
)

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
59 changes: 52 additions & 7 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +31,7 @@
GithubOrg,
GithubRepo,
InstanceType,
OpenStackCloudsYAML,
OpenstackImage,
ProxyConfig,
VirtualMachineResources,
Expand Down Expand Up @@ -874,22 +876,30 @@ 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()
state_mock = MagicMock()
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():
Expand Down Expand Up @@ -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
25 changes: 21 additions & 4 deletions tests/unit/test_charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
}
Expand All @@ -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"

Expand Down

0 comments on commit 43c9ab2

Please sign in to comment.