From 376ef9b4157d41cf4a45a0febe5b6fb6423f6b1a Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 19:05:51 +0200 Subject: [PATCH] fix(observability): pin the Grafana datasource uid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every panel on the provisioned dashboard rendered "No data" while Grafana logged nothing wrong. The datasource file declared no uid, so Grafana generated a random one on first provision. Every panel references the fixed string "prometheus", which therefore resolved to a datasource that does not exist. The dashboard and the datasource come from separate files and that string is the only thing tying them together. The failure mode is the problem: both files provision successfully, the log says so, and eleven empty panels look exactly like a shop with no traffic. It slipped through verification because that verification ran each panel's PromQL against Prometheus directly. Querying the datastore cannot see a broken datasource reference — only a query through Grafana can, and re-checking that way now shows all eleven panels returning data. Adds static checks over the provisioning files: that a datasource declares a uid, that every panel points at one that is actually provisioned, and that no panel ships without a query. They need no running stack, and both fail against the state that shipped. Closes #53 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- .../grafana/datasources/prometheus.yml | 6 + tests/test_grafana_provisioning.py | 114 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/test_grafana_provisioning.py diff --git a/src/docker/observability/grafana/datasources/prometheus.yml b/src/docker/observability/grafana/datasources/prometheus.yml index 8688c47..0a5ee37 100644 --- a/src/docker/observability/grafana/datasources/prometheus.yml +++ b/src/docker/observability/grafana/datasources/prometheus.yml @@ -2,6 +2,12 @@ apiVersion: 1 datasources: - name: Prometheus + # Pinned, not left to Grafana. Without an explicit uid Grafana generates a + # random one on first provision, and every panel in the dashboard — which + # references `prometheus` — resolves to a datasource that does not exist and + # renders "No data". The dashboard and the datasource are provisioned + # separately, so the only thing tying them together is this string. + uid: prometheus type: prometheus access: proxy url: http://opentaberna-prometheus:9090 diff --git a/tests/test_grafana_provisioning.py b/tests/test_grafana_provisioning.py new file mode 100644 index 0000000..37fa844 --- /dev/null +++ b/tests/test_grafana_provisioning.py @@ -0,0 +1,114 @@ +""" +Static checks on the Grafana provisioning files. + +The datasource and the dashboard are provisioned from separate files, and the +only thing tying them together is a uid string. Nothing at build or start time +verifies they agree — Grafana provisions both without complaint and every panel +renders "No data", which looks exactly like a shop with no traffic. + +That happened. These checks are cheap, need no running stack, and would have +caught it. +""" + +import json +from pathlib import Path + +import pytest + +OBSERVABILITY = Path(__file__).resolve().parent.parent / "src" / "docker" / "observability" +DATASOURCES = OBSERVABILITY / "grafana" / "datasources" +DASHBOARDS = OBSERVABILITY / "grafana" / "dashboards" + + +def _dashboard_files() -> list[Path]: + return [p for p in DASHBOARDS.glob("*.json")] + + +def _datasource_uids() -> set[str]: + """ + uids declared in the datasource YAML. + + Parsed by hand rather than with PyYAML: the file is a fixed shape and this + keeps the test suite from gaining a dependency for one assertion. + """ + uids = set() + for path in DATASOURCES.glob("*.yml"): + for line in path.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("uid:"): + uids.add(stripped.split(":", 1)[1].strip()) + return uids + + +def _panel_datasource_uids(dashboard: dict) -> set[str]: + uids = set() + + def walk(node): + if isinstance(node, dict): + datasource = node.get("datasource") + if isinstance(datasource, dict) and datasource.get("uid"): + uids.add(datasource["uid"]) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(dashboard) + return uids + + +def test_provisioning_files_exist(): + assert DATASOURCES.is_dir(), "datasource provisioning directory is missing" + assert _dashboard_files(), "no dashboards to provision" + + +def test_the_datasource_declares_an_explicit_uid(): + """ + Without one Grafana generates a random uid on first provision, and every + panel referencing a fixed string resolves to nothing. + """ + assert _datasource_uids(), ( + "no datasource declares a uid; panels cannot reference it reliably" + ) + + +@pytest.mark.parametrize("path", _dashboard_files(), ids=lambda p: p.name) +def test_every_panel_points_at_a_datasource_that_is_provisioned(path): + """ + The failure this prevents is silent: Grafana provisions both files happily, + logs no error, and renders "No data" on every panel — indistinguishable from + a shop with no traffic. + """ + dashboard = json.loads(path.read_text()) + referenced = _panel_datasource_uids(dashboard) + declared = _datasource_uids() + + missing = referenced - declared + assert not missing, ( + f"{path.name} references datasource uid(s) {sorted(missing)} " + f"which no provisioning file declares (declared: {sorted(declared)})" + ) + + +@pytest.mark.parametrize("path", _dashboard_files(), ids=lambda p: p.name) +def test_dashboard_is_valid_json_with_a_stable_uid(path): + dashboard = json.loads(path.read_text()) + + # The uid is what the URL and any bookmark depend on. + assert dashboard.get("uid"), f"{path.name} has no dashboard uid" + assert dashboard.get("title"), f"{path.name} has no title" + assert dashboard.get("panels"), f"{path.name} has no panels" + + +@pytest.mark.parametrize("path", _dashboard_files(), ids=lambda p: p.name) +def test_every_panel_has_a_query(path): + """A panel with no target is a box that can only ever say "No data".""" + dashboard = json.loads(path.read_text()) + + for panel in dashboard["panels"]: + if panel.get("type") == "row": + continue + targets = panel.get("targets") or [] + assert targets, f"{panel.get('title')} has no query" + assert targets[0].get("expr"), f"{panel.get('title')} has an empty query"