diff --git a/docs/explanation/arm64.md b/docs/explanation/arm64.md new file mode 100644 index 0000000000..5c45a704f9 --- /dev/null +++ b/docs/explanation/arm64.md @@ -0,0 +1,25 @@ +# ARM64 + +### Nested virtualiztion support + +GitHub runner uses [LXD](https://github.com/canonical/lxd) to create a virtual machine to run the +GitHub runner's binary. Some versions of the ARM64 architecture do not support nested +virtualizations. + +Furthermore LXD by default uses QEMU with KVM acceleration options and such behavior cannot +overridden. When run on a machine without KVM support, +the following error will occur: +``` +Error: Failed instance creation: Failed creating instance record: Instance type "virtual-machine" +is not supported on this server: KVM support is missing (no /dev/kvm) +``` + +The kernel for nested virtualizations have not yet landed upstream. + +The current progress of ARM64 nested virtualization support requires a few underlying technologies +to be further developed. +- [Hardware: supported](https://developer.arm.com/documentation/102142/0100/Nested-virtualization) +- Kernel (KVM): upstream not yet ready +- Userspace programs (e.g. qemu): unsupported. + +Therefore, it is currently necessary that the charm is deployed on a bare metal instance. diff --git a/docs/how-to/deploy-on-arm64.md b/docs/how-to/deploy-on-arm64.md new file mode 100644 index 0000000000..db298dba84 --- /dev/null +++ b/docs/how-to/deploy-on-arm64.md @@ -0,0 +1,30 @@ +# How to deploy on ARM64 + +The charm supports deployment on ARM64 machines. However, it should be noted that the ARM64 +deployment currently only supports ARM64 bare-metal machines due to the limitations of +[nested virtualization on ARM64](https://developer.arm.com/documentation/102142/0100/Nested-virtualization). + +The following uses AWS's [m7g.metal](https://aws.amazon.com/blogs/aws/now-available-bare-metal-arm-based-ec2-instances/) +instance to deploy the GitHub Runner on ARM64 architecture. + +### Prerequisites +1. Juju with ARM64 bare metal instance availability. + - On AWS: `juju bootstrap aws ` +2. GitHub [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +3. Repository to register the GitHub runners. + +### Deployment steps + +Run the following command: +```shell +juju deploy github-runner \ + --constraints="instance-type=a1.metal arch=arm64" \ + --config token= --config path= +``` + +The units may take several minutes to settle. Furthermore, due to charm restart (kernel update), +the Unit may become lost for a few minutes. This is an expected behavior and the unit should +automatically re-register itself onto the Juju controller after a successful reboot. + +Goto the repository > Settings (tab) > Actions (left menu dropdown) > Runners and verify that the +runner has successfully registered and is online. diff --git a/docs/reference/arm64.md b/docs/reference/arm64.md new file mode 100644 index 0000000000..bf32203831 --- /dev/null +++ b/docs/reference/arm64.md @@ -0,0 +1,6 @@ +# ARM64 + +### AWS bare metal instances + +Use any of the [ARM64 metal instances](https://aws.amazon.com/ec2/instance-types/) to provide juju +with ARM64 bare metal instances. Some of the examples include: a1.metal, m7g.metal. diff --git a/src-docs/charm_state.py.md b/src-docs/charm_state.py.md index 9d4237fe45..e41bba00d3 100644 --- a/src-docs/charm_state.py.md +++ b/src-docs/charm_state.py.md @@ -7,9 +7,20 @@ State of the Charm. **Global Variables** --------------- +- **ARCHITECTURES_ARM64** +- **ARCHITECTURES_X86** - **COS_AGENT_INTEGRATION_NAME** +--- + +## class `ARCH` +Supported system architectures. + + + + + --- ## class `CharmConfigInvalidError` @@ -21,7 +32,7 @@ Raised when charm config is invalid. - `msg`: Explanation of the error. - + ### function `__init__` @@ -66,7 +77,7 @@ Return the aproxy address. --- - + ### classmethod `check_fields` @@ -78,7 +89,7 @@ Validate the proxy configuration. --- - + ### classmethod `from_charm` @@ -111,13 +122,14 @@ The charm state. - `is_metrics_logging_available`: Whether the charm is able to issue metrics. - `proxy_config`: Whether aproxy should be used. + - `arch`: The underlying compute architecture, i.e. x64, amd64 --- - + ### classmethod `from_charm` @@ -139,3 +151,34 @@ Initialize the state from charm. Current state of the charm. +--- + +## class `UnsupportedArchitectureError` +Raised when given machine charm architecture is unsupported. + + + +**Attributes:** + + - `arch`: The current machine architecture. + + + +### function `__init__` + +```python +__init__(arch: str) → None +``` + +Initialize a new instance of the CharmConfigInvalidError exception. + + + +**Args:** + + - `arch`: The current machine architecture. + + + + + diff --git a/src-docs/runner.py.md b/src-docs/runner.py.md index e1f0ec3c01..fe815da2f8 100644 --- a/src-docs/runner.py.md +++ b/src-docs/runner.py.md @@ -9,6 +9,29 @@ The `Runner` class stores the information on the runners and manages the lifecyc The `RunnerManager` class from `runner_manager.py` creates and manages a collection of `Runner` instances. +**Global Variables** +--------------- +- **YQ_BIN_URL_AMD64** +- **YQ_BIN_URL_ARM64** + + +--- + +## class `CreateRunnerConfig` +The configuration values for creating a single runner instance. + + + +**Args:** + + - `image`: Name of the image to launch the LXD instance with. + - `resources`: Resource setting for the LXD instance. + - `binary_path`: Path to the runner binary. + - `registration_token`: Token for registering the runner on GitHub. + - `arch`: Current machine architecture. + + + --- @@ -16,7 +39,7 @@ The `RunnerManager` class from `runner_manager.py` creates and manages a collect ## class `Runner` Single instance of GitHub self-hosted runner. - + ### function `__init__` @@ -44,17 +67,12 @@ Construct the runner instance. --- - + ### function `create` ```python -create( - image: str, - resources: VirtualMachineResources, - binary_path: Path, - registration_token: str -) +create(config: CreateRunnerConfig) ``` Create the runner instance on LXD and register it on GitHub. @@ -67,6 +85,7 @@ Create the runner instance on LXD and register it on GitHub. - `resources`: Resource setting for the LXD instance. - `binary_path`: Path to the runner binary. - `registration_token`: Token for registering the runner on GitHub. + - `arch`: Current machine architecture. @@ -76,7 +95,7 @@ Create the runner instance on LXD and register it on GitHub. --- - + ### function `remove` diff --git a/src-docs/runner_manager.py.md b/src-docs/runner_manager.py.md index 2deb4b3cf2..6d2bb1618e 100644 --- a/src-docs/runner_manager.py.md +++ b/src-docs/runner_manager.py.md @@ -73,7 +73,7 @@ Check if runner binary exists. --- - + ### function `flush` @@ -96,7 +96,7 @@ Remove existing runners. --- - + ### function `get_github_info` @@ -118,10 +118,7 @@ Get information on the runners from GitHub. ### function `get_latest_runner_bin_url` ```python -get_latest_runner_bin_url( - os_name: str = 'linux', - arch_name: str = 'x64' -) → RunnerApplication +get_latest_runner_bin_url(os_name: str = 'linux') → RunnerApplication ``` Get the URL for the latest runner binary. @@ -133,7 +130,6 @@ The runner binary URL changes when a new version is available. **Args:** - `os_name`: Name of operating system. - - `arch_name`: Name of architecture. @@ -142,7 +138,7 @@ The runner binary URL changes when a new version is available. --- - + ### function `reconcile` @@ -166,7 +162,7 @@ Bring runners in line with target. --- - + ### function `update_runner_bin` diff --git a/src/charm_state.py b/src/charm_state.py index e153222e29..fd9f55cc71 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -5,6 +5,8 @@ import dataclasses import logging +import platform +from enum import Enum from typing import Optional from ops import CharmBase @@ -14,6 +16,18 @@ logger = logging.getLogger(__name__) +ARCHITECTURES_ARM64 = {"aarch64", "arm64"} + +ARCHITECTURES_X86 = {"x86_64"} + + +class ARCH(str, Enum): + """Supported system architectures.""" + + ARM64 = "arm64" + X64 = "x64" + + COS_AGENT_INTEGRATION_NAME = "cos-agent" @@ -94,6 +108,41 @@ def check_fields(cls, values: dict) -> dict: return values +class UnsupportedArchitectureError(Exception): + """Raised when given machine charm architecture is unsupported. + + Attributes: + arch: The current machine architecture. + """ + + def __init__(self, arch: str) -> None: + """Initialize a new instance of the CharmConfigInvalidError exception. + + Args: + arch: The current machine architecture. + """ + self.arch = arch + + +def _get_supported_arch() -> ARCH: + """Get current machine architecture. + + Raises: + UnsupportedArchitectureError: if the current architecture is unsupported. + + Returns: + Arch: Current machine architecture. + """ + arch = platform.machine() + match arch: + case arch if arch in ARCHITECTURES_ARM64: + return ARCH.ARM64 + case arch if arch in ARCHITECTURES_X86: + return ARCH.X64 + case _: + raise UnsupportedArchitectureError(arch=arch) + + @dataclasses.dataclass(frozen=True) class State: """The charm state. @@ -101,10 +150,12 @@ class State: Attributes: is_metrics_logging_available: Whether the charm is able to issue metrics. proxy_config: Whether aproxy should be used. + arch: The underlying compute architecture, i.e. x86_64, amd64, arm64/aarch64. """ is_metrics_logging_available: bool proxy_config: ProxyConfig + arch: ARCH @classmethod def from_charm(cls, charm: CharmBase) -> "State": @@ -122,7 +173,14 @@ def from_charm(cls, charm: CharmBase) -> "State": logger.error("Invalid proxy config: %s", exc) raise CharmConfigInvalidError("Invalid proxy configuration") from exc + try: + arch = _get_supported_arch() + except UnsupportedArchitectureError as exc: + logger.error("Unsupported architecture: %s", exc.arch) + raise CharmConfigInvalidError(f"Unsupported architecture {exc.arch}") from exc + return cls( is_metrics_logging_available=bool(charm.model.relations[COS_AGENT_INTEGRATION_NAME]), proxy_config=proxy_config, + arch=arch, ) diff --git a/src/runner.py b/src/runner.py index fe99edc1db..ca9d838e06 100644 --- a/src/runner.py +++ b/src/runner.py @@ -22,6 +22,7 @@ import yaml import shared_fs +from charm_state import ARCH from errors import ( CreateSharedFilesystemError, LxdError, @@ -52,6 +53,9 @@ Snap = NamedTuple("Snap", [("name", str), ("channel", str)]) +YQ_BIN_URL_AMD64 = "https://github.com/mikefarah/yq/releases/download/v4.34.1/yq_linux_amd64" +YQ_BIN_URL_ARM64 = "https://github.com/mikefarah/yq/releases/download/v4.34.1/yq_linux_arm64" + @dataclass class WgetExecutable: @@ -66,6 +70,25 @@ class WgetExecutable: cmd: str +@dataclass +class CreateRunnerConfig: + """The configuration values for creating a single runner instance. + + Args: + image: Name of the image to launch the LXD instance with. + resources: Resource setting for the LXD instance. + binary_path: Path to the runner binary. + registration_token: Token for registering the runner on GitHub. + arch: Current machine architecture. + """ + + image: str + resources: VirtualMachineResources + binary_path: Path + registration_token: str + arch: ARCH = ARCH.X64 + + class Runner: """Single instance of GitHub self-hosted runner.""" @@ -105,20 +128,11 @@ def __init__( self.config.proxies["no_proxy"] = "" self.config.proxies["no_proxy"] += f"{self.config.name},.svc" - def create( - self, - image: str, - resources: VirtualMachineResources, - binary_path: Path, - registration_token: str, - ): + def create(self, config: CreateRunnerConfig): """Create the runner instance on LXD and register it on GitHub. Args: - image: Name of the image to launch the LXD instance with. - resources: Resource setting for the LXD instance. - binary_path: Path to the runner binary. - registration_token: Token for registering the runner on GitHub. + config: The instance config to create the LXD VMs and configure GitHub runner with. Raises: RunnerCreateError: Unable to create an LXD instance for runner. @@ -135,15 +149,17 @@ def create( self.config.name, ) try: - self.instance = self._create_instance(image, resources) + self.instance = self._create_instance(config.image, config.resources) self._start_instance() # Wait some initial time for the instance to boot up time.sleep(60) self._wait_boot_up() - self._install_binaries(binary_path) + self._install_binaries(config.binary_path, arch=config.arch) self._configure_runner() - self._register_runner(registration_token, labels=[self.config.app_name, image]) + self._register_runner( + config.registration_token, labels=[self.config.app_name, config.image] + ) self._start_runner() except (RunnerError, LxdError) as err: raise RunnerCreateError(f"Unable to create runner {self.config.name}") from err @@ -474,11 +490,12 @@ def _wait_boot_up(self) -> None: logger.info("Finished booting up LXD instance for runner: %s", self.config.name) @retry(tries=5, delay=1, local_logger=logger) - def _install_binaries(self, runner_binary: Path) -> None: + def _install_binaries(self, runner_binary: Path, arch=ARCH.X64) -> None: """Install runner binary and other binaries. Args: runner_binary: Path to the compressed runner binary. + arch: The machine architecture. Raises: RunnerFileLoadError: Unable to load the runner binary into the runner instance. @@ -498,7 +515,7 @@ def _install_binaries(self, runner_binary: Path) -> None: self._wget_install( [ WgetExecutable( - url="https://github.com/mikefarah/yq/releases/download/v4.34.1/yq_linux_amd64", + url=YQ_BIN_URL_AMD64 if arch is ARCH.X64 else YQ_BIN_URL_ARM64, cmd="yq", ) ] diff --git a/src/runner_manager.py b/src/runner_manager.py index 4bee423fa9..eb641264d3 100644 --- a/src/runner_manager.py +++ b/src/runner_manager.py @@ -37,7 +37,7 @@ ) from lxd import LxdClient, LxdInstance from repo_policy_compliance_client import RepoPolicyComplianceClient -from runner import Runner, RunnerClients, RunnerConfig, RunnerStatus +from runner import CreateRunnerConfig, Runner, RunnerClients, RunnerConfig, RunnerStatus from runner_metrics import RUNNER_INSTALLED_TS_FILE_NAME from runner_type import ( GitHubOrg, @@ -166,16 +166,13 @@ def check_runner_bin(self) -> bool: return self.runner_bin_path.exists() @retry(tries=5, delay=30, local_logger=logger) - def get_latest_runner_bin_url( - self, os_name: str = "linux", arch_name: str = "x64" - ) -> RunnerApplication: + def get_latest_runner_bin_url(self, os_name: str = "linux") -> RunnerApplication: """Get the URL for the latest runner binary. The runner binary URL changes when a new version is available. Args: os_name: Name of operating system. - arch_name: Name of architecture. Returns: Information on the runner application. @@ -193,14 +190,13 @@ def get_latest_runner_bin_url( logger.debug("Response of runner binary list: %s", runner_bins) try: + arch = self.config.charm_state.arch.value return next( - bin - for bin in runner_bins - if bin["os"] == os_name and bin["architecture"] == arch_name + bin for bin in runner_bins if bin["os"] == os_name and bin["architecture"] == arch ) except StopIteration as err: raise RunnerBinaryError( - f"Unable query GitHub runner binary information for {os_name} {arch_name}" + f"Unable query GitHub runner binary information for {os_name} {arch}" ) from err @retry(tries=5, delay=30, local_logger=logger) @@ -317,10 +313,13 @@ def _create_runner( if self.config.charm_state.is_metrics_logging_available: ts_now = time.time() runner.create( - self.config.image, - resources, - RunnerManager.runner_bin_path, - registration_token, + config=CreateRunnerConfig( + image=self.config.image, + resources=resources, + binary_path=RunnerManager.runner_bin_path, + registration_token=registration_token, + arch=self.config.charm_state.arch, + ) ) ts_after = time.time() try: @@ -346,10 +345,13 @@ def _create_runner( ) else: runner.create( - self.config.image, - resources, - RunnerManager.runner_bin_path, - registration_token, + config=CreateRunnerConfig( + image=self.config.image, + resources=resources, + binary_path=RunnerManager.runner_bin_path, + registration_token=registration_token, + arch=self.config.charm_state.arch, + ) ) def _issue_runner_metrics(self) -> runner_metrics.IssuedMetricEventsStats: diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 911df6b54c..889455c67c 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -11,6 +11,7 @@ from ops.testing import Harness from charm import GithubRunnerCharm +from charm_state import ARCH from errors import LogrotateSetupError, MissingConfigurationError, RunnerError, SubprocessError from github_type import GitHubRunnerStatus from runner_manager import RunnerInfo, RunnerManagerConfig @@ -31,7 +32,7 @@ def raise_url_error(*args, **kargs): raise urllib.error.URLError("mock error") -def mock_get_latest_runner_bin_url(): +def mock_get_latest_runner_bin_url(os_name: str = "linux", arch: ARCH = ARCH.X64): mock = MagicMock() mock.download_url = "www.example.com" return mock diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index c402743462..6ce5c835a0 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -1,11 +1,13 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. import os +import platform from unittest.mock import MagicMock, patch import pytest -from charm_state import CharmConfigInvalidError, State +from charm import GithubRunnerCharm +from charm_state import ARCH, CharmConfigInvalidError, State def test_metrics_logging_available_true(): @@ -65,3 +67,44 @@ def test_proxy_invalid_format(): with patch.dict(os.environ, {"JUJU_CHARM_HTTP_PROXY": url_without_scheme}): with pytest.raises(CharmConfigInvalidError): State.from_charm(charm) + + +def test_from_charm_invalid_arch(monkeypatch: pytest.MonkeyPatch): + """ + 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. + """ + mock_machine = MagicMock(spec=platform.machine) + mock_machine.return_value = "i686" # 32 bit is unsupported + monkeypatch.setattr(platform, "machine", mock_machine) + mock_charm = MagicMock(spec=GithubRunnerCharm) + mock_charm.config = {} + + with pytest.raises(CharmConfigInvalidError): + State.from_charm(mock_charm) + + +@pytest.mark.parametrize( + "arch, expected_arch", + [ + pytest.param("aarch64", ARCH.ARM64), + pytest.param("arm64", ARCH.ARM64), + pytest.param("x86_64", ARCH.X64), + ], +) +def test_from_charm_arch(monkeypatch: pytest.MonkeyPatch, arch: str, expected_arch: ARCH): + """ + arrange: Given a monkeypatched platform.machine that returns parametrized architectures. + act: when _get_supported_arch is called. + assert: a correct architecture is inferred. + """ + mock_machine = MagicMock(spec=platform.machine) + mock_machine.return_value = arch + monkeypatch.setattr(platform, "machine", mock_machine) + mock_charm = MagicMock(spec=GithubRunnerCharm) + mock_charm.config = {} + + state = State.from_charm(mock_charm) + + assert state.arch == expected_arch diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index 580e2b97de..2234944872 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -13,7 +13,7 @@ from _pytest.monkeypatch import MonkeyPatch from errors import CreateSharedFilesystemError, RunnerCreateError, RunnerRemoveError -from runner import Runner, RunnerClients, RunnerConfig, RunnerStatus +from runner import CreateRunnerConfig, Runner, RunnerClients, RunnerConfig, RunnerStatus from runner_type import GitHubOrg, GitHubRepo, VirtualMachineResources from shared_fs import SharedFilesystem from tests.unit.mock import ( @@ -138,7 +138,14 @@ def test_create( assert: An lxd instance for the runner is created. """ - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) instances = lxd.instances.all() assert len(instances) == 1 @@ -171,7 +178,14 @@ def test_create_lxd_fail( lxd.profiles.exists = mock_lxd_error_func with pytest.raises(RunnerCreateError): - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) assert len(lxd.instances.all()) == 0 @@ -192,7 +206,14 @@ def test_create_runner_fail( runner._clients.lxd.instances.create = mock_runner_error_func with pytest.raises(RunnerCreateError): - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) def test_create_with_metrics( @@ -216,7 +237,14 @@ def test_create_with_metrics( shared_fs.create.return_value = SharedFilesystem( path=Path("/home/ubuntu/shared_fs"), runner_name="test_runner" ) - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) exc_cmd_mock.assert_called_once_with( [ @@ -256,7 +284,14 @@ def test_create_with_metrics_and_shared_fs_error( runner.config.issue_metrics = True shared_fs.create.side_effect = CreateSharedFilesystemError("") - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) instances = lxd.instances.all() assert len(instances) == 1 @@ -275,7 +310,14 @@ def test_remove( assert: The lxd instance for the runner is removed. """ - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) runner.remove("test_token") assert len(lxd.instances.all()) == 0 @@ -294,7 +336,14 @@ def test_remove_failed_instance( """ # Cases where the ephemeral instance encountered errors and the status was Stopped but not # removed was found before. - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) runner.instance.status = "Stopped" runner.remove("test_token") assert len(lxd.instances.all()) == 0 @@ -327,7 +376,14 @@ def test_remove_with_stop_error( act: Remove the runner. assert: RunnerRemoveError is raised. """ - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) runner.instance.stop = mock_lxd_error_func with pytest.raises(RunnerRemoveError): @@ -346,7 +402,14 @@ def test_remove_with_delete_error( act: Remove the runner. assert: RunnerRemoveError is raised. """ - runner.create("test_image", vm_resources, binary_path, token) + runner.create( + config=CreateRunnerConfig( + image="test_image", + resources=vm_resources, + binary_path=binary_path, + registration_token=token, + ) + ) runner.instance.status = "Stopped" runner.instance.delete = mock_lxd_error_func diff --git a/tests/unit/test_runner_manager.py b/tests/unit/test_runner_manager.py index 5f7b9b3826..eff4e6f71f 100644 --- a/tests/unit/test_runner_manager.py +++ b/tests/unit/test_runner_manager.py @@ -11,8 +11,9 @@ from _pytest.monkeypatch import MonkeyPatch import shared_fs -from charm_state import State +from charm_state import ARCH, State from errors import IssueMetricEventError, RunnerBinaryError +from github_type import RunnerApplication from metrics import Reconciliation, RunnerInstalled, RunnerStart, RunnerStop from runner import Runner, RunnerStatus from runner_manager import RunnerManager, RunnerManagerConfig @@ -33,6 +34,7 @@ def token_fixture(): def charm_state_fixture(): mock = MagicMock(spec=State) mock.is_metrics_logging_available = False + mock.arch = ARCH.X64 return mock @@ -96,27 +98,50 @@ def runner_metrics_fixture(monkeypatch: MonkeyPatch) -> MagicMock: return runner_metrics_mock -def test_get_latest_runner_bin_url(runner_manager: RunnerManager): +@pytest.mark.parametrize( + "arch", + [ + pytest.param(ARCH.ARM64), + pytest.param(ARCH.X64), + ], +) +def test_get_latest_runner_bin_url(runner_manager: RunnerManager, arch: ARCH): """ arrange: Nothing. act: Get runner bin url of existing binary. assert: Correct mock data returned. """ - runner_bin = runner_manager.get_latest_runner_bin_url(os_name="linux", arch_name="x64") + runner_manager.config.charm_state.arch = arch + mock_gh_client = MagicMock() + app = RunnerApplication( + os="linux", + architecture=arch.value, + download_url=(download_url := "https://www.example.com"), + filename=(filename := "test_runner_binary"), + ) + mock_gh_client.actions.list_runner_applications_for_repo.return_value = (app,) + mock_gh_client.actions.list_runner_applications_for_org.return_value = (app,) + runner_manager._clients.github = mock_gh_client + + runner_bin = runner_manager.get_latest_runner_bin_url(os_name="linux") assert runner_bin["os"] == "linux" - assert runner_bin["architecture"] == "x64" - assert runner_bin["download_url"] == "https://www.example.com" - assert runner_bin["filename"] == "test_runner_binary" + assert runner_bin["architecture"] == arch.value + assert runner_bin["download_url"] == download_url + assert runner_bin["filename"] == filename def test_get_latest_runner_bin_url_missing_binary(runner_manager: RunnerManager): """ - arrange: Nothing. + arrange: Given a mocked GH API client that does not return any runner binaries. act: Get runner bin url of non-existing binary. assert: Error related to runner bin raised. """ + runner_manager._clients.github.actions = MagicMock() + runner_manager._clients.github.actions.list_runner_applications_for_repo.return_value = [] + runner_manager._clients.github.actions.list_runner_applications_for_org.return_value = [] + with pytest.raises(RunnerBinaryError): - runner_manager.get_latest_runner_bin_url(os_name="not_exist", arch_name="not_exist") + runner_manager.get_latest_runner_bin_url(os_name="not_exist") def test_update_runner_bin(runner_manager: RunnerManager): @@ -136,7 +161,7 @@ def iter_content(self, *arg, **kargs): runner_manager.runner_bin_path.unlink(missing_ok=True) runner_manager.session.get = MockRequestLibResponse - runner_bin = runner_manager.get_latest_runner_bin_url(os_name="linux", arch_name="x64") + runner_bin = runner_manager.get_latest_runner_bin_url(os_name="linux") runner_manager.update_runner_bin(runner_bin)