From fd55db27ce8fb699a76fdf93ae11a6433d19c0be Mon Sep 17 00:00:00 2001 From: Yanks Yoon <37652070+yanksyoon@users.noreply.github.com> Date: Fri, 17 May 2024 17:24:28 +0900 Subject: [PATCH] chore: rewrite unit tests and fix bugs for charm_state module (#278) * rewrite unit tests and fix bugs for charm_state module * fix typo * make lint happy * add private endpoint args to make tests pass * fix typo * add missing opts * fix logger exception --- src-docs/charm_state.py.md | 22 +- src/charm_state.py | 246 +++---- tests/conftest.py | 46 ++ tests/unit/test_charm_state.py | 1192 ++++++++++++++++++++++++-------- 4 files changed, 1076 insertions(+), 430 deletions(-) diff --git a/src-docs/charm_state.py.md b/src-docs/charm_state.py.md index 1aa256c29..48eec33c4 100644 --- a/src-docs/charm_state.py.md +++ b/src-docs/charm_state.py.md @@ -116,7 +116,7 @@ Some charm configurations are grouped into other configuration models. --- - + ### classmethod `check_reconcile_interval` @@ -145,7 +145,7 @@ Validate the general charm configuration. --- - + ### classmethod `from_charm` @@ -225,7 +225,7 @@ The charm state. --- - + ### classmethod `from_charm` @@ -369,7 +369,7 @@ Return a string representing the path. ## class `ImmutableConfigChangedError` Represents an error when changing immutable charm state. - + ### function `__init__` @@ -415,7 +415,7 @@ Return the aproxy address. --- - + ### classmethod `check_use_aproxy` @@ -445,7 +445,7 @@ Validate the proxy configuration. --- - + ### classmethod `from_charm` @@ -486,7 +486,7 @@ Runner configurations for the charm. --- - + ### classmethod `check_virtual_machine_resources` @@ -517,7 +517,7 @@ Validate the virtual_machine_resources field values. --- - + ### classmethod `check_virtual_machines` @@ -546,7 +546,7 @@ Validate the virtual machines configuration value. --- - + ### classmethod `from_charm` @@ -609,7 +609,7 @@ SSH connection information for debug workflow. --- - + ### classmethod `from_charm` @@ -642,7 +642,7 @@ Raised when given machine charm architecture is unsupported. - `arch`: The current machine architecture. - + ### function `__init__` diff --git a/src/charm_state.py b/src/charm_state.py index 83f349bf8..7896c5353 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -147,14 +147,14 @@ def from_charm(cls, charm: CharmBase) -> "GithubConfig": Returns: The parsed GitHub configuration values. """ - runner_group = charm.config.get(GROUP_CONFIG_NAME, "default") + runner_group = cast(str, charm.config.get(GROUP_CONFIG_NAME, "default")) - path_str = charm.config.get(PATH_CONFIG_NAME, "") + path_str = cast(str, charm.config.get(PATH_CONFIG_NAME, "")) if not path_str: raise CharmConfigInvalidError(f"Missing {PATH_CONFIG_NAME} configuration") path = parse_github_path(path_str, runner_group) - token = charm.config.get(TOKEN_CONFIG_NAME) + token = cast(str, charm.config.get(TOKEN_CONFIG_NAME)) if not token: raise CharmConfigInvalidError(f"Missing {TOKEN_CONFIG_NAME} configuration") @@ -252,9 +252,10 @@ def _parse_labels(labels: str) -> tuple[str, ...]: invalid_labels = [] valid_labels = [] for label in labels.split(","): - if not label: + stripped_label = label.strip() + if not stripped_label: continue - if not WORD_ONLY_REGEX.match(stripped_label := label.strip()): + if not WORD_ONLY_REGEX.match(stripped_label): invalid_labels.append(stripped_label) else: valid_labels.append(stripped_label) @@ -299,7 +300,7 @@ def _parse_denylist(cls, charm: CharmBase) -> list[FirewallEntry]: Returns: The firewall deny entries. """ - denylist_str = charm.config.get(DENYLIST_CONFIG_NAME, "") + denylist_str = cast(str, charm.config.get(DENYLIST_CONFIG_NAME, "")) entry_list = [entry.strip() for entry in denylist_str.split(",")] denylist = [FirewallEntry.decode(entry) for entry in entry_list if entry] @@ -318,7 +319,9 @@ def _parse_dockerhub_mirror(cls, charm: CharmBase) -> str | None: Returns: The URL of dockerhub mirror. """ - dockerhub_mirror = charm.config.get(DOCKERHUB_MIRROR_CONFIG_NAME) or None + dockerhub_mirror: str | None = ( + cast(str, charm.config.get(DOCKERHUB_MIRROR_CONFIG_NAME)) or None + ) if not dockerhub_mirror: return None @@ -347,7 +350,9 @@ def _parse_openstack_clouds_config(cls, charm: CharmBase) -> dict | None: Returns: The openstack clouds yaml. """ - openstack_clouds_yaml_str = charm.config.get(OPENSTACK_CLOUDS_YAML_CONFIG_NAME) + openstack_clouds_yaml_str: str | None = cast( + str, charm.config.get(OPENSTACK_CLOUDS_YAML_CONFIG_NAME) + ) if not openstack_clouds_yaml_str: return None @@ -372,6 +377,33 @@ def _parse_openstack_clouds_config(cls, charm: CharmBase) -> dict | None: return cast(dict, openstack_clouds_yaml) + @validator("reconcile_interval") + @classmethod + def check_reconcile_interval(cls, reconcile_interval: int) -> int: + """Validate the general charm configuration. + + Args: + reconcile_interval: The value of reconcile_interval passed to class instantiation. + + Raises: + ValueError: if an invalid reconcile_interval value of less than 2 has been passed. + + Returns: + The validated reconcile_interval value. + """ + # The EventTimer class sets a timeout of `reconcile_interval` - 1. + # Therefore the `reconcile_interval` must be at least 2. + if reconcile_interval < 2: + logger.error( + "The %s configuration must be greater than 1", RECONCILE_INTERVAL_CONFIG_NAME + ) + raise ValueError( + f"The {RECONCILE_INTERVAL_CONFIG_NAME} configuration needs to be greater or equal" + " to 2" + ) + + return reconcile_interval + @classmethod def from_charm(cls, charm: CharmBase) -> "CharmConfig": """Initialize the config from charm. @@ -402,7 +434,7 @@ def from_charm(cls, charm: CharmBase) -> "CharmConfig": openstack_clouds_yaml = cls._parse_openstack_clouds_config(charm) try: - labels = _parse_labels(charm.config.get(LABELS_CONFIG_NAME, "")) + labels = _parse_labels(cast(str, charm.config.get(LABELS_CONFIG_NAME, ""))) except ValueError as exc: raise CharmConfigInvalidError(f"Invalid {LABELS_CONFIG_NAME} config: {exc}") from exc @@ -416,33 +448,6 @@ def from_charm(cls, charm: CharmBase) -> "CharmConfig": token=github_config.token, ) - @validator("reconcile_interval") - @classmethod - def check_reconcile_interval(cls, reconcile_interval: int) -> int: - """Validate the general charm configuration. - - Args: - reconcile_interval: The value of reconcile_interval passed to class instantiation. - - Raises: - ValueError: if an invalid reconcile_interval value of less than 2 has been passed. - - Returns: - The validated reconcile_interval value. - """ - # The EventTimer class sets a timeout of `reconcile_interval` - 1. - # Therefore the `reconcile_interval` must be at least 2. - if reconcile_interval < 2: - logger.exception( - "The %s configuration must be greater than 1", RECONCILE_INTERVAL_CONFIG_NAME - ) - raise ValueError( - f"The {RECONCILE_INTERVAL_CONFIG_NAME} configuration needs to be \ - greater or equal to 2" - ) - - return reconcile_interval - LTS_IMAGE_VERSION_TAG_MAP = {"22.04": "jammy", "24.04": "noble"} @@ -476,7 +481,7 @@ def from_charm(cls, charm: CharmBase) -> "BaseImage": Returns: The base image configuration of the charm. """ - image_name = charm.config.get(BASE_IMAGE_CONFIG_NAME, "jammy").lower().strip() + image_name = cast(str, charm.config.get(BASE_IMAGE_CONFIG_NAME, "jammy")).lower().strip() if image_name in LTS_IMAGE_VERSION_TAG_MAP: return cls(LTS_IMAGE_VERSION_TAG_MAP[image_name]) return cls(image_name) @@ -497,56 +502,6 @@ class RunnerCharmConfig(BaseModel): virtual_machine_resources: VirtualMachineResources runner_storage: RunnerStorage - @classmethod - def from_charm(cls, charm: CharmBase) -> "RunnerCharmConfig": - """Initialize the config from charm. - - Args: - charm: The charm instance. - - Raises: - CharmConfigInvalidError: if an invalid runner charm config has been set on the charm. - - Returns: - Current config of the charm. - """ - try: - base_image = BaseImage.from_charm(charm) - except ValueError as err: - raise CharmConfigInvalidError("Invalid base image") from err - - try: - runner_storage = RunnerStorage(charm.config[RUNNER_STORAGE_CONFIG_NAME]) - except ValueError as err: - raise CharmConfigInvalidError( - f"Invalid {RUNNER_STORAGE_CONFIG_NAME} configuration" - ) from err - except CharmConfigInvalidError as exc: - raise CharmConfigInvalidError(f"Invalid runner storage config, {str(exc)}") from exc - - try: - virtual_machines = int(charm.config[VIRTUAL_MACHINES_CONFIG_NAME]) - except ValueError as err: - raise CharmConfigInvalidError( - f"The {VIRTUAL_MACHINES_CONFIG_NAME} configuration must be int" - ) from err - - try: - cpu = int(charm.config[VM_CPU_CONFIG_NAME]) - except ValueError as err: - raise CharmConfigInvalidError(f"Invalid {VM_CPU_CONFIG_NAME} configuration") from err - - virtual_machine_resources = VirtualMachineResources( - cpu, charm.config[VM_MEMORY_CONFIG_NAME], charm.config[VM_DISK_CONFIG_NAME] - ) - - return cls( - base_image=base_image, - virtual_machines=virtual_machines, - virtual_machine_resources=virtual_machine_resources, - runner_storage=runner_storage, - ) - @validator("virtual_machines") @classmethod def check_virtual_machines(cls, virtual_machines: int) -> int: @@ -601,6 +556,56 @@ def check_virtual_machine_resources( return vm_resources + @classmethod + def from_charm(cls, charm: CharmBase) -> "RunnerCharmConfig": + """Initialize the config from charm. + + Args: + charm: The charm instance. + + Raises: + CharmConfigInvalidError: if an invalid runner charm config has been set on the charm. + + Returns: + Current config of the charm. + """ + try: + base_image = BaseImage.from_charm(charm) + except ValueError as err: + raise CharmConfigInvalidError("Invalid base image") from err + + try: + runner_storage = RunnerStorage(charm.config[RUNNER_STORAGE_CONFIG_NAME]) + except ValueError as err: + raise CharmConfigInvalidError( + f"Invalid {RUNNER_STORAGE_CONFIG_NAME} configuration" + ) from err + + try: + virtual_machines = int(charm.config[VIRTUAL_MACHINES_CONFIG_NAME]) + except ValueError as err: + raise CharmConfigInvalidError( + f"The {VIRTUAL_MACHINES_CONFIG_NAME} configuration must be int" + ) from err + + try: + cpu = int(charm.config[VM_CPU_CONFIG_NAME]) + except ValueError as err: + raise CharmConfigInvalidError(f"Invalid {VM_CPU_CONFIG_NAME} configuration") from err + + virtual_machine_resources = VirtualMachineResources( + cpu, + cast(str, charm.config[VM_MEMORY_CONFIG_NAME]), + cast(str, charm.config[VM_DISK_CONFIG_NAME]), + ) + + return cls( + base_image=base_image, + virtual_machines=virtual_machines, + virtual_machine_resources=virtual_machine_resources, + runner_storage=runner_storage, + ) + class ProxyConfig(BaseModel): """Proxy configuration. @@ -618,40 +623,20 @@ class ProxyConfig(BaseModel): no_proxy: Optional[str] use_aproxy: bool = False - @classmethod - def from_charm(cls, charm: CharmBase) -> "ProxyConfig": - """Initialize the proxy config from charm. - - Args: - charm: The charm instance. - - Returns: - Current proxy config of the charm. - """ - use_aproxy = bool(charm.config.get(USE_APROXY_CONFIG_NAME)) - http_proxy = get_env_var("JUJU_CHARM_HTTP_PROXY") or None - https_proxy = get_env_var("JUJU_CHARM_HTTPS_PROXY") or None - no_proxy = get_env_var("JUJU_CHARM_NO_PROXY") or None - - # there's no need for no_proxy if there's no http_proxy or https_proxy - if not (https_proxy or http_proxy) and no_proxy: - no_proxy = None - - return cls( - http=http_proxy, - https=https_proxy, - no_proxy=no_proxy, - use_aproxy=use_aproxy, - ) - @property def aproxy_address(self) -> Optional[str]: """Return the aproxy address.""" if self.use_aproxy: proxy_address = self.http or self.https # assert is only used to make mypy happy - assert proxy_address is not None # nosec for [B101:assert_used] - aproxy_address = f"{proxy_address.host}:{proxy_address.port}" + assert ( + proxy_address is not None and proxy_address.host is not None + ) # nosec for [B101:assert_used] + aproxy_address = ( + proxy_address.host + if not proxy_address.port + else f"{proxy_address.host}:{proxy_address.port}" + ) else: aproxy_address = None return aproxy_address @@ -684,6 +669,32 @@ def __bool__(self) -> bool: """ return bool(self.http or self.https) + @classmethod + def from_charm(cls, charm: CharmBase) -> "ProxyConfig": + """Initialize the proxy config from charm. + + Args: + charm: The charm instance. + + Returns: + Current proxy config of the charm. + """ + use_aproxy = bool(charm.config.get(USE_APROXY_CONFIG_NAME)) + http_proxy = get_env_var("JUJU_CHARM_HTTP_PROXY") or None + https_proxy = get_env_var("JUJU_CHARM_HTTPS_PROXY") or None + no_proxy = get_env_var("JUJU_CHARM_NO_PROXY") or None + + # there's no need for no_proxy if there's no http_proxy or https_proxy + if not (https_proxy or http_proxy) and no_proxy: + no_proxy = None + + return cls( + http=http_proxy, + https=https_proxy, + no_proxy=no_proxy, + use_aproxy=use_aproxy, + ) + class Config: # pylint: disable=too-few-public-methods """Pydantic model configuration. @@ -771,9 +782,10 @@ def from_charm(cls, charm: CharmBase) -> list["SSHDebugConnection"]: ) continue ssh_debug_connections.append( + # Mypy doesn't know that Pydantic handles type conversions. SSHDebugConnection( - host=host, - port=port, + host=host, # type: ignore + port=port, # type: ignore rsa_fingerprint=rsa_fingerprint, ed25519_fingerprint=ed25519_fingerprint, ) diff --git a/tests/conftest.py b/tests/conftest.py index a51d3bf65..1029a1991 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,3 +57,49 @@ def pytest_addoption(parser: Parser): action="store", help="The OpenStack clouds yaml file for the charm to use.", ) + # Private endpoint options + parser.addoption( + "--openstack-network-name", + action="store", + help="The Openstack network to create testing instances under.", + ) + parser.addoption( + "--openstack-flavor-name", + action="store", + help="The Openstack flavor to create testing instances with.", + ) + parser.addoption( + "--openstack-auth-url", + action="store", + help="The URL to Openstack authentication service, i.e. keystone.", + ) + parser.addoption( + "--openstack-password", + action="store", + help="The password to authenticate to Openstack service.", + ) + parser.addoption( + "--openstack-project-domain-name", + action="store", + help="The Openstack project domain name to use.", + ) + parser.addoption( + "--openstack-project-name", + action="store", + help="The Openstack project name to use.", + ) + parser.addoption( + "--openstack-user-domain-name", + action="store", + help="The Openstack user domain name to use.", + ) + parser.addoption( + "--openstack-username", + action="store", + help="The Openstack user to authenticate as.", + ) + parser.addoption( + "--openstack-region-name", + action="store", + help="The Openstack region to authenticate to.", + ) diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index d3d9e6d04..289732476 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -1,477 +1,1065 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. import json -import os import platform -import secrets -from typing import Any -from unittest.mock import MagicMock, patch +import typing +from pathlib import Path +from unittest.mock import MagicMock -import ops import pytest +from pydantic import BaseModel +from pydantic.error_wrappers import ValidationError +from pydantic.networks import IPv4Address import charm_state +import openstack_cloud from charm_state import ( BASE_IMAGE_CONFIG_NAME, - COS_AGENT_INTEGRATION_NAME, DEBUG_SSH_INTEGRATION_NAME, + DENYLIST_CONFIG_NAME, + DOCKERHUB_MIRROR_CONFIG_NAME, + LABELS_CONFIG_NAME, + OPENSTACK_CLOUDS_YAML_CONFIG_NAME, PATH_CONFIG_NAME, + RECONCILE_INTERVAL_CONFIG_NAME, + RUNNER_STORAGE_CONFIG_NAME, TOKEN_CONFIG_NAME, USE_APROXY_CONFIG_NAME, + VIRTUAL_MACHINES_CONFIG_NAME, + VM_CPU_CONFIG_NAME, + VM_DISK_CONFIG_NAME, + VM_MEMORY_CONFIG_NAME, Arch, + BaseImage, + CharmConfig, CharmConfigInvalidError, CharmState, + FirewallEntry, + GithubConfig, + GithubOrg, + GithubRepo, + ImmutableConfigChangedError, ProxyConfig, + RunnerCharmConfig, + RunnerStorage, SSHDebugConnection, + UnsupportedArchitectureError, + VirtualMachineResources, ) from tests.unit.factories import MockGithubRunnerCharmFactory -@pytest.fixture(name="clouds_yaml") -def clouds_yaml() -> dict: - """Mocked clouds.yaml data. +def test_github_repo_path(): + """ + arrange: Create a GithubRepo instance with owner and repo attributes. + act: Call the path method of the GithubRepo instance with a mock. + assert: Verify that the returned path is constructed correctly. + """ + owner = "test_owner" + repo = "test_repo" + github_repo = GithubRepo(owner, repo) + + path = github_repo.path() + + assert path == f"{owner}/{repo}" + - Returns: - dict: Mocked clouds.yaml data. +def test_github_org_path(): """ - return { - "clouds": { - "microstack": { - "auth": { - "auth_url": secrets.token_hex(16), - "project_name": secrets.token_hex(16), - "project_domain_name": secrets.token_hex(16), - "username": secrets.token_hex(16), - "user_domain_name": secrets.token_hex(16), - "password": secrets.token_hex(16), - } - } - } - } + arrange: Create a GithubOrg instance with org and group attributes. + act: Call the path method of the GithubOrg instance. + assert: Verify that the returned path is constructed correctly. + """ + org = "test_org" + group = "test_group" + github_org = GithubOrg(org, group) + + path = github_org.path() + + assert path == org + + +def test_parse_github_path_invalid(): + """ + arrange: Create an invalid GitHub path string and runner group name. + act: Call parse_github_path with the invalid path string and runner group name. + assert: Verify that the function raises CharmConfigInvalidError. + """ + path_str = "invalidpath/" + runner_group = "test_group" + + with pytest.raises(CharmConfigInvalidError): + charm_state.parse_github_path(path_str, runner_group) + + +def test_github_config_from_charm_invalid_path(): + """ + arrange: Create a mock CharmBase instance with an empty path configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[PATH_CONFIG_NAME] = "" + + with pytest.raises(CharmConfigInvalidError): + GithubConfig.from_charm(mock_charm) + + +def test_github_config_from_charm_invalid_token(): + """ + arrange: Create a mock CharmBase instance with an empty token configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[TOKEN_CONFIG_NAME] = "" + + with pytest.raises(CharmConfigInvalidError): + GithubConfig.from_charm(mock_charm) @pytest.mark.parametrize( - "invalid_path", + "path_str, runner_group, expected_type, expected_attrs", [ - pytest.param("canonical/", id="org only"), - pytest.param("/github-runner-operator", id="repository only"), + ("owner/repo", "test_group", GithubRepo, {"owner": "owner", "repo": "repo"}), + ("test_org", "test_group", GithubOrg, {"org": "test_org", "group": "test_group"}), ], ) -def test_parse_github_path_invalid_path(invalid_path: str): +def test_parse_github_path( + path_str: str, + runner_group: str, + expected_type: GithubRepo | GithubOrg, + expected_attrs: dict[str, str], +): """ - arrange: Given an invalid Github path. - act: when parse_github_path is called. - assert: CharmConfigInvalidError is raised. + arrange: Create different GitHub path strings and runner group names. + act: Call parse_github_path with the given path string and runner group name. + assert: Verify that the function returns the expected type and attributes. """ - with pytest.raises(CharmConfigInvalidError) as exc: - charm_state.parse_github_path(invalid_path, MagicMock()) + result = charm_state.parse_github_path(path_str, runner_group) - assert "Invalid path configuration" in str(exc) + # Assert + assert isinstance(result, expected_type) + for attr, value in expected_attrs.items(): + assert getattr(result, attr) == value @pytest.mark.parametrize( - "missing_config", + "size, expected_result", [ - pytest.param(PATH_CONFIG_NAME, id="missing path"), - pytest.param(TOKEN_CONFIG_NAME, id="missing token"), + ("100KiB", True), + ("10MiB", True), + ("1GiB", True), + ("0TiB", True), + ("1000PiB", True), + ("10000EiB", True), + ("100KB", False), # Invalid suffix + ("100GB", False), # Invalid suffix + ("abc", False), # Non-numeric characters + ("100", False), # No suffix + ("100Ki", False), # Incomplete suffix + ("100.5MiB", False), # Non-integer size ], ) -def test_github_config_from_charm_missing_token(missing_config: str): +def test_valid_storage_size_str(size: str, expected_result: bool): """ - arrange: Given charm with missing token config. - act: when GithubConfig.from_charm is called. - assert: CharmConfigInvalidError is raised. + arrange: Provide storage size string. + act: Call _valid_storage_size_str with the provided storage size string. + assert: Verify that the function returns the expected result. """ - mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[missing_config] = "" + result = charm_state._valid_storage_size_str(size) - with pytest.raises(CharmConfigInvalidError) as exc: - charm_state.GithubConfig.from_charm(mock_charm) + assert result == expected_result - assert f"Missing {missing_config} configuration" in str(exc) - -def test_metrics_logging_available_true(): +def test_parse_labels_invalid(): """ - arrange: Setup mocked charm to return an integration. - act: Retrieve state from charm. - assert: metrics_logging_available returns True. + arrange: Provide labels string with an invalid label. + act: Call _parse_labels with the provided labels string. + assert: Verify that the function raises ValueError with the correct message. """ - mock_charm = MockGithubRunnerCharmFactory() - mock_charm.model.relations = { - COS_AGENT_INTEGRATION_NAME: MagicMock(spec=ops.Relation), - DEBUG_SSH_INTEGRATION_NAME: [], - } + labels = "label1, label 2, label3" # Label containing space, should be considered invalid - state = CharmState.from_charm(mock_charm) + with pytest.raises(ValueError) as exc_info: + charm_state._parse_labels(labels) + assert str(exc_info.value) == "Invalid labels label 2 found." - assert state.is_metrics_logging_available +@pytest.mark.parametrize( + "labels, expected_valid_labels", + [ + ("label1,label2,label3", ("label1", "label2", "label3")), # All labels are valid + ("label1, label2, label3", ("label1", "label2", "label3")), # Labels with spaces + ("label1,label2,label3,", ("label1", "label2", "label3")), # Trailing comma + ("label1,,label2,label3", ("label1", "label2", "label3")), # Double commas + ("label1,label2,label3, ", ("label1", "label2", "label3")), # Trailing space + ("", ()), # Empty string + ( + "label-1, label-2, label-3", + ("label-1", "label-2", "label-3"), + ), # Labels with hyphens + ], +) +def test_parse_labels(labels, expected_valid_labels): + """ + arrange: Provide comma-separated labels string. + act: Call _parse_labels with the provided labels string. + assert: Verify that the function returns the expected valid labels. + """ + result = charm_state._parse_labels(labels) -def test_metrics_logging_available_false(): + assert result == expected_valid_labels + + +@pytest.mark.parametrize( + "denylist_config, expected_entries", + [ + ("", []), + ("192.168.1.1", [FirewallEntry(ip_range="192.168.1.1")]), + ( + "192.168.1.1, 192.168.1.2, 192.168.1.3", + [ + FirewallEntry(ip_range="192.168.1.1"), + FirewallEntry(ip_range="192.168.1.2"), + FirewallEntry(ip_range="192.168.1.3"), + ], + ), + ], +) +def test_parse_denylist(denylist_config: str, expected_entries: typing.List[FirewallEntry]): """ - arrange: Setup mocked charm to return no integration. - act: Retrieve state from charm. - assert: metrics_logging_available returns False. + arrange: Create a mock CharmBase instance with provided denylist configuration. + act: Call _parse_denylist method with the mock CharmBase instance. + assert: Verify that the method returns the expected list of FirewallEntry objects. """ mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[DENYLIST_CONFIG_NAME] = denylist_config - state = CharmState.from_charm(mock_charm) + result = CharmConfig._parse_denylist(mock_charm) - assert not state.is_metrics_logging_available + assert result == expected_entries -def test_aproxy_proxy_missing(): +def test_parse_dockerhub_mirror_invalid_scheme(): """ - arrange: Setup mocked charm to use aproxy without configured http proxy. - act: Retrieve state from charm. - assert: CharmConfigInvalidError is raised. + arrange: Create a mock CharmBase instance with an invalid DockerHub mirror configuration. + act: Call _parse_dockerhub_mirror method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[USE_APROXY_CONFIG_NAME] = "true" + mock_charm.config[DOCKERHUB_MIRROR_CONFIG_NAME] = "http://example.com" - with pytest.raises(CharmConfigInvalidError) as exc: - CharmState.from_charm(mock_charm) - assert "Invalid proxy configuration" in str(exc.value) + with pytest.raises(CharmConfigInvalidError): + CharmConfig._parse_dockerhub_mirror(mock_charm) @pytest.mark.parametrize( - "image", + "mirror_config, expected_mirror_url", [ - pytest.param("eagle", id="non existent image"), - pytest.param("bionic", id="unsupported image"), + ("", None), + ("https://example.com", "https://example.com"), ], ) -def test_invalid_base_image(image: str): +def test_parse_dockerhub_mirror(mirror_config: str, expected_mirror_url: str | None): """ - arrange: Given an invalid base configuration. - act: Retrieve state from charm. - assert: CharmConfigInvalidError is raised. + arrange: Create a mock CharmBase instance with provided DockerHub mirror configuration. + act: Call _parse_dockerhub_mirror method with the mock CharmBase instance. + assert: Verify that the method returns the expected DockerHub mirror URL or None. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[BASE_IMAGE_CONFIG_NAME] = image + mock_charm.config[DOCKERHUB_MIRROR_CONFIG_NAME] = mirror_config + + result = CharmConfig._parse_dockerhub_mirror(mock_charm) + + assert result == expected_mirror_url + + +@pytest.fixture +def valid_yaml_config(): + """Valid YAML config.""" + return """ +clouds: + openstack: + auth: + username: 'admin' + password: 'password' + project_name: 'admin' + auth_url: 'http://keystone.openstack.svc.cluster.local:5000/v3' + user_domain_name: 'Default' + project_domain_name: 'Default' + region_name: 'RegionOne' + """ - with pytest.raises(CharmConfigInvalidError) as exc: - charm_state.RunnerCharmConfig.from_charm(mock_charm) - assert "Invalid base image" in str(exc.value) +@pytest.fixture +def invalid_yaml_config(): + """Invalid YAML config.""" + return """ +clouds: asdfsadf + openstack: + auth: + username: 'admin' + password: 'password' + project_name: 'admin' + auth_url: 'http://keystone.openstack.svc.cluster.local:5000/v3' + user_domain_name: 'Default' + project_domain_name: 'Default' + region_name: 'RegionOne' + """ -@pytest.mark.parametrize( - "image, expected_base_image", - [ - pytest.param("jammy", charm_state.BaseImage.JAMMY, id="jammy"), - pytest.param("22.04", charm_state.BaseImage.JAMMY, id="jammy tag"), - pytest.param("noble", charm_state.BaseImage.NOBLE, id="noble"), - pytest.param("24.04", charm_state.BaseImage.NOBLE, id="noble tag"), - ], -) -def test_base_image(image: str, expected_base_image: charm_state.BaseImage): + +def test_parse_openstack_clouds_config_empty(): """ - arrange: Given supported base image configuration. - act: Retrieve state from charm. - assert: CharmConfigInvalidError is raised. + arrange: Create a mock CharmBase instance with an empty OpenStack clouds YAML config. + act: Call _parse_openstack_clouds_config method with the mock CharmBase instance. + assert: Verify that the method returns None. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[BASE_IMAGE_CONFIG_NAME] = image + mock_charm.config[OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = "" + + result = CharmConfig._parse_openstack_clouds_config(mock_charm) - assert charm_state.BaseImage.from_charm(mock_charm) == expected_base_image + assert result is None -def test_proxy_invalid_format(): +def test_parse_openstack_clouds_config_invalid_yaml(invalid_yaml_config: str): """ - arrange: Setup mocked charm and invalid juju proxy settings. - act: Retrieve state from charm. - assert: CharmConfigInvalidError is raised. + arrange: Create a mock CharmBase instance with an invalid YAML config. + act: Call _parse_openstack_clouds_config method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError. """ mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = invalid_yaml_config - url_without_scheme = "proxy.example.com:8080" - with patch.dict(os.environ, {"JUJU_CHARM_HTTP_PROXY": url_without_scheme}): - with pytest.raises(CharmConfigInvalidError) as err: - CharmState.from_charm(mock_charm) - assert "Invalid proxy configuration" in err.value.msg + with pytest.raises(CharmConfigInvalidError): + CharmConfig._parse_openstack_clouds_config(mock_charm) -def test_proxy_config_bool(): +def test_parse_openstack_clouds_config_invalid_yaml_list(): """ - arrange: Various combinations for ProxyConfig. - act: Create ProxyConfig object. - assert: Expected boolean value. + arrange: Create a mock CharmBase instance with an invalid YAML config. + act: Call _parse_openstack_clouds_config method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError. """ - proxy_url = "http://proxy.example.com:8080" + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = "-1\n-2\n-3" + + with pytest.raises(CharmConfigInvalidError): + CharmConfig._parse_openstack_clouds_config(mock_charm) - # assert True if http or https is set - assert ProxyConfig(http=proxy_url) - assert ProxyConfig(https=proxy_url) - assert ProxyConfig(http=proxy_url, https=proxy_url) - assert ProxyConfig(http=proxy_url, https=proxy_url, no_proxy="localhost") - # assert False if otherwise - assert not ProxyConfig(use_aproxy=False) - assert not ProxyConfig(no_proxy="localhost") - assert not ProxyConfig(use_aproxy=False, no_proxy="localhost") - assert not ProxyConfig() +def test_parse_openstack_clouds_initialize_fail( + valid_yaml_config: str, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: Given monkeypatched openstack_cloud.initialize that raises an error. + act: Call _parse_openstack_clouds_config method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = valid_yaml_config + monkeypatch.setattr( + openstack_cloud, + "initialize", + MagicMock(side_effect=openstack_cloud.OpenStackInvalidConfigError), + ) + + with pytest.raises(CharmConfigInvalidError): + CharmConfig._parse_openstack_clouds_config(mock_charm) -def test_from_charm_invalid_arch(monkeypatch: pytest.MonkeyPatch): +def test_parse_openstack_clouds_config_valid(valid_yaml_config: str): """ - arrange: Given a monkeypatched platform.machine that returns an unsupported architecture type. - act: when _get_supported_arch is called. - assert: a charm config invalid error is raised. + arrange: Create a mock CharmBase instance with a valid OpenStack clouds YAML config. + act: Call _parse_openstack_clouds_config method with the mock CharmBase instance. + assert: Verify that the method returns the parsed YAML dictionary. """ mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = valid_yaml_config - mock_machine = MagicMock(spec=platform.machine) - mock_machine.return_value = "i686" # 32 bit is unsupported - monkeypatch.setattr(platform, "machine", mock_machine) + result = CharmConfig._parse_openstack_clouds_config(mock_charm) - with pytest.raises(CharmConfigInvalidError) as err: - CharmState.from_charm(mock_charm) - assert "Unsupported architecture" in err.value.msg + assert isinstance(result, dict) + assert "clouds" in result + + +@pytest.mark.parametrize("reconcile_interval", [(0), (1)]) +def test_check_reconcile_interval_invalid(reconcile_interval: int): + """ + arrange: Provide an invalid reconcile interval value. + act: Call check_reconcile_interval method with the provided value. + assert: Verify that the method raises ValueError with the correct message. + """ + with pytest.raises(ValueError) as exc_info: + CharmConfig.check_reconcile_interval(reconcile_interval) + assert ( + str(exc_info.value) + == "The reconcile-interval configuration needs to be greater or equal to 2" + ) + + +@pytest.mark.parametrize("reconcile_interval", [(2), (5), (10)]) +def test_check_reconcile_interval_valid(reconcile_interval: int): + """ + arrange: Provide a valid reconcile interval value. + act: Call check_reconcile_interval method with the provided value. + assert: Verify that the method returns the same value. + """ + result = CharmConfig.check_reconcile_interval(reconcile_interval) + + assert result == reconcile_interval + + +def test_charm_config_from_charm_invalid_github_config(): + """ + arrange: Create a mock CharmBase instance with an invalid GitHub configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[PATH_CONFIG_NAME] = "" + + # Act and Assert + with pytest.raises(CharmConfigInvalidError) as exc_info: + CharmConfig.from_charm(mock_charm) + assert str(exc_info.value) == "Invalid Github config, Missing path configuration" + + +def test_charm_config_from_charm_invalid_reconcile_interval(): + """ + arrange: Create a mock CharmBase instance with an invalid reconcile interval. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[RECONCILE_INTERVAL_CONFIG_NAME] = "" + + with pytest.raises(CharmConfigInvalidError) as exc_info: + CharmConfig.from_charm(mock_charm) + assert str(exc_info.value) == "The reconcile-interval config must be int" + + +def test_charm_config_from_charm_invalid_labels(): + """ + arrange: Create a mock CharmBase instance with an invalid reconcile interval. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[LABELS_CONFIG_NAME] = "hell world, space rangers" + + with pytest.raises(CharmConfigInvalidError) as exc_info: + CharmConfig.from_charm(mock_charm) + assert "Invalid labels config" in str(exc_info.value) + + +def test_charm_config_from_charm_valid(): + """ + arrange: Create a mock CharmBase instance with valid configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method returns a CharmConfig instance with the expected values. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config = { + PATH_CONFIG_NAME: "owner/repo", + 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' }}}", + LABELS_CONFIG_NAME: "label1,label2,label3", + TOKEN_CONFIG_NAME: "abc123", + } + + result = CharmConfig.from_charm(mock_charm) + + assert result.path == GithubRepo(owner="owner", repo="repo") + assert result.reconcile_interval == 5 + assert result.denylist == [ + FirewallEntry(ip_range="192.168.1.1"), + 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.labels == ("label1", "label2", "label3") + assert result.token == "abc123" @pytest.mark.parametrize( - "arch, expected_arch", + "base_image, expected_str", [ - pytest.param("aarch64", Arch.ARM64), - pytest.param("arm64", Arch.ARM64), - pytest.param("x86_64", Arch.X64), + (BaseImage.JAMMY, "jammy"), + (BaseImage.NOBLE, "noble"), ], ) -def test_from_charm_arch( - monkeypatch: pytest.MonkeyPatch, - arch: str, - expected_arch: Arch, -): +def test_base_image_str_parametrized(base_image, expected_str): """ - arrange: Given a monkeypatched platform.machine that returns parametrized architectures. - act: when _get_supported_arch is called. - assert: a correct architecture is inferred. + Parametrized test case for __str__ method of BaseImage enum. + + arrange: Pass BaseImage enum values and expected string. + act: Call __str__ method on each enum value. + assert: Ensure the returned string matches the expected string. """ - mock_charm = MockGithubRunnerCharmFactory() + assert str(base_image) == expected_str - mock_machine = MagicMock(spec=platform.machine) - mock_machine.return_value = arch - monkeypatch.setattr(platform, "machine", mock_machine) - state = CharmState.from_charm(mock_charm) +def test_base_image_from_charm_invalid_image(): + """ + arrange: Create a mock CharmBase instance with an invalid base image configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises an error. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[BASE_IMAGE_CONFIG_NAME] = "invalid" - assert state.arch == expected_arch + with pytest.raises(ValueError): + BaseImage.from_charm(mock_charm) -def test_ssh_debug_info_from_charm_no_relations(): +@pytest.mark.parametrize( + "image_name, expected_result", + [ + ("noble", BaseImage.NOBLE), # Valid custom configuration "noble" + ("24.04", BaseImage.NOBLE), # Valid custom configuration "noble" + ("jammy", BaseImage.JAMMY), # Valid custom configuration "jammy" + ("22.04", BaseImage.JAMMY), # Valid custom configuration "jammy" + ], +) +def test_base_image_from_charm(image_name: str, expected_result: BaseImage): """ - arrange: given a mocked charm that has no ssh-debug relations. - act: when SSHDebug.from_charm is called. - assert: None is returned. + arrange: Create a mock CharmBase instance with the provided image_name configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method returns the expected base image tag. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.model.relations = {DEBUG_SSH_INTEGRATION_NAME: []} + mock_charm.config[BASE_IMAGE_CONFIG_NAME] = image_name + + result = BaseImage.from_charm(mock_charm) - assert not SSHDebugConnection.from_charm(mock_charm) + assert result == expected_result + + +@pytest.mark.parametrize("virtual_machines", [(-1), (-5)]) # Invalid value # Invalid value +def test_check_virtual_machines_invalid(virtual_machines): + """ + arrange: Provide an invalid virtual machines value. + act: Call check_virtual_machines method with the provided value. + assert: Verify that the method raises ValueError with the correct message. + """ + with pytest.raises(ValueError) as exc_info: + RunnerCharmConfig.check_virtual_machines(virtual_machines) + assert ( + str(exc_info.value) + == "The virtual-machines configuration needs to be greater or equal to 0" + ) + + +@pytest.mark.parametrize( + "virtual_machines", [(0), (5), (10)] # Minimum valid value # Valid value # Valid value +) +def test_check_virtual_machines_valid(virtual_machines): + """ + arrange: Provide a valid virtual machines value. + act: Call check_virtual_machines method with the provided value. + assert: Verify that the method returns the same value. + """ + result = RunnerCharmConfig.check_virtual_machines(virtual_machines) + + assert result == virtual_machines @pytest.mark.parametrize( - "invalid_relation_data", + "vm_resources", [ - pytest.param( - { - "host": "invalidip", - "port": "8080", - "rsa_fingerprint": "SHA:fingerprint_data", - "ed25519_fingerprint": "SHA:fingerprint_data", - }, - id="invalid host IP", - ), - pytest.param( - { - "host": "127.0.0.1", - "port": "invalidport", - "rsa_fingerprint": "SHA:fingerprint_data", - "ed25519_fingerprint": "SHA:fingerprint_data", - }, - id="invalid port", - ), - pytest.param( - { - "host": "127.0.0.1", - "port": "invalidport", - "rsa_fingerprint": "invalid_fingerprint_data", - "ed25519_fingerprint": "invalid_fingerprint_data", - }, - id="invalid fingerprint", - ), + VirtualMachineResources(cpu=0, memory="1GiB", disk="10GiB"), # Invalid CPU value + VirtualMachineResources(cpu=1, memory="invalid", disk="10GiB"), # Invalid memory value + VirtualMachineResources(cpu=1, memory="1GiB", disk="invalid"), # Invalid disk value ], ) -def test_from_charm_ssh_debug_info_error(invalid_relation_data: dict): +def test_check_virtual_machine_resources_invalid(vm_resources): """ - arrange: Given an mocked charm that has invalid ssh-debug relation data. - act: when from_charm is called. - assert: CharmConfigInvalidError is raised. + arrange: Provide an invalid virtual_machine_resources value. + act: Call check_virtual_machine_resources method with the provided value. + assert: Verify that the method raises ValueError. + """ + with pytest.raises(ValueError): + RunnerCharmConfig.check_virtual_machine_resources(vm_resources) + + +@pytest.mark.parametrize( + "vm_resources, expected_result", + [ + ( + VirtualMachineResources(cpu=1, memory="1GiB", disk="10GiB"), + VirtualMachineResources(cpu=1, memory="1GiB", disk="10GiB"), + ), # Valid configuration + ( + VirtualMachineResources(cpu=2, memory="2GiB", disk="20GiB"), + VirtualMachineResources(cpu=2, memory="2GiB", disk="20GiB"), + ), # Valid configuration + ], +) +def test_check_virtual_machine_resources_valid(vm_resources, expected_result): + """ + arrange: Provide a valid virtual_machine_resources value. + act: Call check_virtual_machine_resources method with the provided value. + assert: Verify that the method returns the same value. + """ + result = RunnerCharmConfig.check_virtual_machine_resources(vm_resources) + + assert result == expected_result + + +def test_runner_charm_config_from_charm_invalid_base_image(): + """ + arrange: Create a mock CharmBase instance with an invalid base image configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. """ mock_charm = MockGithubRunnerCharmFactory() - mock_relation = MagicMock(spec=ops.Relation) - mock_unit = MagicMock(spec=ops.Unit) - mock_unit.name = "tmate-ssh-server-operator/0" - mock_relation.units = {mock_unit} - mock_relation.data = {mock_unit: invalid_relation_data} - mock_charm.model.relations[DEBUG_SSH_INTEGRATION_NAME] = [mock_relation] - - with pytest.raises(CharmConfigInvalidError) as err: - CharmState.from_charm(mock_charm) - assert "Invalid SSH Debug info" in err.value.msg + mock_charm.config[BASE_IMAGE_CONFIG_NAME] = "invalid" + + with pytest.raises(CharmConfigInvalidError) as exc_info: + RunnerCharmConfig.from_charm(mock_charm) + assert str(exc_info.value) == "Invalid base image" -def test_from_charm_ssh_debug_info(): +def test_runner_charm_config_from_charm_invalid_storage_config(): """ - arrange: Given an mocked charm that has invalid ssh-debug relation data. - act: when from_charm is called. - assert: ssh_debug_info data has been correctly parsed. + arrange: Create a mock CharmBase instance with an invalid storage configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. """ mock_charm = MockGithubRunnerCharmFactory() - mock_relation = MagicMock(spec=ops.Relation) - mock_unit = MagicMock(spec=ops.Unit) - mock_unit.name = "tmate-ssh-server-operator/0" - mock_relation.units = {mock_unit} - mock_relation.data = { - mock_unit: ( - mock_relation_data := { - "host": "127.0.0.1", - "port": "8080", - "rsa_fingerprint": "fingerprint_data", - "ed25519_fingerprint": "fingerprint_data", - } - ) + mock_charm.config = { + BASE_IMAGE_CONFIG_NAME: "jammy", + RUNNER_STORAGE_CONFIG_NAME: "invalid", + VIRTUAL_MACHINES_CONFIG_NAME: "5", + VM_CPU_CONFIG_NAME: "2", + VM_MEMORY_CONFIG_NAME: "4GiB", + VM_DISK_CONFIG_NAME: "20GiB", } - mock_charm.model.relations[DEBUG_SSH_INTEGRATION_NAME] = [mock_relation] - ssh_debug_connections = CharmState.from_charm(mock_charm).ssh_debug_connections - assert str(ssh_debug_connections[0].host) == mock_relation_data["host"] - assert str(ssh_debug_connections[0].port) == mock_relation_data["port"] - assert ssh_debug_connections[0].rsa_fingerprint == mock_relation_data["rsa_fingerprint"] - assert ( - ssh_debug_connections[0].ed25519_fingerprint == mock_relation_data["ed25519_fingerprint"] - ) + with pytest.raises(CharmConfigInvalidError) as exc_info: + RunnerCharmConfig.from_charm(mock_charm) + assert "Invalid runner-storage config" in str(exc_info.value) -def test_invalid_runner_storage(): +def test_runner_charm_config_from_charm_invalid_cpu_config(): """ - arrange: Setup mocked charm. - act: Set runner-storage to a non-existing option. - assert: Configuration Error raised. + arrange: Create a mock CharmBase instance with an invalid cpu configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config["runner-storage"] = "not-exist" + mock_charm.config = { + BASE_IMAGE_CONFIG_NAME: "jammy", + RUNNER_STORAGE_CONFIG_NAME: "memory", + VIRTUAL_MACHINES_CONFIG_NAME: "5", + VM_CPU_CONFIG_NAME: "invalid", + VM_MEMORY_CONFIG_NAME: "4GiB", + VM_DISK_CONFIG_NAME: "20GiB", + } - with pytest.raises(CharmConfigInvalidError) as exc: - CharmState.from_charm(mock_charm) - assert "Invalid runner-storage" in str(exc.value) + with pytest.raises(CharmConfigInvalidError) as exc_info: + RunnerCharmConfig.from_charm(mock_charm) + assert str(exc_info.value) == "Invalid vm-cpu configuration" -def test_openstack_config(clouds_yaml: dict): +def test_runner_charm_config_from_charm_invalid_virtual_machines_config(): """ - arrange: Setup mocked charm with openstack-clouds-yaml config. - act: Retrieve state from charm. - assert: openstack-clouds-yaml config is parsed correctly. + arrange: Create a mock CharmBase instance with an invalid virtual machines configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method raises CharmConfigInvalidError with the correct message. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[charm_state.OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = json.dumps(clouds_yaml) - state = CharmState.from_charm(mock_charm) - assert state.charm_config.openstack_clouds_yaml == clouds_yaml + mock_charm.config = { + BASE_IMAGE_CONFIG_NAME: "jammy", + RUNNER_STORAGE_CONFIG_NAME: "memory", + VIRTUAL_MACHINES_CONFIG_NAME: "invalid", + VM_CPU_CONFIG_NAME: "2", + VM_MEMORY_CONFIG_NAME: "4GiB", + VM_DISK_CONFIG_NAME: "20GiB", + } + + with pytest.raises(CharmConfigInvalidError) as exc_info: + RunnerCharmConfig.from_charm(mock_charm) + assert str(exc_info.value) == "The virtual-machines configuration must be int" -def test_openstack_config_invalid_yaml(): +def test_runner_charm_config_from_charm_valid(): """ - arrange: Setup mocked charm with openstack-clouds-yaml config containing invalid yaml. - act: Retrieve state from charm. - assert: CharmConfigInvalidError is raised. + arrange: Create a mock CharmBase instance with valid configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method returns a RunnerCharmConfig instance with the expected values. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[charm_state.OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = ( - "invalid_yaml\n-test: test\n" + mock_charm.config = { + BASE_IMAGE_CONFIG_NAME: "jammy", + RUNNER_STORAGE_CONFIG_NAME: "memory", + VIRTUAL_MACHINES_CONFIG_NAME: "5", + VM_CPU_CONFIG_NAME: "2", + VM_MEMORY_CONFIG_NAME: "4GiB", + VM_DISK_CONFIG_NAME: "20GiB", + } + + result = RunnerCharmConfig.from_charm(mock_charm) + + assert result.base_image == BaseImage.JAMMY + assert result.runner_storage == RunnerStorage("memory") + assert result.virtual_machines == 5 + assert result.virtual_machine_resources == VirtualMachineResources( + cpu=2, memory="4GiB", disk="20GiB" ) - with pytest.raises(CharmConfigInvalidError) as exc: - CharmState.from_charm(mock_charm) - assert "Invalid experimental-openstack-clouds-yaml config. Invalid yaml." in str(exc.value) + +@pytest.mark.parametrize( + "http, https, use_aproxy, expected_address", + [ + ("http://proxy.example.com", None, True, "proxy.example.com"), + (None, "https://secureproxy.example.com", True, "secureproxy.example.com"), + (None, None, False, None), + ("http://proxy.example.com", None, False, None), + ], +) +def test_apropy_address( + http: str | None, https: str | None, use_aproxy: bool, expected_address: str | None +): + """ + arrange: Create a ProxyConfig instance with specified HTTP, HTTPS, and aproxy settings. + act: Access the aproxy_address property of the ProxyConfig instance. + assert: Verify that the property returns the expected apropy address. + """ + proxy_config = ProxyConfig(http=http, https=https, use_aproxy=use_aproxy) + + result = proxy_config.aproxy_address + + assert result == expected_address + + +def test_check_use_aproxy(): + """ + arrange: Create a dictionary of values representing a proxy configuration with use_aproxy set\ + to True but neither http nor https provided. + act: Call the check_use_aproxy method with the provided values. + assert: Verify that the method raises a ValueError with the expected message. + """ + values = {"http": None, "https": None} + use_aproxy = True + + with pytest.raises(ValueError) as exc_info: + ProxyConfig.check_use_aproxy(use_aproxy, values) + + assert str(exc_info.value) == "aproxy requires http or https to be set" @pytest.mark.parametrize( - "clouds_yaml, expected_err_msg", + "http, https, expected_result", [ - pytest.param( - '["invalid", "type", "list"]', - "Invalid openstack config format, expected dict, got ", - ), - pytest.param( - "invalid string type", - "Invalid openstack config format, expected dict, got ", - ), - pytest.param( - "3", - "Invalid openstack config format, expected dict, got ", - ), + ("http://proxy.example.com", None, True), # Test with only http set + (None, "https://secureproxy.example.com", True), # Test with only https set + ( + "http://proxy.example.com", + "https://secureproxy.example.com", + True, + ), # Test with both http and https set + (None, None, False), # Test with neither http nor https set ], ) -def test_openstack_config_invalid_format(clouds_yaml: Any, expected_err_msg: str): +def test___bool__(http: str | None, https: str | None, expected_result: bool): """ - arrange: Given a charm with openstack-clouds-yaml of types other than dict. - act: when charm state is initialized. - assert: + arrange: Create a YourClass instance with http and/or https set. + act: Call the __bool__ method on the instance. + assert: Verify that the method returns the expected boolean value. + """ + proxy_instance = ProxyConfig(http=http, https=https) + + result = bool(proxy_instance) + + assert result == expected_result + + +@pytest.mark.parametrize( + "http, https, no_proxy", + [ + pytest.param(None, None, "localhost"), + pytest.param(None, "http://internal.proxy", None), + ], +) +def test_proxy_config_from_charm( + monkeypatch: pytest.MonkeyPatch, http: str | None, https: str | None, no_proxy: str | None +): + """ + arrange: Create a mock CharmBase instance with use-aproxy configuration. + act: Call from_charm method with the mock CharmBase instance. + assert: Verify that the method returns a ProxyConfig instance with no_proxy set correctly. """ mock_charm = MockGithubRunnerCharmFactory() - mock_charm.config[charm_state.OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = clouds_yaml - with pytest.raises(CharmConfigInvalidError) as exc: - CharmState.from_charm(mock_charm) - assert expected_err_msg in str(exc) + mock_charm.config[USE_APROXY_CONFIG_NAME] = False + monkeypatch.setattr(charm_state, "get_env_var", MagicMock(side_effect=[http, https, no_proxy])) + + result = ProxyConfig.from_charm(mock_charm) + + assert result.no_proxy is None @pytest.mark.parametrize( - "label_str, falsy_labels", + "mocked_arch", [ - pytest.param("$invalid", ("$invalid",), id="invalid label"), - pytest.param("$invalid, valid", ("$invalid",), id="invalid label with valid"), - pytest.param( - "$invalid, valid, *next", ("$invalid", "*next"), id="invalid labels with valid" - ), + "ppc64le", # Test with unsupported architecture + "sparc", # Another example of unsupported architecture + ], +) +def test__get_supported_arch_unsupported(mocked_arch: str, monkeypatch: pytest.MonkeyPatch): + """ + arrange: Mock the platform.machine() function to return an unsupported architecture. + act: Call the _get_supported_arch function. + assert: Verify that the function raises an UnsupportedArchitectureError. + """ + monkeypatch.setattr(platform, "machine", MagicMock(return_value=mocked_arch)) + + with pytest.raises(UnsupportedArchitectureError): + charm_state._get_supported_arch() + + +@pytest.mark.parametrize( + "mocked_arch, expected_result", + [ + ("arm64", Arch.ARM64), # Test with supported ARM64 architecture + ("x86_64", Arch.X64), # Test with supported X64 architecture ], ) -def test__parse_labels_invalid_labels(label_str: str, falsy_labels: tuple[str]): +def test__get_supported_arch_supported( + mocked_arch: str, expected_result: Arch, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: Mock the platform.machine() function to return a specific architecture. + act: Call the _get_supported_arch function. + assert: Verify that the function returns the expected supported architecture. """ - arrange: given labels composed of non-alphanumeric or underscore. - act: when _parse_labels is called. - assert: ValueError with invalid labels are raised. + monkeypatch.setattr(platform, "machine", MagicMock(return_value=mocked_arch)) + + assert charm_state._get_supported_arch() == expected_result + + +def test_ssh_debug_connection_from_charm_no_connections(): + """ + arrange: Mock CharmBase instance without relation. + act: Call SSHDebugConnection.from_charm method. + assert: Verify that the method returns the expected empty list. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.model.relations[DEBUG_SSH_INTEGRATION_NAME] = [] + + connections = SSHDebugConnection.from_charm(mock_charm) + + assert not connections + + +def test_ssh_debug_connection_from_charm_data_not_ready(): + """ + arrange: Mock CharmBase instance with no relation data. + act: Call SSHDebugConnection.from_charm method. + assert: Verify that the method returns the expected list of SSHDebugConnection instances. + """ + mock_charm = MockGithubRunnerCharmFactory() + relation_mock = MagicMock() + unit_mock = MagicMock() + relation_mock.units = [unit_mock] + relation_mock.data = {unit_mock: {}} + mock_charm.model.relations[DEBUG_SSH_INTEGRATION_NAME] = [relation_mock] + + connections = SSHDebugConnection.from_charm(mock_charm) + + assert not connections + + +def test_ssh_debug_connection_from_charm(): + """ + arrange: Mock CharmBase instance with relation data. + act: Call SSHDebugConnection.from_charm method. + assert: Verify that the method returns the expected list of SSHDebugConnection instances. + """ + mock_charm = MockGithubRunnerCharmFactory() + relation_mock = MagicMock() + unit_mock = MagicMock() + relation_mock.units = [unit_mock] + relation_mock.data = { + unit_mock: { + "host": "192.168.0.1", + "port": 22, + "rsa_fingerprint": "SHA256:abcdef", + "ed25519_fingerprint": "SHA256:ghijkl", + } + } + mock_charm.model.relations[DEBUG_SSH_INTEGRATION_NAME] = [relation_mock] + + connections = SSHDebugConnection.from_charm(mock_charm) + + assert isinstance(connections[0], SSHDebugConnection) + assert connections[0].host == IPv4Address("192.168.0.1") + assert connections[0].port == 22 + assert connections[0].rsa_fingerprint == "SHA256:abcdef" + assert connections[0].ed25519_fingerprint == "SHA256:ghijkl" + + +@pytest.fixture +def mock_charm_state_path(): + """Fixture to mock CHARM_STATE_PATH.""" + return MagicMock() + + +@pytest.fixture +def mock_charm_state_data(): + """Fixture to mock previous charm state data.""" + return { + "arch": "x86_64", + "is_metrics_logging_available": True, + "proxy_config": {"http": "http://example.com", "https": "https://example.com"}, + "charm_config": {"denylist": ["192.168.1.1"], "token": "abc123"}, + "runner_config": { + "base_image": "jammy", + "virtual_machines": 2, + "runner_storage": "memory", + }, + "ssh_debug_connections": [ + {"host": "10.1.2.4", "port": 22}, + ], + } + + +def test_check_immutable_config_change_no_previous_state( + mock_charm_state_path: Path, mock_charm_state_data: dict, monkeypatch: pytest.MonkeyPatch +): """ - with pytest.raises(ValueError) as exc: - charm_state._parse_labels(labels=label_str) + arrange: Mock CHARM_STATE_PATH and read_text method to return no previous state. + act: Call _check_immutable_config_change method. + assert: Ensure no exception is raised. + """ + monkeypatch.setattr(charm_state, "CHARM_STATE_PATH", mock_charm_state_path) + monkeypatch.setattr(charm_state.CHARM_STATE_PATH, "exists", MagicMock(return_value=False)) + state = CharmState(**mock_charm_state_data) - assert all(label in str(exc) for label in falsy_labels) + assert state._check_immutable_config_change("new_runner_storage", "new_base_image") is None + + +def test_check_immutable_config_change_storage_changed( + mock_charm_state_path: Path, mock_charm_state_data: dict, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: Mock CHARM_STATE_PATH and read_text method to return previous state with different \ + storage. + act: Call _check_immutable_config_change method. + assert: Ensure ImmutableConfigChangedError is raised. + """ + monkeypatch.setattr(charm_state, "CHARM_STATE_PATH", mock_charm_state_path) + monkeypatch.setattr( + charm_state.CHARM_STATE_PATH, + "read_text", + MagicMock(return_value=json.dumps(mock_charm_state_data)), + ) + state = CharmState(**mock_charm_state_data) + + with pytest.raises(ImmutableConfigChangedError): + state._check_immutable_config_change(RunnerStorage.JUJU_STORAGE, BaseImage.JAMMY) + + +def test_check_immutable_config_change_base_image_changed( + mock_charm_state_path, mock_charm_state_data, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: Mock CHARM_STATE_PATH and read_text method to return previous state with different \ + base image. + act: Call _check_immutable_config_change method. + assert: Ensure ImmutableConfigChangedError is raised. + """ + monkeypatch.setattr(charm_state, "CHARM_STATE_PATH", mock_charm_state_path) + monkeypatch.setattr( + charm_state.CHARM_STATE_PATH, + "read_text", + MagicMock(return_value=json.dumps(mock_charm_state_data)), + ) + state = CharmState(**mock_charm_state_data) + + with pytest.raises(ImmutableConfigChangedError): + state._check_immutable_config_change(RunnerStorage.MEMORY, BaseImage.NOBLE) + + +def test_check_immutable_config( + mock_charm_state_path, mock_charm_state_data, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: Mock CHARM_STATE_PATH and read_text method to return previous state with same config. + act: Call _check_immutable_config_change method. + assert: None is returned. + """ + monkeypatch.setattr(charm_state, "CHARM_STATE_PATH", mock_charm_state_path) + monkeypatch.setattr( + charm_state.CHARM_STATE_PATH, + "read_text", + MagicMock(return_value=json.dumps(mock_charm_state_data)), + ) + state = CharmState(**mock_charm_state_data) + + assert state._check_immutable_config_change(RunnerStorage.MEMORY, BaseImage.JAMMY) is None + + +class MockModel(BaseModel): + """A Mock model class used for pydantic error testing.""" @pytest.mark.parametrize( - "label_str, expected_labels", + "module, target, exc", [ - pytest.param("", tuple(), id="empty"), - pytest.param("a", ("a",), id="single label"), - pytest.param("a ", ("a",), id="single label with space"), - pytest.param("a,b,c", ("a", "b", "c"), id="comma separated labels"), - pytest.param(" a, b, c", ("a", "b", "c"), id="comma separated labels with space"), - pytest.param("1234", ("1234",), id="numeric label"), - pytest.param("_", ("_",), id="underscore"), - pytest.param("-", ("-",), id="dash only"), - pytest.param("_test_", ("_test_",), id="alphabetical with underscore"), - pytest.param("_test1234_", ("_test1234_",), id="alphanumeric with underscore"), - pytest.param("x-large", ("x-large",), id="dash word"), - pytest.param("x-large, two-xlarge", ("x-large", "two-xlarge"), id="dash words"), - pytest.param( - "x-large_1, two-xlarge", ("x-large_1", "two-xlarge"), id="dash underscore words" + ( + ProxyConfig, + "from_charm", + ValidationError([], MockModel), ), + (ProxyConfig, "from_charm", ValueError), + (CharmConfig, "from_charm", ImmutableConfigChangedError("Immutable config changed")), + (CharmConfig, "from_charm", ValidationError([], MockModel)), + (CharmConfig, "from_charm", ValueError), + (charm_state, "_get_supported_arch", UnsupportedArchitectureError(arch="testarch")), + (SSHDebugConnection, "from_charm", ValidationError([], MockModel)), ], ) -def test__parse_labels(label_str: str, expected_labels: tuple[str]): +def test_charm_state_from_charm_invalid_cases( + module: object, target: str, exc: Exception, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: Mock CharmBase and necessary methods to raise the specified exceptions. + act: Call CharmState.from_charm. + assert: Ensure CharmConfigInvalidError is raised with the appropriate message. + """ + mock_charm = MockGithubRunnerCharmFactory() + monkeypatch.setattr(ProxyConfig, "from_charm", MagicMock()) + monkeypatch.setattr(CharmConfig, "from_charm", MagicMock()) + monkeypatch.setattr(RunnerCharmConfig, "from_charm", MagicMock()) + monkeypatch.setattr(charm_state, "_get_supported_arch", MagicMock()) + monkeypatch.setattr(SSHDebugConnection, "from_charm", MagicMock()) + monkeypatch.setattr(module, target, MagicMock(side_effect=exc)) + + with pytest.raises(CharmConfigInvalidError): + CharmState.from_charm(mock_charm) + + +def test_charm_state_from_charm(monkeypatch: pytest.MonkeyPatch): """ - arrange: given a comma separated label strings. - act: when _parse_labels is called. - assert: expected labels are returned. + arrange: Mock CharmBase and necessary methods. + act: Call CharmState.from_charm. + assert: Ensure no errors are raised. """ - assert charm_state._parse_labels(labels=label_str) == expected_labels + mock_charm = MockGithubRunnerCharmFactory() + monkeypatch.setattr(ProxyConfig, "from_charm", MagicMock()) + monkeypatch.setattr(CharmConfig, "from_charm", MagicMock()) + monkeypatch.setattr(RunnerCharmConfig, "from_charm", MagicMock()) + monkeypatch.setattr(CharmState, "_check_immutable_config_change", MagicMock()) + monkeypatch.setattr(charm_state, "_get_supported_arch", MagicMock()) + monkeypatch.setattr(SSHDebugConnection, "from_charm", MagicMock()) + monkeypatch.setattr(json, "loads", MagicMock()) + monkeypatch.setattr(json, "dumps", MagicMock()) + monkeypatch.setattr(charm_state, "CHARM_STATE_PATH", MagicMock()) + + assert CharmState.from_charm(mock_charm)