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\n

server

\n

$server

\n\n

pod

\n

$pod

\n\n

Text

\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 {