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)