|
| 1 | +# -------------------------------------------------------------------------------------------- |
| 2 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | +# Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | +# -------------------------------------------------------------------------------------------- |
| 5 | + |
| 6 | +# Helpers for creating dashboards that use the Grafana v2 ("dynamic dashboards") schema through |
| 7 | +# the dashboard apiserver (dashboard.grafana.app). Classic (v1) dashboards continue to use the |
| 8 | +# legacy /api/dashboards/db endpoint in custom.py. See Grafana's own routing logic: |
| 9 | +# https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/view-dashboard-json-model/ |
| 10 | + |
| 11 | +import copy |
| 12 | +import json |
| 13 | + |
| 14 | +import requests |
| 15 | + |
| 16 | +from azure.cli.core.azclierror import ArgumentUsageError |
| 17 | +from azure.cli.core.util import should_disable_connection_verify |
| 18 | + |
| 19 | +DASHBOARD_APISERVER_GROUP = "dashboard.grafana.app" |
| 20 | +# AMG runs single-org Grafana (org 1), whose apiserver namespace is "default". Grafana Cloud |
| 21 | +# multi-tenant instances use "stacks-<id>". TODO: confirm/derive on a live AMG 13 instance. |
| 22 | +DASHBOARD_APISERVER_NAMESPACE = "default" |
| 23 | + |
| 24 | + |
| 25 | +def is_v2_dashboard_definition(definition): |
| 26 | + # Mirrors Grafana's own discriminator (isDashboardV2Spec: the spec has an "elements" map) and |
| 27 | + # also accepts a full v2 k8s resource envelope (apiVersion: dashboard.grafana.app/v2*). |
| 28 | + if not isinstance(definition, dict): |
| 29 | + return False |
| 30 | + api_version = definition.get("apiVersion") |
| 31 | + if isinstance(api_version, str) and api_version.startswith(DASHBOARD_APISERVER_GROUP + "/v2"): |
| 32 | + return True |
| 33 | + spec = definition.get("spec") |
| 34 | + if isinstance(spec, dict) and "elements" in spec: |
| 35 | + return True |
| 36 | + # a bare v2 spec (no k8s envelope) has "elements" and never the classic "panels" array |
| 37 | + return "elements" in definition and "panels" not in definition |
| 38 | + |
| 39 | + |
| 40 | +def is_v2_stored_version(version): |
| 41 | + # Matches Grafana's isV2StoredVersion — the stored apiserver versions that carry the v2 model. |
| 42 | + return version in ("v2alpha1", "v2beta1", "v2") |
| 43 | + |
| 44 | + |
| 45 | +def _request(grafana_url, http_headers, method, path, *, body=None, raise_for_error_status=True): |
| 46 | + resp = requests.request(method, url=grafana_url.rstrip("/") + path, headers=http_headers, |
| 47 | + json=body, timeout=60, verify=not should_disable_connection_verify()) |
| 48 | + if resp.status_code >= 400 and raise_for_error_status: |
| 49 | + resp.raise_for_status() |
| 50 | + return resp |
| 51 | + |
| 52 | + |
| 53 | +def resolve_dashboard_v2_api_version(grafana_url, http_headers): |
| 54 | + # Discover which dashboard.grafana.app v2 version this instance serves, mirroring Grafana's |
| 55 | + # DashboardAPIVersionResolver (prefer the server's preferredVersion, else v2, else v2beta1). |
| 56 | + # Returns None when the v2 apiserver isn't served at all (e.g. a v1-only gateway) so the caller |
| 57 | + # can fail loudly instead of POSTing a v2 body to the legacy v1 endpoint. |
| 58 | + response = _request(grafana_url, http_headers, "get", "/apis/" + DASHBOARD_APISERVER_GROUP + "/", |
| 59 | + raise_for_error_status=False) |
| 60 | + if response.status_code >= 400: |
| 61 | + return None |
| 62 | + group = json.loads(response.content) |
| 63 | + versions = {v.get("version") for v in group.get("versions", [])} |
| 64 | + preferred = (group.get("preferredVersion") or {}).get("version") |
| 65 | + if preferred in ("v2", "v2beta1"): |
| 66 | + return preferred |
| 67 | + if "v2" in versions: |
| 68 | + return "v2" |
| 69 | + if "v2beta1" in versions: |
| 70 | + return "v2beta1" |
| 71 | + return None |
| 72 | + |
| 73 | + |
| 74 | +def require_dashboard_v2_api_version(grafana_url, http_headers): |
| 75 | + # As resolve_dashboard_v2_api_version, but raises when the instance doesn't serve the v2 API, |
| 76 | + # so a v2 definition is never silently sent to the legacy v1 endpoint. |
| 77 | + version = resolve_dashboard_v2_api_version(grafana_url, http_headers) |
| 78 | + if not version: |
| 79 | + raise ArgumentUsageError( |
| 80 | + "The dashboard uses the Grafana v2 (dynamic dashboards) schema, but this Grafana instance does " |
| 81 | + "not serve the 'dashboard.grafana.app' v2 API. Use a Grafana 13+ instance, or provide a classic " |
| 82 | + "(v1) dashboard JSON.") |
| 83 | + return version |
| 84 | + |
| 85 | + |
| 86 | +def read_v2_dashboard(grafana_url, http_headers, uid, version=None): |
| 87 | + # Return the full v2 dashboard resource when the dashboard is stored as v2, else None. |
| 88 | + # The legacy /api/dashboards/uid endpoint down-converts v2 dashboards to classic (lossy), so |
| 89 | + # callers that need fidelity (show / backup / migrate) should try this first and only fall |
| 90 | + # back to the classic read when it returns None. Detection uses the apiserver's own |
| 91 | + # status.conversion.storedVersion: a v2-stored dashboard read at v2 has no conversion, whereas |
| 92 | + # a classic dashboard read at v2 reports a v0/v1 storedVersion (an up-conversion we must skip). |
| 93 | + # Callers reading many dashboards from one instance (backup) can resolve the version once and |
| 94 | + # pass it in to avoid a discovery request per dashboard. |
| 95 | + if version is None: |
| 96 | + version = resolve_dashboard_v2_api_version(grafana_url, http_headers) |
| 97 | + if not version: |
| 98 | + return None |
| 99 | + path = (f"/apis/{DASHBOARD_APISERVER_GROUP}/{version}" |
| 100 | + f"/namespaces/{DASHBOARD_APISERVER_NAMESPACE}/dashboards/{uid}") |
| 101 | + resp = _request(grafana_url, http_headers, "get", path, raise_for_error_status=False) |
| 102 | + if resp.status_code >= 400: |
| 103 | + return None |
| 104 | + body = json.loads(resp.content) |
| 105 | + stored = ((body.get("status") or {}).get("conversion") or {}).get("storedVersion") |
| 106 | + if stored is None or is_v2_stored_version(stored): |
| 107 | + return body |
| 108 | + return None |
| 109 | + |
| 110 | + |
| 111 | +def read_dashboard(grafana_url, http_headers, uid, v2_version): |
| 112 | + # Read a dashboard losslessly using a caller-resolved v2 API version, then fall back to the |
| 113 | + # classic endpoint when v2 is unavailable or the dashboard itself is stored as classic. |
| 114 | + if v2_version: |
| 115 | + dashboard = read_v2_dashboard(grafana_url, http_headers, uid, version=v2_version) |
| 116 | + if dashboard is not None: |
| 117 | + return dashboard |
| 118 | + response = _request(grafana_url, http_headers, "get", "/api/dashboards/uid/" + uid) |
| 119 | + return json.loads(response.content) |
| 120 | + |
| 121 | + |
| 122 | +def create_dashboard_v2(grafana_url, http_headers, definition, version, *, title=None, folder_uid=None, |
| 123 | + overwrite=None): |
| 124 | + # Normalise the input into a k8s Dashboard resource (apiVersion / kind / metadata / spec). |
| 125 | + if isinstance(definition.get("spec"), dict) and definition.get("kind") == "Dashboard": |
| 126 | + resource = copy.deepcopy(definition) |
| 127 | + metadata = resource.get("metadata") or {} |
| 128 | + spec = resource.get("spec") or {} |
| 129 | + else: # a bare v2 spec, no k8s envelope |
| 130 | + spec = copy.deepcopy(definition) |
| 131 | + metadata = {} |
| 132 | + resource = {} |
| 133 | + |
| 134 | + if title: |
| 135 | + spec["title"] = title |
| 136 | + |
| 137 | + annotations = metadata.get("annotations") or {} |
| 138 | + if folder_uid: |
| 139 | + annotations["grafana.app/folder"] = folder_uid |
| 140 | + annotations["grafana.app/grantPermissions"] = "default" |
| 141 | + metadata["annotations"] = annotations |
| 142 | + metadata["namespace"] = DASHBOARD_APISERVER_NAMESPACE |
| 143 | + for server_field in ("resourceVersion", "creationTimestamp", "generation", "uid"): |
| 144 | + metadata.pop(server_field, None) # server-managed; not valid on create / re-create |
| 145 | + |
| 146 | + resource["apiVersion"] = DASHBOARD_APISERVER_GROUP + "/" + version |
| 147 | + resource["kind"] = "Dashboard" |
| 148 | + resource["metadata"] = metadata |
| 149 | + resource["spec"] = spec |
| 150 | + resource.pop("status", None) |
| 151 | + |
| 152 | + base_path = (f"/apis/{DASHBOARD_APISERVER_GROUP}/{version}" |
| 153 | + f"/namespaces/{DASHBOARD_APISERVER_NAMESPACE}/dashboards") |
| 154 | + name = metadata.get("name") |
| 155 | + |
| 156 | + if name: |
| 157 | + existing = _request(grafana_url, http_headers, "get", base_path + "/" + name, |
| 158 | + raise_for_error_status=False) |
| 159 | + if existing.status_code == 200: |
| 160 | + if not overwrite: |
| 161 | + raise ArgumentUsageError( |
| 162 | + f"A dashboard with uid '{name}' already exists. Use --overwrite to replace it.") |
| 163 | + response = _request(grafana_url, http_headers, "put", base_path + "/" + name, body=resource) |
| 164 | + return json.loads(response.content) |
| 165 | + |
| 166 | + # create: name (if any) is honoured by the apiserver; otherwise the backend generates one |
| 167 | + response = _request(grafana_url, http_headers, "post", base_path, body=resource) |
| 168 | + return json.loads(response.content) |
| 169 | + |
| 170 | + |
| 171 | +def dashboard_identity(content): |
| 172 | + # Return (uid, title) for either a classic {meta, dashboard} object or a v2 resource. |
| 173 | + if is_v2_dashboard_definition(content): |
| 174 | + return content.get("metadata", {}).get("name"), content.get("spec", {}).get("title", "") |
| 175 | + dashboard = content.get("dashboard", content) |
| 176 | + return dashboard.get("uid"), dashboard.get("title", "") |
| 177 | + |
| 178 | + |
| 179 | +def dashboard_folder_uid(content): |
| 180 | + # Return the folder uid for either a classic {meta, dashboard} object or a v2 resource. |
| 181 | + if is_v2_dashboard_definition(content): |
| 182 | + return content.get("metadata", {}).get("annotations", {}).get("grafana.app/folder", "") |
| 183 | + return content.get("meta", {}).get("folderUid", "") |
| 184 | + |
| 185 | + |
| 186 | +def remap_v2_datasource_uids(node, uid_mapping): |
| 187 | + # Rewrite datasource UIDs inside a v2 dashboard spec. In the v2 schema a datasource reference is |
| 188 | + # {"datasource": {"name": "<uid>"}} (the uid is under "name"; the type lives in the sibling |
| 189 | + # DataQuery "group" field) — unlike the classic {"datasource": {"type", "uid"}}. The classic |
| 190 | + # remapper (utils.remap_datasource_uids) matches "uid" and so never reaches v2 refs; this walks |
| 191 | + # the spec and rewrites "name" (and defensively "uid") when it maps to a known source uid. |
| 192 | + if isinstance(node, dict): |
| 193 | + for key, value in node.items(): |
| 194 | + if key == "datasource" and isinstance(value, dict): |
| 195 | + for ref_key in ("name", "uid"): |
| 196 | + if value.get(ref_key) in uid_mapping: |
| 197 | + value[ref_key] = uid_mapping[value[ref_key]] |
| 198 | + else: |
| 199 | + remap_v2_datasource_uids(value, uid_mapping) |
| 200 | + elif isinstance(node, list): |
| 201 | + for item in node: |
| 202 | + remap_v2_datasource_uids(item, uid_mapping) |
| 203 | + |
| 204 | + |
| 205 | +def is_dashboard_provisioned(content): |
| 206 | + # Whether a dashboard is externally managed (file provisioning, repo, terraform, kubectl, ...) |
| 207 | + # and therefore should not be synced/overwritten. Classic exposes this as meta.provisioned; v2 |
| 208 | + # carries the grafana.app/managedBy annotation (absent on user-created dashboards). |
| 209 | + if is_v2_dashboard_definition(content): |
| 210 | + return bool(content.get("metadata", {}).get("annotations", {}).get("grafana.app/managedBy")) |
| 211 | + return bool(content.get("meta", {}).get("provisioned")) |
| 212 | + |
| 213 | + |
| 214 | +def get_v2_library_panel_uids(spec): |
| 215 | + # Collect library-panel uids referenced by a v2 spec. Library panels are elements of kind |
| 216 | + # "LibraryPanel" whose spec.libraryPanel.uid holds the uid; spec.elements is a flat map, so this |
| 217 | + # also catches panels nested inside rows/tabs by the layout. |
| 218 | + uids = [] |
| 219 | + for element in (spec.get("elements") or {}).values(): |
| 220 | + if isinstance(element, dict) and element.get("kind") == "LibraryPanel": |
| 221 | + uid = (element.get("spec") or {}).get("libraryPanel", {}).get("uid") |
| 222 | + if uid: |
| 223 | + uids.append(uid) |
| 224 | + return uids |
0 commit comments