From 2d4e432239eab152928d3c4f2ff22189d5a39702 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 10 Nov 2025 16:45:37 -0700 Subject: [PATCH] Dashboard provisioning: Add support for v2 schema (#113620) --- .../administration/provisioning/index.md | 52 +++++ pkg/services/dashboards/models.go | 68 ++++++ pkg/services/dashboards/models_test.go | 47 +++++ .../dashboards/service/dashboard_service.go | 23 ++- pkg/services/provisioning/dashboards/types.go | 6 + .../provisioning/dashboards/types_test.go | 193 ++++++++++++++++++ 6 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 pkg/services/provisioning/dashboards/types_test.go diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 51fd1f4c604..fadb936e5a0 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -375,6 +375,58 @@ providers: ``` When Grafana starts, it updates or creates all dashboards found in the configured path. +These files can define the dashboard using the dashboard JSON: + +```json +{ + "dashboard": { + "id": null, + "uid": "example-dashboard", + "title": "Production Overview", + "tags": ["production", "monitoring"], + "timezone": "browser", + "schemaVersion": 16, + "version": 0, + "refresh": "30s" + }, + "folderUid": "monitoring-folder", + "overwrite": true +} +``` + +Or using a Kubernetes format, for example `kubernetes-dashboard.json`: + +```json +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "dashboard-uid" + }, + "spec": { + "title": "Dashboard title", + "panels": [ + { + "gridPos": { + "h": 13, + "w": 24, + "x": 0, + "y": 0 + }, + "options": { + "content": "

Example panel

", + "mode": "html" + }, + "transparent": true, + "type": "text" + } + ] + } +} +``` + +You _must_ use the Kubernetes resource format to provision dashboards v2 / dynamic dashboards. + It later polls that path every `updateIntervalSeconds` for updates to the dashboard files and updates its database. {{< admonition type="note" >}} diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 270e449ac07..3c661ee6b9c 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -4,6 +4,9 @@ import ( "fmt" "time" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" @@ -95,6 +98,11 @@ func (d *Dashboard) GetTags() []string { } func NewDashboardFromJson(data *simplejson.Json) *Dashboard { + // if apiVersion is set in the json - use it as an indicator that it is in the k8s format + if apiVersion, err := data.Get("apiVersion").String(); err == nil && apiVersion != "" { + return parseK8sDashboard(data) + } + dash := &Dashboard{} dash.Data = data dash.Title = dash.Data.Get("title").MustString() @@ -127,6 +135,66 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard { return dash } +// parses json in the k8s format +// i.e: +// +// { +// "apiVersion": "dashboard.grafana.app/v1", +// "kind": "Dashboard", +// "metadata": {...}, +// "spec": {...} +// } +func parseK8sDashboard(data *simplejson.Json) *Dashboard { + dash := &Dashboard{} + + dataMap, ok := data.Interface().(map[string]interface{}) + if !ok { + return dash + } + + item := &unstructured.Unstructured{Object: dataMap} + obj, err := utils.MetaAccessor(item) + if err != nil { + return dash + } + dash.APIVersion = item.GetAPIVersion() + info, err := types.ParseNamespace(obj.GetNamespace()) + if err == nil && info.OrgID > 0 { + dash.OrgID = info.OrgID + } + dash.UID = obj.GetName() + + spec, ok := item.Object["spec"].(map[string]any) + if !ok { + return dash + } + dash.Data = simplejson.NewFromAny(spec) + + dash.Title = obj.FindTitle("") + dash.UpdateSlug() + + dash.FolderUID = obj.GetFolder() + dash.Data.Set("uid", dash.UID) + + generation := obj.GetGeneration() + if generation > 0 { + dash.Data.Set("version", generation) + dash.Updated = time.Now() + } else { + dash.Data.Set("version", 0) + dash.Created = time.Now() + dash.Updated = time.Now() + } + + dash.Data.Set("id", obj.GetDeprecatedInternalID()) // nolint:staticcheck + + if gnetId, err := dash.Data.Get("gnetId").Float64(); err == nil { + dash.GnetID = int64(gnetId) + } + + return dash +} + // GetDashboardModel turns the command into the saveable model func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash := NewDashboardFromJson(cmd.Dashboard) diff --git a/pkg/services/dashboards/models_test.go b/pkg/services/dashboards/models_test.go index 06aeb1eba60..0a3035cbecc 100644 --- a/pkg/services/dashboards/models_test.go +++ b/pkg/services/dashboards/models_test.go @@ -86,3 +86,50 @@ func TestSlugifyTitle(t *testing.T) { }) } } + +func TestParseK8sDashboard(t *testing.T) { + t.Run("should parse valid K8s dashboard with all fields", func(t *testing.T) { + data := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "org-123", + "generation": int64(5), + "labels": map[string]interface{}{ + "grafana.app/deprecatedInternalID": "456", + }, + "annotations": map[string]interface{}{ + "grafana.app/folder": "test-folder-uid", + }, + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + "gnetId": float64(12345), + }, + }) + + dash := parseK8sDashboard(data) + assert.Equal(t, "dashboard.grafana.app/v2alpha1", dash.APIVersion) + assert.Equal(t, int64(123), dash.OrgID) + assert.Equal(t, "test-dashboard-uid", dash.UID) + assert.Equal(t, "Test Dashboard", dash.Title) + assert.Equal(t, "test-dashboard", dash.Slug) + assert.Equal(t, "test-folder-uid", dash.FolderUID) + assert.Equal(t, int64(12345), dash.GnetID) + assert.Equal(t, "test-dashboard-uid", dash.Data.Get("uid").MustString()) + assert.Equal(t, int64(5), dash.Data.Get("version").MustInt64()) + assert.Equal(t, int64(456), dash.Data.Get("id").MustInt64()) + assert.False(t, dash.Updated.IsZero()) + assert.True(t, dash.Created.IsZero()) + }) + + t.Run("should handle invalid input (not a map)", func(t *testing.T) { + data := simplejson.NewFromAny("invalid string") + dash := parseK8sDashboard(data) + assert.Empty(t, dash.UID) + assert.Empty(t, dash.Title) // this will fail later in the provisioning chain because its empty + assert.Empty(t, dash.APIVersion) + assert.Nil(t, dash.Data) + }) +} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 7e4dd89dc7d..9e6baf6b8aa 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -745,15 +745,16 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() cmd := &dashboards.SaveDashboardCommand{ - Dashboard: dash.Data, - Message: dto.Message, - OrgID: dto.OrgID, - Overwrite: dto.Overwrite, - UserID: userID, - FolderID: dash.FolderID, // nolint:staticcheck - FolderUID: dash.FolderUID, - IsFolder: dash.IsFolder, - PluginID: dash.PluginID, + Dashboard: dash.Data, + Message: dto.Message, + OrgID: dto.OrgID, + Overwrite: dto.Overwrite, + UserID: userID, + FolderID: dash.FolderID, // nolint:staticcheck + FolderUID: dash.FolderUID, + IsFolder: dash.IsFolder, + PluginID: dash.PluginID, + APIVersion: dash.APIVersion, } if !dto.UpdatedAt.IsZero() { @@ -2288,6 +2289,10 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names meta.SetMessage(cmd.Message) } + if cmd.APIVersion != "" { + finalObj.SetAPIVersion(cmd.APIVersion) + } + return finalObj, nil } diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 717326305ae..db67f0dccd9 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -69,11 +69,17 @@ func createDashboardJSON(data *simplejson.Json, lastModified time.Time, cfg *con dash.Dashboard = dashboards.NewDashboardFromJson(data) dash.UpdatedAt = lastModified dash.Overwrite = true + if dash.Dashboard.OrgID > 0 && dash.Dashboard.OrgID != cfg.OrgID { + return nil, fmt.Errorf("dashboard orgID (%d) does not match provisioning provider orgID (%d)", dash.Dashboard.OrgID, cfg.OrgID) + } dash.OrgID = cfg.OrgID dash.Dashboard.OrgID = cfg.OrgID metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck dash.Dashboard.FolderID = folderID + if dash.Dashboard.FolderUID != "" && folderUID != dash.Dashboard.FolderUID { + return nil, fmt.Errorf("dashboard folderUID (%q) does not match provisioning provider folderUID (%q)", dash.Dashboard.FolderUID, folderUID) + } dash.Dashboard.FolderUID = folderUID if dash.Dashboard.Title == "" { diff --git a/pkg/services/provisioning/dashboards/types_test.go b/pkg/services/provisioning/dashboards/types_test.go new file mode 100644 index 00000000000..49382d3561a --- /dev/null +++ b/pkg/services/provisioning/dashboards/types_test.go @@ -0,0 +1,193 @@ +package dashboards + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDashboardJSON(t *testing.T) { + lastModified := time.Now() + folderID := int64(123) + folderUID := "folder-uid-123" + + t.Run("orgID check", func(t *testing.T) { + t.Run("matching is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Test Dashboard", result.Dashboard.Title) + assert.Equal(t, int64(1), result.OrgID) + assert.Equal(t, int64(1), result.Dashboard.OrgID) + assert.Equal(t, folderID, result.Dashboard.FolderID) // nolint:staticcheck + assert.Equal(t, folderUID, result.Dashboard.FolderUID) + assert.True(t, result.Overwrite) + assert.Equal(t, lastModified, result.UpdatedAt) + }) + + t.Run("not set is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, int64(1), result.OrgID) + assert.Equal(t, int64(1), result.Dashboard.OrgID) + }) + + t.Run("not matching is an error", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "org-123", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dashboard orgID") + }) + }) + + t.Run("folderUID check", func(t *testing.T) { + t.Run("matching is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + "annotations": map[string]interface{}{ + "grafana.app/folder": folderUID, + }, + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, folderUID, result.Dashboard.FolderUID) + }) + + t.Run("not matching is an error", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + "annotations": map[string]interface{}{ + "grafana.app/folder": "different-folder-uid", + }, + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dashboard folderUID") + }) + + t.Run("not set is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, folderUID, result.Dashboard.FolderUID) + }) + }) + + t.Run("empty title is an error", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]any{ + "title": "", + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, dashboards.ErrDashboardTitleEmpty, err) + }) +}