Dashboard provisioning: Add support for v2 schema (#113620)

This commit is contained in:
Stephanie Hingtgen
2025-11-10 17:45:37 -06:00
committed by GitHub
parent fcc243886c
commit 2d4e432239
6 changed files with 380 additions and 9 deletions
@@ -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": "<div><h1>Example panel</h1></div>",
"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" >}}
+68
View File
@@ -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)
+47
View File
@@ -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)
})
}
@@ -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
}
@@ -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 == "" {
@@ -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)
})
}