diff --git a/.github/workflows/e2e_test.yaml b/.github/workflows/e2e_test.yaml index fc49c266a..0001e09d8 100644 --- a/.github/workflows/e2e_test.yaml +++ b/.github/workflows/e2e_test.yaml @@ -17,7 +17,7 @@ jobs: with: juju-channel: 3.6/stable provider: lxd - test-tox-env: integration-juju3.6 + test-tox-env: integration modules: '["test_e2e"]' # INTEGRATION_TOKEN, OS_PASSWORD, GITHUB_APP_INSTALLATION_ID, # GITHUB_APP_PRIVATE_KEY are passed through diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 51f6a69e5..1c6ad3eb2 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -19,7 +19,7 @@ jobs: with: juju-channel: 3.6/stable provider: lxd - test-tox-env: integration-juju3.6 + test-tox-env: integration modules: '["test_multi_unit_same_machine", "test_charm_fork_path_change", "test_charm_no_runner", "test_charm_upgrade"]' # INTEGRATION_TOKEN, INTEGRATION_TOKEN_ALT, OS_* are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_ # mapping. See CONTRIBUTING.md for more details. @@ -47,7 +47,7 @@ jobs: juju-channel: 3.6/stable pre-run-script: tests/integration/setup-integration-tests.sh provider: lxd - test-tox-env: integration-juju3.6 + test-tox-env: integration modules: '["test_prometheus_metrics"]' # INTEGRATION_TOKEN, INTEGRATION_TOKEN_ALT, OS_* are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_ # mapping. See CONTRIBUTING.md for more details. @@ -77,7 +77,7 @@ jobs: charmcraft-channel: latest/stable juju-channel: 3.6/stable provider: lxd - test-tox-env: integration-juju3.6 + test-tox-env: integration modules: '["test_charm_runner"]' extra-arguments: | -m=openstack \ diff --git a/tests/conftest.py b/tests/conftest.py index 70e77a3aa..cdfa69112 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import os +import pytest from pytest import Parser @@ -184,3 +185,41 @@ def pytest_addoption(parser: Parser): help="The GitHub App PEM-encoded private key for GitHub App authentication testing.", default=os.environ.get("GITHUB_APP_PRIVATE_KEY"), ) + parser.addoption( + "--keep-models", + action="store_true", + default=False, + help="Keep temporary Juju models after test runs.", + ) + + +def pytest_configure(config: pytest.Config): + """Register custom markers. + + Args: + config: The pytest Config object. + """ + config.addinivalue_line("markers", "abort_on_fail: skip remaining tests after a failure") + + +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): + """Record abort_on_fail failures so subsequent tests in the module are skipped. + + Args: + item: The test item. + call: The test call info. + """ + if call.when == "call" and call.excinfo is not None: + if any(item.iter_markers("abort_on_fail")): + item.session._abort_on_fail_module = item.module # type: ignore[attr-defined] + + +def pytest_runtest_setup(item: pytest.Item): + """Skip tests if a prior abort_on_fail test in the same module failed. + + Args: + item: The test item. + """ + failed_mod = getattr(item.session, "_abort_on_fail_module", None) + if failed_mod is not None and getattr(item, "module", None) is failed_mod: + pytest.skip("skipped: prior abort_on_fail test failed") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0246b3dd7..94f1127fe 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,24 +13,18 @@ from dataclasses import dataclass from pathlib import Path from time import sleep -from typing import Any, AsyncGenerator, AsyncIterator, Generator, Iterator, Optional, cast +from typing import Any, Generator, Iterator, Optional, cast import jubilant -import nest_asyncio import openstack import pytest -import pytest_asyncio import yaml from git import Repo from github import Github, GithubException from github.Auth import Token from github.Branch import Branch from github.Repository import Repository -from juju.application import Application -from juju.client._definitions import FullStatus, UnitStatus -from juju.model import Model from openstack.connection import Connection -from pytest_operator.plugin import OpsTest from charm_state import ( APROXY_REDIRECT_PORTS_CONFIG_NAME, @@ -51,16 +45,11 @@ wait_for_runner_ready, ) from tests.integration.helpers.openstack import OpenStackInstanceHelper -from tests.status_name import ACTIVE DEFAULT_RECONCILE_INTERVAL = 2 IMAGE_BUILDER_INTEGRATION_TIMEOUT_IN_SECONDS = 30 * 60 -# The following line is required because we are using request.getfixturevalue in conjunction -# with pytest-asyncio. See https://github.com/pytest-dev/pytest-asyncio/issues/112 -nest_asyncio.apply() - @dataclass class GitHubConfig: @@ -213,7 +202,8 @@ def resolve_series(base_token: str) -> tuple[str, str]: mapped = BASE_SERIES_MAP.get(base_token) if not mapped: raise ValueError( - f"Unknown base token '{base_token}'. Please update BASE_SERIES_MAP to include the corresponding series." + f"Unknown base token '{base_token}'. Please update BASE_SERIES_MAP to include" + " the corresponding series." ) return mapped @@ -254,7 +244,8 @@ def selected_artifact( if art.base_token == cli_base_option: return art raise ValueError( - "No charm artifact found matching the specified base token. Please check your --charm-file options." + "No charm artifact found matching the specified base token. Please check your" + " --charm-file options." ) @@ -430,7 +421,8 @@ def dockerhub_mirror(pytestconfig: pytest.Config) -> Optional[str]: def openstack_connection_fixture( openstack_config: OpenStackConfig, app_name: str, - existing_app_suffix: str, + existing_app_suffix: Optional[str], + juju: jubilant.Juju, request: pytest.FixtureRequest, ) -> Generator[Connection, None, None]: """The openstack connection instance.""" @@ -450,9 +442,6 @@ def openstack_connection_fixture( logging.info("Server %s console log:\n%s", server.name, console_log) if not existing_app_suffix: - # servers, keys, security groups, security rules, images are created by the charm. - # don't remove security groups & rules since they are single instances. - # don't remove images since it will be moved to image-builder for server in servers: server_name: str = server.name if server_name.startswith(app_name): @@ -463,38 +452,51 @@ def openstack_connection_fixture( connection.delete_keypair(key_name) -@pytest_asyncio.fixture(scope="module") -async def model(ops_test: OpsTest, proxy_config: ProxyConfig) -> Model: - """Juju model used in the test.""" - assert ops_test.model is not None - await ops_test.model.set_config( - { - "juju-http-proxy": proxy_config.http_proxy, - "juju-https-proxy": proxy_config.https_proxy, - "juju-no-proxy": proxy_config.no_proxy, - } - ) - return ops_test.model +@pytest.fixture(scope="module") +def juju( + request: pytest.FixtureRequest, + proxy_config: ProxyConfig, +) -> Generator[jubilant.Juju, None, None]: + """Pytest fixture that creates a temporary Juju model for integration tests.""" + keep_models = cast(bool, request.config.getoption("--keep-models")) + with jubilant.temp_model(keep=keep_models) as j: + j.wait_timeout = 20 * 60 + j.model_config( + values={ + "juju-http-proxy": proxy_config.http_proxy, + "juju-https-proxy": proxy_config.https_proxy, + "juju-no-proxy": proxy_config.no_proxy, + "logging-config": "=INFO;unit=INFO", + } + ) + yield j + if request.session.testsfailed: + log = j.debug_log(limit=1000) + print(log, end="") -@pytest_asyncio.fixture(scope="module") -async def app_no_runner( - model: Model, - basic_app: Application, -) -> AsyncIterator[Application]: +@pytest.fixture(scope="module") +def app_no_runner( + juju: jubilant.Juju, + basic_app: str, +) -> Iterator[str]: """Application with no runner.""" - await basic_app.set_config({BASE_VIRTUAL_MACHINES_CONFIG_NAME: "0"}) - await model.wait_for_idle(apps=[basic_app.name], status=ACTIVE, timeout=20 * 60) + juju.config(basic_app, values={BASE_VIRTUAL_MACHINES_CONFIG_NAME: "0"}) + juju.wait( + lambda status: jubilant.all_active(status, basic_app), + timeout=20 * 60, + ) yield basic_app -@pytest_asyncio.fixture(scope="module") -async def openstack_model_proxy( +@pytest.fixture(scope="module") +def openstack_model_proxy( openstack_config: OpenStackConfig, - model: Model, + juju: jubilant.Juju, ) -> None: - await model.set_config( - { + """Set model proxy config for OpenStack environments.""" + juju.model_config( + values={ "juju-http-proxy": openstack_config.http_proxy, "juju-https-proxy": openstack_config.https_proxy, "juju-no-proxy": openstack_config.no_proxy, @@ -503,10 +505,10 @@ async def openstack_model_proxy( ) -@pytest_asyncio.fixture(scope="module", name="image_builder_config") -async def image_builder_config_fixture( +@pytest.fixture(scope="module", name="image_builder_config") +def image_builder_config_fixture( openstack_config: OpenStackConfig, -): +) -> dict: """The image builder application default for OpenStack runners.""" return { "build-interval": "12", @@ -524,16 +526,16 @@ async def image_builder_config_fixture( } -@pytest_asyncio.fixture(scope="module", name="image_builder") -async def image_builder_fixture( - model: Model, +@pytest.fixture(scope="module", name="image_builder") +def image_builder_fixture( + juju: jubilant.Juju, existing_app_suffix: Optional[str], image_builder_app_name: str, openstack_config: OpenStackConfig, image_builder_config: dict, - openstack_connection, + openstack_connection: Connection, request: pytest.FixtureRequest, -): +) -> Iterator[str]: """The image builder application for OpenStack runners. If openstack_config.test_image_id is provided, uses any-charm to mock the image relation. @@ -541,27 +543,28 @@ async def image_builder_fixture( """ if existing_app_suffix: logging.info("Using existing image builder %s", image_builder_app_name) - yield model.applications[image_builder_app_name] + yield image_builder_app_name return if not openstack_config.test_image_id: logging.info("Deploying image builder %s", image_builder_app_name) - # Deploy the real github-runner-image-builder - yield await model.deploy( + juju.deploy( "github-runner-image-builder", - application_name=image_builder_app_name, + app=image_builder_app_name, channel="latest/edge", config=image_builder_config, constraints={ - "root-disk": 20 * 1024, - "mem": 2 * 1024, + "root-disk": "20480M", + "mem": "2048M", # 2025-11-26: Set deployment type to virtual-machine due to bug with snapd. See: # https://github.com/canonical/snapd/pull/16131 "virt-type": "virtual-machine", - "cores": 2, + "cores": "2", }, ) + yield image_builder_app_name + # The github-image-builder does not clean keypairs. Until it does, # we clean them manually here. logging.info("Cleaning up image builder resources...") @@ -573,7 +576,6 @@ async def image_builder_fixture( return # Use any-charm to mock the image relation provider - # Determine series based on selected deployment context dep_ctx: DeploymentContext = request.getfixturevalue("deployment_context") series = dep_ctx.series @@ -597,32 +599,33 @@ def _image_relation_changed(self, event): "Deploying fake image builder via any-charm for image ID %s", openstack_config.test_image_id, ) - yield await model.deploy( + juju.deploy( "any-charm", - application_name=image_builder_app_name, + app=image_builder_app_name, channel="latest/beta", config={"src-overwrite": json.dumps(any_charm_src_overwrite)}, ) + yield image_builder_app_name -@pytest_asyncio.fixture(scope="module", name="app_openstack_runner") -async def app_openstack_runner_fixture( - model: Model, +@pytest.fixture(scope="module", name="app_openstack_runner") +def app_openstack_runner_fixture( + juju: jubilant.Juju, deployment_context: DeploymentContext, app_name: str, github_config: GitHubConfig, openstack_config: OpenStackConfig, existing_app_suffix: Optional[str], - image_builder: Application, + image_builder: str, dockerhub_mirror: Optional[str], request: pytest.FixtureRequest, -) -> AsyncIterator[Application]: +) -> Iterator[str]: """Application launching VMs and no runners.""" if existing_app_suffix: - application = model.applications[app_name] + application_name = app_name else: - application = await deploy_github_runner_charm( - model=model, + application_name = deploy_github_runner_charm( + juju=juju, charm_file=deployment_context.charm_path, app_name=app_name, github_config=github_config, @@ -632,7 +635,11 @@ async def app_openstack_runner_fixture( no_proxy=openstack_config.no_proxy, ), reconcile_interval=DEFAULT_RECONCILE_INTERVAL, - constraints={"root-disk": 50 * 1024, "mem": 2 * 1024, "virt-type": "virtual-machine"}, + constraints={ + "root-disk": "51200M", + "mem": "2048M", + "virt-type": "virtual-machine", + }, config={ OPENSTACK_CLOUDS_YAML_CONFIG_NAME: openstack_config.clouds_yaml_contents, OPENSTACK_NETWORK_CONFIG_NAME: openstack_config.network_name, @@ -643,115 +650,112 @@ async def app_openstack_runner_fixture( **({DOCKERHUB_MIRROR_CONFIG_NAME: dockerhub_mirror} if dockerhub_mirror else {}), }, base=deployment_context.base, - series=deployment_context.series, wait_idle=False, ) - await model.integrate(image_builder.name, f"{application.name}:image") - await model.wait_for_idle( - apps=[application.name, image_builder.name], - status=ACTIVE, + juju.integrate(image_builder, f"{application_name}:image") + juju.wait( + lambda status: jubilant.all_active(status, application_name, image_builder), timeout=IMAGE_BUILDER_INTEGRATION_TIMEOUT_IN_SECONDS, ) - yield application + yield application_name if request.session.testsfailed: try: - app_log = await get_github_runner_manager_service_log(unit=application.units[0]) + unit_name = f"{application_name}/0" + app_log = get_github_runner_manager_service_log(juju=juju, unit_name=unit_name) logging.info("Application log: \n%s", app_log) - metrics_log = await get_github_runner_metrics_log(unit=application.units[0]) + metrics_log = get_github_runner_metrics_log(juju=juju, unit_name=unit_name) logging.info("Metrics log: \n%s", metrics_log) except AssertionError: logging.warning("Failed to get application log.", exc_info=True) -@pytest_asyncio.fixture(scope="module", name="app_scheduled_events") -async def app_scheduled_events_fixture( - model: Model, - app_openstack_runner, -): +@pytest.fixture(scope="module", name="app_scheduled_events") +def app_scheduled_events_fixture( + juju: jubilant.Juju, + app_openstack_runner: str, +) -> str: """Application to check scheduled events.""" - application = app_openstack_runner - await application.set_config({"reconcile-interval": "8"}) - await application.set_config({BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1"}) - await model.wait_for_idle(apps=[application.name], status=ACTIVE, timeout=20 * 60) - await wait_for_runner_ready(app=application) - return application + juju.config(app_openstack_runner, values={"reconcile-interval": "8"}) + juju.config(app_openstack_runner, values={BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1"}) + juju.wait( + lambda status: jubilant.all_active(status, app_openstack_runner), + timeout=20 * 60, + ) + wait_for_runner_ready(juju, app_openstack_runner) + return app_openstack_runner -@pytest_asyncio.fixture(scope="module") -async def app_runner( - model: Model, +@pytest.fixture(scope="module") +def app_runner( + juju: jubilant.Juju, deployment_context: DeploymentContext, app_name: str, github_config: GitHubConfig, proxy_config: ProxyConfig, -) -> AsyncIterator[Application]: +) -> str: """Application to test runners.""" # Use a different app_name so workflows can select runners from this deployment. - application = await deploy_github_runner_charm( - model=model, + return deploy_github_runner_charm( + juju=juju, charm_file=deployment_context.charm_path, app_name=f"{app_name}-test", github_config=github_config, proxy_config=proxy_config, reconcile_interval=1, base=deployment_context.base, - series=deployment_context.series, ) - return application -@pytest_asyncio.fixture(scope="module", name="app_no_wait") -async def app_no_wait_fixture( - model: Model, +@pytest.fixture(scope="module", name="app_no_wait") +def app_no_wait_fixture( + juju: jubilant.Juju, deployment_context: DeploymentContext, app_name: str, github_config: GitHubConfig, proxy_config: ProxyConfig, -) -> AsyncIterator[Application]: +) -> str: """Github runner charm application without waiting for active.""" - app: Application = await deploy_github_runner_charm( - model=model, + deployed_name = deploy_github_runner_charm( + juju=juju, charm_file=deployment_context.charm_path, app_name=app_name, github_config=github_config, proxy_config=proxy_config, reconcile_interval=1, base=deployment_context.base, - series=deployment_context.series, wait_idle=False, ) - await app.set_config({BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1"}) - return app + juju.config(deployed_name, values={BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1"}) + return deployed_name -@pytest_asyncio.fixture(scope="module", name="tmate_ssh_server_app") -async def tmate_ssh_server_app_fixture(model: Model) -> AsyncIterator[Application]: +@pytest.fixture(scope="module", name="tmate_ssh_server_app") +def tmate_ssh_server_app_fixture(juju: jubilant.Juju) -> str: """tmate-ssh-server charm application related to GitHub-Runner app charm.""" - tmate_app: Application = await model.deploy( - "tmate-ssh-server", + tmate_app_name = "tmate-ssh-server" + juju.deploy( + tmate_app_name, channel="edge", # 2025-11-26: Set deployment type to virtual-machine due to bug with snapd. See: # https://github.com/canonical/snapd/pull/16131 constraints={"virt-type": "virtual-machine"}, ) - return tmate_app + return tmate_app_name -@pytest_asyncio.fixture(scope="module", name="tmate_ssh_server_unit_ip") -async def tmate_ssh_server_unit_ip_fixture( - model: Model, - tmate_ssh_server_app: Application, -) -> bytes | str: +@pytest.fixture(scope="module", name="tmate_ssh_server_unit_ip") +def tmate_ssh_server_unit_ip_fixture( + juju: jubilant.Juju, + tmate_ssh_server_app: str, +) -> str: """tmate-ssh-server charm unit ip.""" - app_name = tmate_ssh_server_app.name - status: FullStatus = await model.get_status([app_name]) - app_status = status.applications[app_name] - assert app_status is not None, f"Application {app_name} not found in status" + status = juju.status() + app_status = status.apps.get(tmate_ssh_server_app) + assert app_status is not None, f"Application {tmate_ssh_server_app} not found in status" try: - # mypy does not recognize that app_status is of type ApplicationStatus - unit_status: UnitStatus = next(iter(app_status.units.values())) # type: ignore + unit_status = next(iter(app_status.units.values())) assert unit_status.public_address, "Invalid unit address" return unit_status.public_address except StopIteration as exc: @@ -829,22 +833,22 @@ def forked_github_branch( branch_ref.delete() -@pytest_asyncio.fixture(scope="module") -async def app_with_forked_repo( - model: Model, basic_app: Application, forked_github_repository: Repository -) -> Application: +@pytest.fixture(scope="module") +def app_with_forked_repo( + juju: jubilant.Juju, basic_app: str, forked_github_repository: Repository +) -> str: """Application with no runner on a forked repo. Test should ensure it returns with the application in a good state and has one runner. """ - await basic_app.set_config({PATH_CONFIG_NAME: forked_github_repository.full_name}) + juju.config(basic_app, values={PATH_CONFIG_NAME: forked_github_repository.full_name}) return basic_app -@pytest_asyncio.fixture(scope="module", name="test_github_branch") -async def test_github_branch_fixture(github_repository: Repository) -> AsyncIterator[Branch]: +@pytest.fixture(scope="module", name="test_github_branch") +def test_github_branch_fixture(github_repository: Repository) -> Iterator[Branch]: """Create a new branch for testing, from latest commit in current branch.""" test_branch = f"test-{secrets.token_hex(4)}" branch_ref = github_repository.create_git_ref( @@ -869,59 +873,27 @@ def get_branch(): raise return branch - await wait_for(get_branch) + wait_for(get_branch) yield get_branch() branch_ref.delete() -@pytest_asyncio.fixture(scope="module", name="basic_app") -async def basic_app_fixture(request: pytest.FixtureRequest) -> Application: +@pytest.fixture(scope="module", name="basic_app") +def basic_app_fixture(request: pytest.FixtureRequest) -> str: """Setup the charm with the basic configuration.""" return request.getfixturevalue("app_openstack_runner") -@pytest_asyncio.fixture(scope="function", name="instance_helper") -async def instance_helper_fixture(request: pytest.FixtureRequest) -> OpenStackInstanceHelper: +@pytest.fixture(scope="function", name="instance_helper") +def instance_helper_fixture( + request: pytest.FixtureRequest, + juju: jubilant.Juju, +) -> OpenStackInstanceHelper: """Instance helper fixture.""" openstack_connection = request.getfixturevalue("openstack_connection") - return OpenStackInstanceHelper(openstack_connection=openstack_connection) - - -@pytest_asyncio.fixture(scope="module") -async def juju( - request: pytest.FixtureRequest, model: Model -) -> AsyncGenerator[jubilant.Juju, None]: - """Pytest fixture that wraps :meth:`jubilant.with_model`.""" - - def show_debug_log(juju: jubilant.Juju): - """Show debug log if tests failed. - - Args: - juju: The jubilant.Juju instance. - """ - if request.session.testsfailed: - log = juju.debug_log(limit=1000) - print(log, end="") - - controller = await model.get_controller() - if model: - # Currently juju has no way of switching controller context, this is required to operate - # in the right controller's right model when using multiple controllers. - # See: https://github.com/canonical/jubilant/issues/158 - juju = jubilant.Juju(model=f"{controller.controller_name}:{model.name}") - yield juju - show_debug_log(juju) - return - - keep_models = cast(bool, request.config.getoption("--keep-models")) - with jubilant.temp_model(keep=keep_models, controller=controller.controller_name) as juju: - juju.model = f"{controller.controller_name}:{juju.model}" - juju.wait_timeout = 10 * 60 - yield juju - show_debug_log(juju) - return + return OpenStackInstanceHelper(openstack_connection=openstack_connection, juju=juju) @pytest.fixture(scope="module") @@ -930,16 +902,19 @@ def planner_token_secret_name() -> str: return "planner-token-secret" -@pytest_asyncio.fixture(scope="module") -async def planner_token_secret(model: Model, planner_token_secret_name: str) -> str: +@pytest.fixture(scope="module") +def planner_token_secret(juju: jubilant.Juju, planner_token_secret_name: str) -> str: """Create a planner token secret.""" - return await model.add_secret( - name=planner_token_secret_name, data_args=["token=MOCK_PLANNER_TOKEN"] + return str( + juju.add_secret( + name=planner_token_secret_name, + content={"token": "MOCK_PLANNER_TOKEN"}, + ) ) -@pytest_asyncio.fixture(scope="module") -async def mock_planner_app(model: Model, planner_token_secret) -> AsyncIterator[Application]: +@pytest.fixture(scope="module") +def mock_planner_app(juju: jubilant.Juju, planner_token_secret: str) -> Iterator[str]: """Deploy a minimal any-charm that acts as the requires side of the planner relation.""" planner_name = "planner" @@ -961,12 +936,15 @@ def _on_planner_relation_changed(self, event): """), } - planner_app: Application = await model.deploy( + juju.deploy( "any-charm", - planner_name, + app=planner_name, channel="latest/beta", config={"src-overwrite": json.dumps(any_charm_src_overwrite)}, ) - await model.wait_for_idle(apps=[planner_app.name], status=ACTIVE, timeout=10 * 60) - yield planner_app + juju.wait( + lambda status: jubilant.all_active(status, planner_name), + timeout=10 * 60, + ) + yield planner_name diff --git a/tests/integration/helpers/common.py b/tests/integration/helpers/common.py index 53629e93c..2f1550401 100644 --- a/tests/integration/helpers/common.py +++ b/tests/integration/helpers/common.py @@ -3,17 +3,15 @@ """Utilities for integration test.""" -import inspect import logging import pathlib import time -import typing -from asyncio import sleep from datetime import datetime, timezone from functools import partial -from typing import TYPE_CHECKING, Awaitable, Callable, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Callable, Generator, TypeVar, cast import github +import jubilant import requests from github.Branch import Branch from github.Repository import Repository @@ -21,10 +19,6 @@ from github.WorkflowJob import WorkflowJob from github.WorkflowRun import WorkflowRun from github_runner_manager.metrics.events import get_metrics_log_path -from juju.action import Action -from juju.application import Application -from juju.model import Model -from juju.unit import Unit from charm import UPGRADE_MSG from charm_state import ( @@ -38,7 +32,6 @@ TOKEN_CONFIG_NAME, ) from manager_service import _get_log_file_path -from tests.status_name import ACTIVE DISPATCH_TEST_WORKFLOW_FILENAME = "workflow_dispatch_test.yaml" DISPATCH_CRASH_TEST_WORKFLOW_FILENAME = "workflow_dispatch_crash_test.yaml" @@ -49,22 +42,30 @@ # 2025-11-26: Set deployment type to virtual-machine due to bug with snapd. See: # https://github.com/canonical/snapd/pull/16131 -DEFAULT_RUNNER_CONSTRAINTS = {"root-disk": 20 * 1024, "virt-type": "virtual-machine"} +DEFAULT_RUNNER_CONSTRAINTS = { + "root-disk": "20480M", + "virt-type": "virtual-machine", +} logger = logging.getLogger(__name__) if TYPE_CHECKING: - # Import only for type checking to avoid pytest fixture side effects at runtime from tests.integration.conftest import GitHubConfig, ProxyConfig -async def run_in_unit( - unit: Unit, command: str, timeout=None, assert_on_failure=False, assert_msg="" -) -> tuple[int, str | None, str | None]: +def run_in_unit( + juju: jubilant.Juju, + unit_name: str, + command: str, + timeout: float | None = None, + assert_on_failure: bool = False, + assert_msg: str = "", +) -> tuple[int, str, str]: """Run command in juju unit. Args: - unit: Juju unit to execute the command in. + juju: Jubilant Juju instance. + unit_name: Name of the unit (e.g. "app/0"). command: Command to execute. timeout: Amount of time to wait for the execution. assert_on_failure: Whether to assert on command failure. @@ -73,14 +74,11 @@ async def run_in_unit( Returns: Tuple of return code, stdout and stderr. """ - action: Action = await unit.run(command, timeout) - - await action.wait() - code, stdout, stderr = ( - action.results["return-code"], - action.results.get("stdout", None), - action.results.get("stderr", None), - ) + try: + task = juju.exec(command, unit=unit_name, wait=timeout) + code, stdout, stderr = task.return_code, task.stdout, task.stderr + except jubilant.TaskError as e: + code, stdout, stderr = e.task.return_code, e.task.stdout, e.task.stderr if assert_on_failure: assert code == 0, f"{assert_msg}: {stderr}" @@ -88,29 +86,33 @@ async def run_in_unit( return code, stdout, stderr -async def wait_for_runner_ready(app: Application) -> None: - """Wait until a runner is ready. - - Uses the first unit found in the application. +def wait_for_runner_ready(juju: jubilant.Juju, app_name: str, num_runners: int = 1) -> None: + """Wait until the expected number of runners are online. Args: - app: The GitHub Runner Charm application. + juju: Jubilant Juju instance. + app_name: The GitHub Runner Charm application name. + num_runners: The minimum number of runners expected online. """ - # Wait for 10 minutes for the runner to come online. - for _ in range(20): - action = await app.units[0].run_action("check-runners") - await action.wait() - - if action.status == "completed" and int(action.results["online"]) >= 1: + unit_name = f"{app_name}/0" + for attempt in range(20): + try: + result = juju.run(unit_name, "check-runners") + except (jubilant.CLIError, TimeoutError): + logger.info("check-runners failed (attempt %d), retrying...", attempt) + time.sleep(30) + continue + + if result.status == "completed" and int(result.results["online"]) >= num_runners: break - await sleep(30) + time.sleep(30) else: - assert False, "Timeout waiting for runner to be ready" + assert False, f"Timeout waiting for {num_runners} runner(s) to be ready" -async def deploy_github_runner_charm( - model: Model, +def deploy_github_runner_charm( + juju: jubilant.Juju, charm_file: str, app_name: str, github_config: "GitHubConfig", @@ -121,12 +123,11 @@ async def deploy_github_runner_charm( deploy_kwargs: dict | None = None, wait_idle: bool = True, base: str = "ubuntu@22.04", - series: str = "jammy", -) -> Application: +) -> str: """Deploy github-runner charm. Args: - model: Model to deploy the charm. + juju: Jubilant Juju instance. charm_file: Path of the charm file to deploy. app_name: Application name for the deployment. github_config: Object providing GitHub settings with attributes `path` and `token`. @@ -139,13 +140,12 @@ async def deploy_github_runner_charm( deploy_kwargs: Additional model deploy arguments. wait_idle: wait for model to become idle. base: Charm base to deploy on (e.g., ubuntu@22.04). - series: Ubuntu series corresponding to the base (e.g., jammy). Returns: - The charm application that was deployed. + The application name that was deployed. """ - await model.set_config( - { + juju.model_config( + values={ "juju-http-proxy": proxy_config.http_proxy, "juju-https-proxy": proxy_config.https_proxy, "juju-no-proxy": proxy_config.no_proxy, @@ -153,7 +153,7 @@ async def deploy_github_runner_charm( } ) - default_config = { + default_config: dict[str, str | int | bool] = { PATH_CONFIG_NAME: github_config.path, BASE_VIRTUAL_MACHINES_CONFIG_NAME: 0, TEST_MODE_CONFIG_NAME: "insecure", @@ -162,37 +162,42 @@ async def deploy_github_runner_charm( secret_name = None if github_config.has_app_auth: + assert github_config.app_client_id is not None + assert github_config.installation_id is not None + assert github_config.private_key is not None secret_name = f"{app_name}-gh-app-key" - secret_id = await model.add_secret( + secret_id = juju.add_secret( name=secret_name, - data_args=[f"private-key={github_config.private_key}"], + content={"private-key": github_config.private_key}, ) default_config[GITHUB_APP_CLIENT_ID_CONFIG_NAME] = github_config.app_client_id default_config[GITHUB_APP_INSTALLATION_ID_CONFIG_NAME] = github_config.installation_id - default_config[GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME] = secret_id + default_config[GITHUB_APP_PRIVATE_KEY_SECRET_ID_CONFIG_NAME] = str(secret_id) else: default_config[TOKEN_CONFIG_NAME] = github_config.token if config: default_config.update(config) - application = await model.deploy( + juju.deploy( charm_file, - application_name=app_name, + app=app_name, base=base, - series=series, config=default_config, constraints=constraints or DEFAULT_RUNNER_CONSTRAINTS, **(deploy_kwargs or {}), ) if secret_name: - await model.grant_secret(secret_name, app_name) + juju.grant_secret(secret_name, app_name) if wait_idle: - await model.wait_for_idle(status=ACTIVE, timeout=60 * 20) + juju.wait( + lambda status: jubilant.all_active(status, app_name), + timeout=60 * 20, + ) - return application + return app_name def get_job_logs(job: WorkflowJob) -> str: @@ -214,7 +219,7 @@ def get_workflow_runs( workflow: Workflow, runner_name: str, branch: Branch | None = None, -) -> typing.Generator[WorkflowRun, None, None]: +) -> Generator[WorkflowRun, None, None]: """Fetch the latest matching runs of a workflow for a given runner. Args: @@ -286,8 +291,8 @@ def _has_workflow_run_status(run: WorkflowRun, status: str) -> bool: return False -async def dispatch_workflow( - app: Application | None, +def dispatch_workflow( + app_name: str | None, branch: Branch, github_repository: Repository, conclusion: str, @@ -300,7 +305,7 @@ async def dispatch_workflow( The function assumes that there is only one runner running in the unit. Args: - app: The charm to dispatch the workflow for. + app_name: The charm application name to dispatch the workflow for. branch: The branch to dispatch the workflow on. github_repository: The github repository to dispatch the workflow on. conclusion: The expected workflow run conclusion. @@ -314,8 +319,8 @@ async def dispatch_workflow( The workflow run. """ if dispatch_input is None: - assert app is not None, "If dispatch input not given the app cannot be None." - dispatch_input = {"runner": app.name} + assert app_name is not None, "If dispatch input not given the app_name cannot be None." + dispatch_input = {"runner": app_name} start_time = datetime.now(timezone.utc) @@ -325,7 +330,7 @@ async def dispatch_workflow( assert workflow.create_dispatch(branch, dispatch_input), "Failed to create workflow" # There is a very small chance of selecting a run not created by the dispatch above. - run: WorkflowRun | None = await wait_for( + run: WorkflowRun | None = wait_for( partial(_get_latest_run, workflow=workflow, start_time=start_time, branch=branch), timeout=10 * 60, ) @@ -333,33 +338,33 @@ async def dispatch_workflow( if not wait: return run - await wait_for_completion(run=run, conclusion=conclusion) + wait_for_completion(run=run, conclusion=conclusion) return run -async def wait_for_status(run: WorkflowRun, status: str) -> None: +def wait_for_status(run: WorkflowRun, status: str) -> None: """Wait for the workflow run to start. Args: run: The workflow run to wait for. status: The expected status of the run. """ - await wait_for( + wait_for( partial(_has_workflow_run_status, run=run, status=status), timeout=60 * 5, check_interval=10, ) -async def wait_for_completion(run: WorkflowRun, conclusion: str) -> None: +def wait_for_completion(run: WorkflowRun, conclusion: str) -> None: """Wait for the workflow run to complete. Args: run: The workflow run to wait for. conclusion: The expected conclusion of the run. """ - await wait_for( + wait_for( partial(_is_workflow_run_complete, run=run), timeout=60 * 30, check_interval=60, @@ -370,13 +375,11 @@ async def wait_for_completion(run: WorkflowRun, conclusion: str) -> None: ), f"Unexpected run conclusion, expected: {conclusion}, got: {run.conclusion}" -P = ParamSpec("P") R = TypeVar("R") -S = Callable[P, R] | Callable[P, Awaitable[R]] -async def wait_for( - func: S, +def wait_for( + func: Callable[[], R], timeout: int | float = 300, check_interval: int = 10, ) -> R: @@ -393,79 +396,78 @@ async def wait_for( Returns: The result of the function if any. """ - - async def _call() -> R: - """Await the function if it returns an awaitable, otherwise cast and return.""" - result = func() - if inspect.isawaitable(result): - return await cast(Awaitable, result) - return cast(R, result) - deadline = time.time() + timeout while time.time() < deadline: - if result := await _call(): + result = func() + if cast(bool, result): return result logger.info("Wait for condition not met, sleeping %s", check_interval) time.sleep(check_interval) # final check before raising TimeoutError. - if result := await _call(): + result = func() + if cast(bool, result): return result raise TimeoutError() -async def is_upgrade_charm_event_emitted(unit: Unit) -> bool: +def is_upgrade_charm_event_emitted(juju: jubilant.Juju, unit_name: str) -> bool: """Check if the upgrade_charm event is emitted. This is to ensure false positives from only waiting for ACTIVE status. Args: - unit: The unit to check for upgrade charm event. + juju: Jubilant Juju instance. + unit_name: The unit name to check for upgrade charm event. Returns: bool: True if the event is emitted, False otherwise. """ - unit_name_without_slash = unit.name.replace("/", "-") + unit_name_without_slash = unit_name.replace("/", "-") juju_unit_log_file = f"/var/log/juju/unit-{unit_name_without_slash}.log" - ret_code, stdout, stderr = await run_in_unit( - unit=unit, command=f"cat {juju_unit_log_file} | grep '{UPGRADE_MSG}'" + ret_code, stdout, stderr = run_in_unit( + juju=juju, unit_name=unit_name, command=f"cat {juju_unit_log_file} | grep '{UPGRADE_MSG}'" ) assert ret_code == 0, f"Failed to read the log file: {stderr}" return stdout is not None and UPGRADE_MSG in stdout -async def get_file_content(unit: Unit, filepath: pathlib.Path) -> str: +def get_file_content(juju: jubilant.Juju, unit_name: str, filepath: pathlib.Path) -> str: """Retrieve the file content in the unit. Args: - unit: The unit to retrieve the file content from. + juju: Jubilant Juju instance. + unit_name: The unit name to retrieve the file content from. filepath: The path of the file to retrieve. Returns: The file content """ - retcode, stdout, stderr = await run_in_unit( - unit=unit, + retcode, stdout, stderr = run_in_unit( + juju=juju, + unit_name=unit_name, command=f"if [ -f {filepath} ]; then cat {filepath}; else echo ''; fi", ) assert retcode == 0, f"Failed to get content of {filepath}: {stdout} {stderr}" - assert stdout is not None, f"Failed to get content of {filepath}, no stdout message" + assert stdout, f"Failed to get content of {filepath}, no stdout message" logging.info("File content of %s: %s", filepath, stdout) return stdout.strip() -async def get_github_runner_manager_service_log(unit: Unit) -> str: +def get_github_runner_manager_service_log(juju: jubilant.Juju, unit_name: str) -> str: """Get the logs of github-runner-manager service. Args: - unit: The unit to get the logs from. + juju: Jubilant Juju instance. + unit_name: The unit name to get the logs from. Returns: The logs. """ - log_file_path = _get_log_file_path(unit.name) - return_code, stdout, stderr = await run_in_unit( - unit, + log_file_path = _get_log_file_path(unit_name) + return_code, stdout, stderr = run_in_unit( + juju, + unit_name, f"cat {log_file_path}", timeout=60, assert_on_failure=True, @@ -473,22 +475,24 @@ async def get_github_runner_manager_service_log(unit: Unit) -> str: ) assert return_code == 0, f"Get log with cat {log_file_path} failed with: {stderr}" - assert stdout is not None + assert stdout return stdout -async def get_github_runner_metrics_log(unit: Unit) -> str: +def get_github_runner_metrics_log(juju: jubilant.Juju, unit_name: str) -> str: """Get the github-runner-manager metric logs. Args: - unit: The unit to get the logs from. + juju: Jubilant Juju instance. + unit_name: The unit name to get the logs from. Returns: Runner metrics logs. """ log_file_path = get_metrics_log_path() - _, stdout, stderr = await run_in_unit( - unit, + _, stdout, stderr = run_in_unit( + juju, + unit_name, f"cat {log_file_path}", timeout=60, assert_on_failure=False, diff --git a/tests/integration/helpers/openstack.py b/tests/integration/helpers/openstack.py index f6df71b27..cf6f4d19b 100644 --- a/tests/integration/helpers/openstack.py +++ b/tests/integration/helpers/openstack.py @@ -1,13 +1,12 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. import logging -from asyncio import sleep +import time from typing import TypedDict +import jubilant import openstack.connection from github_runner_manager import constants -from juju.application import Application -from juju.unit import Unit from openstack.compute.v2.server import Server from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME @@ -19,17 +18,23 @@ class OpenStackInstanceHelper: """Helper class to interact with OpenStack instances.""" - def __init__(self, openstack_connection: openstack.connection.Connection): + def __init__( + self, + openstack_connection: openstack.connection.Connection, + juju: jubilant.Juju, + ): """Initialize OpenStackInstanceHelper. Args: openstack_connection: OpenStack connection object. + juju: Jubilant Juju instance. """ self.openstack_connection = openstack_connection + self.juju = juju - async def expose_to_instance( + def expose_to_instance( self, - unit: Unit, + unit_name: str, port: int, host: str = "localhost", ) -> None: @@ -39,12 +44,12 @@ async def expose_to_instance( the runner. Args: - unit: The juju unit of the github-runner charm. + unit_name: The juju unit name of the github-runner charm. port: The port on the juju machine to expose to the runner. host: Host for the reverse tunnel. """ - runner = self.get_single_runner(unit=unit) - assert runner, f"Runner not found for unit {unit.name}" + runner = self.get_single_runner(unit_name=unit_name) + assert runner, f"Runner not found for unit {unit_name}" logger.info("[TEST SETUP] Exposing port %s on %s", port, runner.name) network_address_list = runner.addresses.values() logger.warning(network_address_list) @@ -60,38 +65,38 @@ async def expose_to_instance( assert ip, f"Failed to get IP address for OpenStack server {runner.name}" key_path = f"/home/{constants.RUNNER_MANAGER_USER}/.ssh/{runner.name}.key" - exit_code, _, _ = await run_in_unit(unit, f"ls {key_path}") + exit_code, _, _ = run_in_unit(self.juju, unit_name, f"ls {key_path}") assert exit_code == 0, f"Unable to find key file {key_path}" ssh_cmd = f'ssh -fNT -R {port}:{host}:{port} -i {key_path} -o "StrictHostKeyChecking no" -o "ControlPersist yes" ubuntu@{ip} &' logger.info("ssh tunnel command %s", ssh_cmd) - exit_code, stdout, stderr = await run_in_unit(unit, ssh_cmd) + exit_code, stdout, stderr = run_in_unit(self.juju, unit_name, ssh_cmd) logger.info("ssh tunnel result %s %s %s", exit_code, stdout, stderr) assert ( exit_code == 0 ), f"Error in starting background process of SSH remote forwarding of port {port}: {stderr}" - await sleep(1) + time.sleep(1) for _ in range(10): - exit_code, _, _ = await self.run_in_instance( - unit=unit, command=f"nc -z localhost {port}" + exit_code, _, _ = self.run_in_instance( + unit_name=unit_name, command=f"nc -z localhost {port}" ) if exit_code == 0: return - await sleep(10) + time.sleep(10) assert False, f"Exposing the port {port} failed" - async def run_in_instance( + def run_in_instance( self, - unit: Unit, + unit_name: str, command: str, - timeout: int | None = None, + timeout: float | None = None, assert_on_failure: bool = False, assert_msg: str | None = None, - ) -> tuple[int, str | None, str | None]: + ) -> tuple[int, str, str]: """Run command in OpenStack instance. Args: - unit: Juju unit to execute the command in. + unit_name: Juju unit name to execute the command in. command: Command to execute. timeout: Amount of time to wait for the execution. assert_on_failure: Perform assertion on non-zero exit code. @@ -100,8 +105,8 @@ async def run_in_instance( Returns: Tuple of return code, stdout and stderr. """ - runner = self.get_single_runner(unit=unit) - assert runner, f"Runner not found for unit {unit.name}" + runner = self.get_single_runner(unit_name=unit_name) + assert runner, f"Runner not found for unit {unit_name}" logger.info("[TEST SETUP] Run command %s on %s", command, runner.name) network_address_list = runner.addresses.values() logger.warning(network_address_list) @@ -117,12 +122,14 @@ async def run_in_instance( assert ip, f"Failed to get IP address for OpenStack server {runner.name}" key_path = f"/home/{constants.RUNNER_MANAGER_USER}/.ssh/{runner.name}.key" - exit_code, _, _ = await run_in_unit(unit, f"ls {key_path}") + exit_code, _, _ = run_in_unit(self.juju, unit_name, f"ls {key_path}") assert exit_code == 0, f"Unable to find key file {key_path}" ssh_cmd = f'ssh -i {key_path} -o "StrictHostKeyChecking no" ubuntu@{ip} {command}' ssh_cmd_as_ubuntu_user = f"su - ubuntu -c '{ssh_cmd}'" logging.warning("ssh_cmd: %s", ssh_cmd_as_ubuntu_user) - exit_code, stdout, stderr = await run_in_unit(unit, ssh_cmd, timeout) + exit_code, stdout, stderr = run_in_unit( + self.juju, unit_name, ssh_cmd_as_ubuntu_user, timeout + ) logger.info( "Run command '%s' in runner with result %s: '%s' '%s'", command, @@ -134,83 +141,84 @@ async def run_in_instance( assert exit_code == 0, assert_msg return exit_code, stdout, stderr - async def ensure_charm_has_runner(self, app: Application) -> None: + def ensure_charm_has_runner(self, app_name: str) -> None: """Reconcile the charm to contain one runner. Args: - app: The GitHub Runner Charm app to create the runner for. + app_name: The GitHub Runner Charm app name to create the runner for. """ - await OpenStackInstanceHelper.set_app_runner_amount(app, 1) + self.set_app_runner_amount(app_name, 1) - @staticmethod - async def set_app_runner_amount(app: Application, num_runners: int) -> None: + def set_app_runner_amount(self, app_name: str, num_runners: int) -> None: """Reconcile the application to a runner amount. Args: - app: The GitHub Runner Charm app to create the runner for. + app_name: The GitHub Runner Charm app name to create the runner for. num_runners: The number of runners. """ - await app.set_config({BASE_VIRTUAL_MACHINES_CONFIG_NAME: f"{num_runners}"}) - await wait_for_runner_ready(app=app) + self.juju.config(app_name, values={BASE_VIRTUAL_MACHINES_CONFIG_NAME: f"{num_runners}"}) + if num_runners == 0: + return + wait_for_runner_ready(self.juju, app_name, num_runners=num_runners) - async def get_runner_names(self, unit: Unit) -> list[str]: + def get_runner_names(self, unit_name: str) -> list[str]: """Get the name of all the runners in the unit. Args: - unit: The GitHub Runner Charm unit to get the runner names for. + unit_name: The GitHub Runner Charm unit name to get the runner names for. Returns: List of names for the runners. """ - runners = self._get_runners(unit) + runners = self._get_runners(unit_name) return [runner.name for runner in runners] - async def get_runner_name(self, unit: Unit) -> str: + def get_runner_name(self, unit_name: str) -> str: """Get the name of the runner. Expects only one runner to be present. Args: - unit: The GitHub Runner Charm unit to get the runner name for. + unit_name: The GitHub Runner Charm unit name to get the runner name for. Returns: The Github runner name deployed in the given unit. """ - runners = self._get_runners(unit) + runners = self._get_runners(unit_name) assert len(runners) == 1 return runners[0].name - async def delete_single_runner(self, unit: Unit) -> None: + def delete_single_runner(self, unit_name: str) -> None: """Delete the only runner. Args: - unit: The GitHub Runner Charm unit to delete the runner name for. + unit_name: The GitHub Runner Charm unit name to delete the runner name for. """ - runner = self.get_single_runner(unit) + runner = self.get_single_runner(unit_name) self.openstack_connection.delete_server(name_or_id=runner.id) - def _get_runners(self, unit: Unit) -> list[Server]: + def _get_runners(self, unit_name: str) -> list[Server]: """Get all runners for the unit.""" servers: list[Server] = self.openstack_connection.list_servers() - unit_name_without_slash = unit.name.replace("/", "-") + unit_name_without_slash = unit_name.replace("/", "-") runners = [server for server in servers if server.name.startswith(unit_name_without_slash)] return runners - def get_single_runner(self, unit: Unit) -> Server: + def get_single_runner(self, unit_name: str) -> Server: """Get the only runner for the unit. This method asserts for exactly one runner for the unit. Args: - unit: The unit to get the runner for. + unit_name: The unit name to get the runner for. Returns: The runner server. """ - runners = self._get_runners(unit) + runners = self._get_runners(unit_name) assert ( len(runners) == 1 - ), f"In {unit.name} found more than one runners or no runners: {runners}" + ), f"In {unit_name} found more than one runners or no runners: {runners}" return runners[0] diff --git a/tests/integration/test_charm_fork_path_change.py b/tests/integration/test_charm_fork_path_change.py index c0b83fa67..29b185fa1 100644 --- a/tests/integration/test_charm_fork_path_change.py +++ b/tests/integration/test_charm_fork_path_change.py @@ -11,7 +11,6 @@ import jubilant import pytest from github.Repository import Repository -from juju.application import Application from charm_state import PATH_CONFIG_NAME from tests.integration.conftest import GitHubConfig @@ -22,11 +21,10 @@ @pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_path_config_change( +def test_path_config_change( juju: jubilant.Juju, - app_with_forked_repo: Application, + app_with_forked_repo: str, github_repository: Repository, github_config: GitHubConfig, instance_helper: OpenStackInstanceHelper, @@ -38,22 +36,21 @@ async def test_path_config_change( """ logger.info("test_path_config_change") juju.wait( - lambda status: jubilant.all_active(status, app_with_forked_repo.name), + lambda status: jubilant.all_active(status, app_with_forked_repo), delay=10, timeout=10 * 60, ) - unit = app_with_forked_repo.units[0] - logger.info("Ensure there is a runner (this calls reconcile)") - await instance_helper.ensure_charm_has_runner(app_with_forked_repo) + instance_helper.ensure_charm_has_runner(app_with_forked_repo) - juju.config(app_with_forked_repo.name, values={PATH_CONFIG_NAME: github_config.path}) + juju.config(app_with_forked_repo, values={PATH_CONFIG_NAME: github_config.path}) logger.info("Reconciling (again)") - await wait_for_runner_ready(app=app_with_forked_repo) + wait_for_runner_ready(juju, app_with_forked_repo) - runner_names = await instance_helper.get_runner_names(unit) + unit_name = f"{app_with_forked_repo}/0" + runner_names = instance_helper.get_runner_names(unit_name) logger.info("runners: %s", runner_names) assert len(runner_names) == 1 runner_name = runner_names[0] diff --git a/tests/integration/test_charm_no_runner.py b/tests/integration/test_charm_no_runner.py index 28060748c..46c2c86dd 100644 --- a/tests/integration/test_charm_no_runner.py +++ b/tests/integration/test_charm_no_runner.py @@ -4,45 +4,35 @@ """Integration tests for github-runner charm with no runner.""" import json -import logging import jubilant import pytest -from juju.application import Application -from juju.model import Model -from ops import ActiveStatus - -logger = logging.getLogger(__name__) pytestmark = pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_check_runners_no_runners(app_no_runner: Application) -> None: +def test_check_runners_no_runners(juju: jubilant.Juju, app_no_runner: str) -> None: """ arrange: A working application with no runners. act: Run check-runners action. assert: Action returns result with no runner. """ - unit = app_no_runner.units[0] + unit_name = f"{app_no_runner}/0" - action = await unit.run_action("check-runners") - await action.wait() + result = juju.run(unit_name, "check-runners") - assert action.results["online"] == "0" - assert action.results["offline"] == "0" - assert action.results["unknown"] == "0" - assert action.results["runners"] == "()" + assert result.results["online"] == "0" + assert result.results["offline"] == "0" + assert result.results["unknown"] == "0" + assert result.results["runners"] == "()" -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_planner_integration( - model: Model, +def test_planner_integration( juju: jubilant.Juju, - app_no_runner: Application, - mock_planner_app: Application, + app_no_runner: str, + mock_planner_app: str, planner_token_secret_name: str, ) -> None: """ @@ -54,21 +44,20 @@ async def test_planner_integration( 1. The charm writes its flavor data to the planner relation app data bag. 2. The charm returns to active status after the relation is removed. """ - await model.grant_secret(planner_token_secret_name, app_no_runner.name) - await model.grant_secret(planner_token_secret_name, mock_planner_app.name) + juju.grant_secret(planner_token_secret_name, app_no_runner) + juju.grant_secret(planner_token_secret_name, mock_planner_app) - await model.relate(f"{app_no_runner.name}:planner", mock_planner_app.name) - await model.wait_for_idle( - apps=[app_no_runner.name, mock_planner_app.name], - status=ActiveStatus.name, - idle_period=30, + juju.integrate(f"{app_no_runner}:planner", mock_planner_app) + juju.wait( + lambda status: jubilant.all_active(status, app_no_runner, mock_planner_app), + delay=10, timeout=10 * 60, ) # Verify the runner charm wrote flavor data to the relation app databag. # Query from the planner unit's perspective so "application-data" shows the # remote (runner) app's data rather than the planner's own app data. - planner_unit_name = mock_planner_app.units[0].name + planner_unit_name = f"{mock_planner_app}/0" raw = juju.cli("show-unit", planner_unit_name, "--format", "json") unit_data = json.loads(raw)[planner_unit_name] planner_rel = next( @@ -77,14 +66,17 @@ async def test_planner_integration( if rel["endpoint"] == "provide-github-runner-planner-v0" ) app_data = planner_rel["application-data"] - assert app_data["flavor"] == app_no_runner.name + assert app_data["flavor"] == app_no_runner assert app_data["platform"] == "github" assert app_data["priority"] == "50" assert app_data["minimum-pressure"] == "0" - await mock_planner_app.remove_relation( - "provide-github-runner-planner-v0", f"{app_no_runner.name}:planner" + juju.remove_relation( + f"{mock_planner_app}:provide-github-runner-planner-v0", + f"{app_no_runner}:planner", ) - await model.wait_for_idle( - apps=[app_no_runner.name], status=ActiveStatus.name, idle_period=30, timeout=10 * 60 + juju.wait( + lambda status: jubilant.all_active(status, app_no_runner), + delay=10, + timeout=10 * 60, ) diff --git a/tests/integration/test_charm_runner.py b/tests/integration/test_charm_runner.py index 93ebf94bf..7efb8b4e8 100644 --- a/tests/integration/test_charm_runner.py +++ b/tests/integration/test_charm_runner.py @@ -3,14 +3,12 @@ """Integration tests for github-runner charm containing one runner.""" -from typing import AsyncIterator +from typing import Iterator +import jubilant import pytest -import pytest_asyncio from github.Branch import Branch from github.Repository import Repository -from juju.action import Action -from juju.application import Application from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME, CUSTOM_PRE_JOB_SCRIPT_CONFIG_NAME from tests.integration.helpers.common import ( @@ -24,57 +22,62 @@ from tests.integration.helpers.openstack import OpenStackInstanceHelper -@pytest_asyncio.fixture(scope="function", name="app") -async def app_fixture( - basic_app: Application, -) -> AsyncIterator[Application]: +@pytest.fixture(scope="function", name="app") +def app_fixture( + juju: jubilant.Juju, + basic_app: str, +) -> Iterator[str]: """Setup and teardown the charm after each test. Ensure the charm has no runner after a test. """ yield basic_app - await basic_app.set_config({BASE_VIRTUAL_MACHINES_CONFIG_NAME: "0"}) + juju.config(basic_app, values={BASE_VIRTUAL_MACHINES_CONFIG_NAME: "0"}) - async def _no_runners() -> bool: + unit_name = f"{basic_app}/0" + + def _no_runners() -> bool: """Check that no runners are active.""" - action: Action = await basic_app.units[0].run_action("check-runners") - await action.wait() + try: + result = juju.run(unit_name, "check-runners") + except (jubilant.CLIError, TimeoutError): + return False return ( - action.status == "completed" - and action.results["online"] == "0" - and action.results["offline"] == "0" - and action.results["unknown"] == "0" + result.status == "completed" + and result.results["online"] == "0" + and result.results["offline"] == "0" + and result.results["unknown"] == "0" ) - await wait_for(_no_runners, timeout=10 * 60, check_interval=10) + wait_for(_no_runners, timeout=10 * 60, check_interval=10) @pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_check_runner(app: Application, instance_helper: OpenStackInstanceHelper) -> None: +def test_check_runner( + juju: jubilant.Juju, app: str, instance_helper: OpenStackInstanceHelper +) -> None: """ arrange: A working application with one runner. act: Run check_runner action. assert: Action returns result with one runner. """ - await instance_helper.set_app_runner_amount(app, 2) + instance_helper.set_app_runner_amount(app, 2) - action = await app.units[0].run_action("check-runners") - await action.wait() + result = juju.run(f"{app}/0", "check-runners") - assert action.status == "completed" - assert action.results["online"] == "2" - assert action.results["offline"] == "0" - assert action.results["unknown"] == "0" + assert result.status == "completed" + assert result.results["online"] == "2" + assert result.results["offline"] == "0" + assert result.results["unknown"] == "0" @pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_flush_runner_and_resource_config( - app: Application, +def test_flush_runner_and_resource_config( + juju: jubilant.Juju, + app: str, github_repository: Repository, test_github_branch: Branch, instance_helper: OpenStackInstanceHelper, @@ -93,60 +96,58 @@ async def test_flush_runner_and_resource_config( Test are combined to reduce number of runner spawned. """ - await instance_helper.ensure_charm_has_runner(app) + instance_helper.ensure_charm_has_runner(app) + + unit_name = f"{app}/0" # 1. - action: Action = await app.units[0].run_action("check-runners") - await action.wait() + result = juju.run(unit_name, "check-runners") - assert action.status == "completed" - assert action.results["online"] == "1" - assert action.results["offline"] == "0" - assert action.results["unknown"] == "0" + assert result.status == "completed" + assert result.results["online"] == "1" + assert result.results["offline"] == "0" + assert result.results["unknown"] == "0" - runner_names = action.results["runners"].split(", ") + runner_names = result.results["runners"].split(", ") assert len(runner_names) == 1 # 2. - action = await app.units[0].run_action("flush-runners") - await action.wait() + juju.run(unit_name, "flush-runners") - await wait_for_runner_ready(app) + wait_for_runner_ready(juju, app) - action = await app.units[0].run_action("check-runners") - await action.wait() + result = juju.run(unit_name, "check-runners") - assert action.status == "completed" - assert action.results["online"] == "1" - assert action.results["offline"] == "0" - assert action.results["unknown"] == "0" + assert result.status == "completed" + assert result.results["online"] == "1" + assert result.results["offline"] == "0" + assert result.results["unknown"] == "0" - new_runner_names = action.results["runners"].split(", ") + new_runner_names = result.results["runners"].split(", ") assert len(new_runner_names) == 1 assert new_runner_names[0] != runner_names[0] # 3. - workflow = await dispatch_workflow( - app=app, + workflow = dispatch_workflow( + app_name=app, branch=test_github_branch, github_repository=github_repository, conclusion="success", workflow_id_or_name=DISPATCH_WAIT_TEST_WORKFLOW_FILENAME, - dispatch_input={"runner": app.name, "minutes": "5"}, + dispatch_input={"runner": app, "minutes": "5"}, wait=False, ) - await wait_for(lambda: workflow.update() or workflow.status == "in_progress") - action = await app.units[0].run_action("flush-runners") - await action.wait() + wait_for(lambda: workflow.update() or workflow.status == "in_progress") + result = juju.run(unit_name, "flush-runners") - assert action.status == "completed" + assert result.status == "completed" @pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_custom_pre_job_script( - app: Application, +def test_custom_pre_job_script( + juju: jubilant.Juju, + app: str, github_repository: Repository, test_github_branch: Branch, ) -> None: @@ -155,8 +156,9 @@ async def test_custom_pre_job_script( act: Dispatch a workflow. assert: Workflow run successfully passed and pre-job script has been executed. """ - await app.set_config( - { + juju.config( + app, + values={ BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1", CUSTOM_PRE_JOB_SCRIPT_CONFIG_NAME: """ #!/usr/bin/env bash @@ -169,17 +171,17 @@ async def test_custom_pre_job_script( EOF logger -s "SSH config: $(cat ~/.ssh/config)" """, - } + }, ) - await wait_for_runner_ready(app) + wait_for_runner_ready(juju, app) - workflow_run = await dispatch_workflow( - app=app, + workflow_run = dispatch_workflow( + app_name=app, branch=test_github_branch, github_repository=github_repository, conclusion="success", workflow_id_or_name=DISPATCH_TEST_WORKFLOW_FILENAME, - dispatch_input={"runner": app.name}, + dispatch_input={"runner": app}, ) logs = get_job_logs(workflow_run.jobs("latest")[0]) assert "SSH config" in logs diff --git a/tests/integration/test_charm_upgrade.py b/tests/integration/test_charm_upgrade.py index 2e36e0044..1a594d805 100644 --- a/tests/integration/test_charm_upgrade.py +++ b/tests/integration/test_charm_upgrade.py @@ -8,9 +8,6 @@ import jubilant import pytest -from juju.application import Application -from juju.client import client -from juju.model import Model from charm_state import ( BASE_VIRTUAL_MACHINES_CONFIG_NAME, @@ -35,16 +32,14 @@ pytestmark = pytest.mark.openstack -@pytest.mark.asyncio -async def test_charm_upgrade( +def test_charm_upgrade( juju: jubilant.Juju, - model: Model, deployment_context: DeploymentContext, app_name: str, github_config: GitHubConfig, openstack_config: OpenStackConfig, tmp_path: pathlib.Path, - image_builder: Application, + image_builder: str, ): """ arrange: given latest edge version of the charm. @@ -61,8 +56,8 @@ async def test_charm_upgrade( # --revision cannot be specified together with --arch, --base, --channel "--channel", "latest/edge", - "--series", - "jammy", + "--base", + "ubuntu@22.04", "--filepath", str(latest_edge_path), "--no-progress", @@ -72,8 +67,8 @@ async def test_charm_upgrade( pytest.fail(f"failed to download charm, {exc}") # deploy latest edge version of the charm - application = await deploy_github_runner_charm( - model=model, + deployed_name = deploy_github_runner_charm( + juju=juju, charm_file=str(latest_edge_path), app_name=app_name, github_config=github_config, @@ -83,7 +78,6 @@ async def test_charm_upgrade( no_proxy=openstack_config.no_proxy, ), reconcile_interval=5, - # override default virtual_machines=0 config. config={ OPENSTACK_CLOUDS_YAML_CONFIG_NAME: openstack_config.clouds_yaml_contents, OPENSTACK_NETWORK_CONFIG_NAME: openstack_config.network_name, @@ -94,42 +88,22 @@ async def test_charm_upgrade( }, wait_idle=False, ) - await model.integrate(f"{image_builder.name}", f"{application.name}:image") - await model.wait_for_idle( - apps=[application.name, image_builder.name], - raise_on_error=False, - wait_for_active=True, + juju.integrate(image_builder, f"{deployed_name}:image") + juju.wait( + lambda status: jubilant.all_active(status, deployed_name, image_builder), timeout=25 * 60, - check_freq=30, - ) - origin = client.CharmOrigin( - source="charm-hub", - track="22.04", - risk="latest/edge", - branch="deadbeef", - hash_="hash", - id_="id", - revision=0, # arbitrary number - base=client.Base("22.04", "ubuntu"), ) # upgrade the charm with current local charm - await application.local_refresh( - path=deployment_context.charm_path, - charm_origin=origin, - force=False, - force_series=False, - force_units=False, - resources=None, - ) - unit = application.units[0] - await wait_for( - functools.partial(is_upgrade_charm_event_emitted, unit), timeout=360, check_interval=60 + juju.refresh(deployed_name, path=deployment_context.charm_path) + + unit_name = f"{deployed_name}/0" + wait_for( + functools.partial(is_upgrade_charm_event_emitted, juju, unit_name), + timeout=360, + check_interval=60, ) - await model.wait_for_idle( - apps=[application.name], - raise_on_error=False, - wait_for_active=True, + juju.wait( + lambda status: jubilant.all_active(status, deployed_name), timeout=20 * 60, - check_freq=30, ) diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index 5ff836dba..5fb3f81c3 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -7,14 +7,11 @@ """ import logging -from typing import AsyncIterator +from typing import Iterator import pytest -import pytest_asyncio from github.Branch import Branch from github.Repository import Repository -from juju.application import Application -from juju.model import Model from tests.integration.conftest import GitHubConfig from tests.integration.helpers.common import ( @@ -45,26 +42,23 @@ def github_config(pytestconfig: pytest.Config, github_config: GitHubConfig) -> G ) -@pytest_asyncio.fixture(scope="function", name="app") -async def app_fixture( - model: Model, - basic_app: Application, +@pytest.fixture(scope="function", name="app") +def app_fixture( + basic_app: str, instance_helper: OpenStackInstanceHelper, -) -> AsyncIterator[Application]: +) -> Iterator[str]: """Setup and teardown the charm after each test. Ensure the charm has one runner before starting a test. """ - await instance_helper.ensure_charm_has_runner(basic_app) + instance_helper.ensure_charm_has_runner(basic_app) yield basic_app @pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_e2e_workflow( - model: Model, - app: Application, +def test_e2e_workflow( + app: str, github_repository: Repository, test_github_branch: Branch, ): @@ -73,11 +67,11 @@ async def test_e2e_workflow( act: Run e2e test workflow. assert: No exception thrown. """ - await dispatch_workflow( - app=app, + dispatch_workflow( + app_name=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}, + dispatch_input={"runner-tag": app}, ) diff --git a/tests/integration/test_multi_unit_same_machine.py b/tests/integration/test_multi_unit_same_machine.py index 35ad2b6c8..40bf20ca1 100644 --- a/tests/integration/test_multi_unit_same_machine.py +++ b/tests/integration/test_multi_unit_same_machine.py @@ -10,18 +10,16 @@ from pathlib import Path +import jubilant import pytest -from juju.application import Application -from juju.model import Model from tests.integration.helpers.common import get_file_content, run_in_unit @pytest.mark.openstack -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_multi_unit_same_machine_co_location( - model: Model, app_openstack_runner: Application +def test_multi_unit_same_machine_co_location( + juju: jubilant.Juju, app_openstack_runner: str ) -> None: """ arrange: Have one deployed unit, find its machine, and add a second unit to the same machine. @@ -31,60 +29,60 @@ async def test_multi_unit_same_machine_co_location( app = app_openstack_runner # Get machine id of the first unit - unit0 = app.units[0] - status = await model.get_status([app.name]) - app_status = status.applications.get(app.name) + status = juju.status() + app_status = status.apps.get(app) assert app_status is not None, "Application status missing for deployed app" - unit0_status = app_status.units.get(unit0.name) - assert unit0_status is not None, "Unit status missing for first unit" - machine_id = unit0_status.machine + unit_names = list(app_status.units.keys()) + assert len(unit_names) >= 1, "Expected at least one unit" + u0_name = unit_names[0] + machine_id = app_status.units[u0_name].machine # Add a second unit to the same machine and wait for it to settle - await app.add_unit(to=machine_id) - await model.wait_for_idle(apps=[app.name], status="active", timeout=20 * 60) - - # Refresh units reference (juju lib may not auto-refresh the list) - status = await model.get_status([app.name]) - app_status = status.applications.get(app.name) + juju.add_unit(app, to=machine_id) + juju.wait( + lambda status: jubilant.all_active(status, app), + timeout=20 * 60, + ) + + # Refresh units reference + status = juju.status() + app_status = status.apps.get(app) assert app_status is not None, "Application status missing after add-unit" unit_names = list(app_status.units.keys()) assert len(unit_names) >= 2, "Expected at least two units after add-unit" # Work with the first two units u0_name, u1_name = unit_names[0], unit_names[1] - unit_map = {u.name: u for u in app.units} - unit0 = unit_map[u0_name] - unit1 = unit_map[u1_name] # Instance service names inst0 = f"github-runner-manager@{u0_name.replace('/', '-')}" inst1 = f"github-runner-manager@{u1_name.replace('/', '-')}" # Services should be active - rc, out, err = await run_in_unit(unit0, f"systemctl is-active {inst0}.service") + rc, out, err = run_in_unit(juju, u0_name, f"systemctl is-active {inst0}.service") assert rc == 0 and (out or "").strip() == "active", f"{inst0} not active: {out} {err}" - rc, out, err = await run_in_unit(unit1, f"systemctl is-active {inst1}.service") + rc, out, err = run_in_unit(juju, u1_name, f"systemctl is-active {inst1}.service") assert rc == 0 and (out or "").strip() == "active", f"{inst1} not active: {out} {err}" # Read persisted ports for each unit port_file0 = Path(f"/var/lib/github-runner-manager/{u0_name.replace('/', '-')}/http_port") port_file1 = Path(f"/var/lib/github-runner-manager/{u1_name.replace('/', '-')}/http_port") - p0 = int((await get_file_content(unit0, port_file0))) - p1 = int((await get_file_content(unit1, port_file1))) + p0 = int(get_file_content(juju, u0_name, port_file0)) + p1 = int(get_file_content(juju, u1_name, port_file1)) assert p0 != p1, f"Expected distinct ports, got {p0} and {p1}" # Metrics endpoint should respond on both ports - rc, _, err = await run_in_unit(unit0, f"curl -sf http://127.0.0.1:{p0}/metrics | head -n 1") + rc, _, err = run_in_unit(juju, u0_name, f"curl -sf http://127.0.0.1:{p0}/metrics | head -n 1") assert rc == 0, f"Metrics not responding on unit0:{p0} - {err}" - rc, _, err = await run_in_unit(unit1, f"curl -sf http://127.0.0.1:{p1}/metrics | head -n 1") + rc, _, err = run_in_unit(juju, u1_name, f"curl -sf http://127.0.0.1:{p1}/metrics | head -n 1") assert rc == 0, f"Metrics not responding on unit1:{p1} - {err}" # Restart instance service on unit1 and verify persisted port doesn't change - before = int((await get_file_content(unit1, port_file1))) - await run_in_unit(unit1, f"sudo systemctl restart {inst1}.service", assert_on_failure=True) - rc, out, err = await run_in_unit(unit1, f"systemctl is-active {inst1}.service") + before = int(get_file_content(juju, u1_name, port_file1)) + run_in_unit(juju, u1_name, f"sudo systemctl restart {inst1}.service", assert_on_failure=True) + rc, out, err = run_in_unit(juju, u1_name, f"systemctl is-active {inst1}.service") assert ( rc == 0 and (out or "").strip() == "active" ), f"{inst1} not active after restart: {out} {err}" - after = int((await get_file_content(unit1, port_file1))) + after = int(get_file_content(juju, u1_name, port_file1)) assert before == after, "Persisted port changed after restart" diff --git a/tests/integration/test_prometheus_metrics.py b/tests/integration/test_prometheus_metrics.py index 115dca708..300f2e5e9 100644 --- a/tests/integration/test_prometheus_metrics.py +++ b/tests/integration/test_prometheus_metrics.py @@ -1,12 +1,9 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Prometheus metrics integration test — fully jubilant, no python-libjuju awaits. +"""Prometheus metrics integration test — fully jubilant, no python-libjuju. -Uses jubilant for ALL juju operations (deploy, integrate, wait, config, run) -to avoid the python-libjuju AllWatcher hang after cross-controller integrations. -The conftest ``app_openstack_runner`` fixture still creates a libjuju Model in the -background, but this test never awaits on any libjuju object. +Uses jubilant for ALL juju operations (deploy, integrate, wait, config, run). """ import logging @@ -15,12 +12,10 @@ import jubilant import pytest -import pytest_asyncio import requests from github.Branch import Branch from github.Repository import Repository from jubilant.statustypes import AppStatus -from juju.application import Application from tenacity import retry, stop_after_attempt, wait_exponential from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME @@ -37,7 +32,7 @@ COS_AGENT_CHARM = "opentelemetry-collector" -@pytest_asyncio.fixture(scope="module", name="k8s_juju") +@pytest.fixture(scope="module", name="k8s_juju") def k8s_juju_fixture(request: pytest.FixtureRequest) -> Generator[jubilant.Juju, None, None]: """The machine model for K8s charms.""" keep_models = cast(bool, request.config.getoption("--keep-models")) @@ -50,6 +45,7 @@ def prometheus_app_fixture(k8s_juju: jubilant.Juju) -> AppStatus: """Deploy prometheus charm.""" k8s_juju.deploy("prometheus-k8s", channel="1/stable") k8s_juju.wait(lambda status: jubilant.all_active(status, "prometheus-k8s")) + assert k8s_juju.model is not None k8s_juju_model_name = k8s_juju.model.split(":", 1)[1] k8s_juju.offer( f"{k8s_juju_model_name}.prometheus-k8s", @@ -65,6 +61,7 @@ def grafana_app_fixture(k8s_juju: jubilant.Juju, prometheus_app: AppStatus) -> A k8s_juju.deploy("grafana-k8s", channel="1/stable") k8s_juju.integrate("grafana-k8s:grafana-source", f"{prometheus_app.charm_name}:grafana-source") k8s_juju.wait(lambda status: jubilant.all_active(status, "grafana-k8s", "prometheus-k8s")) + assert k8s_juju.model is not None k8s_juju_model_name = k8s_juju.model.split(":", 1)[1] k8s_juju.offer( f"{k8s_juju_model_name}.grafana-k8s", @@ -93,9 +90,9 @@ def grafana_password_fixture(k8s_juju: jubilant.Juju, grafana_app: AppStatus) -> @pytest.fixture(scope="module", name="openstack_app_cos_agent") -def openstack_app_cos_agent_fixture(juju: jubilant.Juju, app_openstack_runner: Application) -> str: +def openstack_app_cos_agent_fixture(juju: jubilant.Juju, app_openstack_runner: str) -> str: """Deploy cos-agent subordinate charm. Return the app name as a string.""" - app_name = app_openstack_runner.name + app_name = app_openstack_runner juju.deploy( COS_AGENT_CHARM, channel="2/candidate", @@ -109,7 +106,7 @@ def openstack_app_cos_agent_fixture(juju: jubilant.Juju, app_openstack_runner: A @pytest.mark.usefixtures("traefik_ingress") @pytest.mark.openstack -async def test_prometheus_metrics( +def test_prometheus_metrics( juju: jubilant.Juju, k8s_juju: jubilant.Juju, openstack_app_cos_agent: str, @@ -125,6 +122,7 @@ async def test_prometheus_metrics( assert: the datasource is registered and basic metrics are available. """ app_name = openstack_app_cos_agent + assert k8s_juju.model is not None k8s_juju_model_name = k8s_juju.model.split(":", 1)[1] juju.consume( f"{k8s_juju_model_name}.prometheus-k8s", @@ -149,8 +147,8 @@ async def test_prometheus_metrics( juju.config(app_name, values={BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1"}) _wait_for_runner_ready(juju, app_name) - await dispatch_workflow( - app=None, + dispatch_workflow( + app_name=None, branch=test_github_branch, github_repository=github_repository, conclusion="success", @@ -188,7 +186,7 @@ def _wait_for_runner_ready(juju: jubilant.Juju, app_name: str) -> None: for attempt in range(20): try: result = juju.run(unit, "check-runners") - except TimeoutError: + except (jubilant.CLIError, TimeoutError): logger.info("check-runners action timed out (attempt %d), retrying...", attempt) time.sleep(30) continue @@ -205,7 +203,7 @@ def _wait_for_no_runners(juju: jubilant.Juju, app_name: str) -> None: for attempt in range(20): try: result = juju.run(unit, "check-runners") - except TimeoutError: + except (jubilant.CLIError, TimeoutError): logger.info("check-runners action timed out (attempt %d), retrying...", attempt) time.sleep(30) continue diff --git a/tox.ini b/tox.ini index 01868c6d2..572b6f811 100644 --- a/tox.ini +++ b/tox.ini @@ -50,13 +50,10 @@ deps = pylint pytest ops - pytest_operator - # types for dateutil lib in integration tests - types-python-dateutil + jubilant types-requests types-PyYAML types-paramiko - pytest_asyncio pydocstyle>=2.10 # To simplify development -r{toxinidir}/base_requirements.txt @@ -110,7 +107,7 @@ deps = commands = bandit -c {toxinidir}/pyproject.toml -r {[vars]src_path} -[testenv:integration-juju{3.1,3.6}] +[testenv:integration] description = Run integration tests pass_env = PYTEST_ADDOPTS @@ -128,16 +125,10 @@ pass_env = GITHUB_APP_INSTALLATION_ID GITHUB_APP_PRIVATE_KEY deps = - juju3.1: juju==3.1.* - juju3.6: juju==3.6.* - nest-asyncio # Required due to https://github.com/pytest-dev/pytest-asyncio/issues/112 - pytest-operator - pytest-asyncio pytest_httpserver - websockets<14.0 # https://github.com/juju/python-libjuju/issues/1184 -r{toxinidir}/requirements.txt allure-pytest>=2.8.18 git+https://github.com/canonical/data-platform-workflows@v24.0.0\#subdirectory=python/pytest_plugins/allure_pytest_collection_report -r{[vars]tst_path}integration/requirements.txt commands = - pytest -v --tb native --ignore={[vars]github_runner_manager_path} --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} + pytest -v --tb native --ignore={[vars]github_runner_manager_path} --ignore={[vars]tst_path}unit --log-cli-level=INFO --basetemp={envtmpdir}/pytest -s {posargs}