From bf9f601b0c68801ebcf897c738917132f17a3740 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 18:49:13 +1300 Subject: [PATCH 01/15] Migrate integration tests from pytest-operator to jubilant Replace pytest-operator/python-libjuju with jubilant and pytest-jubilant for integration tests. All async patterns converted to synchronous. Generated with GitHub Copilot (Claude Sonnet 4.6). --- tests/integration/conftest.py | 174 +++++++++++++++--------------- tests/integration/test_actions.py | 66 ++++-------- tests/integration/test_charm.py | 71 ++++-------- tests/integration/test_loki.py | 16 ++- tests/integration/test_s3.py | 25 ++--- tests/integration/test_saml.py | 23 ++-- tox.ini | 16 ++- 7 files changed, 155 insertions(+), 236 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5dc71520..651f37f5 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,15 +3,32 @@ """Fixtures for Indico charm integration tests.""" -import asyncio from pathlib import Path from secrets import token_hex +from typing import Dict, Union -import pytest_asyncio +import jubilant +import pytest +import pytest_jubilant import yaml -from ops import Application from pytest import Config, fixture -from pytest_operator.plugin import OpsTest + + +def pytest_addoption(parser): + """Add Indico-specific command-line options. + + Args: + parser: The pytest argument parser. + """ + parser.addoption( + "--charm-file", action="store", default=None, help="Pre-built charm file path" + ) + parser.addoption("--indico-image", action="store", default=None, help="Indico OCI image") + parser.addoption( + "--indico-nginx-image", action="store", default=None, help="Indico nginx OCI image" + ) + parser.addoption("--saml-email", action="store", default=None, help="SAML test email address") + parser.addoption("--saml-password", action="store", default=None, help="SAML test password") @fixture(scope="module", name="external_url") @@ -22,7 +39,7 @@ def external_url_fixture(): @fixture(scope="module") def saml_email(pytestconfig: Config): - """SAML login email address test argument for SAML integration tests""" + """SAML login email address test argument for SAML integration tests.""" email = pytestconfig.getoption("--saml-email") if not email: raise ValueError("--saml-email argument is required for selected test cases") @@ -31,7 +48,7 @@ def saml_email(pytestconfig: Config): @fixture(scope="module") def saml_password(pytestconfig: Config): - """SAML login password test argument for SAML integration tests""" + """SAML login password test argument for SAML integration tests.""" password = pytestconfig.getoption("--saml-password") if not password: raise ValueError("--saml-password argument is required for selected test cases") @@ -52,13 +69,13 @@ def app_name_fixture(metadata): @fixture(scope="module") def requests_timeout(): - """Provides a global default timeout for HTTP requests""" + """Provides a global default timeout for HTTP requests.""" yield 15 -@pytest_asyncio.fixture(scope="module", name="app") -async def app_fixture( - ops_test: OpsTest, +@pytest.fixture(scope="module", name="app") +def app_fixture( + juju: jubilant.Juju, app_name: str, pytestconfig: Config, ): @@ -66,126 +83,103 @@ async def app_fixture( Builds the charm and deploys it and the relations it depends on. """ - assert ops_test.model - # Deploy relations to speed up overall execution - postgresql_config = { - "plugin_pg_trgm_enable": str(True), - "plugin_unaccent_enable": str(True), + postgresql_config: Dict[str, Union[bool, str]] = { + "plugin_pg_trgm_enable": True, + "plugin_unaccent_enable": True, "profile": "testing", } - await asyncio.gather( - ops_test.model.deploy( - "postgresql-k8s", channel="14/edge", config=postgresql_config, trust=True - ), - ops_test.model.deploy("redis-k8s", "redis-broker", channel="latest/edge"), - ops_test.model.deploy("redis-k8s", "redis-cache", channel="latest/edge"), - ops_test.model.deploy( - "nginx-ingress-integrator", - channel="latest/edge", - revision=133, - trust=True, - series="focal", - ), + juju.deploy("postgresql-k8s", channel="14/edge", config=postgresql_config, trust=True) + juju.deploy("redis-k8s", "redis-broker", channel="latest/edge") + juju.deploy("redis-k8s", "redis-cache", channel="latest/edge") + juju.deploy( + "nginx-ingress-integrator", + channel="latest/edge", + revision=133, + trust=True, ) + resources = { "indico-image": pytestconfig.getoption("--indico-image"), "indico-nginx-image": pytestconfig.getoption("--indico-nginx-image"), } if charm := pytestconfig.getoption("--charm-file"): - application = await ops_test.model.deploy( - f"./{charm}", - resources=resources, - application_name=app_name, - series="focal", - ) + juju.deploy(f"./{charm}", app_name, resources=resources) else: - charm = await ops_test.build_charm(".") - application = await ops_test.model.deploy( + charm = pytest_jubilant.pack() + juju.deploy( charm, + app_name, resources=resources, - application_name=app_name, config={ "external_plugins": "https://github.com/canonical/flask-multipass-saml-groups/releases/download/1.2.2/flask_multipass_saml_groups-1.2.2-py3-none-any.whl" # noqa: E501 pylint: disable=line-too-long }, - series="focal", ) - await asyncio.gather( - ops_test.model.add_relation(app_name, "postgresql-k8s"), - ops_test.model.add_relation(f"{app_name}:redis-broker", "redis-broker"), - ops_test.model.add_relation(f"{app_name}:redis-cache", "redis-cache"), - ops_test.model.add_relation(app_name, "nginx-ingress-integrator"), - ) - await ops_test.model.wait_for_idle( - apps=[ - application.name, + juju.integrate(app_name, "postgresql-k8s") + juju.integrate(f"{app_name}:redis-broker", "redis-broker") + juju.integrate(f"{app_name}:redis-cache", "redis-cache") + juju.integrate(app_name, "nginx-ingress-integrator") + juju.wait( + lambda status: jubilant.all_active( + status, + app_name, "postgresql-k8s", "redis-broker", "redis-cache", "nginx-ingress-integrator", - ], - status="active", - raise_on_error=True, + ), + error=jubilant.any_error, ) - yield application + yield app_name -@pytest_asyncio.fixture(scope="module", name="saml_integrator") -async def saml_integrator_fixture(ops_test: OpsTest, app: Application): +@pytest.fixture(scope="module", name="saml_integrator") +def saml_integrator_fixture(juju: jubilant.Juju, app: str): """SAML integrator charm used for integration testing.""" - assert ops_test.model saml_config = { "entity_id": "https://login.staging.ubuntu.com", "metadata_url": "https://login.staging.ubuntu.com/saml/metadata", } - saml_integrator = await ops_test.model.deploy( - "saml-integrator", channel="latest/stable", config=saml_config, trust=True - ) - await ops_test.model.add_relation(app.name, saml_integrator.name) - await ops_test.model.wait_for_idle( - apps=[saml_integrator.name, app.name], status="active", raise_on_error=True + juju.deploy("saml-integrator", channel="latest/stable", config=saml_config, trust=True) + juju.integrate(app, "saml-integrator") + juju.wait( + lambda status: jubilant.all_active(status, "saml-integrator", app), + error=jubilant.any_error, ) - yield saml_integrator + yield "saml-integrator" -@pytest_asyncio.fixture(scope="module", name="s3_integrator") -async def s3_integrator_fixture(ops_test: OpsTest, app: Application): - """SAML integrator charm used for integration testing.""" - assert ops_test.model +@pytest.fixture(scope="module", name="s3_integrator") +def s3_integrator_fixture(juju: jubilant.Juju, app: str): + """S3 integrator charm used for integration testing.""" s3_config = { "bucket": "some-bucket", "endpoint": "s3.example.com", } - s3_integrator = await ops_test.model.deploy( - "s3-integrator", channel="latest/edge", config=s3_config - ) - await ops_test.model.wait_for_idle(apps=[s3_integrator.name], idle_period=5, status="blocked") + juju.deploy("s3-integrator", channel="latest/edge", config=s3_config) + juju.wait(lambda status: jubilant.all_blocked(status, "s3-integrator")) params = {"access-key": token_hex(16), "secret-key": token_hex(16)} - # Application actually does have units - action_sync_s3_credentials = await s3_integrator.units[0].run_action( # type: ignore - "sync-s3-credentials", **params + juju.run("s3-integrator/0", "sync-s3-credentials", params=params) + juju.wait( + lambda status: jubilant.all_active(status, "s3-integrator"), + error=jubilant.any_error, ) - await action_sync_s3_credentials.wait() - await ops_test.model.wait_for_idle( - apps=[s3_integrator.name], status="active", raise_on_error=True + juju.integrate(app, "s3-integrator") + juju.wait( + lambda status: jubilant.all_active(status, "s3-integrator", app), + error=jubilant.any_error, ) - await ops_test.model.add_relation(app.name, s3_integrator.name) - await ops_test.model.wait_for_idle( - apps=[s3_integrator.name, app.name], status="active", raise_on_error=True - ) - yield s3_integrator + yield "s3-integrator" -@pytest_asyncio.fixture(scope="module", name="loki") -async def loki_fixture(ops_test: OpsTest, app: Application): +@pytest.fixture(scope="module", name="loki") +def loki_fixture(juju: jubilant.Juju, app: str): """Loki charm used for integration testing.""" - assert ops_test.model - loki = await ops_test.model.deploy( - "loki-k8s", channel="1/edge", trust=True, revision=97, series="focal" - ) - await ops_test.model.add_relation(app.name, loki.name) - await ops_test.model.wait_for_idle( - apps=[loki.name, app.name], status="active", raise_on_error=True + juju.deploy("loki-k8s", channel="1/edge", trust=True, revision=97) + juju.integrate(app, "loki-k8s") + juju.wait( + lambda status: jubilant.all_active(status, "loki-k8s", app), + error=jubilant.any_error, ) - yield loki + yield "loki-k8s" diff --git a/tests/integration/test_actions.py b/tests/integration/test_actions.py index 8f8b4cff..d3172225 100644 --- a/tests/integration/test_actions.py +++ b/tests/integration/test_actions.py @@ -6,83 +6,61 @@ from secrets import token_hex -import juju.action +import jubilant import pytest -import pytest_asyncio -from ops import Application ADMIN_USER_EMAIL = "sample@email.com" ADMIN_USER_EMAIL_FAIL = "sample2@email.com" -@pytest_asyncio.fixture(scope="module") -async def add_admin(app: Application): +@pytest.fixture(scope="module") +def add_admin(juju: jubilant.Juju, app: str): """ arrange: given charm in its initial state act: run the add-admin action assert: check the output in the action result """ - assert hasattr(app, "units") - - assert app.units[0] - email = ADMIN_USER_EMAIL email_fail = ADMIN_USER_EMAIL_FAIL # This is a test password password = token_hex(16) - # Application actually does have units - action: juju.action.Action = await app.units[0].run_action( # type: ignore - "add-admin", email=email, password=password - ) - await action.wait() - assert action.status == "completed" - assert action.results["user"] == email - assert f'Admin with email "{email}" correctly created' in action.results["output"] - action2: juju.action.Action = await app.units[0].run_action( # type: ignore - "add-admin", email=email_fail, password=password - ) - await action2.wait() - assert action2.status == "completed" - assert action2.results["user"] == email_fail - assert f'Admin with email "{email_fail}" correctly created' in action2.results["output"] + task = juju.run(f"{app}/0", "add-admin", params={"email": email, "password": password}) + assert task.success + assert task.results["user"] == email + assert f'Admin with email "{email}" correctly created' in task.results["output"] + + task2 = juju.run(f"{app}/0", "add-admin", params={"email": email_fail, "password": password}) + assert task2.success + assert task2.results["user"] == email_fail + assert f'Admin with email "{email_fail}" correctly created' in task2.results["output"] -@pytest.mark.asyncio @pytest.mark.abort_on_fail @pytest.mark.usefixtures("add_admin") -async def test_anonymize_user(app: Application): +def test_anonymize_user(juju: jubilant.Juju, app: str): """ arrange: admin user created act: run the anonymize-user action assert: check the output in the action result """ - # Application actually does have units - action_anonymize: juju.action.Action = await app.units[0].run_action( # type: ignore - "anonymize-user", email=ADMIN_USER_EMAIL - ) - await action_anonymize.wait() - assert action_anonymize.status == "completed" - assert action_anonymize.results["user"] == ADMIN_USER_EMAIL + task = juju.run(f"{app}/0", "anonymize-user", params={"email": ADMIN_USER_EMAIL}) + assert task.success + assert task.results["user"] == ADMIN_USER_EMAIL expected_words = [ADMIN_USER_EMAIL, "correctly anonymized"] - assert all(word in action_anonymize.results["output"] for word in expected_words) + assert all(word in task.results["output"] for word in expected_words) -@pytest.mark.asyncio @pytest.mark.abort_on_fail @pytest.mark.usefixtures("add_admin") -async def test_anonymize_user_fail(app: Application): +def test_anonymize_user_fail(juju: jubilant.Juju, app: str): """ arrange: admin user created act: run the anonymize-user action assert: check the output in the action result """ - # Application actually does have units - action_anonymize: juju.action.Action = await app.units[0].run_action( # type: ignore - "anonymize-user", email=f",{ADMIN_USER_EMAIL_FAIL}" - ) - await action_anonymize.wait() - assert action_anonymize.status == "failed" - assert action_anonymize.results["user"] == f",{ADMIN_USER_EMAIL_FAIL}" + task = juju.run(f"{app}/0", "anonymize-user", params={"email": f",{ADMIN_USER_EMAIL_FAIL}"}) + assert task.status == "failed" + assert task.results["user"] == f",{ADMIN_USER_EMAIL_FAIL}" expected_words = [ADMIN_USER_EMAIL_FAIL, "correctly anonymized", "Failed to anonymize user"] - assert all(word in action_anonymize.results["output"] for word in expected_words) + assert all(word in task.results["output"] for word in expected_words) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 25241225..dcd26428 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -6,10 +6,9 @@ import logging +import jubilant import pytest import requests -from ops import ActiveStatus, Application -from pytest_operator.plugin import OpsTest from charm import CELERY_PROMEXP_PORT, NGINX_PROMEXP_PORT, STATSD_PROMEXP_PORT @@ -19,87 +18,63 @@ logger = logging.getLogger() -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_active(app: Application): +def test_active(juju: jubilant.Juju, app: str): """Check that the charm is active. Assume that the charm has already been built and is running. """ - # Application actually does have units - assert app.units[0].workload_status == ActiveStatus.name # type: ignore + status = juju.status() + assert status.apps[app].app_status.current == "active" -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_indico_is_up(ops_test: OpsTest, app: Application): +def test_indico_is_up(juju: jubilant.Juju, app: str): """Check that the bootstrap page is reachable. Assume that the charm has already been built and is running. """ - assert ops_test.model - # Read the IP address of indico - status = await ops_test.model.get_status() - unit = list(status.applications[app.name].units)[0] - address = status["applications"][app.name]["units"][unit]["address"] - # Send request to bootstrap page and set Host header to app_name (which the application - # expects) + status = juju.status() + unit_status = list(status.apps[app].units.values())[0] + address = unit_status.address response = requests.get( - f"http://{address}:8080/bootstrap", headers={"Host": f"{app.name}.local"}, timeout=10 + f"http://{address}:8080/bootstrap", headers={"Host": f"{app}.local"}, timeout=10 ) assert response.status_code == 200 -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_health_checks(app: Application): +def test_health_checks(juju: jubilant.Juju, app: str): """Runs health checks for each container. Assume that the charm has already been built and is running. """ - container__checks_list = [("indico-nginx", 2), ("indico", 4)] - # Application actually does have units - indico_unit = app.units[0] # type: ignore - for container_checks in container__checks_list: - container = container_checks[0] - cmd = f"PEBBLE_SOCKET=/charm/containers/{container}/pebble.socket /charm/bin/pebble checks" - action = await indico_unit.run(cmd, timeout=10) - # Change this if upgrading Juju lib version to >= 3 - # See https://github.com/juju/python-libjuju/issues/707#issuecomment-1212296289 - result = action.data - code = result["results"].get("Code") - stdout = result["results"].get("Stdout") - stderr = result["results"].get("Stderr") - assert code == "0", f"{cmd} failed ({code}): {stderr or stdout}" + container_checks_list = [("indico-nginx", 2), ("indico", 4)] + for container, expected_count in container_checks_list: + cmd = ( + f"PEBBLE_SOCKET=/charm/containers/{container}/pebble.socket" + " /charm/bin/pebble checks" + ) + stdout = juju.cli("exec", "--unit", f"{app}/0", "--", "bash", "-c", cmd) # When executing the checks, `0/3` means there are 0 errors of 3. - # Each check has it's own `0/3`, so we will count `n` times, - # where `n` is the number of checks for that container. - assert stdout.count("0/3") == container_checks[1] + # Each check has its own `0/3`, so we count `n` times where `n` is the + # number of checks for that container. + assert stdout.count("0/3") == expected_count -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_prom_exporters_are_up(app: Application): +def test_prom_exporters_are_up(juju: jubilant.Juju, app: str): """ arrange: given charm in its initial state act: when the metrics endpoints are scraped assert: the response is 200 (HTTP OK) """ - # Application actually does have units - indico_unit = app.units[0] # type: ignore prometheus_targets = [ f"localhost:{NGINX_PROMEXP_PORT}", f"localhost:{STATSD_PROMEXP_PORT}", f"localhost:{CELERY_PROMEXP_PORT}", ] - # Send request to /metrics for each target and check the response for target in prometheus_targets: cmd = f"curl -m 10 http://{target}/metrics" - action = await indico_unit.run(cmd, timeout=15) - # Change this if upgrading Juju lib version to >= 3 - # See https://github.com/juju/python-libjuju/issues/707#issuecomment-1212296289 - result = action.data - code = result["results"].get("Code") - stdout = result["results"].get("Stdout") - stderr = result["results"].get("Stderr") - assert code == "0", f"{cmd} failed ({code}): {stderr or stdout}" + # CLIError is raised if the command fails, so a successful return means status 200. + juju.cli("exec", "--unit", f"{app}/0", "--", "bash", "-c", cmd) diff --git a/tests/integration/test_loki.py b/tests/integration/test_loki.py index b738b25d..bf3c01dd 100644 --- a/tests/integration/test_loki.py +++ b/tests/integration/test_loki.py @@ -4,30 +4,26 @@ """Indico Loki integration tests.""" -import asyncio -import json import logging import time +import jubilant import pytest import requests -from juju.application import Application -from pytest_operator.plugin import OpsTest logger = logging.getLogger(__name__) -@pytest.mark.asyncio @pytest.mark.abort_on_fail -async def test_loki(loki: Application, ops_test: OpsTest): +def test_loki(juju: jubilant.Juju, loki: str): """ arrange: given charm integrated with Loki. act: do nothing. assert: Loki starts to receive log files from indico. """ - _, status, _ = await ops_test.juju("status", "--format", "json") - status = json.loads(status) - loki_ip = list(status["applications"][loki.name]["units"].values())[0]["address"] + status = juju.status() + loki_unit = list(status.apps[loki].units.values())[0] + loki_ip = loki_unit.address logger.info("loki IP: %s", loki_ip) deadline = time.time() + 1200 logged_files = [] @@ -42,7 +38,7 @@ async def test_loki(loki: Application, ops_test: OpsTest): .get("data", []) ) except (requests.exceptions.RequestException, TimeoutError): - await asyncio.sleep(1) + time.sleep(1) if all( file in logged_files for file in ["/srv/indico/log/celery.log", "/srv/indico/log/indico.log"] diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py index b9097049..20fec475 100644 --- a/tests/integration/test_s3.py +++ b/tests/integration/test_s3.py @@ -6,41 +6,30 @@ import re -import juju +import jubilant import pytest import yaml -from ops import Application -from pytest_operator.plugin import OpsTest -@pytest.mark.asyncio @pytest.mark.abort_on_fail @pytest.mark.usefixtures("s3_integrator") -async def test_s3(app: Application, s3_integrator: Application, ops_test: OpsTest): +def test_s3(juju: jubilant.Juju, app: str, s3_integrator: str): """ arrange: given charm integrated with S3. act: do nothing. assert: the pebble plan matches the S3 values as configured by the integrator. """ - # Application actually does have units - return_code, stdout, _ = await ops_test.juju( - "ssh", "--container", app.name, app.units[0].name, "pebble", "plan" # type: ignore - ) - assert return_code == 0 + stdout = juju.cli("ssh", "--container", app, f"{app}/0", "pebble", "plan") plan = yaml.safe_load(stdout) indico_env = plan["services"]["indico"]["environment"] # STORAGE_DICT is a string representation of a Python dict # pylint: disable=eval-used storage_config = eval(indico_env["STORAGE_DICT"]) # nosec - # Application actually does have units - action: juju.action.Action = await s3_integrator.units[0].run_action( # type: ignore - "get-s3-connection-info" - ) - await action.wait() - assert action.status == "completed" + task = juju.run(f"{s3_integrator}/0", "get-s3-connection-info") + assert task.success # in get-s3-connection-info, access_key and secret_key are redacted assert re.match( - f"s3:bucket={action.results['bucket']}," - f"access_key=[^=]+,secret_key=[^=]+,proxy=true,host={action.results['endpoint']}", + f"s3:bucket={task.results['bucket']}," + f"access_key=[^=]+,secret_key=[^=]+,proxy=true,host={task.results['endpoint']}", storage_config["s3"], ) diff --git a/tests/integration/test_saml.py b/tests/integration/test_saml.py index 6ebafb92..dfe535e4 100644 --- a/tests/integration/test_saml.py +++ b/tests/integration/test_saml.py @@ -9,19 +9,17 @@ from unittest.mock import patch from urllib.parse import urlparse +import jubilant import pytest import requests import urllib3.exceptions -from ops import Application -from pytest_operator.plugin import OpsTest -@pytest.mark.asyncio @pytest.mark.abort_on_fail @pytest.mark.usefixtures("saml_integrator") -async def test_saml_auth( # pylint: disable=too-many-arguments, too-many-positional-arguments - ops_test: OpsTest, - app: Application, +def test_saml_auth( # pylint: disable=too-many-arguments, too-many-positional-arguments + juju: jubilant.Juju, + app: str, saml_email: str, saml_password: str, requests_timeout: float, @@ -32,13 +30,8 @@ async def test_saml_auth( # pylint: disable=too-many-arguments, too-many-positi act: configure a SAML target url and fire SAML authentication assert: The SAML authentication process is executed successfully. """ - # The linter does not recognize set_config as a method, so this errors must be ignored. - await app.set_config( # type: ignore[attr-defined] # pylint: disable=W0106 - {"site_url": external_url} - ) - # The linter does not recognize wait_for_idle as a method, - # since ops_test has a model as Optional, so this error must be ignored. - await ops_test.model.wait_for_idle(status="active", idle_period=15) # type: ignore[union-attr] + juju.config(app, {"site_url": external_url}) + juju.wait(jubilant.all_active) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) host = urlparse(external_url).netloc @@ -111,6 +104,4 @@ def patched_getaddrinfo(*args): ) assert dashboard_page.status_code == 200 # Revert SAML config for zap to be able to run - await app.set_config( # type: ignore[attr-defined] # pylint: disable=W0106 - {"site_url": ""} - ) + juju.config(app, {"site_url": ""}) diff --git a/tox.ini b/tox.ini index 4639e64a..65af6829 100644 --- a/tox.ini +++ b/tox.ini @@ -50,9 +50,9 @@ deps = pydocstyle>=2.10 pylint pyproject-flake8 + jubilant pytest - pytest-asyncio - pytest-operator + pytest-jubilant requests types-PyYAML types-requests @@ -96,14 +96,12 @@ deps = pydocstyle>=2.10 pylint pyproject-flake8 + jubilant pytest - pytest-asyncio - pytest-operator + pytest-jubilant requests types-PyYAML types-requests - ; 2024/11/19 - there is an incompatibility issue with latest websockets lib release and pylib juju - websockets<14.0 # https://github.com/juju/python-libjuju/issues/1184 -r{toxinidir}/requirements.txt commands = codespell {[vars]plugins_path} --skip {toxinidir}/.git --skip {toxinidir}/.tox \ @@ -157,11 +155,9 @@ deps = allure-pytest>=2.8.18 cosl git+https://github.com/canonical/data-platform-workflows@v24.0.0\#subdirectory=python/pytest_plugins/allure_pytest_collection_report - juju==2.9.49.0 + jubilant pytest - pytest-asyncio - pytest-operator - websockets<14.0 # https://github.com/juju/python-libjuju/issues/1184 + pytest-jubilant commands = pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} From 6875f99b701f1ec66933afce07270a443729c86d Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 21:02:56 +1300 Subject: [PATCH 02/15] Add release-note artifact for jubilant migration Co-Authored-By: Claude Opus 4.6 --- .../artifacts/migrate_to_jubilant.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/release-notes/artifacts/migrate_to_jubilant.yaml diff --git a/docs/release-notes/artifacts/migrate_to_jubilant.yaml b/docs/release-notes/artifacts/migrate_to_jubilant.yaml new file mode 100644 index 00000000..08047dd1 --- /dev/null +++ b/docs/release-notes/artifacts/migrate_to_jubilant.yaml @@ -0,0 +1,16 @@ +version_schema: 2 + +changes: +- title: Migrated integration tests from pytest-operator to jubilant + author: tonyandrewmeyer + type: minor + description: | + Migrated integration tests to use the jubilant library and + pytest-jubilant plugin instead of pytest-operator. + urls: + pr: + - https://github.com/tonyandrewmeyer/indico-operator/pull/3 + related_doc: + related_issue: + visibility: internal + highlight: false From 38bd3cad9f1f2c730333393e3138f4e0e2d83167 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 21:40:36 +1300 Subject: [PATCH 03/15] Remove duplicate --charm-file option registration pytest-jubilant already registers this option, so adding it again causes a ValueError during collection. Co-Authored-By: Claude Opus 4.6 --- tests/integration/conftest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 651f37f5..e1b7c74f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,9 +20,6 @@ def pytest_addoption(parser): Args: parser: The pytest argument parser. """ - parser.addoption( - "--charm-file", action="store", default=None, help="Pre-built charm file path" - ) parser.addoption("--indico-image", action="store", default=None, help="Indico OCI image") parser.addoption( "--indico-nginx-image", action="store", default=None, help="Indico nginx OCI image" From 1874cb074e23f58af797172fe7d3859c774b628f Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 21:57:24 +1300 Subject: [PATCH 04/15] Remove duplicate pytest_addoption from integration conftest These options are already registered in tests/conftest.py, causing ValueError on collection. Co-Authored-By: Claude Opus 4.6 --- tests/integration/conftest.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e1b7c74f..a1e526bd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -14,19 +14,6 @@ from pytest import Config, fixture -def pytest_addoption(parser): - """Add Indico-specific command-line options. - - Args: - parser: The pytest argument parser. - """ - parser.addoption("--indico-image", action="store", default=None, help="Indico OCI image") - parser.addoption( - "--indico-nginx-image", action="store", default=None, help="Indico nginx OCI image" - ) - parser.addoption("--saml-email", action="store", default=None, help="SAML test email address") - parser.addoption("--saml-password", action="store", default=None, help="SAML test password") - @fixture(scope="module", name="external_url") def external_url_fixture(): From 13ed49659e438f1f988fb80f1e1b2fa290711910 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 23:18:42 +1300 Subject: [PATCH 05/15] Add requests to integration test dependencies The requests module is imported by test_charm.py, test_loki.py, and test_saml.py but was missing from the integration tox env deps. Co-Authored-By: Claude Opus 4.6 --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 65af6829..95851781 100644 --- a/tox.ini +++ b/tox.ini @@ -158,6 +158,7 @@ deps = jubilant pytest pytest-jubilant + requests commands = pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} From 65b9da645da8265131e044f9521e158bb0308fec Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 23:53:26 +1300 Subject: [PATCH 06/15] Set juju-channel to 3.6/stable for jubilant compatibility Jubilant uses 'juju integrate' which requires Juju 3.x. The reusable workflow defaults to 2.9/stable which doesn't have this command. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/integration_test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 7064062f..1498d963 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -12,6 +12,7 @@ jobs: secrets: inherit with: modules: '["test_actions.py", "test_charm.py", "test_s3.py", "test_saml.py", "test_loki.py"]' + juju-channel: 3.6/stable trivy-exit-code: 0 allure-report: if: ${{ !cancelled() && github.event_name == 'schedule' }} From d8b8d7ed92bbc5ac6650c5ebc90c21acb8c3dbb7 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sun, 8 Mar 2026 00:07:24 +1300 Subject: [PATCH 07/15] Use strictly confined microk8s for Juju 3.6 compatibility Juju 3.6 requires strictly confined microk8s. Set the microk8s channel to 1.34-strict/stable. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/integration_test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 1498d963..2c62c178 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -12,6 +12,7 @@ jobs: secrets: inherit with: modules: '["test_actions.py", "test_charm.py", "test_s3.py", "test_saml.py", "test_loki.py"]' + channel: 1.34-strict/stable juju-channel: 3.6/stable trivy-exit-code: 0 allure-report: From ca803636f2175bafd9bc5176ab5353c1e82f7521 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sun, 8 Mar 2026 00:25:42 +1300 Subject: [PATCH 08/15] Fix test_anonymize_user_fail to catch TaskError jubilant.Juju.run() raises TaskError when the action fails, so the test must use pytest.raises to catch it and inspect the task from the exception. Co-Authored-By: Claude Opus 4.6 --- tests/integration/test_actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_actions.py b/tests/integration/test_actions.py index d3172225..c3414c9b 100644 --- a/tests/integration/test_actions.py +++ b/tests/integration/test_actions.py @@ -59,7 +59,9 @@ def test_anonymize_user_fail(juju: jubilant.Juju, app: str): act: run the anonymize-user action assert: check the output in the action result """ - task = juju.run(f"{app}/0", "anonymize-user", params={"email": f",{ADMIN_USER_EMAIL_FAIL}"}) + with pytest.raises(jubilant.TaskError) as exc_info: + juju.run(f"{app}/0", "anonymize-user", params={"email": f",{ADMIN_USER_EMAIL_FAIL}"}) + task = exc_info.value.task assert task.status == "failed" assert task.results["user"] == f",{ADMIN_USER_EMAIL_FAIL}" expected_words = [ADMIN_USER_EMAIL_FAIL, "correctly anonymized", "Failed to anonymize user"] From 691ff1bbbd61be0e76cafd09f4da8d88b02a0a7e Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sun, 8 Mar 2026 10:21:49 +1300 Subject: [PATCH 09/15] Fix flake8 E303: remove extra blank line in conftest.py Co-Authored-By: Claude Opus 4.6 --- tests/integration/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a1e526bd..1d15ba08 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -14,7 +14,6 @@ from pytest import Config, fixture - @fixture(scope="module", name="external_url") def external_url_fixture(): """Provides the external URL for Indico.""" From ae4f22a3f144ab10de214f3984a34b571b9c211d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 8 Apr 2026 16:20:37 +1200 Subject: [PATCH 10/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/release-notes/artifacts/migrate_to_jubilant.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/artifacts/migrate_to_jubilant.yaml b/docs/release-notes/artifacts/migrate_to_jubilant.yaml index 08047dd1..b562e904 100644 --- a/docs/release-notes/artifacts/migrate_to_jubilant.yaml +++ b/docs/release-notes/artifacts/migrate_to_jubilant.yaml @@ -9,7 +9,7 @@ changes: pytest-jubilant plugin instead of pytest-operator. urls: pr: - - https://github.com/tonyandrewmeyer/indico-operator/pull/3 + - https://github.com/canonical/indico-operator/pull/3 related_doc: related_issue: visibility: internal From f1c7119805ab0c714bc938e15064dbcb2b659a99 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 9 Apr 2026 09:31:51 +1200 Subject: [PATCH 11/15] Replace series= with base= --- tests/integration/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1d15ba08..e411ca43 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -79,6 +79,7 @@ def app_fixture( channel="latest/edge", revision=133, trust=True, + base="ubuntu@20.04", ) resources = { @@ -87,7 +88,7 @@ def app_fixture( } if charm := pytestconfig.getoption("--charm-file"): - juju.deploy(f"./{charm}", app_name, resources=resources) + juju.deploy(f"./{charm}", app_name, resources=resources, base="ubuntu@20.04") else: charm = pytest_jubilant.pack() juju.deploy( @@ -97,6 +98,7 @@ def app_fixture( config={ "external_plugins": "https://github.com/canonical/flask-multipass-saml-groups/releases/download/1.2.2/flask_multipass_saml_groups-1.2.2-py3-none-any.whl" # noqa: E501 pylint: disable=line-too-long }, + base="ubuntu@20.04", ) juju.integrate(app_name, "postgresql-k8s") @@ -159,7 +161,7 @@ def s3_integrator_fixture(juju: jubilant.Juju, app: str): @pytest.fixture(scope="module", name="loki") def loki_fixture(juju: jubilant.Juju, app: str): """Loki charm used for integration testing.""" - juju.deploy("loki-k8s", channel="1/edge", trust=True, revision=97) + juju.deploy("loki-k8s", channel="1/edge", trust=True, revision=97, base="ubuntu@20.04") juju.integrate(app, "loki-k8s") juju.wait( lambda status: jubilant.all_active(status, "loki-k8s", app), From c0f148b3cb55ba8ef555040da606f7a7634dacb3 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 9 Apr 2026 09:38:18 +1200 Subject: [PATCH 12/15] Drive-by fix recommended in review. --- tests/integration/test_charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index dcd26428..f1a0f513 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -75,6 +75,6 @@ def test_prom_exporters_are_up(juju: jubilant.Juju, app: str): f"localhost:{CELERY_PROMEXP_PORT}", ] for target in prometheus_targets: - cmd = f"curl -m 10 http://{target}/metrics" + cmd = f"curl -f -m 10 http://{target}/metrics" # CLIError is raised if the command fails, so a successful return means status 200. juju.cli("exec", "--unit", f"{app}/0", "--", "bash", "-c", cmd) From 0441fe6a5686df304c439ddb7dac6660c79b068f Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 10 Apr 2026 10:21:01 +1200 Subject: [PATCH 13/15] Compatibility with pytest-jubilant 2. --- tests/integration/conftest.py | 31 +++++++++++++++++++++++++++++-- uv.lock | 3 +++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 uv.lock diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e411ca43..1f1a685a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,17 +3,32 @@ """Fixtures for Indico charm integration tests.""" +import shlex +import subprocess from pathlib import Path from secrets import token_hex from typing import Dict, Union import jubilant import pytest -import pytest_jubilant import yaml from pytest import Config, fixture +# Shim: the CI action passes --keep-models (pytest-jubilant 1.x flag) but +# pytest-jubilant 2.x renamed it to --no-juju-teardown. +# Remove this once the action is updated to use --no-juju-teardown. +def pytest_addoption(parser: pytest.Parser): + """Register the legacy --keep-models flag.""" + parser.addoption("--keep-models", action="store_true", default=False) + + +def pytest_configure(config: pytest.Config): + """Translate --keep-models into --no-juju-teardown.""" + if config.getoption("--keep-models", default=False): + config.option.no_juju_teardown = True + + @fixture(scope="module", name="external_url") def external_url_fixture(): """Provides the external URL for Indico.""" @@ -90,7 +105,19 @@ def app_fixture( if charm := pytestconfig.getoption("--charm-file"): juju.deploy(f"./{charm}", app_name, resources=resources, base="ubuntu@20.04") else: - charm = pytest_jubilant.pack() + proc = subprocess.run( + shlex.split("charmcraft pack -p ./"), + check=True, + capture_output=True, + text=True, + ) + charm = Path( + next( + line.split()[1] + for line in proc.stderr.strip().splitlines() + if line.startswith("Packed") + ) + ).resolve() juju.deploy( charm, app_name, diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..a5bc5147 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" From 8eb8511228237a86df9e331e5c8f511c374731b5 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 10 Apr 2026 10:25:23 +1200 Subject: [PATCH 14/15] Linting fixes. --- tests/integration/conftest.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1f1a685a..b08c5b23 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -18,13 +18,13 @@ # Shim: the CI action passes --keep-models (pytest-jubilant 1.x flag) but # pytest-jubilant 2.x renamed it to --no-juju-teardown. # Remove this once the action is updated to use --no-juju-teardown. -def pytest_addoption(parser: pytest.Parser): - """Register the legacy --keep-models flag.""" +def pytest_addoption(parser): + """Register the legacy --keep-models flag.""" # noqa: DCO020 parser.addoption("--keep-models", action="store_true", default=False) -def pytest_configure(config: pytest.Config): - """Translate --keep-models into --no-juju-teardown.""" +def pytest_configure(config): + """Translate --keep-models into --no-juju-teardown.""" # noqa: DCO020 if config.getoption("--keep-models", default=False): config.option.no_juju_teardown = True @@ -111,13 +111,14 @@ def app_fixture( capture_output=True, text=True, ) - charm = Path( - next( - line.split()[1] - for line in proc.stderr.strip().splitlines() - if line.startswith("Packed") - ) - ).resolve() + packed = [ + line.split()[1] + for line in proc.stderr.strip().splitlines() + if line.startswith("Packed") + ] + if not packed: + raise RuntimeError(f"No packed charm found in charmcraft output: {proc.stderr}") + charm = Path(packed[0]).resolve() juju.deploy( charm, app_name, From dd264bbe83b10e0f37cc95c6a7c4030310ef9f1e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 10 Apr 2026 10:57:41 +1200 Subject: [PATCH 15/15] Mark the subprocess usage as safe. --- tests/integration/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b08c5b23..f72e5928 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,7 +4,7 @@ """Fixtures for Indico charm integration tests.""" import shlex -import subprocess +import subprocess # nosec B404 from pathlib import Path from secrets import token_hex from typing import Dict, Union @@ -105,7 +105,7 @@ def app_fixture( if charm := pytestconfig.getoption("--charm-file"): juju.deploy(f"./{charm}", app_name, resources=resources, base="ubuntu@20.04") else: - proc = subprocess.run( + proc = subprocess.run( # nosec B603 shlex.split("charmcraft pack -p ./"), check=True, capture_output=True,