Tinyproxy was unable to connect to the remote web server.
- #Generated by tinyproxy version 1.11.0.
- # - # - return_code, stdout, stderr = await _curl_as_ubuntu_user( - unit, - runner_name, - f"http://canonical.com:{NON_STANDARD_PORT}", - ) - assert return_code == 0, ( - f"Expected error response from proxy for http://canonical.com:{NON_STANDARD_PORT}. " - f"Error msg: {stdout} {stderr}" - ) - - proxy_logs = proxy_logs_filepath.read_text(encoding="utf-8") - assert "GET http://canonical.com/" in proxy_logs - assert f"GET http://canonical.com:{NON_STANDARD_PORT}/" in proxy_logs - - aproxy_logs = await _get_aproxy_logs(unit, runner_name) - assert aproxy_logs is None diff --git a/tests/integration/test_debug_ssh.py b/tests/integration/test_debug_ssh.py index d153b6591..eefaf5854 100644 --- a/tests/integration/test_debug_ssh.py +++ b/tests/integration/test_debug_ssh.py @@ -10,8 +10,8 @@ from juju.application import Application from juju.model import Model -from charm_state import DENYLIST_CONFIG_NAME -from tests.integration.helpers.common import InstanceHelper, dispatch_workflow, get_job_logs +from tests.integration.helpers.common import dispatch_workflow, get_job_logs +from tests.integration.helpers.openstack import OpenStackInstanceHelper from tests.status_name import ACTIVE logger = logging.getLogger(__name__) @@ -27,30 +27,19 @@ async def test_ssh_debug( github_repository: Repository, test_github_branch: Branch, tmate_ssh_server_unit_ip: str, - instance_helper: InstanceHelper, + instance_helper: OpenStackInstanceHelper, ): """ - arrange: given an integrated GitHub-Runner charm and tmate-ssh-server charm with a denylist \ - covering ip ranges of tmate-ssh-server. + arrange: given an integrated GitHub-Runner charm and tmate-ssh-server charm. act: when canonical/action-tmate is triggered. assert: the ssh connection info from action-log and tmate-ssh-server matches. """ - await app_no_wait_tmate.set_config( - { - DENYLIST_CONFIG_NAME: ( - "0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,169.254.0.0/16," - "172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16," - "198.18.0.0/15,198.51.100.0/24,203.0.113.0/24,224.0.0.0/4,233.252.0.0/24," - "240.0.0.0/4" - ), - } - ) await model.wait_for_idle(status=ACTIVE, timeout=60 * 120) unit = app_no_wait_tmate.units[0] # We need the runner to connect to the current machine, instead of the tmate_ssh_server unit, # as the tmate_ssh_server is not routable. - dnat_comman_in_runner = "sudo iptables -t nat -A OUTPUT -p tcp --dport 10022 -j DNAT --to-destination 127.0.0.1:10022" + dnat_comman_in_runner = f"sudo iptables -t nat -A OUTPUT -p tcp -d {tmate_ssh_server_unit_ip} --dport 10022 -j DNAT --to-destination 127.0.0.1:10022" _, _, _ = await instance_helper.run_in_instance( unit, dnat_comman_in_runner, diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index bed193216..ffc371649 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -9,19 +9,18 @@ from juju.application import Application from juju.model import Model -from charm_state import InstanceType from tests.integration.helpers.common import ( DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, - InstanceHelper, dispatch_workflow, ) +from tests.integration.helpers.openstack import OpenStackInstanceHelper @pytest_asyncio.fixture(scope="function", name="app") async def app_fixture( model: Model, basic_app: Application, - instance_helper: InstanceHelper, + instance_helper: OpenStackInstanceHelper, ) -> AsyncIterator[Application]: """Setup and teardown the charm after each test. @@ -39,24 +38,17 @@ async def test_e2e_workflow( app: Application, github_repository: Repository, test_github_branch: Branch, - instance_type: InstanceType, ): """ arrange: An app connected to an OpenStack cloud with no runners. act: Run e2e test workflow. assert: No exception thrown. """ - virt_type: str - if instance_type == InstanceType.OPENSTACK: - virt_type = "openstack" - else: - virt_type = "lxd" - await dispatch_workflow( app=app, branch=test_github_branch, github_repository=github_repository, conclusion="success", workflow_id_or_name=DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, - dispatch_input={"runner-tag": app.name, "runner-virt-type": virt_type}, + dispatch_input={"runner-tag": app.name}, ) diff --git a/tests/integration/test_reactive.py b/tests/integration/test_reactive.py index f99e3ff24..e3bf41928 100644 --- a/tests/integration/test_reactive.py +++ b/tests/integration/test_reactive.py @@ -57,6 +57,7 @@ async def app_fixture( await reconcile(app_for_reactive, app_for_reactive.model) +@pytest.mark.abort_on_fail async def test_reactive_mode_spawns_runner( ops_test: OpsTest, app: Application, @@ -123,6 +124,7 @@ async def _runner_installed_in_metrics_log() -> bool: await _assert_metrics_are_logged(app, github_repository) +@pytest.mark.abort_on_fail async def test_reactive_mode_does_not_consume_jobs_with_unsupported_labels( ops_test: OpsTest, app: Application, @@ -160,6 +162,7 @@ async def test_reactive_mode_does_not_consume_jobs_with_unsupported_labels( run.cancel() # cancel the run to avoid a queued run in GitHub actions page +@pytest.mark.abort_on_fail async def test_reactive_mode_scale_down( ops_test: OpsTest, app: Application, diff --git a/tests/integration/test_self_hosted_runner.py b/tests/integration/test_self_hosted_runner.py deleted file mode 100644 index 46c8280b1..000000000 --- a/tests/integration/test_self_hosted_runner.py +++ /dev/null @@ -1,192 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Integration tests for self-hosted runner managed by the github-runner charm.""" - -from datetime import datetime, timezone -from time import sleep - -import github -import pytest -from github.Repository import Repository -from github_runner_manager.types_.github import GitHubRepo -from juju.application import Application -from juju.model import Model - -from charm_state import ( - DOCKERHUB_MIRROR_CONFIG_NAME, - PATH_CONFIG_NAME, - VIRTUAL_MACHINES_CONFIG_NAME, -) -from github_client import GithubClient -from tests.integration.helpers.common import ( - DISPATCH_TEST_WORKFLOW_FILENAME, - DISPATCH_WAIT_TEST_WORKFLOW_FILENAME, - get_job_logs, - get_workflow_runs, - reconcile, -) -from tests.integration.helpers.lxd import ( - get_runner_names, - run_in_lxd_instance, - wait_till_num_of_runners, -) -from tests.status_name import ACTIVE - - -@pytest.mark.asyncio -@pytest.mark.abort_on_fail -async def test_dispatch_workflow_with_dockerhub_mirror( - model: Model, app_runner: Application, github_repository: Repository -) -> None: - """ - arrange: A working application with no runners. - act: - 1. Set dockerhub-mirror config and spawn one runner. - 2. Dispatch a workflow. - assert: - 1. registry-mirrors is setup in /etc/docker/daemon.json of runner. - 2. Message about dockerhub_mirror appears in logs. - """ - start_time = datetime.now(timezone.utc) - - unit = app_runner.units[0] - - fake_url = "https://example.com:5000" - - # 1. - await app_runner.set_config( - {VIRTUAL_MACHINES_CONFIG_NAME: "1", DOCKERHUB_MIRROR_CONFIG_NAME: fake_url} - ) - action = await unit.run_action("reconcile-runners") - await action.wait() - await model.wait_for_idle(status=ACTIVE) - names = await get_runner_names(unit) - assert len(names) == 1 - - runner_to_be_used = names[0] - - return_code, stdout, stderr = await run_in_lxd_instance( - unit, runner_to_be_used, "cat /etc/docker/daemon.json" - ) - assert return_code == 0, f"Failed to get docker daemon contents, {stdout} {stderr}" - assert stdout is not None - assert "registry-mirrors" in stdout - assert fake_url in stdout - - # 2. - main_branch = github_repository.get_branch(github_repository.default_branch) - workflow = github_repository.get_workflow(id_or_file_name=DISPATCH_TEST_WORKFLOW_FILENAME) - - workflow.create_dispatch(main_branch, {"runner": app_runner.name}) - - # Wait until the runner is used up. - for _ in range(30): - runners = await get_runner_names(unit) - if runner_to_be_used not in runners: - break - sleep(30) - else: - assert False, "Timeout while waiting for workflow to complete" - - # Unable to find the run id of the workflow that was dispatched. - # Therefore, all runs after this test start should pass the conditions. - for run in get_workflow_runs(start_time, workflow, runner_to_be_used): - jobs = run.jobs() - try: - logs = get_job_logs(jobs[0]) - except github.GithubException.GithubException: - continue - - if f"Job is about to start running on the runner: {app_runner.name}-" in logs: - assert run.jobs()[0].conclusion == "success" - assert ( - "A private docker registry is setup as a dockerhub mirror for this self-hosted" - " runner." - ) in logs - - -@pytest.mark.asyncio -@pytest.mark.abort_on_fail -async def test_flush_busy_runner( - model: Model, - app_runner: Application, - forked_github_repository: Repository, - runner_manager_github_client: GithubClient, -) -> None: - """ - arrange: A working application with one runner. - act: - 1. Dispatch a workflow that waits for 30 mins. - 2. Run flush-runners action. - assert: - 1. The runner is in busy status. - 2. a. The flush-runners action should take less than the timeout. - b. The runner should be flushed. - """ - unit = app_runner.units[0] - - config = await app_runner.get_config() - - await app_runner.set_config( - {PATH_CONFIG_NAME: forked_github_repository.full_name, VIRTUAL_MACHINES_CONFIG_NAME: "1"} - ) - await reconcile(app=app_runner, model=model) - await wait_till_num_of_runners(unit, 1) - - names = await get_runner_names(unit) - assert len(names) == 1 - - runner_to_be_used = names[0] - - # 1. - main_branch = forked_github_repository.get_branch(forked_github_repository.default_branch) - workflow = forked_github_repository.get_workflow( - id_or_file_name=DISPATCH_WAIT_TEST_WORKFLOW_FILENAME - ) - - assert workflow.create_dispatch(main_branch, {"runner": app_runner.name, "minutes": "30"}) - - # Wait until runner online and then busy. - for _ in range(30): - all_runners = runner_manager_github_client.get_runner_github_info( - GitHubRepo( - owner=forked_github_repository.owner.login, repo=forked_github_repository.name - ) - ) - runners = [runner for runner in all_runners if runner.name == runner_to_be_used] - - if not runners: - # if runner is not online yet. - sleep(30) - continue - - assert len(runners) == 1, "Should not occur as GitHub enforce unique naming of runner" - runner = runners[0] - if runner["busy"]: - start_time = datetime.now(timezone.utc) - break - - sleep(30) - else: - assert False, "Timeout while waiting for runner to take up the workflow" - - # 2. - action = await unit.run_action("flush-runners") - await action.wait() - - end_time = datetime.now(timezone.utc) - - # The flushing of runner should take less than the 30 minutes timeout of the workflow. - diff = end_time - start_time - assert diff.total_seconds() < 30 * 60 - - names = await get_runner_names(unit) - assert runner_to_be_used not in names, "Found a runner that should be flushed" - - # Ensure the app_runner is back to 0 runners. - await app_runner.set_config( - {VIRTUAL_MACHINES_CONFIG_NAME: "0", PATH_CONFIG_NAME: config[PATH_CONFIG_NAME]} - ) - await reconcile(app=app_runner, model=model) - await wait_till_num_of_runners(unit, 0) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 5a9182080..bb06bf614 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -11,7 +11,7 @@ from github_runner_manager.manager.runner_scaler import RunnerScaler import utilities -from tests.unit.mock import MockGhapiClient, MockLxdClient, MockRepoPolicyComplianceClient +from tests.unit.mock import MockGhapiClient @pytest.fixture(name="exec_command") @@ -19,16 +19,6 @@ def exec_command_fixture(): return unittest.mock.MagicMock(return_value=("", 0)) -@pytest.fixture(name="lxd_exec_command") -def lxd_exec_command_fixture(): - return unittest.mock.MagicMock(return_value=("", 0)) - - -@pytest.fixture(name="runner_binary_path") -def runner_binary_path_fixture(tmp_path): - return tmp_path / "github-runner-app" - - def disk_usage_mock(total_disk: int): """Mock disk usage factory. @@ -45,54 +35,21 @@ def disk_usage_mock(total_disk: int): @pytest.fixture(autouse=True) -def mocks(monkeypatch, tmp_path, exec_command, lxd_exec_command, runner_binary_path): +def mocks(monkeypatch, tmp_path, exec_command): runner_scaler_mock = unittest.mock.MagicMock(spec=RunnerScaler) cron_path = tmp_path / "cron.d" cron_path.mkdir() - monkeypatch.setattr( - "charm.GithubRunnerCharm.service_token_path", tmp_path / "mock_service_token" - ) - monkeypatch.setattr( - "charm.GithubRunnerCharm.repo_check_web_service_path", - tmp_path / "repo_policy_compliance_service", - ) - monkeypatch.setattr( - "charm.GithubRunnerCharm.repo_check_systemd_service", tmp_path / "systemd_service" - ) monkeypatch.setattr("charm.RunnerScaler", runner_scaler_mock) - monkeypatch.setattr("charm.GithubRunnerCharm.kernel_module_path", tmp_path / "modules") - monkeypatch.setattr("charm.GithubRunnerCharm._update_kernel", lambda self, now: None) monkeypatch.setattr("charm.execute_command", exec_command) - monkeypatch.setattr("charm.shutil", unittest.mock.MagicMock()) - monkeypatch.setattr("charm.shutil.disk_usage", disk_usage_mock(30 * 1024 * 1024 * 1024)) monkeypatch.setattr("charm_state.CHARM_STATE_PATH", Path(tmp_path / "charm_state.json")) monkeypatch.setattr("event_timer.jinja2", unittest.mock.MagicMock()) monkeypatch.setattr("event_timer.execute_command", exec_command) - monkeypatch.setattr( - "firewall.Firewall.get_host_ip", unittest.mock.MagicMock(return_value="10.0.0.1") - ) - monkeypatch.setattr("firewall.Firewall.refresh_firewall", unittest.mock.MagicMock()) - monkeypatch.setattr("runner.execute_command", lxd_exec_command) - monkeypatch.setattr("runner.shared_fs", unittest.mock.MagicMock()) monkeypatch.setattr( "github_runner_manager.metrics.events.METRICS_LOG_PATH", Path(tmp_path / "metrics.log") ) - monkeypatch.setattr("runner.time", unittest.mock.MagicMock()) monkeypatch.setattr("github_runner_manager.github_client.GhApi", MockGhapiClient) - monkeypatch.setattr("runner_manager_type.jinja2", unittest.mock.MagicMock()) - monkeypatch.setattr("runner_manager_type.LxdClient", MockLxdClient) - monkeypatch.setattr("runner_manager.github_metrics", unittest.mock.MagicMock()) - monkeypatch.setattr("runner_manager.runner_logs", unittest.mock.MagicMock()) - monkeypatch.setattr("runner_manager.LxdClient", MockLxdClient) - monkeypatch.setattr("runner_manager.shared_fs", unittest.mock.MagicMock()) - monkeypatch.setattr("runner_manager.execute_command", exec_command) - monkeypatch.setattr("runner_manager.LXDRunnerManager.runner_bin_path", runner_binary_path) - monkeypatch.setattr("runner_manager.LXDRunnerManager.cron_path", cron_path) - monkeypatch.setattr( - "runner_manager.RepoPolicyComplianceClient", MockRepoPolicyComplianceClient - ) monkeypatch.setattr("github_runner_manager.utilities.time", unittest.mock.MagicMock()) diff --git a/tests/unit/factories.py b/tests/unit/factories.py index 2dc9d2a97..fff8cbdb0 100644 --- a/tests/unit/factories.py +++ b/tests/unit/factories.py @@ -7,6 +7,7 @@ # pylint: disable=too-few-public-methods import random +import secrets from typing import Generic, TypeVar from unittest.mock import MagicMock @@ -14,12 +15,12 @@ import factory.fuzzy import invoke.runners import openstack.compute.v2.server +import yaml from pydantic.networks import IPvAnyAddress from charm_state import ( COS_AGENT_INTEGRATION_NAME, DEBUG_SSH_INTEGRATION_NAME, - DENYLIST_CONFIG_NAME, DOCKERHUB_MIRROR_CONFIG_NAME, GROUP_CONFIG_NAME, LABELS_CONFIG_NAME, @@ -29,14 +30,10 @@ OPENSTACK_NETWORK_CONFIG_NAME, PATH_CONFIG_NAME, RECONCILE_INTERVAL_CONFIG_NAME, - RUNNER_STORAGE_CONFIG_NAME, TEST_MODE_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, SSHDebugConnection, ) @@ -127,23 +124,34 @@ class Meta: model = factory.SubFactory(MockGithubRunnerCharmModelFactory) config = factory.Dict( { - DENYLIST_CONFIG_NAME: "", DOCKERHUB_MIRROR_CONFIG_NAME: "", GROUP_CONFIG_NAME: "default", LABELS_CONFIG_NAME: "", - OPENSTACK_CLOUDS_YAML_CONFIG_NAME: "", + OPENSTACK_CLOUDS_YAML_CONFIG_NAME: yaml.safe_dump( + { + "clouds": { + "openstack": { + "auth": { + "auth_url": "https://project-keystone.url/", + "password": secrets.token_hex(16), + "project_domain_name": "Default", + "project_name": "test-project-name", + "user_domain_name": "Default", + "username": "test-user-name", + }, + "region_name": secrets.token_hex(16), + } + } + } + ), OPENSTACK_NETWORK_CONFIG_NAME: "external", OPENSTACK_FLAVOR_CONFIG_NAME: "m1.small", PATH_CONFIG_NAME: factory.Sequence(lambda n: f"mock_path_{n}"), RECONCILE_INTERVAL_CONFIG_NAME: 10, - RUNNER_STORAGE_CONFIG_NAME: "juju-storage", TEST_MODE_CONFIG_NAME: "", TOKEN_CONFIG_NAME: factory.Sequence(lambda n: f"mock_token_{n}"), USE_APROXY_CONFIG_NAME: False, VIRTUAL_MACHINES_CONFIG_NAME: 1, - VM_CPU_CONFIG_NAME: 2, - VM_MEMORY_CONFIG_NAME: "7GiB", - VM_DISK_CONFIG_NAME: "10GiB", } ) diff --git a/tests/unit/mock.py b/tests/unit/mock.py index 78c0c6990..61c4c8b4b 100644 --- a/tests/unit/mock.py +++ b/tests/unit/mock.py @@ -6,17 +6,12 @@ from __future__ import annotations import hashlib -import io import logging import secrets -from pathlib import Path -from typing import IO, Optional, Sequence, Union from github_runner_manager.types_.github import RegistrationToken, RemoveToken, RunnerApplication -from errors import LxdError, RunnerError -from lxd_type import LxdNetwork -from runner import LxdInstanceConfig +from errors import RunnerError logger = logging.getLogger(__name__) @@ -32,315 +27,6 @@ ) -class MockLxdClient: - """Mock the behavior of the LXD client.""" - - def __init__(self): - """Fake init implementation for LxdClient.""" - self.instances = MockLxdInstanceManager() - self.profiles = MockLxdProfileManager() - self.networks = MockLxdNetworkManager() - self.storage_pools = MockLxdStoragePoolManager() - self.images = MockLxdImageManager() - - -class MockLxdInstanceManager: - """Mock the behavior of the LXD Instances.""" - - def __init__(self): - """Fake init implementation for LxdInstanceManager.""" - self.instances = {} - - def create(self, config: LxdInstanceConfig, wait: bool = False) -> MockLxdInstance: - """Create an instance with given config. - - Args: - config: The instance configuration to create the instance with. - wait: Placeholder for wait argument. - - Returns: - Mock instance that was created. - """ - self.instances[config["name"]] = MockLxdInstance(config["name"]) - return self.instances[config["name"]] - - def get(self, name: str): - """Get an instance with given name. - - Args: - name: The name of the instance to get. - - Returns: - Instance with given name. - """ - return self.instances[name] - - def all(self): - """Return all instances that have not been deleted. - - Returns: - All Lxd fake instances that have not been deleted. - """ - return [i for i in self.instances.values() if not i.deleted] - - -class MockLxdProfileManager: - """Mock the behavior of the LXD Profiles.""" - - def __init__(self): - """Initialization method for LxdProfileManager fake.""" - self.profiles = set() - - def create(self, name: str, config: dict[str, str], devices: dict[str, str]): - """Fake implementation of create method of LxdProfile manager. - - Args: - name: The name of LXD profile. - config: The config of LXD profile to create. - devices: The devices mapping of LXD profile to create with. - """ - self.profiles.add(name) - - def exists(self, name: str) -> bool: - """Fake implementation of exists method of LxdProfile manager. - - Args: - name: The name of LXD profile. - - Returns: - Whether given LXD profile exists. - """ - return name in self.profiles - - -class MockLxdNetworkManager: - """Mock the behavior of the LXD networks.""" - - def __init__(self): - """Placeholder for initialization method for LxdInstance stub.""" - pass - - def get(self, name: str) -> LxdNetwork: - """Stub method get for LxdNetworkManager. - - Args: - name: the name of the LxdNetwork to get. - - Returns: - LxdNetwork stub. - """ - return LxdNetwork( - "lxdbr0", "", "bridge", {"ipv4.address": "10.1.1.1/24"}, True, ("default") - ) - - -class MockLxdInstance: - """Mock the behavior of an LXD Instance.""" - - def __init__(self, name: str): - """Fake implementation of initialization method for LxdInstance fake. - - Args: - name: The mock instance name to create. - """ - self.name = name - self.status = "Stopped" - self.deleted = False - - self.files = MockLxdInstanceFileManager() - - def start(self, wait: bool = True, timeout: int = 60): - """Fake implementation of start method for LxdInstance fake. - - Args: - wait: Placeholder for wait argument. - timeout: Placeholder for timeout argument. - """ - self.status = "Running" - - def stop(self, wait: bool = True, timeout: int = 60): - """Fake implementation of stop method for LxdInstance fake. - - Args: - wait: Placeholder for wait argument. - timeout: Placeholder for timeout argument. - """ - self.status = "Stopped" - # Ephemeral virtual machine should be deleted on stop. - self.deleted = True - - def delete(self, wait: bool = True): - """Fake implementation of delete method for LxdInstance fake. - - Args: - wait: Placeholder for wait argument. - """ - self.deleted = True - - def execute( - self, cmd: Sequence[str], cwd: Optional[str] = None, hide_cmd: bool = False - ) -> tuple[int, IO, IO]: - """Implementation for execute for LxdInstance fake. - - Args: - cmd: Placeholder for command to execute. - cwd: Placeholder for working directory to execute command. - hide_cmd: Placeholder for to hide command that is being executed. - - Returns: - Empty tuples values that represent a successful command execution. - """ - return 0, io.BytesIO(b""), io.BytesIO(b"") - - -class MockLxdInstanceFileManager: - """Mock the behavior of an LXD Instance's files.""" - - def __init__(self): - """Initializer for fake instance of LxdInstanceFileManager.""" - self.files = {} - - def mk_dir(self, path): - """Placeholder for mk_dir implementation of LxdInstanceFileManager. - - Args: - path: The path to create. - """ - pass - - def push_file(self, source: str, destination: str, mode: Optional[str] = None): - """Fake push_file implementation of LxdInstanceFileManager. - - Args: - source: Placeholder argument for source file path copy from. - destination: File path to write to. - mode: Placeholder for file write mode. - """ - self.files[destination] = "mock_content" - - def write_file(self, filepath: str, data: Union[bytes, str], mode: Optional[str] = None): - """Fake write_file implementation of LxdInstanceFileManager. - - Args: - filepath: The file path to read. - data: File contents to write - mode: Placeholder for file write mode. - """ - self.files[filepath] = data - - def read_file(self, filepath: str): - """Fake read_file implementation of LxdInstanceFileManager. - - Args: - filepath: The file path to read. - - Returns: - Contents of file. - """ - return self.files.get(str(filepath), None) - - -class MockLxdStoragePoolManager: - """Mock the behavior of LXD storage pools.""" - - def __init__(self): - """Initialize fake storage pools.""" - self.pools = {} - - def all(self): - """Get all non-deleted fake lxd storage pools. - - Returns: - List of all non deleted fake LXD storages. - """ - return [pool for pool in self.pools.values() if not pool.deleted] - - def get(self, name): - """Get a fake storage pool of given name. - - Args: - name: Name of the storage pool to get. - - Returns: - Fake storage pool of given name. - """ - return self.pools[name] - - def exists(self, name): - """Check if given storage exists in the fake LxdStoragePool. - - Args: - name: Fake storage pool name to check for existence. - - Returns: - If storage pool of given name exists. - """ - if name in self.pools: - return not self.pools[name].deleted - else: - return False - - def create(self, config): - """Fake LxdStoragePoolManager create function. - - Args: - config: The LXD storage pool config. - - Returns: - Created LXDStoragePool fake. - """ - self.pools[config["name"]] = MockLxdStoragePool() - return self.pools[config["name"]] - - -class MockLxdStoragePool: - """Mock the behavior of an LXD storage pool.""" - - def __init__(self): - """LXD storage pool fake initialization method.""" - self.deleted = False - - def save(self): - """LXD storage pool fake save method placeholder.""" - pass - - def delete(self): - """LXD storage pool fake delete method.""" - self.deleted = True - - -class MockLxdImageManager: - """Mock the behavior of LXD images.""" - - def __init__(self, images: set[str] | None = None): - """Fake init implementation for LxdImageManager. - - Args: - images: Set of images to initialize. - """ - self.images: set[str] = images or set() - - def create(self, name: str, _: Path) -> None: - """Import an LXD image into the fake set. - - Args: - name: Alias for the image. - _: Path of the LXD image file. - """ - self.images.add(name) - - def exists(self, name: str) -> bool: - """Check if an image with the given name exists. - - Args: - name: image name. - - Returns: - Whether the image exists. - """ - return name in self.images - - class MockErrorResponse: """Mock of an error response for request library.""" @@ -357,19 +43,6 @@ def json(self): return {"metadata": {"err": "test error"}} -def mock_lxd_error_func(*args, **kwargs): - """A stub function that always raises LxdError. - - Args: - args: Placeholder for positional arguments. - kwargs: Placeholder for key word arguments. - - Raises: - LxdError: always. - """ - raise LxdError(MockErrorResponse()) - - def mock_runner_error_func(*args, **kwargs): """A stub function that always raises RunnerError. diff --git a/tests/unit/mock_runner_managers.py b/tests/unit/mock_runner_managers.py index b52afa538..558b8159f 100644 --- a/tests/unit/mock_runner_managers.py +++ b/tests/unit/mock_runner_managers.py @@ -7,6 +7,7 @@ from typing import Iterable, Iterator, Sequence from unittest.mock import MagicMock +from github_runner_manager.github_client import GithubClient from github_runner_manager.manager.cloud_runner_manager import ( CloudRunnerInstance, CloudRunnerManager, @@ -18,7 +19,6 @@ from github_runner_manager.types_.github import GitHubRunnerStatus, SelfHostedRunner from charm_state import GitHubPath -from github_client import GithubClient from tests.unit.mock import MockGhapiClient diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 7e70c6d59..7508f29be 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -7,15 +7,13 @@ import typing import unittest import urllib.error -from pathlib import Path -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import pytest import yaml from github_runner_manager.errors import ReconcileError from github_runner_manager.manager.runner_manager import FlushMode from github_runner_manager.manager.runner_scaler import RunnerScaler -from github_runner_manager.types_.github import GitHubOrg, GitHubRepo, GitHubRunnerStatus from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, StatusBase, WaitingStatus from ops.testing import Harness @@ -27,35 +25,24 @@ catch_charm_errors, ) from charm_state import ( - GROUP_CONFIG_NAME, IMAGE_INTEGRATION_NAME, OPENSTACK_CLOUDS_YAML_CONFIG_NAME, PATH_CONFIG_NAME, - RECONCILE_INTERVAL_CONFIG_NAME, TOKEN_CONFIG_NAME, USE_APROXY_CONFIG_NAME, - VIRTUAL_MACHINES_CONFIG_NAME, - VM_CPU_CONFIG_NAME, - VM_DISK_CONFIG_NAME, Arch, - InstanceType, OpenStackCloudsYAML, OpenstackImage, - ProxyConfig, - VirtualMachineResources, ) from errors import ( ConfigurationError, LogrotateSetupError, MissingMongoDBError, - MissingRunnerBinaryError, RunnerError, SubprocessError, TokenError, ) from event_timer import EventTimer, TimerEnableError -from firewall import FirewallEntry -from runner_manager import LXDRunnerManagerConfig, RunnerInfo TEST_PROXY_SERVER_URL = "http://proxy.server:1234" @@ -126,56 +113,47 @@ def mock_download_latest_runner_image(*args): return "www.example.com" -def mock_get_github_info(): - """A stub function that returns mock Github runner information. - - Returns: - RunnerInfo with different name, statuses, busy values. - """ - return [ - RunnerInfo("test runner 0", GitHubRunnerStatus.ONLINE.value, True), - RunnerInfo("test runner 1", GitHubRunnerStatus.ONLINE.value, False), - RunnerInfo("test runner 2", GitHubRunnerStatus.OFFLINE.value, False), - RunnerInfo("test runner 3", GitHubRunnerStatus.OFFLINE.value, False), - RunnerInfo("test runner 4", "unknown", False), - ] - - -def setup_charm_harness(monkeypatch: pytest.MonkeyPatch, runner_bin_path: Path) -> Harness: +def setup_charm_harness(monkeypatch: pytest.MonkeyPatch) -> Harness: """Setup harness with patched runner manager methods. Args: monkeypatch: Instance of pytest monkeypatch for patching RunnerManager methods. - runner_bin_path: Runner binary temporary path fixture. Returns: Harness with patched RunnerManager instance. """ - - def stub_update_runner_bin(*args, **kwargs) -> None: - """Update runner bin stub function. - - Args: - args: Placeholder for positional argument values. - kwargs: Placeholder for keyword argument values. - """ - runner_bin_path.touch() - harness = Harness(GithubRunnerCharm) - harness.update_config({PATH_CONFIG_NAME: "mock/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.begin() - monkeypatch.setattr( - "runner_manager.LXDRunnerManager.update_runner_bin", stub_update_runner_bin + cloud_yaml = { + "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), + }, + "region_name": secrets.token_hex(16), + } + } + } + harness.update_config( + { + PATH_CONFIG_NAME: "mock/repo", + TOKEN_CONFIG_NAME: "mocktoken", + OPENSTACK_CLOUDS_YAML_CONFIG_NAME: yaml.safe_dump(cloud_yaml), + } ) - monkeypatch.setattr("runner_manager.LXDRunnerManager._runners_in_pre_job", lambda self: False) + harness.begin() monkeypatch.setattr("charm.EventTimer.ensure_event_timer", MagicMock()) monkeypatch.setattr("charm.logrotate.setup", MagicMock()) return harness @pytest.fixture(name="harness") -def harness_fixture(monkeypatch, runner_binary_path: Path) -> Harness: - return setup_charm_harness(monkeypatch, runner_binary_path) +def harness_fixture(monkeypatch) -> Harness: + return setup_charm_harness(monkeypatch) @patch.dict( @@ -213,54 +191,16 @@ def test_common_install_code( act: Fire install/upgrade event. assert: Common install commands are run on the mock. """ - monkeypatch.setattr("charm.logrotate.setup", setup_logrotate := MagicMock()) + state_mock = MagicMock() + harness.charm._setup_state = MagicMock(return_value=state_mock) - monkeypatch.setattr( - "runner_manager.LXDRunnerManager.schedule_build_runner_image", - schedule_build_runner_image := MagicMock(), - ) + monkeypatch.setattr("charm.logrotate.setup", setup_logrotate := MagicMock()) event_timer_mock = MagicMock(spec=EventTimer) harness.charm._event_timer = event_timer_mock getattr(harness.charm.on, hook).emit() - calls = [ - call(["/usr/bin/snap", "install", "lxd", "--channel=latest/stable"]), - call(["/snap/bin/lxd", "init", "--auto"]), - call(["/usr/bin/systemctl", "enable", "repo-policy-compliance"]), - ] - exec_command.assert_has_calls(calls, any_order=True) setup_logrotate.assert_called_once() - schedule_build_runner_image.assert_called_once() - event_timer_mock.ensure_event_timer.assert_called_once() - - -@pytest.mark.parametrize( - "hook", - [ - pytest.param("install", id="Install"), - pytest.param("upgrade_charm", id="Upgrade"), - ], -) -def test_common_install_code_does_not_rebuild_image( - hook: str, harness: Harness, monkeypatch: pytest.MonkeyPatch -): - """ - arrange: Set up charm and runner manager to not have runner image. - act: Fire upgrade event. - assert: Image is not rebuilt. - """ - monkeypatch.setattr( - "runner_manager.LXDRunnerManager.build_runner_image", - build_runner_image := MagicMock(), - ) - monkeypatch.setattr( - "runner_manager.LXDRunnerManager.has_runner_image", - MagicMock(return_value=True), - ) - getattr(harness.charm.on, hook).emit() - - assert not build_runner_image.called def test_on_config_changed_failure(harness: Harness): @@ -275,36 +215,6 @@ def test_on_config_changed_failure(harness: Harness): assert "Invalid proxy configuration" in harness.charm.unit.status.message -def test_get_runner_manager(harness: Harness): - """ - arrange: Set up charm. - act: Get runner manager. - assert: Runner manager is returned with the correct config. - """ - state = harness.charm._setup_state() - runner_manager = harness.charm._get_runner_manager(state) - assert runner_manager is not None - assert runner_manager.config.token == "mocktoken" - assert runner_manager.proxies == ProxyConfig( - http=None, https=None, no_proxy=None, use_aproxy=False - ) - - -def test_on_flush_runners_action_fail(harness: Harness, runner_binary_path: Path): - """ - arrange: Set up charm without runner binary downloaded. - act: Run flush runner action. - assert: Action fail with missing runner binary. - """ - runner_binary_path.unlink(missing_ok=True) - mock_event = MagicMock() - harness.charm._on_flush_runners_action(mock_event) - mock_event.fail.assert_called_with( - "GitHub runner application not downloaded; the charm will retry download on reconcile " - "interval" - ) - - def test_on_flush_runners_reconcile_error_fail(harness: Harness): """ arrange: Set up charm with Openstack mode and ReconcileError. @@ -312,7 +222,6 @@ def test_on_flush_runners_reconcile_error_fail(harness: Harness): assert: Action fails with generic message and goes in ActiveStatus. """ state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK harness.charm._setup_state = MagicMock(return_value=state_mock) runner_scaler_mock = MagicMock(spec=RunnerScaler) @@ -326,18 +235,6 @@ def test_on_flush_runners_reconcile_error_fail(harness: Harness): assert harness.charm.unit.status.message == ACTIVE_STATUS_RECONCILIATION_FAILED_MSG -def test_on_flush_runners_action_success(harness: Harness, runner_binary_path: Path): - """ - arrange: Set up charm without runner binary downloaded. - act: Run flush runner action. - assert: Action fail with missing runner binary. - """ - mock_event = MagicMock() - runner_binary_path.touch() - harness.charm._on_flush_runners_action(mock_event) - mock_event.set_results.assert_called() - - def test_on_reconcile_runners_action_reconcile_error_fail( harness: Harness, monkeypatch: pytest.MonkeyPatch ): @@ -347,7 +244,6 @@ def test_on_reconcile_runners_action_reconcile_error_fail( assert: Action fails with generic message and goes in ActiveStatus """ state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK harness.charm._setup_state = MagicMock(return_value=state_mock) runner_scaler_mock = MagicMock(spec=RunnerScaler) @@ -374,7 +270,6 @@ def test_on_reconcile_runners_reconcile_error(harness: Harness, monkeypatch: pyt assert: Unit goes into ActiveStatus with error message. """ state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK harness.charm._setup_state = MagicMock(return_value=state_mock) runner_scaler_mock = MagicMock(spec=RunnerScaler) @@ -400,7 +295,6 @@ def test_on_stop_busy_flush(harness: Harness, monkeypatch: pytest.MonkeyPatch): assert: Runner scaler mock flushes the runners using busy mode. """ state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK harness.charm._setup_state = MagicMock(return_value=state_mock) runner_scaler_mock = MagicMock(spec=RunnerScaler) harness.charm._get_runner_scaler = MagicMock(return_value=runner_scaler_mock) @@ -426,6 +320,8 @@ def test_on_install_failure(hook: str, harness: Harness, monkeypatch: pytest.Mon 2. Mock _install_deps raises error. assert: Charm goes into error state in both cases. """ + state_mock = MagicMock() + harness.charm._setup_state = MagicMock(return_value=state_mock) monkeypatch.setattr("charm.logrotate.setup", setup_logrotate := unittest.mock.MagicMock()) setup_logrotate.side_effect = LogrotateSetupError("Failed to setup logrotate") @@ -440,61 +336,6 @@ def test_on_install_failure(hook: str, harness: Harness, monkeypatch: pytest.Mon assert "mock stderr" in str(exc.value) -def test__refresh_firewall(monkeypatch, harness: Harness, runner_binary_path: Path): - """ - arrange: given multiple tmate-ssh-server units in relation. - act: when refresh_firewall is called. - assert: the unit ip addresses are included in allowlist. - """ - runner_binary_path.touch() - - relation_id = harness.add_relation("debug-ssh", "tmate-ssh-server") - harness.add_relation_unit(relation_id, "tmate-ssh-server/0") - harness.add_relation_unit(relation_id, "tmate-ssh-server/1") - harness.add_relation_unit(relation_id, "tmate-ssh-server/2") - test_unit_ip_addresses = ["127.0.0.1", "127.0.0.2", "127.0.0.3"] - - harness.update_relation_data( - relation_id, - "tmate-ssh-server/0", - { - "host": test_unit_ip_addresses[0], - "port": "10022", - "rsa_fingerprint": "SHA256:abcd", - "ed25519_fingerprint": "abcd", - }, - ) - harness.update_relation_data( - relation_id, - "tmate-ssh-server/1", - { - "host": test_unit_ip_addresses[1], - "port": "10022", - "rsa_fingerprint": "SHA256:abcd", - "ed25519_fingerprint": "abcd", - }, - ) - harness.update_relation_data( - relation_id, - "tmate-ssh-server/2", - { - "host": test_unit_ip_addresses[2], - "port": "10022", - "rsa_fingerprint": "SHA256:abcd", - "ed25519_fingerprint": "abcd", - }, - ) - - monkeypatch.setattr("charm.Firewall", mock_firewall := unittest.mock.MagicMock()) - state = harness.charm._setup_state() - harness.charm._refresh_firewall(state) - mocked_firewall_instance = mock_firewall.return_value - allowlist = mocked_firewall_instance.refresh_firewall.call_args_list[0][1]["allowlist"] - assert all( - FirewallEntry(ip) in allowlist for ip in test_unit_ip_addresses - ), "Expected IP firewall entry not found in allowlist arg." - - def test_charm_goes_into_waiting_state_on_missing_integration_data( monkeypatch: pytest.MonkeyPatch, harness: Harness ): @@ -541,156 +382,10 @@ def test_database_integration_events_trigger_reconciliation( class TestCharm(unittest.TestCase): """Test the GithubRunner charm.""" - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_org_register(self, run, wt, mkdir, rm): - harness = Harness(GithubRunnerCharm) - harness.update_config( - { - PATH_CONFIG_NAME: "mockorg", - TOKEN_CONFIG_NAME: "mocktoken", - GROUP_CONFIG_NAME: "mockgroup", - RECONCILE_INTERVAL_CONFIG_NAME: 5, - } - ) - harness.begin() - harness.charm.on.config_changed.emit() - token = harness.charm.service_token - state = harness.charm._setup_state() - rm.assert_called_with( - "github-runner", - "0", - LXDRunnerManagerConfig( - path=GitHubOrg(org="mockorg", group="mockgroup"), - token="mocktoken", - image="jammy", - service_token=token, - lxd_storage_path=GithubRunnerCharm.juju_storage_path, - charm_state=state, - ), - ) - - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_repo_register(self, run, wt, mkdir, rm): - harness = Harness(GithubRunnerCharm) - harness.update_config( - { - PATH_CONFIG_NAME: "mockorg/repo", - TOKEN_CONFIG_NAME: "mocktoken", - RECONCILE_INTERVAL_CONFIG_NAME: 5, - } - ) - harness.begin() - harness.charm.on.config_changed.emit() - token = harness.charm.service_token - state = harness.charm._setup_state() - rm.assert_called_with( - "github-runner", - "0", - LXDRunnerManagerConfig( - path=GitHubRepo(owner="mockorg", repo="repo"), - token="mocktoken", - image="jammy", - service_token=token, - lxd_storage_path=GithubRunnerCharm.juju_storage_path, - charm_state=state, - ), - ) - - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_exceed_free_disk_size(self, run, wt, mkdir, rm): - """ - arrange: Charm with 30GiB of storage for runner. - act: Configuration that uses 100GiB of disk. - assert: Charm enters block state. - """ - rm.return_value = mock_rm = MagicMock() - mock_rm.get_latest_runner_bin_url = mock_get_latest_runner_bin_url - mock_rm.download_latest_runner_image = mock_download_latest_runner_image - - harness = Harness(GithubRunnerCharm) - harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.begin() - - harness.update_config({VIRTUAL_MACHINES_CONFIG_NAME: 10}) - harness.charm.on.reconcile_runners.emit() - assert harness.charm.unit.status == BlockedStatus( - ( - "Required disk space for runners 102400.0MiB is greater than storage total size " - "30720.0MiB" - ) - ) - - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_update_config(self, run, wt, mkdir, rm): - rm.return_value = mock_rm = MagicMock() - mock_rm.get_latest_runner_bin_url = mock_get_latest_runner_bin_url - mock_rm.download_latest_runner_image = mock_download_latest_runner_image - - harness = Harness(GithubRunnerCharm) - harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.begin() - - # update to 0 virtual machines - harness.update_config({VIRTUAL_MACHINES_CONFIG_NAME: 0}) - harness.charm.on.reconcile_runners.emit() - token = harness.charm.service_token - state = harness.charm._setup_state() - rm.assert_called_with( - "github-runner", - "0", - LXDRunnerManagerConfig( - path=GitHubRepo(owner="mockorg", repo="repo"), - token="mocktoken", - image="jammy", - service_token=token, - lxd_storage_path=GithubRunnerCharm.juju_storage_path, - charm_state=state, - ), - ) - mock_rm.reconcile.assert_called_with(0, VirtualMachineResources(2, "7GiB", "10GiB")), - mock_rm.reset_mock() - - # update to 10 VMs with 4 cpu and 7GiB memory - harness.update_config( - {VIRTUAL_MACHINES_CONFIG_NAME: 5, VM_CPU_CONFIG_NAME: 4, VM_DISK_CONFIG_NAME: "6GiB"} - ) - harness.charm.on.reconcile_runners.emit() - token = harness.charm.service_token - state = harness.charm._setup_state() - rm.assert_called_with( - "github-runner", - "0", - LXDRunnerManagerConfig( - path=GitHubRepo(owner="mockorg", repo="repo"), - token="mocktoken", - image="jammy", - service_token=token, - lxd_storage_path=GithubRunnerCharm.juju_storage_path, - charm_state=state, - ), - ) - mock_rm.reconcile.assert_called_with( - 5, VirtualMachineResources(cpu=4, memory="7GiB", disk="6GiB") - ) - mock_rm.reset_mock() - - @patch("charm.LXDRunnerManager") @patch("pathlib.Path.mkdir") @patch("pathlib.Path.write_text") @patch("subprocess.run") - def test_on_update_status(self, run, wt, mkdir, rm): + def test_on_update_status(self, run, wt, mkdir): """ arrange: reconciliation event timer mocked to be \ 1. active. \ @@ -702,10 +397,6 @@ def test_on_update_status(self, run, wt, mkdir, rm): 2. ensure_event_timer is called. 3. Charm throws error. """ - rm.return_value = mock_rm = MagicMock() - mock_rm.get_latest_runner_bin_url = mock_get_latest_runner_bin_url - mock_rm.download_latest_runner_image = mock_download_latest_runner_image - harness = Harness(GithubRunnerCharm) harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) @@ -731,41 +422,11 @@ def test_on_update_status(self, run, wt, mkdir, rm): with pytest.raises(TimerEnableError): harness.charm.on.update_status.emit() - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_on_stop(self, run, wt, mkdir, rm): - rm.return_value = mock_rm = MagicMock() - harness = Harness(GithubRunnerCharm) - harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.begin() - harness.charm.on.stop.emit() - mock_rm.flush.assert_called() - - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_on_start_failure(self, run, wt, mkdir, rm): - """Test various error thrown during install.""" - rm.return_value = mock_rm = MagicMock() - mock_rm.get_latest_runner_bin_url = mock_get_latest_runner_bin_url - - harness = Harness(GithubRunnerCharm) - harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.begin() - - harness.charm._reconcile_lxd_runners = raise_runner_error - harness.charm.on.start.emit() - assert harness.charm.unit.status == ActiveStatus("Failed to start runners: mock error") - - @patch("charm.LXDRunnerManager") @patch("charm.RunnerScaler") @patch("pathlib.Path.mkdir") @patch("pathlib.Path.write_text") @patch("subprocess.run") - def test_on_config_changed_openstack_clouds_yaml(self, run, wt, mkdir, orm, rm): + def test_on_config_changed_openstack_clouds_yaml(self, run, wt, mkdir, orm): """ arrange: Setup mocked charm. act: Fire config changed event to use openstack-clouds-yaml. @@ -801,30 +462,10 @@ def test_on_config_changed_openstack_clouds_yaml(self, run, wt, mkdir, orm, rm): assert harness.charm.unit.status == BlockedStatus("Please provide image integration.") - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_check_runners_action(self, run, wt, mkdir, rm): - rm.return_value = mock_rm = MagicMock() - mock_event = MagicMock() - - mock_rm.get_github_info = mock_get_github_info - - harness = Harness(GithubRunnerCharm) - harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.begin() - - harness.charm._on_check_runners_action(mock_event) - mock_event.set_results.assert_called_with( - {"online": 2, "offline": 2, "unknown": 1, "runners": "test runner 0, test runner 1"} - ) - - @patch("charm.LXDRunnerManager") @patch("pathlib.Path.mkdir") @patch("pathlib.Path.write_text") @patch("subprocess.run") - def test_check_runners_action_with_errors(self, run, wt, mkdir, rm): + def test_check_runners_action_with_errors(self, run, wt, mkdir): mock_event = MagicMock() harness = Harness(GithubRunnerCharm) @@ -834,32 +475,12 @@ def test_check_runners_action_with_errors(self, run, wt, mkdir, rm): harness.charm._on_check_runners_action(mock_event) mock_event.fail.assert_called_with("Invalid Github config, Missing path configuration") - @patch("charm.LXDRunnerManager") - @patch("pathlib.Path.mkdir") - @patch("pathlib.Path.write_text") - @patch("subprocess.run") - def test_on_flush_runners_action(self, run, wt, mkdir, rm): - mock_event = MagicMock() - - harness = Harness(GithubRunnerCharm) - harness.begin() - - harness.charm._on_flush_runners_action(mock_event) - mock_event.fail.assert_called_with("Invalid Github config, Missing path configuration") - mock_event.reset_mock() - - harness.update_config({PATH_CONFIG_NAME: "mockorg/repo", TOKEN_CONFIG_NAME: "mocktoken"}) - harness.charm._on_flush_runners_action(mock_event) - mock_event.set_results.assert_called() - mock_event.reset_mock() - @pytest.mark.parametrize( "exception, expected_status", [ pytest.param(ConfigurationError, BlockedStatus, id="charm config error"), pytest.param(TokenError, BlockedStatus, id="github token error"), - pytest.param(MissingRunnerBinaryError, MaintenanceStatus, id="runner binary error"), ], ) def test_catch_charm_errors( @@ -901,7 +522,6 @@ def test_event_handler(self, _: typing.Any): "exception, expected_status", [ pytest.param(ConfigurationError, BlockedStatus, id="charm config error"), - pytest.param(MissingRunnerBinaryError, MaintenanceStatus, id="runner binary error"), ], ) def test_catch_action_errors( @@ -976,32 +596,6 @@ def test_openstack_image_ready_status( assert is_ready == expected_value -@pytest.mark.parametrize( - "hook", - [ - pytest.param("_on_image_relation_changed", id="image relation changed"), - pytest.param("_on_image_relation_joined", id="image relation joined"), - ], -) -def test__on_image_relation_hooks_not_openstack(hook: str): - """ - arrange: given a hook that is for OpenStack mode but the image relation exists. - act: when the hook is triggered. - assert: the charm falls into BlockedStatus. - """ - harness = Harness(GithubRunnerCharm) - harness.begin() - state_mock = MagicMock() - state_mock.instance_type = InstanceType.LOCAL_LXD - harness.charm._setup_state = MagicMock(return_value=state_mock) - - getattr(harness.charm, hook)(MagicMock()) - - assert harness.charm.unit.status == BlockedStatus( - "Openstack mode not enabled. Please remove the image integration." - ) - - def test__on_image_relation_image_not_ready(): """ arrange: given a charm with OpenStack instance type and a monkeypatched \ @@ -1012,7 +606,6 @@ def test__on_image_relation_image_not_ready(): harness = Harness(GithubRunnerCharm) harness.begin() state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK harness.charm._setup_state = MagicMock(return_value=state_mock) harness.charm._get_set_image_ready_status = MagicMock(return_value=False) @@ -1032,7 +625,6 @@ def test__on_image_relation_image_ready(): harness = Harness(GithubRunnerCharm) harness.begin() state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK harness.charm._setup_state = MagicMock(return_value=state_mock) harness.charm._get_set_image_ready_status = MagicMock(return_value=True) runner_manager_mock = MagicMock() @@ -1056,7 +648,6 @@ def test__on_image_relation_joined(): harness.add_relation_unit(relation_id, "image-builder/0") harness.begin() state_mock = MagicMock() - state_mock.instance_type = InstanceType.OPENSTACK state_mock.charm_config.openstack_clouds_yaml = OpenStackCloudsYAML( clouds={ "test-cloud": { diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index f3d3564f7..f29c47a3a 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -4,8 +4,6 @@ import logging import platform import secrets -import typing -from pathlib import Path from unittest.mock import MagicMock import pytest @@ -18,9 +16,7 @@ import charm_state from charm_state import ( - BASE_IMAGE_CONFIG_NAME, DEBUG_SSH_INTEGRATION_NAME, - DENYLIST_CONFIG_NAME, DOCKERHUB_MIRROR_CONFIG_NAME, GROUP_CONFIG_NAME, IMAGE_INTEGRATION_NAME, @@ -28,30 +24,18 @@ 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, - ImmutableConfigChangedError, - LocalLxdRunnerConfig, OpenstackImage, OpenstackRunnerConfig, ProxyConfig, - ReactiveConfig, - RunnerStorage, SSHDebugConnection, UnsupportedArchitectureError, - VirtualMachineResources, ) from errors import MissingMongoDBError from tests.unit.factories import MockGithubRunnerCharmFactory @@ -220,35 +204,6 @@ def test_parse_labels(labels, expected_valid_labels): 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: 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 - - result = CharmConfig._parse_denylist(mock_charm) - - assert result == expected_entries - - def test_parse_dockerhub_mirror_invalid_scheme(): """ arrange: Create a mock CharmBase instance with an invalid DockerHub mirror configuration. @@ -321,14 +276,13 @@ def test_parse_openstack_clouds_config_empty(): """ 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. + assert: Verify that the method raises CharmConfigInvalidError """ mock_charm = MockGithubRunnerCharmFactory() mock_charm.config[OPENSTACK_CLOUDS_YAML_CONFIG_NAME] = "" - result = CharmConfig._parse_openstack_clouds_config(mock_charm) - - assert result is None + with pytest.raises(CharmConfigInvalidError): + CharmConfig._parse_openstack_clouds_config(mock_charm) def test_parse_openstack_clouds_config_invalid_yaml(invalid_yaml_config: str): @@ -452,7 +406,6 @@ def test_charm_config_from_charm_valid(): 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", # "clouds: { openstack: { auth: { username: 'admin' }}}" OPENSTACK_CLOUDS_YAML_CONFIG_NAME: yaml.safe_dump( @@ -482,70 +435,12 @@ def test_charm_config_from_charm_valid(): 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 == test_openstack_config assert result.labels == ("label1", "label2", "label3") assert result.token == "abc123" -@pytest.mark.parametrize( - "base_image, expected_str", - [ - (BaseImage.JAMMY, "jammy"), - (BaseImage.NOBLE, "noble"), - ], -) -def test_base_image_str_parametrized(base_image, expected_str): - """ - 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. - """ - assert str(base_image) == expected_str - - -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" - - with pytest.raises(ValueError): - BaseImage.from_charm(mock_charm) - - -@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: 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.config[BASE_IMAGE_CONFIG_NAME] = image_name - - result = BaseImage.from_charm(mock_charm) - - assert result == expected_result - - def test_openstack_image_from_charm_no_connections(): """ arrange: Mock CharmBase instance without relation. @@ -607,181 +502,6 @@ def test_openstack_image_from_charm(): assert image.tags == test_tags -@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: - LocalLxdRunnerConfig.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 = LocalLxdRunnerConfig.check_virtual_machines(virtual_machines) - - assert result == virtual_machines - - -@pytest.mark.parametrize( - "vm_resources", - [ - 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_check_virtual_machine_resources_invalid(vm_resources): - """ - 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): - LocalLxdRunnerConfig.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 = LocalLxdRunnerConfig.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_charm.config[BASE_IMAGE_CONFIG_NAME] = "invalid" - - with pytest.raises(CharmConfigInvalidError) as exc_info: - LocalLxdRunnerConfig.from_charm(mock_charm) - assert str(exc_info.value) == "Invalid base image" - - -def test_runner_charm_config_from_charm_invalid_storage_config(): - """ - 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_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", - } - - with pytest.raises(CharmConfigInvalidError) as exc_info: - LocalLxdRunnerConfig.from_charm(mock_charm) - assert "Invalid runner-storage config" in str(exc_info.value) - - -def test_runner_charm_config_from_charm_invalid_cpu_config(): - """ - 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 = { - 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_info: - LocalLxdRunnerConfig.from_charm(mock_charm) - assert str(exc_info.value) == "Invalid vm-cpu configuration" - - -def test_runner_charm_config_from_charm_invalid_virtual_machines_config(): - """ - 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 = { - 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: - LocalLxdRunnerConfig.from_charm(mock_charm) - assert str(exc_info.value) == "The virtual-machines configuration must be int" - - -def test_runner_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 LocalLxdRunnerConfig instance with the expected - values. - """ - mock_charm = MockGithubRunnerCharmFactory() - 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 = LocalLxdRunnerConfig.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" - ) - - @pytest.mark.parametrize( "http, https, use_aproxy, expected_address", [ @@ -1059,130 +779,17 @@ def mock_charm_state_data(): "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": secrets.token_hex(16)}, + "charm_config": {"token": secrets.token_hex(16)}, "reactive_config": {"uri": "mongodb://user:password@localhost:27017"}, "runner_config": { - "base_image": "jammy", "virtual_machines": 2, - "runner_storage": "memory", }, - "instance_type": "local-lxd", "ssh_debug_connections": [ {"host": "10.1.2.4", "port": 22}, ], } -@pytest.mark.parametrize( - "immutable_config", - [ - pytest.param("runner_storage", id="Runner storage"), - pytest.param("base_image", id="Base image"), - ], -) -def test_check_immutable_config_key_error( - mock_charm_state_path: Path, - mock_charm_state_data: dict[str, typing.Any], - immutable_config: str, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -): - """ - arrange: Mock CHARM_STATE_PATH and read_text method to return modified immutable config values. - act: Call _check_immutable_config_change method. - assert: None is returned. - """ - mock_charm_state_data["runner_config"].pop(immutable_config) - 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)), - ) - - assert CharmState._check_immutable_config_change(RunnerStorage.MEMORY, BaseImage.JAMMY) is None - assert any( - f"Key {immutable_config} not found, this will be updated to current config." in message - for message in caplog.messages - ) - - -def test_check_immutable_config_change_no_previous_state( - mock_charm_state_path: Path, mock_charm_state_data: dict, monkeypatch: pytest.MonkeyPatch -): - """ - 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 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.""" @@ -1196,11 +803,6 @@ class MockModel(BaseModel): ValidationError([], MockModel), ), (ProxyConfig, "from_charm", ValueError), - ( - CharmState, - "_check_immutable_config_change", - ImmutableConfigChangedError("Immutable config changed"), - ), (CharmConfig, "from_charm", ValidationError([], MockModel)), (CharmConfig, "from_charm", ValueError), (charm_state, "_get_supported_arch", UnsupportedArchitectureError(arch="testarch")), @@ -1224,7 +826,6 @@ def test_charm_state_from_charm_invalid_cases( mock_charm_config_from_charm.return_value = mock_charm_config monkeypatch.setattr(CharmConfig, "from_charm", mock_charm_config_from_charm) monkeypatch.setattr(OpenstackRunnerConfig, "from_charm", MagicMock()) - monkeypatch.setattr(LocalLxdRunnerConfig, "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)) @@ -1244,8 +845,6 @@ def test_charm_state_from_charm(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ProxyConfig, "from_charm", MagicMock()) monkeypatch.setattr(CharmConfig, "from_charm", MagicMock()) monkeypatch.setattr(OpenstackRunnerConfig, "from_charm", MagicMock()) - monkeypatch.setattr(LocalLxdRunnerConfig, "from_charm", MagicMock()) - monkeypatch.setattr(CharmState, "_check_immutable_config_change", MagicMock()) monkeypatch.setattr(charm_state, "_get_supported_arch", MagicMock()) monkeypatch.setattr(charm_state, "ReactiveConfig", MagicMock()) monkeypatch.setattr(SSHDebugConnection, "from_charm", MagicMock()) @@ -1269,38 +868,3 @@ def test_charm_state__log_prev_state_redacts_sensitive_information( assert mock_charm_state_data["charm_config"]["token"] not in caplog.text assert charm_state.SENSITIVE_PLACEHOLDER in caplog.text - - -def test_charm_state_from_charm_reactive_with_lxd_raises_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: Mock CharmBase and necessary methods to enable reactive config and lxd storage. - act: Call CharmState.from_charm. - assert: Ensure an error is raised - """ - mock_charm = MockGithubRunnerCharmFactory() - mock_database = MagicMock(spec=DatabaseRequires) - - monkeypatch.setattr( - ReactiveConfig, - "from_database", - MagicMock(return_value=ReactiveConfig(mq_uri="mongodb://localhost:27017")), - ) - charm_config_mock = MagicMock() - charm_config_mock.openstack_clouds_yaml = None - monkeypatch.setattr(CharmConfig, "from_charm", MagicMock(return_value=charm_config_mock)) - - # mock all other required methods - monkeypatch.setattr(ProxyConfig, "from_charm", MagicMock()) - monkeypatch.setattr(OpenstackRunnerConfig, "from_charm", MagicMock()) - monkeypatch.setattr(LocalLxdRunnerConfig, "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()) - - with pytest.raises(CharmConfigInvalidError) as exc: - CharmState.from_charm(mock_charm, mock_database) - - assert "Reactive mode not supported for local LXD instances" in str(exc.value) diff --git a/tests/unit/test_firewall.py b/tests/unit/test_firewall.py deleted file mode 100644 index 917c0bde4..000000000 --- a/tests/unit/test_firewall.py +++ /dev/null @@ -1,160 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Test cases for firewall module.""" - -from ipaddress import IPv4Network - -import pytest - -from firewall import Firewall - - -@pytest.mark.parametrize( - "domain_ranges, exclude_ranges, expected", - [ - pytest.param([], [], [], id="empty domain[no exclude]"), - pytest.param([], (IPv4Network("127.0.0.1/32")), [], id="empty domain[one ip exclude]"), - pytest.param( - [], - [IPv4Network("127.0.0.1/32"), IPv4Network("127.0.0.2/32")], - [], - id="empty domain[multiple ips exclude]", - ), - pytest.param( - [IPv4Network("127.0.0.1/32")], - [IPv4Network("127.0.0.2/32")], - [IPv4Network("127.0.0.1/32")], - id="single ip single exclude ip[no overlap]", - ), - pytest.param( - [IPv4Network("127.0.0.1/32")], - [IPv4Network("127.0.0.1/32")], - [], - id="single ip single exclude ip[overlap]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30")], - [IPv4Network("127.0.0.2/32")], - [IPv4Network("127.0.0.0/31"), IPv4Network("127.0.0.3/32")], - id="single domain single exclude ip[overlap single ip]", - ), - pytest.param( - [IPv4Network("127.0.0.0/28")], # 127.0.0.0-14 - [IPv4Network("127.0.0.1/32"), IPv4Network("127.0.1.1/32")], - [ - IPv4Network("127.0.0.0/32"), # 127.0.0.0 - IPv4Network("127.0.0.2/31"), # 127.0.0.2-3 - IPv4Network("127.0.0.4/30"), # 127.0.0.4-7 - IPv4Network("127.0.0.8/29"), # 127.0.0.8-14 - ], - id="single domain multiple exclude ips[overlap partial ips]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30")], # 127.0.0.0-3 - [ - IPv4Network("127.0.0.0/32"), - IPv4Network("127.0.0.1/32"), - IPv4Network("127.0.0.2/32"), - IPv4Network("127.0.0.3/32"), - ], - [], - id="single domain multiple exclude ips[overlap all ips]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30")], - [IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.0.0/30")], - id="single domain single exclude domain[no overlap]", - ), - pytest.param( - [IPv4Network("127.0.0.0/28")], # 127.0.0.0-15 - [IPv4Network("127.0.0.0/30")], # 127.0.0.0-4 - [ - IPv4Network("127.0.0.8/29"), # 127.0.0.8-15 - IPv4Network("127.0.0.4/30"), # 127.0.0.5-7 - ], - id="single domain single exclude domain[overlap partial range]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30")], - [IPv4Network("127.0.0.0/30")], - [], - id="single domain single exclude domain[overlap full range]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.2.0/30")], - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - id="multiple domain single exclude domain[no overlap]", - ), - pytest.param( - [IPv4Network("127.0.0.0/28"), IPv4Network("127.0.1.0/28")], - [IPv4Network("127.0.0.0/30")], - [ - IPv4Network("127.0.0.8/29"), # 127.0.0.8-15 - IPv4Network("127.0.0.4/30"), # 127.0.0.5-7 - IPv4Network("127.0.1.0/28"), - ], - id="multiple domain single exclude domain[partial overlap]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.0.0/30")], - id="multiple domain single exclude domain[full overlap(equivalent network)]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.0.0/8")], - [], - id="multiple domain single exclude domain[full overlap(bigger network)]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.2.0/30"), IPv4Network("127.0.3.0/30")], - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - id="multiple domain multiple exclude domain[no overlaps]", - ), - pytest.param( - [IPv4Network("127.0.0.0/28"), IPv4Network("127.0.1.0/28")], - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [ - IPv4Network("127.0.0.4/30"), # 127.0.0.5-7 - IPv4Network("127.0.0.8/29"), # 127.0.0.8-15 - IPv4Network("127.0.1.4/30"), # 127.0.1.5-7 - IPv4Network("127.0.1.8/29"), # 127.0.1.8-15 - ], - id="multiple domain multiple exclude domain[multiple partial overlaps]", - ), - pytest.param( - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [], - id=( - "multiple domain multiple exclude domain[multiple full " - "overlaps(equivalent network)]" - ), - ), - pytest.param( - [IPv4Network("127.0.0.0/30"), IPv4Network("127.0.1.0/30")], - [IPv4Network("127.0.0.0/8")], - [], - id="multiple domain multiple exclude domain[multiple full overlaps(bigger network)]", - ), - ], -) -def test__exclude_network( - domain_ranges: list[IPv4Network], - exclude_ranges: list[IPv4Network], - expected: list[IPv4Network], -): - """ - arrange: given domain networks and some IPs to exclude from the domains. - act: when _exclude_network is called. - assert: new ip networks are returned with excluded target IP ranges. - """ - result = Firewall("test")._exclude_network(domain_ranges, exclude_ranges) - assert all(net in result for net in expected) and all( - net in expected for net in result - ), f"Difference in networks found, expected: {expected}, got: {result}." diff --git a/tests/unit/test_lxd_runner_manager.py b/tests/unit/test_lxd_runner_manager.py deleted file mode 100644 index b55757622..000000000 --- a/tests/unit/test_lxd_runner_manager.py +++ /dev/null @@ -1,568 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Test cases of LXDRunnerManager class.""" -import random -import secrets -from pathlib import Path -from unittest.mock import MagicMock, call - -import github_runner_manager.reactive.runner_manager -import pytest -from github_runner_manager.metrics.events import ( - Reconciliation, - RunnerInstalled, - RunnerStart, - RunnerStop, -) -from github_runner_manager.metrics.runner import RUNNER_INSTALLED_TS_FILE_NAME -from github_runner_manager.metrics.storage import MetricsStorage -from github_runner_manager.types_.github import GitHubOrg, GitHubRepo, RunnerApplication -from pytest import MonkeyPatch - -import shared_fs -from charm_state import Arch, CharmConfig, CharmState, ProxyConfig, VirtualMachineResources -from errors import IssueMetricEventError, RunnerBinaryError -from runner import Runner, RunnerStatus -from runner_manager import BUILD_IMAGE_SCRIPT_FILENAME, LXDRunnerManager, LXDRunnerManagerConfig -from runner_type import RunnerNameByHealth -from tests.unit.mock import TEST_BINARY, MockLxdImageManager - -FAKE_MONGODB_URI = "mongodb://example.com/db" - -IMAGE_NAME = "jammy" - -RUNNER_MANAGER_TIME_MODULE = "runner_manager.time.time" -TEST_PROXY_SERVER_URL = "http://proxy.server:1234" - - -@pytest.fixture(scope="function", name="token") -def token_fixture(): - return secrets.token_hex() - - -@pytest.fixture(scope="function", name="charm_config") -def charm_config_fixture(): - """Mock charm config instance.""" - mock_charm_config = MagicMock(spec=CharmConfig) - mock_charm_config.labels = ("test",) - return mock_charm_config - - -@pytest.fixture(scope="function", name="charm_state") -def charm_state_fixture(charm_config: MagicMock): - mock = MagicMock(spec=CharmState) - mock.is_metrics_logging_available = False - mock.arch = Arch.X64 - mock.ssh_debug_connections = None - mock.charm_config = charm_config - return mock - - -@pytest.fixture( - scope="function", - name="runner_manager", - params=[ - (GitHubOrg("test_org", "test_group"), ProxyConfig()), - ( - GitHubRepo("test_owner", "test_repo"), - ProxyConfig( - no_proxy="test_no_proxy", - http=TEST_PROXY_SERVER_URL, - https=TEST_PROXY_SERVER_URL, - use_aproxy=False, - ), - ), - ], -) -def runner_manager_fixture(request, tmp_path, monkeypatch, token, charm_state): - charm_state.proxy_config = request.param[1] - monkeypatch.setattr( - "runner_manager.LXDRunnerManager.runner_bin_path", tmp_path / "mock_runner_binary" - ) - pool_path = tmp_path / "test_storage" - pool_path.mkdir(exist_ok=True) - - runner_manager = LXDRunnerManager( - "test app", - "0", - LXDRunnerManagerConfig( - path=request.param[0], - token=token, - image=IMAGE_NAME, - service_token=secrets.token_hex(16), - lxd_storage_path=pool_path, - charm_state=charm_state, - ), - ) - runner_manager.runner_bin_path.write_bytes(TEST_BINARY) - return runner_manager - - -@pytest.fixture(autouse=True, name="issue_event_mock") -def issue_event_mock_fixture(monkeypatch: MonkeyPatch) -> MagicMock: - """Mock the issue_event function.""" - issue_event_mock = MagicMock() - monkeypatch.setattr("github_runner_manager.metrics.events.issue_event", issue_event_mock) - return issue_event_mock - - -@pytest.fixture(autouse=True, name="shared_fs") -def shared_fs_fixture(tmp_path: Path, monkeypatch: MonkeyPatch) -> MagicMock: - """Mock the shared filesystem module.""" - shared_fs_mock = MagicMock(spec=shared_fs) - monkeypatch.setattr("runner_manager.shared_fs", shared_fs_mock) - monkeypatch.setattr("runner.shared_fs", shared_fs_mock) - return shared_fs_mock - - -@pytest.fixture(autouse=True, name="runner_metrics") -def runner_metrics_fixture(monkeypatch: MonkeyPatch) -> MagicMock: - """Mock the runner metrics module.""" - runner_metrics_mock = MagicMock() - monkeypatch.setattr("runner_manager.runner_metrics", runner_metrics_mock) - return runner_metrics_mock - - -@pytest.fixture(name="reactive_reconcile_mock") -def reactive_reconcile_fixture(monkeypatch: MonkeyPatch, tmp_path: Path) -> MagicMock: - """Mock the job class.""" - reconcile_mock = MagicMock(spec=github_runner_manager.reactive.runner_manager.reconcile) - monkeypatch.setattr("runner_manager.reactive_runner_manager.reconcile", reconcile_mock) - reconcile_mock.side_effect = lambda quantity, **kwargs: quantity - return reconcile_mock - - -@pytest.mark.parametrize( - "arch", - [ - pytest.param(Arch.ARM64), - pytest.param(Arch.X64), - ], -) -def test_get_latest_runner_bin_url(runner_manager: LXDRunnerManager, arch: Arch, charm_state): - """ - arrange: Nothing. - act: Get runner bin url of existing binary. - assert: Correct mock data returned. - """ - 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.get_runner_application.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"] == 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: LXDRunnerManager): - """ - 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 = MagicMock() - runner_manager._clients.github.get_runner_application.side_effect = RunnerBinaryError - - with pytest.raises(RunnerBinaryError): - runner_manager.get_latest_runner_bin_url(os_name="not_exist") - - -def test_update_runner_bin(runner_manager: LXDRunnerManager): - """ - arrange: Remove the existing runner binary. - act: Update runner binary. - assert: Runner binary in runner manager is set. - """ - - class MockRequestLibResponse: - """A mock requests library response.""" - - def __init__(self, *args, **kwargs): - """Initialize successful requests library response. - - Args: - args: Placeholder for positional arguments. - kwargs: Placeholder for keyword arguments. - """ - self.status_code = 200 - - def iter_content(self, *args, **kwargs): - """Mock content iterator returning an iterator over a single test runner binary. - - Args: - args: Placeholder positional arguments. - kwargs: Placeholder keyword arguments. - - Returns: - An iterator over a single test runner binary. - """ - return iter([TEST_BINARY]) - - 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") - - runner_manager.update_runner_bin(runner_bin) - - assert runner_manager.runner_bin_path.read_bytes() == TEST_BINARY - - -def test_reconcile_zero_count(runner_manager: LXDRunnerManager): - """ - arrange: Nothing. - act: Reconcile with the current amount of runner. - assert: No error should be raised. - """ - # Reconcile with no change to runner count. - delta = runner_manager.reconcile(0, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert delta == 0 - - -def test_reconcile_create_runner(runner_manager: LXDRunnerManager): - """ - arrange: Nothing. - act: Reconcile to create a runner. - assert: One runner should be created. - """ - # Create a runner. - delta = runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert delta == 1 - - -def test_reconcile_remove_runner(runner_manager: LXDRunnerManager): - """ - arrange: Create online runners. - act: Reconcile to remove a runner. - assert: One runner should be removed. - """ - - def mock_get_runners(): - """Create three mock runners. - - Returns: - Three mock runners. - """ - runners = [] - for _ in range(3): - # 0 is a mock runner id. - status = RunnerStatus(0, True, True, False) - runners.append(Runner(MagicMock(), MagicMock(), status, None)) - return runners - - # Create online runners. - runner_manager._get_runners = mock_get_runners - runner_manager._get_runner_health_states = lambda: RunnerNameByHealth( - ( - f"{runner_manager.instance_name}-0", - f"{runner_manager.instance_name}-1", - f"{runner_manager.instance_name}-2", - ), - (), - ) - - delta = runner_manager.reconcile(2, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert delta == -1 - - -def test_reconcile(runner_manager: LXDRunnerManager, tmp_path: Path): - """ - arrange: Setup one runner. - act: Reconcile with the current amount of runner. - assert: Still have one runner. - """ - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - # Reconcile with no change to runner count. - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert len(runner_manager._get_runners()) == 1 - - -def test_empty_flush(runner_manager: LXDRunnerManager): - """ - arrange: No initial runners. - act: Perform flushing with no runners. - assert: No error thrown. - """ - # Verifying the RunnerManager does not crash if flushing with no runners. - runner_manager.flush() - - -def test_flush(runner_manager: LXDRunnerManager, tmp_path: Path): - """ - arrange: Create some runners. - act: Perform flushing. - assert: No runners. - """ - # Create a runner. - runner_manager.reconcile(2, VirtualMachineResources(2, "7GiB", "10Gib")) - - runner_manager.flush() - assert len(runner_manager._get_runners()) == 0 - - -def test_reconcile_issues_runner_installed_event( - runner_manager: LXDRunnerManager, - monkeypatch: MonkeyPatch, - issue_event_mock: MagicMock, - charm_state: MagicMock, -): - """ - arrange: Enable issuing of metrics and mock timestamps. - act: Reconcile to create a runner. - assert: The expected event is issued. - """ - charm_state.is_metrics_logging_available = True - t_mock = MagicMock(return_value=12345) - monkeypatch.setattr(RUNNER_MANAGER_TIME_MODULE, t_mock) - - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - issue_event_mock.assert_has_calls( - [call(event=RunnerInstalled(timestamp=12345, flavor=runner_manager.app_name, duration=0))] - ) - - -def test_reconcile_issues_no_runner_installed_event_if_metrics_disabled( - runner_manager: LXDRunnerManager, issue_event_mock: MagicMock, charm_state: MagicMock -): - """ - arrange: Disable issuing of metrics. - act: Reconcile to create a runner. - assert: The expected event is not issued. - """ - charm_state.is_metrics_logging_available = False - - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - issue_event_mock.assert_not_called() - - -def test_reconcile_error_on_issue_event_is_ignored( - runner_manager: LXDRunnerManager, - issue_event_mock: MagicMock, - charm_state: MagicMock, -): - """ - arrange: Enable issuing of metrics and mock the metric issuing to raise an expected error. - act: Reconcile. - assert: No error is raised. - """ - charm_state.is_metrics_logging_available = True - - issue_event_mock.side_effect = IssueMetricEventError("test error") - - delta = runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert delta == 1 - - -def test_reconcile_issues_reconciliation_metric_event( - runner_manager: LXDRunnerManager, - monkeypatch: MonkeyPatch, - issue_event_mock: MagicMock, - runner_metrics: MagicMock, - charm_state: MagicMock, -): - """ - arrange: \ - - Enable issuing of metrics \ - - Mock timestamps \ - - Mock the result of runner_metrics.issue_event to contain 2 RunnerStart and 1 RunnerStop \ - events, meaning one runner was active and one crashed. \ - - Create two online runners , one active and one idle. - act: Reconcile. - assert: The expected event is issued. We expect two idle runners and one crashed runner - to be reported. - """ - charm_state.is_metrics_logging_available = True - t_mock = MagicMock(return_value=12345) - monkeypatch.setattr(RUNNER_MANAGER_TIME_MODULE, t_mock) - runner_metrics.extract.return_value = (MagicMock() for _ in range(2)) - runner_metrics.issue_events.side_effect = [{RunnerStart, RunnerStop}, {RunnerStart}] - - online_idle_runner_name = f"{runner_manager.instance_name}-0" - offline_idle_runner_name = f"{runner_manager.instance_name}-1" - active_runner_name = f"{runner_manager.instance_name}-2" - - def mock_get_runners(): - """Create three mock runners where one is busy. - - Returns: - Mock runners with one busy runner. - """ - runners = [] - - online_idle_runner = RunnerStatus(runner_id=0, exist=True, online=True, busy=False) - offline_idle_runner = RunnerStatus(runner_id=1, exist=True, online=False, busy=False) - active_runner = RunnerStatus(runner_id=2, exist=True, online=True, busy=True) - - for runner_status, runner_config in zip( - (online_idle_runner, offline_idle_runner, active_runner), - (online_idle_runner_name, offline_idle_runner_name, active_runner_name), - ): - config = MagicMock() - config.name = runner_config - runners.append( - Runner( - clients=MagicMock(), - runner_config=config, - runner_status=runner_status, - instance=None, - ) - ) - - return runners - - # Create online runners. - runner_manager._get_runners = mock_get_runners - runner_manager._get_runner_health_states = lambda: RunnerNameByHealth( - healthy=( - online_idle_runner_name, - offline_idle_runner_name, - active_runner_name, - ), - unhealthy=(), - ) - - quantity = random.randint(0, 5) - runner_manager.reconcile( - quantity=quantity, resources=VirtualMachineResources(2, "7GiB", "10Gib") - ) - - issue_event_mock.assert_any_call( - event=Reconciliation( - timestamp=12345, - flavor=runner_manager.app_name, - crashed_runners=1, - idle_runners=2, - active_runners=1, - expected_runners=quantity, - duration=0, - ) - ) - - -def test_reconcile_places_timestamp_in_newly_created_runner( - runner_manager: LXDRunnerManager, - monkeypatch: MonkeyPatch, - shared_fs: MagicMock, - tmp_path: Path, - charm_state: MagicMock, -): - """ - arrange: Enable issuing of metrics, mock timestamps and create the directory for the shared\ - filesystem. - act: Reconcile to create a runner. - assert: The expected timestamp is placed in the shared filesystem. - """ - charm_state.is_metrics_logging_available = True - t_mock = MagicMock(return_value=12345) - monkeypatch.setattr(RUNNER_MANAGER_TIME_MODULE, t_mock) - runner_shared_fs = tmp_path / "runner_fs" - runner_shared_fs.mkdir() - fs = MetricsStorage(path=runner_shared_fs, runner_name="test_runner") - shared_fs.get.return_value = fs - - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert (fs.path / RUNNER_INSTALLED_TS_FILE_NAME).exists() - assert (fs.path / RUNNER_INSTALLED_TS_FILE_NAME).read_text() == "12345" - - -def test_reconcile_error_on_placing_timestamp_is_ignored( - runner_manager: LXDRunnerManager, shared_fs: MagicMock, tmp_path: Path, charm_state: MagicMock -): - """ - arrange: Enable issuing of metrics and do not create the directory for the shared filesystem\ - in order to let a FileNotFoundError to be raised inside the RunnerManager. - act: Reconcile to create a runner. - assert: No exception is raised. - """ - charm_state.is_metrics_logging_available = True - runner_shared_fs = tmp_path / "runner_fs" - fs = MetricsStorage(path=runner_shared_fs, runner_name="test_runner") - shared_fs.get.return_value = fs - - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert not (fs.path / RUNNER_INSTALLED_TS_FILE_NAME).exists() - - -def test_reconcile_places_no_timestamp_in_newly_created_runner_if_metrics_disabled( - runner_manager: LXDRunnerManager, shared_fs: MagicMock, tmp_path: Path, charm_state: MagicMock -): - """ - arrange: Disable issuing of metrics, mock timestamps and the shared filesystem module. - act: Reconcile to create a runner. - assert: No timestamp is placed in the shared filesystem. - """ - charm_state.is_metrics_logging_available = False - - fs = MetricsStorage(path=tmp_path, runner_name="test_runner") - shared_fs.get.return_value = fs - - runner_manager.reconcile(1, VirtualMachineResources(2, "7GiB", "10Gib")) - - assert not (fs.path / RUNNER_INSTALLED_TS_FILE_NAME).exists() - - -def test_schedule_build_runner_image( - runner_manager: LXDRunnerManager, - tmp_path: Path, - charm_state: CharmState, - monkeypatch: MonkeyPatch, -): - """ - arrange: Mock the cron path and the randint function. - act: Schedule the build runner image. - assert: The cron file is created with the expected content. - """ - runner_manager.cron_path = tmp_path / "cron" - runner_manager.cron_path.mkdir() - monkeypatch.setattr(random, "randint", MagicMock(spec=random.randint, return_value=4)) - - runner_manager.schedule_build_runner_image() - - cronfile = runner_manager.cron_path / "build-runner-image" - http = charm_state.proxy_config.http or "''" - https = charm_state.proxy_config.https or "''" - no_proxy = charm_state.proxy_config.no_proxy or "''" - - cmd = f"/usr/bin/bash {BUILD_IMAGE_SCRIPT_FILENAME.absolute()} {http} {https} {no_proxy}" - - assert cronfile.exists() - assert cronfile.read_text() == f"4 4,10,16,22 * * * ubuntu {cmd} jammy\n" - - -def test_has_runner_image(runner_manager: LXDRunnerManager): - """ - arrange: Multiple setups. - 1. no runner image exists. - 2. runner image with wrong name exists. - 3. runner image with correct name exists. - act: Check if runner image exists. - assert: - 1 and 2. False is returned. - 3. True is returned. - """ - assert not runner_manager.has_runner_image() - - runner_manager._clients.lxd.images = MockLxdImageManager({"hirsute"}) - assert not runner_manager.has_runner_image() - - runner_manager._clients.lxd.images = MockLxdImageManager({IMAGE_NAME}) - assert runner_manager.has_runner_image() diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py deleted file mode 100644 index e6d57f305..000000000 --- a/tests/unit/test_runner.py +++ /dev/null @@ -1,565 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Test cases of Runner class.""" - -import secrets -import unittest -from pathlib import Path -from unittest.mock import MagicMock, call - -import github_runner_manager.metrics.runner_logs -import jinja2 -import pytest -from _pytest.monkeypatch import MonkeyPatch -from github_runner_manager.metrics.storage import MetricsStorage -from github_runner_manager.types_.github import GitHubOrg, GitHubRepo - -from charm_state import SSHDebugConnection, VirtualMachineResources -from errors import ( - CreateMetricsStorageError, - LxdError, - RunnerCreateError, - RunnerLogsError, - RunnerRemoveError, -) -from lxd import LxdInstance, LxdInstanceFileManager -from runner import DIAG_DIR_PATH, CreateRunnerConfig, Runner, RunnerConfig, RunnerStatus -from runner_manager_type import RunnerManagerClients -from runner_type import ProxySetting -from tests.unit.factories import SSHDebugInfoFactory -from tests.unit.mock import ( - MockLxdClient, - MockRepoPolicyComplianceClient, - mock_lxd_error_func, - mock_runner_error_func, -) - -TEST_PROXY_SERVER_URL = "http://proxy.server:1234" - - -@pytest.fixture(scope="module", name="vm_resources") -def vm_resources_fixture(): - return VirtualMachineResources(2, "7Gib", "10Gib") - - -@pytest.fixture(scope="function", name="token") -def token_fixture(): - return secrets.token_hex() - - -@pytest.fixture(scope="function", name="binary_path") -def binary_path_fixture(tmp_path: Path): - return tmp_path / "test_binary" - - -@pytest.fixture(scope="module", name="instance", params=["Running", "Stopped", None]) -def instance_fixture(request): - if request.param[0] is None: - return None - - attrs = {"status": request.param[0], "execute.return_value": (0, "", "")} - instance = unittest.mock.MagicMock(**attrs) - return instance - - -@pytest.fixture(scope="function", name="lxd") -def mock_lxd_client_fixture(): - return MockLxdClient() - - -@pytest.fixture(autouse=True, scope="function", name="shared_fs") -def shared_fs_fixture(monkeypatch: MonkeyPatch) -> MagicMock: - """Mock the module for handling the Shared Filesystem.""" - mock = MagicMock() - monkeypatch.setattr("runner.shared_fs", mock) - return mock - - -@pytest.fixture(autouse=True, scope="function", name="exc_cmd_mock") -def exc_command_fixture(monkeypatch: MonkeyPatch) -> MagicMock: - """Mock the execution of a command.""" - exc_cmd_mock = MagicMock() - monkeypatch.setattr("runner.execute_command", exc_cmd_mock) - return exc_cmd_mock - - -@pytest.fixture(name="log_dir_base_path") -def log_dir_base_path_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Mock the create_logs_dir function and return the base path of the log directory.""" - log_dir_base_path = tmp_path / "log_dir" - - def create_logs_dir(runner_name: str) -> Path: - """Create the directory to store the logs of the crashed runners. - - Args: - runner_name: The name of the runner. - - Returns: - The path to the directory where the logs of the crashed runners will be stored. - """ - target_log_path = log_dir_base_path / runner_name - target_log_path.mkdir(parents=True, exist_ok=True) - - return target_log_path - - create_logs_dir_mock = MagicMock( - spec=github_runner_manager.metrics.runner_logs.create_logs_dir - ) - create_logs_dir_mock.side_effect = create_logs_dir - monkeypatch.setattr("runner.create_logs_dir", create_logs_dir_mock) - - return log_dir_base_path - - -@pytest.fixture(scope="function", name="jinja") -def jinja2_environment_fixture() -> MagicMock: - """Mock the jinja2 environment. - - Provides distinct mocks for each template. - """ - jinja2_mock = MagicMock(spec=jinja2.Environment) - template_mocks = { - "start.j2": MagicMock(), - "pre-job.j2": MagicMock(), - "env.j2": MagicMock(), - "environment.j2": MagicMock(), - "systemd-docker-proxy.j2": MagicMock(), - } - jinja2_mock.get_template.side_effect = lambda x: template_mocks.get(x, MagicMock()) - return jinja2_mock - - -@pytest.fixture(scope="function", name="ssh_debug_connections") -def ssh_debug_connections_fixture() -> list[SSHDebugConnection]: - """A list of randomly generated ssh_debug_connections.""" - return SSHDebugInfoFactory.create_batch(size=100) - - -@pytest.fixture( - scope="function", - name="runner", - params=[ - ( - GitHubOrg("test_org", "test_group"), - ProxySetting(no_proxy=None, http=None, https=None, aproxy_address=None), - ), - ( - GitHubRepo("test_owner", "test_repo"), - ProxySetting( - no_proxy="test_no_proxy", - http=TEST_PROXY_SERVER_URL, - https=TEST_PROXY_SERVER_URL, - aproxy_address=None, - ), - ), - ], -) -def runner_fixture( - request, - lxd: MockLxdClient, - jinja: MagicMock, - tmp_path: Path, - ssh_debug_connections: list[SSHDebugConnection], -): - client = RunnerManagerClients( - MagicMock(), - jinja, - lxd, - MockRepoPolicyComplianceClient(), - ) - pool_path = tmp_path / "test_storage" - pool_path.mkdir(exist_ok=True) - config = RunnerConfig( - name="test_runner", - app_name="test_app", - path=request.param[0], - proxies=request.param[1], - lxd_storage_path=pool_path, - labels=("test", "label"), - dockerhub_mirror=None, - issue_metrics=False, - ssh_debug_connections=ssh_debug_connections, - ) - lxd_instance_mock = MagicMock(spec=LxdInstance) - lxd_instance_mock.files = MagicMock(LxdInstanceFileManager) - status = RunnerStatus() - return Runner(client, config, status, lxd_instance_mock) - - -def test_create( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, -): - """ - arrange: Nothing. - act: Create a runner. - assert: An lxd instance for the runner is created. - """ - 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 - - if runner.config.proxies: - instance = instances[0] - env_proxy = instance.files.read_file("/home/ubuntu/github-runner/.env") - systemd_docker_proxy = instance.files.read_file( - "/etc/systemd/system/docker.service.d/http-proxy.conf" - ) - # Test the file has being written to. This value does not contain the string as the - # jinja2.environment.Environment is mocked with MagicMock. - assert env_proxy is not None - assert systemd_docker_proxy is not None - - -def test_create_lxd_fail( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, - monkeypatch: pytest.MonkeyPatch, -): - """ - arrange: Setup the create runner to fail with lxd error. - act: Create a runner. - assert: Correct exception should be thrown. Any created instance should be - cleanup. - """ - monkeypatch.setattr(lxd.profiles, "exists", mock_lxd_error_func) - - with pytest.raises(RunnerCreateError): - runner.create( - config=CreateRunnerConfig( - image="test_image", - resources=vm_resources, - binary_path=binary_path, - registration_token=token, - ) - ) - - assert len(lxd.instances.all()) == 0 - - -def test_create_runner_fail( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, -): - """ - arrange: Setup the create runner to fail with runner error. - act: Create a runner. - assert: Correct exception should be thrown. Any created instance should be - cleanup. - """ - runner._clients.lxd.instances.create = mock_runner_error_func - - with pytest.raises(RunnerCreateError): - runner.create( - config=CreateRunnerConfig( - image="test_image", - resources=vm_resources, - binary_path=binary_path, - registration_token=token, - ) - ) - - -def test_create_with_metrics( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, - shared_fs: MagicMock, - exc_cmd_mock: MagicMock, - jinja: MagicMock, -): - """ - arrange: Config the runner to issue metrics and mock the shared filesystem. - act: Create a runner. - assert: The command for adding a device has been executed and the templates are - rendered to issue metrics. - """ - runner.config.issue_metrics = True - shared_fs.create.return_value = MetricsStorage( - path=Path("/home/ubuntu/shared_fs"), runner_name="test_runner" - ) - runner.create( - config=CreateRunnerConfig( - image="test_image", - resources=vm_resources, - binary_path=binary_path, - registration_token=token, - ) - ) - - exc_cmd_mock.assert_called_once_with( - [ - "sudo", - "lxc", - "config", - "device", - "add", - "test_runner", - "metrics", - "disk", - "source=/home/ubuntu/shared_fs", - "path=/metrics-exchange", - ], - check_exit=True, - ) - - jinja.get_template("start.j2").render.assert_called_once_with(issue_metrics=True) - jinja.get_template("pre-job.j2").render.assert_called_once() - assert "issue_metrics" in jinja.get_template("pre-job.j2").render.call_args[1] - - -def test_create_with_metrics_and_shared_fs_error( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, - shared_fs: MagicMock, -): - """ - arrange: Config the runner to issue metrics and mock the shared filesystem module\ - to throw an expected error. - act: Create a runner. - assert: The runner is created despite the error on the shared filesystem. - """ - runner.config.issue_metrics = True - shared_fs.create.side_effect = CreateMetricsStorageError("") - - 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 - - -def test_remove( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, -): - """ - arrange: Create a runner. - act: Remove the runner. - assert: The lxd instance for the runner is removed. - """ - 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 - - -def test_remove_failed_instance( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, -): - """ - arrange: Create a stopped runner that failed to remove itself. - act: Remove the runner. - assert: The lxd instance for the runner is removed. - """ - # Cases where the ephemeral instance encountered errors and the status was Stopped but not - # removed was found before. - 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 - - -def test_remove_none( - runner: Runner, - token: str, - lxd: MockLxdClient, -): - """ - arrange: Not creating a runner. - act: Remove the runner. - assert: The lxd instance for the runner is removed. - """ - runner.remove(token) - assert len(lxd.instances.all()) == 0 - - -def test_remove_with_stop_error( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, -): - """ - arrange: Create a runner. Set up LXD stop fails with LxdError. - act: Remove the runner. - assert: RunnerRemoveError is raised. - """ - 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): - runner.remove("test_token") - - -def test_remove_with_delete_error( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, - lxd: MockLxdClient, -): - """ - arrange: Create a runner. Set up LXD delete fails with LxdError. - act: Remove the runner. - assert: RunnerRemoveError is raised. - """ - 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 - - with pytest.raises(RunnerRemoveError): - runner.remove("test_token") - - -def test_random_ssh_connection_choice( - runner: Runner, - vm_resources: VirtualMachineResources, - token: str, - binary_path: Path, -): - """ - arrange: given a mock runner with random batch of ssh debug infos. - act: when runner.configure_runner is called. - assert: selected ssh_debug_info is random. - """ - runner.create( - config=CreateRunnerConfig( - image="test_image", - resources=vm_resources, - binary_path=binary_path, - registration_token=token, - ) - ) - runner._configure_runner() - first_call_args = runner._clients.jinja.get_template("env.j2").render.call_args.kwargs - runner._configure_runner() - second_call_args = runner._clients.jinja.get_template("env.j2").render.call_args.kwargs - - assert first_call_args["ssh_debug_info"] != second_call_args["ssh_debug_info"], ( - "Same ssh debug info found, this may have occurred with a very low probability. " - "Just try again." - ) - - -def test_pull_logs(runner: Runner, log_dir_base_path: Path): - """ - arrange: Mock the Runner instance and the base log directory path. - act: Get the logs of the runner. - assert: The expected log directory is created and logs are pulled. - """ - runner.config.name = "test-runner" - runner.instance.files.pull_file = MagicMock() - - runner.pull_logs() - - assert log_dir_base_path.exists() - - log_dir_path = log_dir_base_path / "test-runner" - log_dir_base_path.exists() - - runner.instance.files.pull_file.assert_has_calls( - [ - call(str(DIAG_DIR_PATH), str(log_dir_path), is_dir=True), - call(str(github_runner_manager.metrics.runner_logs.SYSLOG_PATH), str(log_dir_path)), - ] - ) - - -@pytest.mark.usefixtures("log_dir_base_path") -def test_pull_logs_no_instance(runner: Runner): - """ - arrange: Mock the Runner instance to be None. - act: Get the logs of the runner. - assert: A RunnerLogsError is raised. - """ - runner.config.name = "test-runner" - runner.instance = None - - with pytest.raises(RunnerLogsError) as exc_info: - runner.pull_logs() - - assert "Cannot pull the logs for test-runner as runner has no running instance." in str( - exc_info.value - ) - - -@pytest.mark.usefixtures("log_dir_base_path") -def test_pull_logs_lxd_error(runner: Runner): - """ - arrange: Mock the Runner instance to raise an LxdError. - act: Get the logs of the runner. - assert: A RunnerLogsError is raised. - """ - runner.config.name = "test-runner" - runner.instance.files.pull_file = MagicMock(side_effect=LxdError("Cannot pull file")) - - with pytest.raises(RunnerLogsError) as exc_info: - runner.pull_logs() - - assert "Cannot pull the logs for test-runner." in str(exc_info.value) - assert "Cannot pull file" in str(exc_info.value.__cause__) diff --git a/tests/unit/test_shared_fs.py b/tests/unit/test_shared_fs.py deleted file mode 100644 index 12dc11506..000000000 --- a/tests/unit/test_shared_fs.py +++ /dev/null @@ -1,334 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. -import secrets -import shutil -from pathlib import Path -from unittest.mock import MagicMock, Mock - -import pytest -from _pytest.monkeypatch import MonkeyPatch -from github_runner_manager.metrics.storage import MetricsStorage - -import shared_fs -from errors import ( - CreateMetricsStorageError, - DeleteMetricsStorageError, - GetMetricsStorageError, - SubprocessError, -) - -MOUNTPOINT_FAILURE_EXIT_CODE = 1 - - -@pytest.fixture(autouse=True, name="filesystem_paths") -def filesystem_paths_fixture(monkeypatch: MonkeyPatch, tmp_path: Path) -> dict[str, Path]: - """Mock the hardcoded filesystem paths.""" - fs_path = tmp_path / "runner-fs" - fs_images_path = tmp_path / "images" - monkeypatch.setattr("shared_fs.FILESYSTEM_IMAGES_PATH", fs_images_path) - return {"base": fs_path, "images": fs_images_path} - - -@pytest.fixture(autouse=True, name="metrics_storage_mock") -def metrics_storage_fixture( - monkeypatch: MonkeyPatch, filesystem_paths: dict[str, Path] -) -> MagicMock: - """Mock the metrics storage.""" - metrics_storage_mock = MagicMock() - storage_manager_cls_mock = MagicMock(return_value=metrics_storage_mock) - monkeypatch.setattr(shared_fs.metrics_storage, "StorageManager", storage_manager_cls_mock) - fs_base_path = filesystem_paths["base"] - fs_base_path.mkdir() - - def create(runner_name: str) -> MetricsStorage: - """Create metrics storage for the runner. - - Args: - runner_name: The name of the runner. - - Raises: - CreateMetricsStorageError: If the creation of the metrics storage fails. - - Returns: - The metrics storage. - """ - if (fs_base_path / runner_name).exists(): - raise CreateMetricsStorageError("Filesystem already exists") - (fs_base_path / runner_name).mkdir() - return MetricsStorage(fs_base_path, runner_name) - - def list_all(): - """List all shared filesystems. - - Returns: - A generator of metrics storage objects. - """ - return ( - MetricsStorage(runner_dir, str(runner_dir.name)) - for runner_dir in fs_base_path.iterdir() - ) - - def get(runner_name: str) -> MetricsStorage: - """Get the metrics storage for the runner. - - Args: - runner_name: The name of the runner. - - Raises: - GetMetricsStorageError: If the filesystem is not found. - - Returns: - The metrics storage. - """ - if not (fs_base_path / runner_name).exists(): - raise GetMetricsStorageError("Filesystem not found") - return MetricsStorage(fs_base_path / runner_name, runner_name) - - metrics_storage_mock.create.side_effect = create - metrics_storage_mock.get.side_effect = get - metrics_storage_mock.list_all.side_effect = list_all - metrics_storage_mock.delete.side_effect = lambda runner_name: shutil.rmtree( - fs_base_path / runner_name - ) - - return metrics_storage_mock - - -@pytest.fixture(autouse=True, name="exc_cmd_mock") -def exc_command_fixture(monkeypatch: MonkeyPatch) -> Mock: - """Mock the execution of a command.""" - exc_cmd_mock = Mock(return_value=("", 0)) - monkeypatch.setattr("shared_fs.execute_command", exc_cmd_mock) - return exc_cmd_mock - - -def exc_cmd_side_effect(*args, **_): - """Mock command to return NOT_A_MOUNTPOINT exit code. - - Args: - args: Positional argument placeholder. - - Returns: - Fake exc_cmd return values. - """ - if args[0][0] == "mountpoint": - return "", shared_fs.DIR_NO_MOUNTPOINT_EXIT_CODE - return "", 0 - - -def test_create_creates_directory(): - """ - arrange: Given a runner name and a path for the filesystems. - act: Call create. - assert: The shared filesystem path is created. - """ - runner_name = secrets.token_hex(16) - - fs = shared_fs.create(runner_name) - - assert fs.path.exists() - assert fs.path.is_dir() - - -def test_create_raises_exception(exc_cmd_mock: MagicMock): - """ - arrange: Given a runner name and a mocked execute_command which raises an expected exception. - act: Call create. - assert: The expected exception is raised. - """ - runner_name = secrets.token_hex(16) - exc_cmd_mock.side_effect = SubprocessError( - cmd=["mock"], return_code=1, stdout="mock stdout", stderr="mock stderr" - ) - - with pytest.raises(CreateMetricsStorageError): - shared_fs.create(runner_name) - - -def test_create_raises_exception_if_already_exists(): - """ - arrange: Given a runner name and an already existing shared filesystem. - act: Call create. - assert: The expected exception is raised. - """ - runner_name = secrets.token_hex(16) - shared_fs.create(runner_name) - - with pytest.raises(CreateMetricsStorageError): - shared_fs.create(runner_name) - - -def test_list_shared_filesystems(): - """ - arrange: Create shared filesystems for multiple runners. - act: Call list. - assert: A generator listing all the shared filesystems is returned. - """ - runner_names = [secrets.token_hex(16) for _ in range(3)] - for runner_name in runner_names: - shared_fs.create(runner_name) - - fs_list = list(shared_fs.list_all()) - - assert len(fs_list) == 3 - for fs in fs_list: - assert isinstance(fs, MetricsStorage) - assert fs.runner_name in runner_names - - -def test_list_shared_filesystems_empty(): - """ - arrange: Nothing. - act: Call list. - assert: An empty generator is returned. - """ - fs_list = list(shared_fs.list_all()) - - assert len(fs_list) == 0 - - -def test_list_shared_filesystems_ignore_unmounted_fs(exc_cmd_mock: MagicMock): - """ - arrange: Create shared filesystems for multiple runners and mock mountpoint cmd \ - to return NOT_A_MOUNTPOINT exit code for a dedicated runner. - act: Call list. - assert: A generator listing all the shared filesystems except the one of the dedicated runner - is returned. - """ - runner_names = [secrets.token_hex(16) for _ in range(3)] - for runner_name in runner_names: - shared_fs.create(runner_name) - - runner_with_mount_failure = runner_names[0] - - def exc_cmd_side_effect(*args, **_): - """Mock command to return NOT_A_MOUNTPOINT exit code. - - Args: - args: Positional argument placeholder. - - Returns: - Fake exc_cmd return values. - """ - if args[0][0] == "mountpoint" and runner_with_mount_failure in args[0][2]: - return "", MOUNTPOINT_FAILURE_EXIT_CODE - return "", 0 - - exc_cmd_mock.side_effect = exc_cmd_side_effect - - fs_list = list(shared_fs.list_all()) - - assert len(fs_list) == 2 - assert runner_with_mount_failure not in [fs.runner_name for fs in fs_list] - - -def test_delete_filesystem(): - """ - arrange: Create a shared filesystem for a runner. - act: Call delete - assert: The shared filesystem is deleted. - """ - runner_name = secrets.token_hex(16) - shared_fs.create(runner_name) - - shared_fs.delete(runner_name) - - with pytest.raises(GetMetricsStorageError): - shared_fs.get(runner_name) - - -def test_delete_raises_error(): - """ - arrange: Nothing. - act: Call delete. - assert: A DeleteMetricsStorageError is raised. - """ - runner_name = secrets.token_hex(16) - - with pytest.raises(DeleteMetricsStorageError): - shared_fs.delete(runner_name) - - -def test_delete_filesystem_ignores_unmounted_filesystem(exc_cmd_mock: MagicMock): - """ - arrange: Create a shared filesystem for a runner and mock mountpoint cmd \ - to return NOT_A_MOUNTPOINT exit code. - act: Call delete. - assert: The shared filesystem is deleted. - """ - runner_name = secrets.token_hex(16) - shared_fs.create(runner_name) - - exc_cmd_mock.side_effect = exc_cmd_side_effect - - shared_fs.delete(runner_name) - - with pytest.raises(GetMetricsStorageError): - shared_fs.get(runner_name) - - -def test_get_shared_filesystem(): - """ - arrange: Given a runner name. - act: Call create and get. - assert: A metrics storage object for this runner is returned. - """ - runner_name = secrets.token_hex(16) - - shared_fs.create(runner_name) - fs = shared_fs.get(runner_name) - - assert isinstance(fs, MetricsStorage) - assert fs.runner_name == runner_name - - -def test_get_raises_error_if_not_found(): - """ - arrange: Nothing. - act: Call get. - assert: A GetMetricsStorageError is raised. - """ - runner_name = secrets.token_hex(16) - - with pytest.raises(GetMetricsStorageError): - shared_fs.get(runner_name) - - -def test_get_mounts_if_unmounted(filesystem_paths: dict[str, Path], exc_cmd_mock: MagicMock): - """ - arrange: Given a runner name and a mock mountpoint cmd which returns NOT_A_MOUNTPOINT \ - exit code. - act: Call create and get. - assert: The shared filesystem is mounted. - """ - runner_name = secrets.token_hex(16) - shared_fs.create(runner_name) - - exc_cmd_mock.side_effect = exc_cmd_side_effect - - shared_fs.get(runner_name) - - exc_cmd_mock.assert_any_call( - [ - "sudo", - "mount", - "-o", - "loop", - str(shared_fs._get_runner_image_path(runner_name)), - str(filesystem_paths["base"] / runner_name), - ], - check_exit=True, - ) - - -def test_move_to_quarantine(metrics_storage_mock: MagicMock): - """ - arrange: Given a runner name. - act: Call move_to_quarantine. - assert: The method is called on the metrics storage manager. - """ - runner_name = secrets.token_hex(16) - - shared_fs.move_to_quarantine(runner_name) - - metrics_storage_mock.move_to_quarantine.assert_called_once_with(runner_name) diff --git a/tox.ini b/tox.ini index f58f1cb86..181e4073a 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,8 @@ envlist = lint, unit, static, coverage-report [vars] src_path = {toxinidir}/src/ tst_path = {toxinidir}/tests/ -scripts_path = {toxinidir}/scripts/ github_runner_manager_path = {toxinidir}/github-runner-manager/ -all_path = {[vars]src_path} {[vars]tst_path} {[vars]scripts_path} +all_path = {[vars]src_path} {[vars]tst_path} [testenv] @@ -72,8 +71,8 @@ commands = isort --check-only --diff {[vars]all_path} black --check --diff {[vars]all_path} mypy {[vars]all_path} - pylint {[vars]src_path} {[vars]scripts_path} - pydocstyle {[vars]src_path} {[vars]scripts_path} + pylint {[vars]src_path} + pydocstyle {[vars]src_path} [testenv:unit] description = Run unit tests