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)
+ })
+}