From 24feff6eab12d66d1c99739936a816e3c95b6397 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 27 Sep 2024 11:58:05 +0200 Subject: [PATCH] feat: Job label validation (#382) * pass over supported_labels * fix docstrings for integration tests * pin correct commit --- requirements.txt | 2 +- src-docs/charm.md | 11 +-- src/charm.py | 31 ++++++-- tests/integration/conftest.py | 10 +-- tests/integration/test_reactive.py | 118 +++++++++++++++++++++++++---- 5 files changed, 142 insertions(+), 30 deletions(-) diff --git a/requirements.txt b/requirements.txt index e8529761c..c0420e69d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,4 @@ cosl ==0.0.15 # juju 3.1.2.0 depends on pyyaml<=6.0 and >=5.1.2 PyYAML ==6.0.* pyOpenSSL==24.2.1 -github_runner_manager @ git+https://github.com/canonical/github-runner-manager.git@2866c28b62e966a179931201e786c548573e6e7c +github_runner_manager @ git+https://github.com/canonical/github-runner-manager.git@30fcc502eba82c4c4216f66f61380cfcbed517b1 diff --git a/src-docs/charm.md b/src-docs/charm.md index 5458c6a13..7bed9337b 100644 --- a/src-docs/charm.md +++ b/src-docs/charm.md @@ -18,10 +18,11 @@ Charm for creating and managing GitHub self-hosted runner instances. - **RECONCILIATION_INTERVAL_TIMEOUT_FACTOR** - **RECONCILE_RUNNERS_EVENT** - **REACTIVE_MQ_DB_NAME** +- **GITHUB_SELF_HOSTED_ARCH_LABELS** --- - + ## function `catch_charm_errors` @@ -47,7 +48,7 @@ Catch common errors in charm. --- - + ## function `catch_action_errors` @@ -73,7 +74,7 @@ Catch common errors in actions. --- - + ## class `ReconcileRunnersEvent` Event representing a periodic check to ensure runners are ok. @@ -84,7 +85,7 @@ Event representing a periodic check to ensure runners are ok. --- - + ## class `GithubRunnerCharm` Charm for managing GitHub self-hosted runners. @@ -101,7 +102,7 @@ Charm for managing GitHub self-hosted runners. - `ram_pool_path`: The path to memdisk storage. - `kernel_module_path`: The path to kernel modules. - + ### method `__init__` diff --git a/src/charm.py b/src/charm.py index b5e437104..ed98094ee 100755 --- a/src/charm.py +++ b/src/charm.py @@ -107,6 +107,8 @@ REACTIVE_MQ_DB_NAME = "github-runner-webhook-router" +GITHUB_SELF_HOSTED_ARCH_LABELS = {"x64", "arm64"} + logger = logging.getLogger(__name__) @@ -1281,6 +1283,9 @@ def _get_runner_scaler( ) reactive_runner_config = None if reactive_config := state.reactive_config: + # The charm is not able to determine which architecture the runner is running on, + # so we add all architectures to the supported labels. + supported_labels = set(self._create_labels(state)) | GITHUB_SELF_HOSTED_ARCH_LABELS reactive_runner_config = ReactiveRunnerConfig( queue=ReactiveQueueConfig( mongodb_uri=reactive_config.mq_uri, queue_name=self.app.name @@ -1288,11 +1293,29 @@ def _get_runner_scaler( runner_manager=runner_manager_config, cloud_runner_manager=openstack_runner_manager_config, github_token=token, + supported_labels=supported_labels, ) return RunnerScaler( runner_manager=runner_manager, reactive_runner_config=reactive_runner_config ) + @staticmethod + def _create_labels(state: CharmState) -> list[str]: + """Create Labels instance. + + Args: + state: Charm state used to create the labels. + + Returns: + An instance of Labels. + """ + image_labels = [] + image = state.runner_config.openstack_image + if image and image.id and image.tags: + image_labels = image.tags + + return list(state.charm_config.labels) + image_labels + def _create_openstack_runner_manager_config( self, path: GitHubPath, state: CharmState ) -> OpenStackRunnerManagerConfig: @@ -1316,7 +1339,6 @@ def _create_openstack_runner_manager_config( cloud=clouds[0], ) server_config = None - image_labels = [] image = state.runner_config.openstack_image if image and image.id: server_config = OpenStackServerConfig( @@ -1324,11 +1346,8 @@ def _create_openstack_runner_manager_config( flavor=state.runner_config.openstack_flavor, network=state.runner_config.openstack_network, ) - if image.tags: - image_labels += image.tags - runner_config = GitHubRunnerConfig( - github_path=path, labels=(*state.charm_config.labels, *image_labels) - ) + labels = self._create_labels(state) + runner_config = GitHubRunnerConfig(github_path=path, labels=labels) service_config = SupportServiceConfig( proxy_config=state.proxy_config, dockerhub_mirror=state.charm_config.dockerhub_mirror, diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4ee3200be..e88b399e8 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -761,18 +761,18 @@ async def mongodb_fixture(model: Model, existing_app: str | None) -> Application @pytest_asyncio.fixture(scope="module", name="app_for_reactive") async def app_for_reactive_fixture( model: Model, - basic_app: Application, + app_openstack_runner: Application, mongodb: Application, existing_app: Optional[str], ) -> Application: """Application for testing reactive.""" if not existing_app: - await model.relate(f"{basic_app.name}:mongodb", f"{mongodb.name}:database") + await model.relate(f"{app_openstack_runner.name}:mongodb", f"{mongodb.name}:database") - await basic_app.set_config({VIRTUAL_MACHINES_CONFIG_NAME: "1"}) - await model.wait_for_idle(apps=[basic_app.name, mongodb.name], status=ACTIVE) + await app_openstack_runner.set_config({VIRTUAL_MACHINES_CONFIG_NAME: "1"}) + await model.wait_for_idle(apps=[app_openstack_runner.name, mongodb.name], status=ACTIVE) - return basic_app + return app_openstack_runner @pytest_asyncio.fixture(scope="module", name="basic_app") diff --git a/tests/integration/test_reactive.py b/tests/integration/test_reactive.py index fc07154cd..ac4966d13 100644 --- a/tests/integration/test_reactive.py +++ b/tests/integration/test_reactive.py @@ -1,9 +1,9 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. + +"""Testing reactive mode. This is only supported for the OpenStack cloud.""" import json -import random import re -import secrets import pytest from github import Branch, Repository @@ -21,9 +21,21 @@ wait_for_completion, ) +pytestmark = pytest.mark.openstack + + +@pytest.fixture(name="setup_queue", autouse=True) +async def setup_queue_fixture( + ops_test: OpsTest, + app_for_reactive: Application, +): + mongodb_uri = await _get_mongodb_uri(ops_test, app_for_reactive) + + _clear_queue(mongodb_uri, app_for_reactive.name) + _assert_queue_is_empty(mongodb_uri, app_for_reactive.name) + -@pytest.mark.openstack -async def test_reactive_mode_consumes_jobs( +async def test_reactive_mode_spawns_runner( ops_test: OpsTest, app_for_reactive: Application, github_repository: Repository, @@ -32,13 +44,9 @@ async def test_reactive_mode_consumes_jobs( """ arrange: A charm integrated with mongodb and a message is added to the queue. act: Call reconcile. - assert: The message is consumed and the job details are logged. + assert: The message is consumed and a runner is spawned. """ - unit = app_for_reactive.units[0] - mongodb_uri = await _get_mongodb_uri_from_integration_data(ops_test, unit) - if not mongodb_uri: - mongodb_uri = await _get_mongodb_uri_from_secrets(ops_test, unit.model) - assert mongodb_uri, "mongodb uri not found in integration data or secret" + mongodb_uri = await _get_mongodb_uri(ops_test, app_for_reactive) run = await dispatch_workflow( app=app_for_reactive, @@ -53,10 +61,12 @@ async def test_reactive_mode_consumes_jobs( job = jobs[0] job_url = job.url job = JobDetails( - labels=[secrets.token_hex(16) for _ in range(random.randint(1, 4))], url=job_url + labels={app_for_reactive.name, "x64"}, # The architecture label should be ignored in the + # label validation in the reactive consumer. + url=job_url, ) _add_to_queue( - json.dumps(job.dict() | {"ignored_noise": "foobar"}), + json.dumps(json.loads(job.json()) | {"ignored_noise": "foobar"}), mongodb_uri, app_for_reactive.name, ) @@ -70,6 +80,65 @@ async def test_reactive_mode_consumes_jobs( await reconcile(app_for_reactive, app_for_reactive.model) +async def test_reactive_mode_does_not_consume_jobs_with_unsupported_labels( + ops_test: OpsTest, + app_for_reactive: Application, + github_repository: Repository, + test_github_branch: Branch, +): + """ + arrange: A charm integrated with mongodb and an unsupported label is added to the queue. + act: Call reconcile. + assert: No runner is spawned and the message is requeued. + """ + mongodb_uri = await _get_mongodb_uri(ops_test, app_for_reactive) + + run = await dispatch_workflow( + app=app_for_reactive, + branch=test_github_branch, + github_repository=github_repository, + conclusion="success", # this is ignored currently if wait=False kwarg is used + workflow_id_or_name=DISPATCH_TEST_WORKFLOW_FILENAME, + wait=False, + ) + jobs = list(run.jobs()) + assert len(jobs) == 1, "Expected 1 job to be created" + job = jobs[0] + job_url = job.url + job = JobDetails(labels={"not supported label"}, url=job_url) + _add_to_queue( + job.json(), + mongodb_uri, + app_for_reactive.name, + ) + + await reconcile(app_for_reactive, app_for_reactive.model) + + run.update() + assert run.status == "queued" + run.cancel() + + _assert_queue_has_size(mongodb_uri, app_for_reactive.name, 1) + + +async def _get_mongodb_uri(ops_test: OpsTest, app_for_reactive: Application) -> str: + """Get the mongodb uri. + + Args: + ops_test: The ops_test plugin. + app_for_reactive: The juju application containing the unit. + + Returns: + The mongodb uri. + + """ + mongodb_uri = await _get_mongodb_uri_from_integration_data(ops_test, app_for_reactive.units[0]) + if not mongodb_uri: + mongodb_uri = await _get_mongodb_uri_from_secrets(ops_test, app_for_reactive.model) + assert mongodb_uri, "mongodb uri not found in integration data or secret" + return mongodb_uri + + async def _get_mongodb_uri_from_integration_data(ops_test: OpsTest, unit: Unit) -> str | None: """Get the mongodb uri from the relation data. @@ -136,6 +205,18 @@ def _add_to_queue(msg: str, mongodb_uri: str, queue_name: str) -> None: simple_queue.put(msg, retry=True) +def _clear_queue(mongodb_uri: str, queue_name: str) -> None: + """Clear the queue. + + Args: + mongodb_uri: The mongodb uri. + queue_name: The name of the queue to clear. + """ + with Connection(mongodb_uri) as conn: + with conn.SimpleQueue(queue_name) as simple_queue: + simple_queue.clear() + + def _assert_queue_is_empty(mongodb_uri: str, queue_name: str): """Assert that the queue is empty. @@ -143,6 +224,17 @@ def _assert_queue_is_empty(mongodb_uri: str, queue_name: str): mongodb_uri: The mongodb uri. queue_name: The name of the queue to check. """ + _assert_queue_has_size(mongodb_uri, queue_name, 0) + + +def _assert_queue_has_size(mongodb_uri: str, queue_name: str, size: int): + """Assert that the queue is empty. + + Args: + mongodb_uri: The mongodb uri. + queue_name: The name of the queue to check. + size: The expected size of the queue. + """ with Connection(mongodb_uri) as conn: with conn.SimpleQueue(queue_name) as simple_queue: - assert simple_queue.qsize() == 0 + assert simple_queue.qsize() == size