diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 29b97f72f54..c0273d7f7d8 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -69,7 +69,6 @@
/pkg/apis/provisioning @grafana/grafana-git-ui-sync-team
/public/app/features/provisioning @grafana/grafana-git-ui-sync-team
-/conf/provisioning/sample @grafana/grafana-git-ui-sync-team
/pkg/registry/apis/provisioning @grafana/grafana-git-ui-sync-team
/apps/alerting/ @grafana/alerting-backend
@@ -844,3 +843,4 @@ embed.go @grafana/grafana-as-code
/conf/provisioning/dashboards/ @grafana/dashboards-squad
/conf/provisioning/datasources/ @grafana/plugins-platform-backend
/conf/provisioning/plugins/ @grafana/plugins-platform-backend
+/conf/provisioning/sample/ @grafana/grafana-git-ui-sync-team
diff --git a/conf/provisioning/sample/classic-dashboard.json b/conf/provisioning/sample/classic-dashboard.json
deleted file mode 100644
index d0746c0b20b..00000000000
--- a/conf/provisioning/sample/classic-dashboard.json
+++ /dev/null
@@ -1,297 +0,0 @@
-{
- "annotations": {
- "list": [
- {
- "builtIn": 1,
- "datasource": {
- "type": "datasource",
- "uid": "grafana"
- },
- "enable": true,
- "hide": true,
- "iconColor": "rgba(0, 211, 255, 1)",
- "name": "Annotations & Alerts",
- "target": {
- "limit": 100,
- "matchAny": false,
- "tags": [],
- "type": "dashboard"
- },
- "type": "dashboard"
- }
- ]
- },
- "editable": true,
- "fiscalYearStartMonth": 0,
- "graphTooltip": 0,
- "id": 1348,
- "links": [],
- "liveNow": false,
- "panels": [
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "gridPos": {
- "h": 9,
- "w": 12,
- "x": 0,
- "y": 0
- },
- "id": 4,
- "options": {
- "code": {
- "language": "plaintext",
- "showLineNumbers": false,
- "showMiniMap": false
- },
- "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod\n\n---\ntext = $Text",
- "mode": "markdown"
- },
- "pluginVersion": "9.2.0-pre",
- "targets": [
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "refId": "A",
- "scenarioId": "random_walk"
- }
- ],
- "title": "Markdown (with variables)",
- "type": "text"
- },
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "gridPos": {
- "h": 9,
- "w": 12,
- "x": 12,
- "y": 0
- },
- "id": 5,
- "options": {
- "code": {
- "language": "json",
- "showLineNumbers": true,
- "showMiniMap": false
- },
- "content": "{\n \"datacenter\": $datacenter,\n \"server\": $server,\n \"pod\": $pod\n \"text\": $Text\n}\n",
- "mode": "code"
- },
- "pluginVersion": "9.2.0-pre",
- "targets": [
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "refId": "A",
- "scenarioId": "random_walk"
- }
- ],
- "title": "JSON (with variables)",
- "type": "text"
- },
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "gridPos": {
- "h": 9,
- "w": 12,
- "x": 0,
- "y": 9
- },
- "id": 6,
- "options": {
- "code": {
- "language": "plaintext",
- "showLineNumbers": false,
- "showMiniMap": false
- },
- "content": "
Data center
\n$datacenter
\n\nserver
\n$server
\n\npod
\n$pod
\n\nText
\n$Text
",
- "mode": "html"
- },
- "pluginVersion": "9.2.0-pre",
- "targets": [
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "refId": "A",
- "scenarioId": "random_walk"
- }
- ],
- "title": "HTML (with variables)",
- "type": "text"
- },
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "gridPos": {
- "h": 9,
- "w": 12,
- "x": 12,
- "y": 9
- },
- "id": 7,
- "options": {
- "code": {
- "language": "markdown",
- "showLineNumbers": true,
- "showMiniMap": true
- },
- "content": "## Data center\n$datacenter\n\n### server\n$server\n\n#### pod = \n$pod\n",
- "mode": "code"
- },
- "pluginVersion": "9.2.0-pre",
- "targets": [
- {
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "refId": "A",
- "scenarioId": "random_walk"
- }
- ],
- "title": "Markdown (code w/ with variables)",
- "type": "text"
- }
- ],
- "refresh": false,
- "schemaVersion": 37,
- "tags": [],
- "templating": {
- "list": [
- {
- "current": {
- "selected": false,
- "text": ["All"],
- "value": ["$__all"]
- },
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "definition": "*",
- "hide": 0,
- "includeAll": true,
- "multi": true,
- "name": "datacenter",
- "options": [],
- "query": {
- "query": "*",
- "refId": "gdev-testdata-datacenter-Variable-Query"
- },
- "refresh": 1,
- "regex": "",
- "skipUrlSync": false,
- "sort": 0,
- "tagValuesQuery": "",
- "tagsQuery": "",
- "type": "query",
- "useTags": false
- },
- {
- "current": {
- "selected": false,
- "text": ["All"],
- "value": ["$__all"]
- },
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "definition": "$datacenter.*",
- "hide": 0,
- "includeAll": true,
- "multi": true,
- "name": "server",
- "options": [],
- "query": {
- "query": "$datacenter.*",
- "refId": "gdev-testdata-server-Variable-Query"
- },
- "refresh": 1,
- "regex": "",
- "skipUrlSync": false,
- "sort": 0,
- "tagValuesQuery": "",
- "tagsQuery": "",
- "type": "query",
- "useTags": false
- },
- {
- "current": {
- "selected": false,
- "text": ["AAA", "ACB"],
- "value": ["AAA", "ACB"]
- },
- "datasource": {
- "type": "testdata",
- "uid": "PD8C576611E62080A"
- },
- "definition": "$datacenter.$server.*",
- "hide": 0,
- "includeAll": true,
- "multi": true,
- "name": "pod",
- "options": [],
- "query": {
- "query": "$datacenter.$server.*",
- "refId": "gdev-testdata-pod-Variable-Query"
- },
- "refresh": 1,
- "regex": "",
- "skipUrlSync": false,
- "sort": 0,
- "tagValuesQuery": "",
- "tagsQuery": "",
- "type": "query",
- "useTags": false
- },
- {
- "current": {
- "selected": false,
- "text": "temp",
- "value": "temp"
- },
- "hide": 0,
- "name": "Text",
- "options": [
- {
- "selected": true,
- "text": "temp",
- "value": "temp"
- }
- ],
- "query": "temp",
- "skipUrlSync": false,
- "type": "textbox"
- }
- ]
- },
- "time": {
- "from": "now-1h",
- "to": "now"
- },
- "timepicker": {
- "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
- },
- "timezone": "",
- "title": "Text options",
- "uid": "WZ7AhQiVz",
- "version": 1,
- "weekStart": ""
-}
diff --git a/conf/provisioning/sample/dashboard-classic.json b/conf/provisioning/sample/dashboard-classic.json
new file mode 100644
index 00000000000..0cc24338340
--- /dev/null
+++ b/conf/provisioning/sample/dashboard-classic.json
@@ -0,0 +1,25 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "panels": [
+ {
+ "gridPos": {
+ "h": 13,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "options": {
+ "content": "This dashboard was loaded from provisioning (classic) 🎉🎉🎉🎉🎉🎉
",
+ "mode": "html"
+ },
+ "transparent": true,
+ "type": "text"
+ }
+ ],
+ "schemaVersion": 37,
+ "tags": [],
+ "title": "Dashboard classic",
+ "uid": "dash-classic"
+}
diff --git a/conf/provisioning/sample/dashboard-a.json b/conf/provisioning/sample/dashboard-v1.json
similarity index 60%
rename from conf/provisioning/sample/dashboard-a.json
rename to conf/provisioning/sample/dashboard-v1.json
index 482d093c23d..6f5c272da70 100644
--- a/conf/provisioning/sample/dashboard-a.json
+++ b/conf/provisioning/sample/dashboard-v1.json
@@ -1,21 +1,17 @@
{
"kind": "Dashboard",
- "apiVersion": "dashboard.grafana.app/v0alpha1",
+ "apiVersion": "dashboard.grafana.app/v1alpha1",
"metadata": {
- "name": "ignored?",
- "namespace": "default",
- "uid": "ignored",
- "resourceVersion": "also ignored",
- "creationTimestamp": "2024-11-15T19:54:07Z",
+ "name": "sample-dash",
"annotations": {
- "hello": "world:"
+ "hello": "world"
},
"labels": {
"region": "west"
}
},
"spec": {
- "title": "Green dashboard from provisioning",
+ "title": "Dashboard saved at v1alpha1",
"panels": [
{
"gridPos": {
@@ -25,7 +21,7 @@
"y": 0
},
"options": {
- "content": "This dashboard was loaded from provisioning 🎉🎉🎉🎉🎉🎉
",
+ "content": "This dashboard was loaded from provisioning (v1) 🎉🎉🎉🎉🎉🎉
",
"mode": "html"
},
"transparent": true,
diff --git a/conf/provisioning/sample/folder-A/folder-B/dashboard-nested.json b/conf/provisioning/sample/folder-A/folder-B/dashboard-nested.json
new file mode 100644
index 00000000000..62b4ae7e583
--- /dev/null
+++ b/conf/provisioning/sample/folder-A/folder-B/dashboard-nested.json
@@ -0,0 +1,32 @@
+{
+ "kind": "Dashboard",
+ "apiVersion": "dashboard.grafana.app/v1alpha1",
+ "metadata": {
+ "name": "dashboard-nested",
+ "annotations": {
+ "hello": "world"
+ },
+ "labels": {
+ "region": "west"
+ }
+ },
+ "spec": {
+ "title": "Dashboard nested under A/B",
+ "panels": [
+ {
+ "gridPos": {
+ "h": 13,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "options": {
+ "content": "This dashboard was loaded from provisioning (nested) 🎉🎉🎉🎉🎉🎉
",
+ "mode": "html"
+ },
+ "transparent": true,
+ "type": "text"
+ }
+ ]
+ }
+}
diff --git a/conf/provisioning/sample/sample-playlist.json b/conf/provisioning/sample/sample-playlist.json
index 26473e8bfc8..5b34cfd6682 100644
--- a/conf/provisioning/sample/sample-playlist.json
+++ b/conf/provisioning/sample/sample-playlist.json
@@ -2,11 +2,7 @@
"kind": "Playlist",
"apiVersion": "playlist.grafana.app/v0alpha1",
"metadata": {
- "name": "adcmdrw",
- "namespace": "default",
- "uid": "8a2b984d-4663-4182-861b-edec2f987dff",
- "resourceVersion": "1731336353828005",
- "creationTimestamp": "2024-11-11T14:45:53Z"
+ "name": "sample-palylist"
},
"spec": {
"title": "Playlist from provisioning",
diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go
index 4898a195199..2bfb9130f65 100644
--- a/pkg/registry/apis/provisioning/resources/client.go
+++ b/pkg/registry/apis/provisioning/resources/client.go
@@ -24,6 +24,9 @@ var (
// SupportedProvisioningResources is the list of resources that can fully managed from the UI
SupportedProvisioningResources = []schema.GroupVersionResource{FolderResource, DashboardResource}
+
+ // SupportsFolderAnnotation is the list of resources that can be saved in a folder
+ SupportsFolderAnnotation = []schema.GroupResource{FolderResource.GroupResource(), DashboardResource.GroupResource()}
)
// ClientFactory is a factory for creating clients for a given namespace
diff --git a/pkg/registry/apis/provisioning/resources/parser.go b/pkg/registry/apis/provisioning/resources/parser.go
index 61eaa004cf6..2fa91f48a52 100644
--- a/pkg/registry/apis/provisioning/resources/parser.go
+++ b/pkg/registry/apis/provisioning/resources/parser.go
@@ -21,6 +21,7 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/util"
)
// ParserFactory is a factory for creating parsers for a given repository
@@ -177,8 +178,12 @@ func (r *parser) Parse(ctx context.Context, info *repository.FileInfo) (parsed *
Checksum: info.Hash,
})
- if obj.GetName() == "" && obj.GetGenerateName() == "" {
- return nil, ErrMissingName
+ if obj.GetName() == "" {
+ if obj.GetGenerateName() == "" {
+ return nil, ErrMissingName
+ }
+ // Generate a new UID
+ obj.SetName(obj.GetGenerateName() + util.GenerateShortUID())
}
// Calculate folder identifier from the file path
@@ -226,12 +231,6 @@ func (f *ParsedResource) DryRun(ctx context.Context) error {
DryRun: []string{"All"},
})
}
-
- // When the name is missing (and generateName is configured) use the value from DryRun
- if f.Obj.GetName() == "" && f.DryRunResponse != nil {
- f.Obj.SetName(f.DryRunResponse.GetName())
- }
-
return err
}
diff --git a/pkg/registry/apis/provisioning/resources/parser_test.go b/pkg/registry/apis/provisioning/resources/parser_test.go
index f26432ecc20..85b509eae3d 100644
--- a/pkg/registry/apis/provisioning/resources/parser_test.go
+++ b/pkg/registry/apis/provisioning/resources/parser_test.go
@@ -2,14 +2,14 @@ package resources
import (
"context"
+ "strings"
"testing"
"github.com/stretchr/testify/require"
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
-
- "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
@@ -21,8 +21,8 @@ func TestParser(t *testing.T) {
Return(nil, dashboardV1.DashboardResourceInfo.GroupVersionResource(), nil).Maybe()
parser := &parser{
- repo: v0alpha1.ResourceRepositoryInfo{
- Type: v0alpha1.LocalRepositoryType,
+ repo: provisioning.ResourceRepositoryInfo{
+ Type: provisioning.LocalRepositoryType,
Namespace: "xxx",
Name: "repo",
},
@@ -63,13 +63,34 @@ spec:
`),
})
require.EqualError(t, err, "name.metadata.name: Required value: missing name in resource")
+ })
- // Read the name from classic grafana format
- dash, err = parser.Parse(context.Background(), &repository.FileInfo{
+ t.Run("generate name will generate a name", func(t *testing.T) {
+ dash, err := parser.Parse(context.Background(), &repository.FileInfo{
+ Data: []byte(`apiVersion: dashboard.grafana.app/v0alpha1
+kind: Dashboard
+metadata:
+ generateName: rand-
+spec:
+ title: Test dashboard
+`),
+ })
+ require.NoError(t, err)
+ require.Equal(t, "dashboard.grafana.app", dash.GVK.Group)
+ require.Equal(t, "v0alpha1", dash.GVK.Version)
+ require.True(t, strings.HasPrefix(dash.Obj.GetName(), "rand-"), "set name")
+ })
+
+ t.Run("dashboard classic format", func(t *testing.T) {
+ dash, err := parser.Parse(context.Background(), &repository.FileInfo{
Data: []byte(`{ "uid": "test", "schemaVersion": 30, "panels": [], "tags": [] }`),
})
require.NoError(t, err)
- require.Equal(t, v0alpha1.ClassicDashboard, dash.Classic)
require.Equal(t, "test", dash.Obj.GetName())
+ require.Equal(t, provisioning.ClassicDashboard, dash.Classic)
+ require.Equal(t, "dashboard.grafana.app", dash.GVK.Group)
+ require.Equal(t, "v0alpha1", dash.GVK.Version)
+ require.Equal(t, "dashboard.grafana.app", dash.GVR.Group)
+ require.Equal(t, "v0alpha1", dash.GVR.Version)
})
}
diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go
index a5f622f1f03..26d79bf4485 100644
--- a/pkg/registry/apis/provisioning/resources/resources.go
+++ b/pkg/registry/apis/provisioning/resources/resources.go
@@ -6,7 +6,9 @@ import (
"encoding/json"
"errors"
"fmt"
+ "slices"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -161,18 +163,25 @@ func (r *ResourcesManager) WriteResourceFromFile(ctx context.Context, path strin
}
r.resourcesLookup[id] = path
- // Make sure the parent folders exist
- folder, err := r.folders.EnsureFolderPathExist(ctx, path)
- if err != nil {
- return "", parsed.GVK, fmt.Errorf("failed to ensure folder path exists: %w", err)
+ // For resources that exist in folders, set the header annotation
+ if slices.Contains(SupportsFolderAnnotation, parsed.GVR.GroupResource()) {
+ // Make sure the parent folders exist
+ folder, err := r.folders.EnsureFolderPathExist(ctx, path)
+ if err != nil {
+ return "", parsed.GVK, fmt.Errorf("failed to ensure folder path exists: %w", err)
+ }
+ parsed.Meta.SetFolder(folder)
}
- parsed.Meta.SetFolder(folder)
- parsed.Meta.SetUID("") // clear identifiers
- parsed.Meta.SetResourceVersion("") // clear identifiers
+ // Clear any saved identifiers
+ parsed.Meta.SetUID("")
+ parsed.Meta.SetResourceVersion("")
- // Update will also create (for resources we care about)
- _, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
+ // Update or Create resource
+ parsed.Upsert, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
+ if apierrors.IsNotFound(err) {
+ parsed.Upsert, err = parsed.Client.Create(ctx, parsed.Obj, metav1.CreateOptions{})
+ }
return parsed.Obj.GetName(), parsed.GVK, err
}
diff --git a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts
index 48514b0f376..3da832cb425 100644
--- a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts
+++ b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts
@@ -62,7 +62,7 @@ export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRe
// For nested folders we need to see what the folder thinks
const annotatedFolderName = folder?.metadata?.annotations?.[AnnoKeyManagerIdentity];
- if (annotatedFolderName && name) {
+ if (annotatedFolderName) {
repository = items.find((repo) => repo.name === annotatedFolderName);
if (repository) {
return {