Skip to content

Commit da13f11

Browse files
authored
Support for dynamic dashboard layout (#10247)
* Support new dynamic dashboard schema * Missing test recordings * v2 provision dashboard detection fix * Dashboard v2 resolver
1 parent fecf7e7 commit da13f11

22 files changed

Lines changed: 171216 additions & 33452 deletions

‎src/amg/HISTORY.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,7 @@ Release History
168168
* `az grafana integrations`: rename to `az grafana integration`
169169
* `az grafana integration monitor`: drop `--monitor-resource-group-name` option; use `--monitor-rg-name` instead
170170
* `az grafana data-source`: drop lookup by numeric ID and switch to UID-based endpoints due to deprecated Grafana API
171+
172+
3.1.0
173+
++++++
174+
* Support dynamic dashboards schema

‎src/amg/azext_amg/backup.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from .backup_core import (get_all_dashboards, get_all_library_panels, get_all_folders, get_all_snapshots,
1919
get_all_annotations, get_all_datasources, print_an_empty_line)
20+
from .dashboard_v2 import dashboard_identity
2021

2122
logger = get_logger(__name__)
2223

@@ -80,13 +81,13 @@ def _save_dashboards(grafana_url, backup_dir, timestamp, http_headers, **kwargs)
8081
dashboards = get_all_dashboards(grafana_url, http_headers, **kwargs)
8182
# now go through all the dashboards and save them
8283
for dashboard_content in dashboards:
83-
dashboard = dashboard_content['dashboard']
84-
board_uri = "uid/" + dashboard['uid']
85-
_save_dashboard_setting(dashboard['title'], board_uri, dashboard_content, folder_path)
84+
uid, title = dashboard_identity(dashboard_content)
85+
board_uri = "uid/" + uid
86+
_save_dashboard_setting(title, board_uri, dashboard_content, folder_path)
8687

8788
log_file_path = folder_path + '/' + log_file
8889
with open(log_file_path, 'w', encoding="utf8") as f:
89-
f.write(board_uri + '\t' + dashboard['title'] + '\n')
90+
f.write(board_uri + '\t' + title + '\n')
9091

9192

9293
def _save_dashboard_setting(dashboard_name, file_name, dashboard_settings, folder_path):

‎src/amg/azext_amg/backup_core.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .utils import search_folders, get_folder, get_folder_permissions
1414
from .utils import search_datasource
1515
from .utils import search_annotations
16+
from .dashboard_v2 import resolve_dashboard_v2_api_version, read_v2_dashboard, is_dashboard_provisioned
1617

1718
logger = get_logger(__name__)
1819

@@ -73,9 +74,14 @@ def _get_individual_dashboard_setting(dashboards, grafana_url, http_headers):
7374
if not dashboards:
7475
return []
7576

77+
# Resolve the served v2 API version once for the whole backup. On instances that don't serve
78+
# the v2 apiserver (e.g. Grafana < 13) this is None and we skip the per-dashboard v2 read
79+
# entirely, so a classic-only backup makes no extra requests beyond this single discovery.
80+
v2_version = resolve_dashboard_v2_api_version(grafana_url, http_headers)
7681
all_individual_dashboards = []
7782
for board in dashboards:
78-
board_uri = "uid/" + board['uid']
83+
uid = board['uid']
84+
board_uri = "uid/" + uid
7985

8086
(status, content) = get_dashboard(board_uri, grafana_url, http_headers)
8187
if status == 200:
@@ -84,7 +90,17 @@ def _get_individual_dashboard_setting(dashboards, grafana_url, http_headers):
8490
logger.warning("Dashboard: \"%s\" is provisioned, skipping...", board['title'])
8591
continue
8692

87-
all_individual_dashboards.append(content)
93+
# A v2 (dynamic dashboards) dashboard has no lossless classic form, so store the true
94+
# v2 resource from the apiserver instead of the down-converted legacy payload.
95+
v2_dashboard = (read_v2_dashboard(grafana_url, http_headers, uid, version=v2_version)
96+
if v2_version else None)
97+
# The classic read only reports meta.provisioned for file-provisioned dashboards; a
98+
# v2-stored dashboard managed via repo/terraform/kubectl exposes that solely through its
99+
# grafana.app/managedBy annotation, so skip those too and never back up managed content.
100+
if v2_dashboard is not None and is_dashboard_provisioned(v2_dashboard):
101+
logger.warning("Dashboard: \"%s\" is externally managed, skipping...", board['title'])
102+
continue
103+
all_individual_dashboards.append(v2_dashboard if v2_dashboard is not None else content)
88104

89105
return all_individual_dashboards
90106

‎src/amg/azext_amg/custom.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
from ._client_factory import cf_amg
2323
from .utils import get_yes_or_no_option, search_folders
24+
from .dashboard_v2 import (is_v2_dashboard_definition, require_dashboard_v2_api_version,
25+
create_dashboard_v2, resolve_dashboard_v2_api_version, read_dashboard)
2426

2527
logger = get_logger(__name__)
2628

@@ -171,6 +173,9 @@ def _create_role_assignment(cli_ctx, principal_id, role_definition_id, scope):
171173
from azure.core.exceptions import HttpResponseError, ResourceExistsError
172174

173175
assignments_client = get_mgmt_service_client(cli_ctx, AuthorizationManagementClient).role_assignments
176+
# Unwrap AAZ values (e.g. instance.identity.principal_id) to a plain str so the
177+
# authorization SDK can JSON-serialize the request body.
178+
principal_id = str(principal_id)
174179
principal_types = [p.value for p in PrincipalType]
175180
current_principal_type = principal_types.pop(0)
176181

@@ -323,9 +328,12 @@ def sync_dashboard(cmd, source, destination, folders_to_include=None, folders_to
323328

324329

325330
def show_dashboard(cmd, grafana_name, uid, resource_group_name=None, api_key_or_token=None, subscription=None):
326-
response = _send_request(cmd, resource_group_name, grafana_name, "get", "/api/dashboards/uid/" + uid,
327-
api_key_or_token=api_key_or_token, subscription=subscription)
328-
return json.loads(response.content)
331+
# A v2 (dynamic dashboards) resource has no lossless classic representation, so read it through
332+
# the dashboard apiserver. Classic dashboards keep using the legacy endpoint (unchanged shape).
333+
endpoint, headers = _get_grafana_request_context(cmd, resource_group_name, grafana_name, subscription,
334+
api_key_or_token=api_key_or_token)
335+
v2_version = resolve_dashboard_v2_api_version(endpoint, headers)
336+
return read_dashboard(endpoint, headers, uid, v2_version)
329337

330338

331339
def list_dashboards(cmd, grafana_name, resource_group_name=None, api_key_or_token=None, subscription=None):
@@ -356,6 +364,16 @@ def create_dashboard(cmd, grafana_name, definition, title=None, folder=None, res
356364

357365
def _create_dashboard(cmd, grafana_name, definition, title=None, folder_uid=None, resource_group_name=None,
358366
overwrite=None, api_key_or_token=None, for_sync=True, subscription=None):
367+
# v1 vs v2 is decided by the dashboard's own schema, not a global default: a v2 (dynamic
368+
# dashboards) definition can only be created through the dashboard apiserver, while a classic
369+
# definition must stay on the legacy endpoint.
370+
if is_v2_dashboard_definition(definition):
371+
endpoint, headers = _get_grafana_request_context(cmd, resource_group_name, grafana_name, subscription,
372+
api_key_or_token=api_key_or_token)
373+
version = require_dashboard_v2_api_version(endpoint, headers)
374+
return create_dashboard_v2(endpoint, headers, definition, version, title=title,
375+
folder_uid=folder_uid, overwrite=overwrite)
376+
359377
if "dashboard" in definition:
360378
payload = definition
361379
else:
@@ -396,6 +414,19 @@ def import_dashboard(cmd, grafana_name, definition, folder=None, resource_group_
396414
if data.get("meta", {}).get("isFolder", False):
397415
raise ArgumentUsageError("The provided definition is a folder, not a dashboard")
398416

417+
# A v2 (dynamic dashboards) definition must go through the dashboard apiserver. The classic
418+
# __inputs datasource remapping below is a v1-only concept and doesn't apply.
419+
if is_v2_dashboard_definition(data):
420+
endpoint, headers = _get_grafana_request_context(cmd, resource_group_name, grafana_name, None,
421+
api_key_or_token=api_key_or_token)
422+
version = require_dashboard_v2_api_version(endpoint, headers)
423+
folder_uid = None
424+
if folder:
425+
folder_uid = _find_folder(cmd, resource_group_name, grafana_name, folder,
426+
api_key_or_token=api_key_or_token)['uid']
427+
return create_dashboard_v2(endpoint, headers, data, version, folder_uid=folder_uid,
428+
overwrite=overwrite)
429+
399430
if "dashboard" in data:
400431
payload = data
401432
else:

‎src/amg/azext_amg/dashboard_v2.py‎

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)