From fed1d039d5cefb5ad777279ec74c1546739e8239 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Wed, 25 Mar 2026 16:14:36 +0100 Subject: [PATCH 1/7] fix(cos): use appropriate cos directory --- .../grafana_dashboards/go.json | 0 charms/planner-operator/src/charm.py | 9 ----- .../src/cos/loki_alert_rules/.gitkeep | 0 .../src/cos/prometheus_alert_rules/.gitkeep | 0 charms/tests/integration/conftest.py | 18 +++++++++ charms/tests/integration/test_planner.py | 36 ++++++++++++++++++ .../tests/integration/test_webhook_gateway.py | 38 +++++++++++++++++++ 7 files changed, 92 insertions(+), 9 deletions(-) rename charms/planner-operator/{src/cos => cos_custom}/grafana_dashboards/go.json (100%) delete mode 100644 charms/planner-operator/src/cos/loki_alert_rules/.gitkeep delete mode 100644 charms/planner-operator/src/cos/prometheus_alert_rules/.gitkeep diff --git a/charms/planner-operator/src/cos/grafana_dashboards/go.json b/charms/planner-operator/cos_custom/grafana_dashboards/go.json similarity index 100% rename from charms/planner-operator/src/cos/grafana_dashboards/go.json rename to charms/planner-operator/cos_custom/grafana_dashboards/go.json diff --git a/charms/planner-operator/src/charm.py b/charms/planner-operator/src/charm.py index 56e8e012..99a69ae1 100755 --- a/charms/planner-operator/src/charm.py +++ b/charms/planner-operator/src/charm.py @@ -7,7 +7,6 @@ import dataclasses import json import logging -import pathlib import typing import ops @@ -62,14 +61,6 @@ def __init__(self, *args: typing.Any) -> None: self._on_planner_relation_broken, ) - def get_cos_dir(self) -> str: - """Get the COS directory for this charm. - - Returns: - The COS directory. - """ - return str((pathlib.Path(__file__).parent / "cos").absolute()) - def _create_app(self): """Patch _create_app to add OpenTelemetry environment variables.""" original_app = super()._create_app() diff --git a/charms/planner-operator/src/cos/loki_alert_rules/.gitkeep b/charms/planner-operator/src/cos/loki_alert_rules/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/charms/planner-operator/src/cos/prometheus_alert_rules/.gitkeep b/charms/planner-operator/src/cos/prometheus_alert_rules/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/charms/tests/integration/conftest.py b/charms/tests/integration/conftest.py index 1d98d5cb..2ee5fb38 100644 --- a/charms/tests/integration/conftest.py +++ b/charms/tests/integration/conftest.py @@ -248,6 +248,24 @@ def integrate_planner_rabbitmq_postgresql_fixture( return planner_app +@pytest.fixture(scope="module", name="any_charm_grafana_consumer_app") +def deploy_any_charm_grafana_consumer_app_fixture(juju: jubilant.Juju) -> str: + """Deploy any charm to act as a grafana-dashboard consumer.""" + app_name = "grafana-consumer" + + juju.deploy( + "any-charm", + app=app_name, + channel="latest/beta", + ) + juju.wait( + lambda status: jubilant.all_active(status, app_name), + timeout=10 * 60, + delay=10, + ) + return app_name + + @pytest.fixture(scope="module", name="any_charm_github_runner_app") def deploy_any_charm_github_runner_app_fixture(juju: jubilant.Juju) -> str: """Deploy any charm to act as a GitHub runner application.""" diff --git a/charms/tests/integration/test_planner.py b/charms/tests/integration/test_planner.py index adde1b73..d94afb75 100644 --- a/charms/tests/integration/test_planner.py +++ b/charms/tests/integration/test_planner.py @@ -204,6 +204,42 @@ def test_planner_enable_disable_flavor_actions( assert flavor_data["is_disabled"] is False, "Flavor should be enabled after action" +@pytest.mark.usefixtures("planner_with_integrations") +def test_planner_grafana_dashboard( + juju: jubilant.Juju, + planner_app: str, + any_charm_grafana_consumer_app: str, +): + """ + arrange: The planner app is deployed with required integrations. + act: Integrate with a grafana-dashboard consumer and inspect relation data. + assert: The grafana-dashboard relation contains non-empty dashboard templates. + """ + juju.integrate( + f"{planner_app}:grafana-dashboard", + f"{any_charm_grafana_consumer_app}:require-grafana-dashboard", + ) + juju.wait( + lambda status: jubilant.all_active(status, planner_app), + timeout=6 * 60, + delay=10, + ) + + unit = f"{planner_app}/0" + stdout = juju.cli("show-unit", unit, "--format=json") + result = json.loads(stdout) + for relation in result[unit]["relation-info"]: + if relation["endpoint"] == "grafana-dashboard": + dashboards_raw = relation["application-data"].get("dashboards") + assert dashboards_raw, "expected non-empty dashboards in relation data" + dashboards = json.loads(dashboards_raw) + templates = dashboards.get("templates", {}) + assert len(templates) >= 1, "expected at least one dashboard template" + break + else: + pytest.fail("No grafana-dashboard relation found on planner") + + def poll_flavor_status(unit_ip, flavor_name, token, expected_status, attempts=24, interval=5): """Poll the flavor API until the expected HTTP status is returned.""" for _ in range(attempts): diff --git a/charms/tests/integration/test_webhook_gateway.py b/charms/tests/integration/test_webhook_gateway.py index b213c226..4b762743 100644 --- a/charms/tests/integration/test_webhook_gateway.py +++ b/charms/tests/integration/test_webhook_gateway.py @@ -1,5 +1,7 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. +import json + import jubilant import pytest import requests @@ -51,3 +53,39 @@ def test_webhook_gateway_prometheus_metrics( response = requests.get(f"http://{unit_ip}:{METRICS_PORT}/metrics") assert response.status_code == requests.status_codes.codes.OK + + +@pytest.mark.usefixtures("webhook_gateway_with_rabbitmq") +def test_webhook_gateway_grafana_dashboard( + juju: jubilant.Juju, + webhook_gateway_app: str, + any_charm_grafana_consumer_app: str, +): + """ + arrange: The webhook gateway app is deployed with required integrations. + act: Integrate with a grafana-dashboard consumer and inspect relation data. + assert: The grafana-dashboard relation contains non-empty dashboard templates. + """ + juju.integrate( + f"{webhook_gateway_app}:grafana-dashboard", + f"{any_charm_grafana_consumer_app}:require-grafana-dashboard", + ) + juju.wait( + lambda status: jubilant.all_active(status, webhook_gateway_app), + timeout=6 * 60, + delay=10, + ) + + unit = f"{webhook_gateway_app}/0" + stdout = juju.cli("show-unit", unit, "--format=json") + result = json.loads(stdout) + for relation in result[unit]["relation-info"]: + if relation["endpoint"] == "grafana-dashboard": + dashboards_raw = relation["application-data"].get("dashboards") + assert dashboards_raw, "expected non-empty dashboards in relation data" + dashboards = json.loads(dashboards_raw) + templates = dashboards.get("templates", {}) + assert len(templates) >= 1, "expected at least one dashboard template" + break + else: + pytest.fail("No grafana-dashboard relation found on webhook-gateway") From ed11ac6aada87e936fc6fa6864761b309b970aa2 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Wed, 25 Mar 2026 16:25:32 +0100 Subject: [PATCH 2/7] test(planner): test details of dashboard --- charms/tests/integration/test_planner.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/charms/tests/integration/test_planner.py b/charms/tests/integration/test_planner.py index d94afb75..9e4213c2 100644 --- a/charms/tests/integration/test_planner.py +++ b/charms/tests/integration/test_planner.py @@ -1,7 +1,10 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. +import base64 import json +import lzma import time + import jubilant import pytest import requests @@ -213,7 +216,8 @@ def test_planner_grafana_dashboard( """ arrange: The planner app is deployed with required integrations. act: Integrate with a grafana-dashboard consumer and inspect relation data. - assert: The grafana-dashboard relation contains non-empty dashboard templates. + assert: The grafana-dashboard relation contains dashboard templates including + the custom planner dashboard with planner-specific metrics. """ juju.integrate( f"{planner_app}:grafana-dashboard", @@ -235,6 +239,14 @@ def test_planner_grafana_dashboard( dashboards = json.loads(dashboards_raw) templates = dashboards.get("templates", {}) assert len(templates) >= 1, "expected at least one dashboard template" + + all_content = "" + for template in templates.values(): + raw = base64.b64decode(template["content"].encode("utf-8")) + all_content += lzma.decompress(raw).decode("utf-8") + assert "github_runner_planner_webhook_consumed_total" in all_content, ( + "expected custom planner dashboard with planner-specific metrics" + ) break else: pytest.fail("No grafana-dashboard relation found on planner") From 463db319d01c76e0a90ad5b5fd40327bee0d8373 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Thu, 26 Mar 2026 07:57:39 +0100 Subject: [PATCH 3/7] fix: use polling for grafana dashboard templates --- charms/tests/integration/helpers.py | 29 +++++++++++++++ charms/tests/integration/test_planner.py | 35 ++++++------------- .../tests/integration/test_webhook_gateway.py | 23 ++---------- 3 files changed, 42 insertions(+), 45 deletions(-) create mode 100644 charms/tests/integration/helpers.py diff --git a/charms/tests/integration/helpers.py b/charms/tests/integration/helpers.py new file mode 100644 index 00000000..1b61bf70 --- /dev/null +++ b/charms/tests/integration/helpers.py @@ -0,0 +1,29 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +import json +import time +from typing import Any + +import jubilant + + +def poll_grafana_dashboard_templates( + juju: jubilant.Juju, unit: str, attempts: int = 24, interval: int = 5 +) -> dict[str, Any]: + """Poll for dashboard templates in the grafana-dashboard relation data. + + Returns the templates dict if found, or an empty dict after all attempts are exhausted. + """ + for _ in range(attempts): + stdout = juju.cli("show-unit", unit, "--format=json") + result = json.loads(stdout) + for relation in result[unit]["relation-info"]: + if relation["endpoint"] == "grafana-dashboard": + dashboards_raw = relation["application-data"].get("dashboards") + if dashboards_raw: + dashboards = json.loads(dashboards_raw) + templates = dashboards.get("templates", {}) + if templates: + return templates + time.sleep(interval) + return {} diff --git a/charms/tests/integration/test_planner.py b/charms/tests/integration/test_planner.py index 9e4213c2..f213e66d 100644 --- a/charms/tests/integration/test_planner.py +++ b/charms/tests/integration/test_planner.py @@ -8,6 +8,7 @@ import jubilant import pytest import requests +from tests.integration.helpers import poll_grafana_dashboard_templates APP_PORT = 8080 METRICS_PORT = 9464 @@ -223,33 +224,17 @@ def test_planner_grafana_dashboard( f"{planner_app}:grafana-dashboard", f"{any_charm_grafana_consumer_app}:require-grafana-dashboard", ) - juju.wait( - lambda status: jubilant.all_active(status, planner_app), - timeout=6 * 60, - delay=10, - ) - unit = f"{planner_app}/0" - stdout = juju.cli("show-unit", unit, "--format=json") - result = json.loads(stdout) - for relation in result[unit]["relation-info"]: - if relation["endpoint"] == "grafana-dashboard": - dashboards_raw = relation["application-data"].get("dashboards") - assert dashboards_raw, "expected non-empty dashboards in relation data" - dashboards = json.loads(dashboards_raw) - templates = dashboards.get("templates", {}) - assert len(templates) >= 1, "expected at least one dashboard template" + templates = poll_grafana_dashboard_templates(juju, f"{planner_app}/0") + assert templates, "expected non-empty dashboard templates in grafana-dashboard relation" - all_content = "" - for template in templates.values(): - raw = base64.b64decode(template["content"].encode("utf-8")) - all_content += lzma.decompress(raw).decode("utf-8") - assert "github_runner_planner_webhook_consumed_total" in all_content, ( - "expected custom planner dashboard with planner-specific metrics" - ) - break - else: - pytest.fail("No grafana-dashboard relation found on planner") + all_content = "" + for template in templates.values(): + raw = base64.b64decode(template["content"].encode("utf-8")) + all_content += lzma.decompress(raw).decode("utf-8") + assert "github_runner_planner_webhook_consumed_total" in all_content, ( + "expected custom planner dashboard with planner-specific metrics" + ) def poll_flavor_status(unit_ip, flavor_name, token, expected_status, attempts=24, interval=5): diff --git a/charms/tests/integration/test_webhook_gateway.py b/charms/tests/integration/test_webhook_gateway.py index 4b762743..68bd8c8a 100644 --- a/charms/tests/integration/test_webhook_gateway.py +++ b/charms/tests/integration/test_webhook_gateway.py @@ -1,10 +1,9 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. -import json - import jubilant import pytest import requests +from tests.integration.helpers import poll_grafana_dashboard_templates APP_PORT = 8080 METRICS_PORT = 9464 @@ -70,22 +69,6 @@ def test_webhook_gateway_grafana_dashboard( f"{webhook_gateway_app}:grafana-dashboard", f"{any_charm_grafana_consumer_app}:require-grafana-dashboard", ) - juju.wait( - lambda status: jubilant.all_active(status, webhook_gateway_app), - timeout=6 * 60, - delay=10, - ) - unit = f"{webhook_gateway_app}/0" - stdout = juju.cli("show-unit", unit, "--format=json") - result = json.loads(stdout) - for relation in result[unit]["relation-info"]: - if relation["endpoint"] == "grafana-dashboard": - dashboards_raw = relation["application-data"].get("dashboards") - assert dashboards_raw, "expected non-empty dashboards in relation data" - dashboards = json.loads(dashboards_raw) - templates = dashboards.get("templates", {}) - assert len(templates) >= 1, "expected at least one dashboard template" - break - else: - pytest.fail("No grafana-dashboard relation found on webhook-gateway") + templates = poll_grafana_dashboard_templates(juju, f"{webhook_gateway_app}/0") + assert templates, "expected non-empty dashboard templates in grafana-dashboard relation" From d1a2f52241d4163b31fee90d6e8c810ca86dc496 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 27 Mar 2026 07:45:49 +0100 Subject: [PATCH 4/7] fix(charmcraft): use binary packages for pydantic to fix build setuptools-scm 10.0.2 is incompatible with charmcraft's pinned pip 24.1.1 when building from source, causing "paths must be inside source tree" errors. Use charm-binary-python-packages to install pydantic and pydantic-core from wheels instead. --- charms/planner-operator/charmcraft.yaml | 4 ++++ charms/webhook-gateway-operator/charmcraft.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/charms/planner-operator/charmcraft.yaml b/charms/planner-operator/charmcraft.yaml index c5fee6b2..3f112708 100644 --- a/charms/planner-operator/charmcraft.yaml +++ b/charms/planner-operator/charmcraft.yaml @@ -21,6 +21,10 @@ description: | extensions: - go-framework +parts: + charm: + charm-binary-python-packages: [pydantic, pydantic-core] + requires: rabbitmq: interface: rabbitmq diff --git a/charms/webhook-gateway-operator/charmcraft.yaml b/charms/webhook-gateway-operator/charmcraft.yaml index 0f5f1fa3..71b99429 100644 --- a/charms/webhook-gateway-operator/charmcraft.yaml +++ b/charms/webhook-gateway-operator/charmcraft.yaml @@ -19,6 +19,10 @@ description: | extensions: - go-framework +parts: + charm: + charm-binary-python-packages: [pydantic, pydantic-core] + config: options: webhook-secret: From c0af55c6bbc3c0d53963b8aba9475d11b26e1a1c Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 27 Mar 2026 08:02:46 +0100 Subject: [PATCH 5/7] Revert "fix(charmcraft): use binary packages for pydantic to fix build" This reverts commit d1a2f52241d4163b31fee90d6e8c810ca86dc496. --- charms/planner-operator/charmcraft.yaml | 4 ---- charms/webhook-gateway-operator/charmcraft.yaml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/charms/planner-operator/charmcraft.yaml b/charms/planner-operator/charmcraft.yaml index 3f112708..c5fee6b2 100644 --- a/charms/planner-operator/charmcraft.yaml +++ b/charms/planner-operator/charmcraft.yaml @@ -21,10 +21,6 @@ description: | extensions: - go-framework -parts: - charm: - charm-binary-python-packages: [pydantic, pydantic-core] - requires: rabbitmq: interface: rabbitmq diff --git a/charms/webhook-gateway-operator/charmcraft.yaml b/charms/webhook-gateway-operator/charmcraft.yaml index 71b99429..0f5f1fa3 100644 --- a/charms/webhook-gateway-operator/charmcraft.yaml +++ b/charms/webhook-gateway-operator/charmcraft.yaml @@ -19,10 +19,6 @@ description: | extensions: - go-framework -parts: - charm: - charm-binary-python-packages: [pydantic, pydantic-core] - config: options: webhook-secret: From 614fde67293a66ea38887a25abc7c09bbd79e22f Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 27 Mar 2026 08:25:04 +0100 Subject: [PATCH 6/7] fix(charmcraft): work around setuptools-scm build failure Use --only-binary=pluggy in requirements.txt to prevent pip from building pluggy from source, avoiding the setuptools-scm >= 10 incompatibility with charmcraft's pinned pip 24.1.1. See https://github.com/pypa/setuptools-scm/issues/1302 --- charms/planner-operator/requirements.txt | 1 + charms/webhook-gateway-operator/requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/charms/planner-operator/requirements.txt b/charms/planner-operator/requirements.txt index 26b7a350..28677f10 100644 --- a/charms/planner-operator/requirements.txt +++ b/charms/planner-operator/requirements.txt @@ -1,3 +1,4 @@ +--only-binary=pluggy ops==3.6.0 paas-charm==1.11.1 requests==2.32.5 diff --git a/charms/webhook-gateway-operator/requirements.txt b/charms/webhook-gateway-operator/requirements.txt index a4f538d4..5c92d4cb 100644 --- a/charms/webhook-gateway-operator/requirements.txt +++ b/charms/webhook-gateway-operator/requirements.txt @@ -1,2 +1,3 @@ +--only-binary=pluggy ops==3.6.0 paas-charm==1.11.1 From d5c09c696f660b1211f65d3d19549715888ce06d Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 27 Mar 2026 17:39:21 +0100 Subject: [PATCH 7/7] fix(test): read grafana dashboard data from consumer side of relation juju show-unit displays the remote application's data under application-data. We were checking from the provider side and seeing the consumer's empty data. Check from the consumer side instead. --- charms/tests/integration/helpers.py | 12 +++++++----- charms/tests/integration/test_planner.py | 2 +- charms/tests/integration/test_webhook_gateway.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/charms/tests/integration/helpers.py b/charms/tests/integration/helpers.py index 1b61bf70..118bfc74 100644 --- a/charms/tests/integration/helpers.py +++ b/charms/tests/integration/helpers.py @@ -8,17 +8,19 @@ def poll_grafana_dashboard_templates( - juju: jubilant.Juju, unit: str, attempts: int = 24, interval: int = 5 + juju: jubilant.Juju, consumer_unit: str, attempts: int = 24, interval: int = 5 ) -> dict[str, Any]: - """Poll for dashboard templates in the grafana-dashboard relation data. + """Poll for dashboard templates via the grafana-dashboard consumer's relation data. + Checks show-unit on the consumer side, where application-data contains + the provider's data (including the dashboards key). Returns the templates dict if found, or an empty dict after all attempts are exhausted. """ for _ in range(attempts): - stdout = juju.cli("show-unit", unit, "--format=json") + stdout = juju.cli("show-unit", consumer_unit, "--format=json") result = json.loads(stdout) - for relation in result[unit]["relation-info"]: - if relation["endpoint"] == "grafana-dashboard": + for relation in result[consumer_unit]["relation-info"]: + if relation["endpoint"] == "require-grafana-dashboard": dashboards_raw = relation["application-data"].get("dashboards") if dashboards_raw: dashboards = json.loads(dashboards_raw) diff --git a/charms/tests/integration/test_planner.py b/charms/tests/integration/test_planner.py index f213e66d..217ee7b3 100644 --- a/charms/tests/integration/test_planner.py +++ b/charms/tests/integration/test_planner.py @@ -225,7 +225,7 @@ def test_planner_grafana_dashboard( f"{any_charm_grafana_consumer_app}:require-grafana-dashboard", ) - templates = poll_grafana_dashboard_templates(juju, f"{planner_app}/0") + templates = poll_grafana_dashboard_templates(juju, f"{any_charm_grafana_consumer_app}/0") assert templates, "expected non-empty dashboard templates in grafana-dashboard relation" all_content = "" diff --git a/charms/tests/integration/test_webhook_gateway.py b/charms/tests/integration/test_webhook_gateway.py index 68bd8c8a..80d0a6f3 100644 --- a/charms/tests/integration/test_webhook_gateway.py +++ b/charms/tests/integration/test_webhook_gateway.py @@ -70,5 +70,5 @@ def test_webhook_gateway_grafana_dashboard( f"{any_charm_grafana_consumer_app}:require-grafana-dashboard", ) - templates = poll_grafana_dashboard_templates(juju, f"{webhook_gateway_app}/0") + templates = poll_grafana_dashboard_templates(juju, f"{any_charm_grafana_consumer_app}/0") assert templates, "expected non-empty dashboard templates in grafana-dashboard relation"