Dashboards API: v2alpha2 missing pieces (#108293)

* Dashboards API: v2alpha2 missing pieces

* Fix issue with dashboard client scope for alpha versions

As we now have 2 different alpha versions for v2 we need to store the
clients separately.

* Improve debuggability of provisioning export test

- Add a helper function to print the tree structure.
- Be explicit about the expected file names expected in each case.

* Update pkg/registry/apis/dashboard/mutate.go

* Update pkg/services/authz/zanzana/server/server.go

Co-authored-by: Igor Suleymanov <radiohead@users.noreply.github.com>

* Review

* go lint

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
Co-authored-by: Igor Suleymanov <radiohead@users.noreply.github.com>
This commit is contained in:
Dominik Prokop
2025-07-22 11:44:05 +02:00
committed by GitHub
co-authored by Igor Suleymanov Roberto Jimenez Sanchez Stephanie Hingtgen
parent eeb44c1d63
commit d72a70d246
15 changed files with 877 additions and 512 deletions
+13 -4
View File
@@ -8,7 +8,8 @@ import (
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
commonV0 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
@@ -35,8 +36,14 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme, threshold int) *apis
reduceUnstructredSpec(&dash.Spec)
case *dashboardV1.Dashboard:
reduceUnstructredSpec(&dash.Spec)
case *dashboardV2.Dashboard:
dash.Spec = dashboardV2.DashboardSpec{
case *dashboardV2alpha1.Dashboard:
dash.Spec = dashboardV2alpha1.DashboardSpec{
Title: dash.Spec.Title,
Description: dash.Spec.Description,
Tags: dash.Spec.Tags,
}
case *dashboardV2alpha2.Dashboard:
dash.Spec = dashboardV2alpha2.DashboardSpec{
Title: dash.Spec.Title,
Description: dash.Spec.Description,
Tags: dash.Spec.Tags,
@@ -55,7 +62,9 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme, threshold int) *apis
return dash.Spec.UnmarshalJSON(blob)
case *dashboardV1.Dashboard:
return dash.Spec.UnmarshalJSON(blob)
case *dashboardV2.Dashboard:
case *dashboardV2alpha1.Dashboard:
return json.Unmarshal(blob, &dash.Spec)
case *dashboardV2alpha2.Dashboard:
return json.Unmarshal(blob, &dash.Spec)
default:
return fmt.Errorf("unsupported dashboard type %T", obj)
+116 -15
View File
@@ -11,7 +11,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
)
func TestLargeDashboardSupportV1(t *testing.T) {
@@ -73,47 +74,47 @@ func TestLargeDashboardSupportV1(t *testing.T) {
require.Len(t, panels, expectedPanelCount)
}
func TestLargeDashboardSupportV2(t *testing.T) {
func TestLargeDashboardSupportV2alpha1(t *testing.T) {
// Test RebuildSpec functionality specifically for v2 dashboards
// This tests the json.Unmarshal(blob, &dash.Spec) path for structured specs
// unlike v0/v1 which use the UnmarshalJSON path for unstructured specs
// Create a v2 dashboard with structured spec
originalV2Dash := &dashv2.Dashboard{
originalV2Dash := &dashv2alpha1.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Name: "test-v2",
Namespace: "test",
},
Spec: dashv2.DashboardSpec{
Spec: dashv2alpha1.DashboardSpec{
Title: "Test V2 Dashboard",
Description: stringPtr("A test dashboard for v2 large object support"),
Tags: []string{"test", "v2", "large-object"},
Editable: boolPtr(true),
LiveNow: boolPtr(false),
Preload: false,
Annotations: []dashv2.DashboardAnnotationQueryKind{
Annotations: []dashv2alpha1.DashboardAnnotationQueryKind{
{
Kind: "AnnotationQuery",
Spec: dashv2.DashboardAnnotationQuerySpec{
Spec: dashv2alpha1.DashboardAnnotationQuerySpec{
Name: "Test Annotation",
},
},
},
Elements: map[string]dashv2.DashboardElement{
Elements: map[string]dashv2alpha1.DashboardElement{
"panel-1": {
PanelKind: &dashv2.DashboardPanelKind{},
PanelKind: &dashv2alpha1.DashboardPanelKind{},
},
},
Layout: dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{},
TimeSettings: dashv2.DashboardTimeSettingsSpec{},
CursorSync: dashv2.DashboardDashboardCursorSyncOff,
Variables: []dashv2.DashboardVariableKind{},
Links: []dashv2.DashboardDashboardLink{},
Layout: dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{},
TimeSettings: dashv2alpha1.DashboardTimeSettingsSpec{},
CursorSync: dashv2alpha1.DashboardDashboardCursorSyncOff,
Variables: []dashv2alpha1.DashboardVariableKind{},
Links: []dashv2alpha1.DashboardDashboardLink{},
},
}
scheme := runtime.NewScheme()
err := dashv2.AddToScheme(scheme)
err := dashv2alpha1.AddToScheme(scheme)
require.NoError(t, err)
largeObject := NewDashboardLargeObjectSupport(scheme, 0)
@@ -142,7 +143,107 @@ func TestLargeDashboardSupportV2(t *testing.T) {
require.Empty(t, dashToReduce.Spec.Links)
// Now test RebuildSpec - this is the key test for v2!
rehydratedDash := &dashv2.Dashboard{
rehydratedDash := &dashv2alpha1.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Name: "test-v2-rehydrated",
Namespace: "test",
},
}
// This tests the json.Unmarshal(blob, &dash.Spec) path for v2 dashboards
err = largeObject.RebuildSpec(rehydratedDash, originalSpecBlob)
require.NoError(t, err)
// Verify the full dashboard spec is restored correctly
require.Equal(t, originalV2Dash.Spec.Title, rehydratedDash.Spec.Title)
require.Equal(t, originalV2Dash.Spec.Description, rehydratedDash.Spec.Description)
require.Equal(t, originalV2Dash.Spec.Tags, rehydratedDash.Spec.Tags)
require.Equal(t, originalV2Dash.Spec.Editable, rehydratedDash.Spec.Editable)
require.Equal(t, originalV2Dash.Spec.LiveNow, rehydratedDash.Spec.LiveNow)
require.Equal(t, originalV2Dash.Spec.Preload, rehydratedDash.Spec.Preload)
// Verify annotations are restored
require.Len(t, rehydratedDash.Spec.Annotations, 1)
annotation := rehydratedDash.Spec.Annotations[0]
require.Equal(t, "AnnotationQuery", annotation.Kind)
require.Equal(t, "Test Annotation", annotation.Spec.Name)
// Verify elements are restored
require.Len(t, rehydratedDash.Spec.Elements, 1)
_, exists := rehydratedDash.Spec.Elements["panel-1"]
require.True(t, exists)
}
func TestLargeDashboardSupportV2alpha2(t *testing.T) {
// Test RebuildSpec functionality specifically for v2 dashboards
// This tests the json.Unmarshal(blob, &dash.Spec) path for structured specs
// unlike v0/v1 which use the UnmarshalJSON path for unstructured specs
// Create a v2 dashboard with structured spec
originalV2Dash := &dashv2alpha2.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Name: "test-v2",
Namespace: "test",
},
Spec: dashv2alpha2.DashboardSpec{
Title: "Test V2 Dashboard",
Description: stringPtr("A test dashboard for v2 large object support"),
Tags: []string{"test", "v2", "large-object"},
Editable: boolPtr(true),
LiveNow: boolPtr(false),
Preload: false,
Annotations: []dashv2alpha2.DashboardAnnotationQueryKind{
{
Kind: "AnnotationQuery",
Spec: dashv2alpha2.DashboardAnnotationQuerySpec{
Name: "Test Annotation",
},
},
},
Elements: map[string]dashv2alpha2.DashboardElement{
"panel-1": {
PanelKind: &dashv2alpha2.DashboardPanelKind{},
},
},
Layout: dashv2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{},
TimeSettings: dashv2alpha2.DashboardTimeSettingsSpec{},
CursorSync: dashv2alpha2.DashboardDashboardCursorSyncOff,
Variables: []dashv2alpha2.DashboardVariableKind{},
Links: []dashv2alpha2.DashboardDashboardLink{},
},
}
scheme := runtime.NewScheme()
err := dashv2alpha2.AddToScheme(scheme)
require.NoError(t, err)
largeObject := NewDashboardLargeObjectSupport(scheme, 0)
// Marshal the original spec to use as our "blob" data
originalSpecBlob, err := json.Marshal(originalV2Dash.Spec)
require.NoError(t, err)
// Create a copy to test reduction
dashToReduce := originalV2Dash.DeepCopy()
// Convert the dashboard to a small value (ReduceSpec)
err = largeObject.ReduceSpec(dashToReduce)
require.NoError(t, err)
// Verify only essential fields remain after reduction
require.Equal(t, "Test V2 Dashboard", dashToReduce.Spec.Title)
require.Equal(t, stringPtr("A test dashboard for v2 large object support"), dashToReduce.Spec.Description)
require.Equal(t, []string{"test", "v2", "large-object"}, dashToReduce.Spec.Tags)
// Everything else should be empty/default
require.Empty(t, dashToReduce.Spec.Annotations)
require.Empty(t, dashToReduce.Spec.Elements)
require.Nil(t, dashToReduce.Spec.Layout.GridLayoutKind)
require.Empty(t, dashToReduce.Spec.Variables)
require.Empty(t, dashToReduce.Spec.Links)
// Now test RebuildSpec - this is the key test for v2!
rehydratedDash := &dashv2alpha2.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Name: "test-v2-rehydrated",
Namespace: "test",
+19 -5
View File
@@ -11,7 +11,8 @@ import (
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
"github.com/grafana/grafana/apps/dashboard/pkg/migration"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -57,17 +58,30 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute
Error: migrationErr.Error(),
}
}
case *dashboardV2.Dashboard:
case *dashboardV2alpha1.Dashboard:
// Temporary fix: The generator fails to properly initialize this property, so we'll do it here
// until the generator is fixed.
if v.Spec.Layout.GridLayoutKind == nil && v.Spec.Layout.RowsLayoutKind == nil && v.Spec.Layout.AutoGridLayoutKind == nil && v.Spec.Layout.TabsLayoutKind == nil {
v.Spec.Layout.GridLayoutKind = &dashboardV2.DashboardGridLayoutKind{
v.Spec.Layout.GridLayoutKind = &dashboardV2alpha1.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: dashboardV2.DashboardGridLayoutSpec{},
Spec: dashboardV2alpha1.DashboardGridLayoutSpec{},
}
}
resourceInfo = dashboardV2.DashboardResourceInfo
resourceInfo = dashboardV2alpha1.DashboardResourceInfo
case *dashboardV2alpha2.Dashboard:
// Temporary fix: The generator fails to properly initialize this property, so we'll do it here
// until the generator is fixed.
if v.Spec.Layout.GridLayoutKind == nil && v.Spec.Layout.RowsLayoutKind == nil && v.Spec.Layout.AutoGridLayoutKind == nil && v.Spec.Layout.TabsLayoutKind == nil {
v.Spec.Layout.GridLayoutKind = &dashboardV2alpha2.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: dashboardV2alpha2.DashboardGridLayoutSpec{},
}
}
resourceInfo = dashboardV2alpha2.DashboardResourceInfo
// Noop for V2
default:
+17 -5
View File
@@ -12,7 +12,8 @@ import (
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
"github.com/grafana/grafana/apps/dashboard/pkg/migration"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil"
@@ -141,9 +142,19 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
expectedError: false,
},
{
name: "v2 should set layout if it is not set",
inputObj: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
name: "v2alpha1 should set layout if it is not set",
inputObj: &dashv2alpha1.Dashboard{
Spec: dashv2alpha1.DashboardSpec{
Title: "test123",
},
},
operation: admission.Create,
expectedTitle: "test123",
},
{
name: "v2alpha2 should set layout if it is not set",
inputObj: &dashv2alpha2.Dashboard{
Spec: dashv2alpha2.DashboardSpec{
Title: "test123",
},
},
@@ -203,7 +214,8 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
if tt.migrationExpected {
require.Equal(t, schemaversion.LATEST_VERSION, schemaVersion, "dashboard should be migrated to the latest version")
}
case *v2alpha1.Dashboard:
case *dashv2alpha1.Dashboard:
case *dashv2alpha2.Dashboard:
require.Equal(t, tt.expectedTitle, v.Spec.Title, "title should be set")
require.NotNil(t, v.Spec.Layout, "layout should be set")
require.NotNil(t, v.Spec.Layout.GridLayoutKind, "layout should be a GridLayout")
@@ -12,7 +12,8 @@ import (
v0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
v1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
v2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
v2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
@@ -32,7 +33,8 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru
errorOnSchemaMismatches = false // Never error for v0
case *v1.Dashboard:
errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1)
case *v2.Dashboard:
case *v2alpha1.Dashboard:
case *v2alpha2.Dashboard:
errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2)
default:
return nil, fmt.Errorf("invalid dashboard type: %T", obj)
@@ -52,8 +54,10 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru
errors, schemaVersionError = v0.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors)
case *v1.Dashboard:
errors, schemaVersionError = v1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors)
case *v2.Dashboard:
errors = v2.ValidateDashboardSpec(v)
case *v2alpha1.Dashboard:
errors = v2alpha1.ValidateDashboardSpec(v)
case *v2alpha2.Dashboard:
errors = v2alpha2.ValidateDashboardSpec(v)
}
}
@@ -38,21 +38,30 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
// When requesting v2 (or v0) dashboards over the v1 api, we want to keep the original apiVersion if conversion fails
var shim conversionShim
if kind.GroupResource() == resources.DashboardResource.GroupResource() {
var v2client dynamic.ResourceInterface
var v2clientAlphaV1, v2clientAlphaV2 dynamic.ResourceInterface
shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
// For v2 we need to request the original version
if strings.HasPrefix(storedVersion, "v2") {
if v2client == nil {
v2client, _, err = clients.ForResource(resources.DashboardResourceV2)
if strings.HasPrefix(storedVersion, "v2alpha1") {
if v2clientAlphaV1 == nil {
v2clientAlphaV1, _, err = clients.ForResource(resources.DashboardResourceV2alpha1)
if err != nil {
return nil, err
}
}
return v2client.Get(ctx, item.GetName(), metav1.GetOptions{})
return v2clientAlphaV1.Get(ctx, item.GetName(), metav1.GetOptions{})
}
if strings.HasPrefix(storedVersion, "v2alpha2") {
if v2clientAlphaV2 == nil {
v2clientAlphaV2, _, err = clients.ForResource(resources.DashboardResourceV2alpha2)
if err != nil {
return nil, err
}
}
return v2clientAlphaV2.Get(ctx, item.GetName(), metav1.GetOptions{})
}
// For v0 we can simply fallback -- the full model is saved, but
@@ -97,6 +106,7 @@ func exportResource(ctx context.Context,
if shim != nil {
item, err = shim(ctx, item)
}
if err == nil {
result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{
Path: options.Path,
@@ -16,432 +16,519 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
func TestExportResources_Dashboards(t *testing.T) {
tests := []struct {
name string
mockItems []unstructured.Unstructured
expectedError string
setupProgress func(progress *jobs.MockJobProgressRecorder)
setupResources func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind)
}{
{
name: "successful dashboard export",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-1",
},
},
},
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-2",
},
},
},
},
expectedError: "",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-1" && result.Action == repository.FileActionCreated
})).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-1"
}), options).Return("dashboard-1.json", nil)
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-2"
}), options).Return("dashboard-2.json", nil)
// Helper function to create dashboard objects
func createDashboardObject(name string) unstructured.Unstructured {
return unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": name,
},
},
{
name: "client error",
mockItems: nil,
expectedError: "get client for dashboards: didn't work",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, fmt.Errorf("didn't work"))
},
},
{
name: "dashboard export with errors",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-1",
},
},
},
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-2",
},
},
},
},
expectedError: "",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard"
})).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-1"
}), options).Return("", fmt.Errorf("failed to export dashboard"))
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-2"
}), options).Return("dashboard-2.json", nil)
},
},
{
name: "dashboard export too many errors",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-1",
},
},
},
},
expectedError: "export dashboards: too many errors encountered",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard"
})).Return()
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered"))
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-1"
}), options).Return("", fmt.Errorf("failed to export dashboard"))
},
},
{
name: "ignores existing dashboards",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "existing-dashboard",
},
},
},
},
expectedError: "",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored
})).Return()
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "existing-dashboard"
}), options).Return("", resources.ErrAlreadyInRepository)
},
},
{
name: "uses saved dashboard version",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "existing-dashboard",
},
"spec": map[string]interface{}{
"hello": "world",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v0xyz",
},
},
},
},
},
expectedError: "",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored
})).Return()
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
// Verify that the object has the expected status.conversion.storedVersion field
status, exists, err := unstructured.NestedMap(obj.Object, "status")
if !exists || err != nil {
return false
}
conversion, exists, err := unstructured.NestedMap(status, "conversion")
if !exists || err != nil {
return false
}
storedVersion, exists, err := unstructured.NestedString(conversion, "storedVersion")
if !exists || err != nil {
return false
}
if storedVersion != "v0xyz" {
return false
}
return obj.GetName() == "existing-dashboard"
}), options).Return("", fmt.Errorf("XXX"))
},
},
{
name: "dashboard with failed conversion but no stored version",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-no-stored-version",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
// No storedVersion field
},
},
},
},
},
expectedError: "",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-no-stored-version" &&
result.Action == repository.FileActionIgnored &&
result.Error != nil
})).Return()
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
// The value is not saved
},
},
{
name: "handles v2 dashboard version",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2",
},
},
},
},
},
expectedError: "",
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
// Setup v1 client
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
// Setup v2 client
// Mock v2 client Get call
v2Dashboard := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard",
},
"spec": map[string]interface{}{
"version": 2,
"title": "V2 Dashboard",
},
},
}
v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{*v2Dashboard}}
resourceClients.On("ForResource", resources.DashboardResourceV2).Return(v2Client, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, v2Dashboard, options).Return("v2-dashboard.json", nil)
},
},
{
name: "handles v2 client creation error",
mockItems: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard-error",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2",
},
},
},
},
},
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
if result.Name != "v2-dashboard-error" {
return false
}
if result.Action != repository.FileActionIgnored {
return false
}
if result.Error == nil {
return false
}
if result.Error.Error() != "v2 client error" {
return false
}
return true
})).Return()
progress.On("TooManyErrors").Return(nil)
},
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
resourceClients.On("ForResource", resources.DashboardResourceV2).Return(nil, gvk, fmt.Errorf("v2 client error"))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockDynamicInterface{
items: tt.mockItems,
}
resourceClients := resources.NewMockResourceClients(t)
mockProgress := jobs.NewMockJobProgressRecorder(t)
tt.setupProgress(mockProgress)
repoResources := resources.NewMockRepositoryResources(t)
tt.setupResources(repoResources, resourceClients, mockClient, schema.GroupVersionKind{
Group: resources.DashboardResource.Group,
Version: resources.DashboardResource.Version,
Kind: "DashboardList",
})
options := provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
}
err := ExportResources(context.Background(), options, resourceClients, repoResources, mockProgress)
if tt.expectedError != "" {
require.EqualError(t, err, tt.expectedError)
} else {
require.NoError(t, err)
}
mockProgress.AssertExpectations(t)
repoResources.AssertExpectations(t)
resourceClients.AssertExpectations(t)
})
}
}
// Helper function to create v2 dashboard objects
func createV2DashboardObject(name, version string) unstructured.Unstructured {
return unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": fmt.Sprintf("dashboard.grafana.app/%s", version),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": name,
},
"spec": map[string]interface{}{
"version": 2,
"title": "V2 Dashboard",
},
},
}
}
// Helper function to run export test
func runExportTest(t *testing.T, mockItems []unstructured.Unstructured, setupProgress func(*jobs.MockJobProgressRecorder), setupResources func(*resources.MockRepositoryResources, *resources.MockResourceClients, *mockDynamicInterface, schema.GroupVersionKind)) error {
mockClient := &mockDynamicInterface{
items: mockItems,
}
resourceClients := resources.NewMockResourceClients(t)
mockProgress := jobs.NewMockJobProgressRecorder(t)
setupProgress(mockProgress)
repoResources := resources.NewMockRepositoryResources(t)
setupResources(repoResources, resourceClients, mockClient, schema.GroupVersionKind{
Group: resources.DashboardResource.Group,
Version: resources.DashboardResource.Version,
Kind: "DashboardList",
})
options := provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
}
err := ExportResources(context.Background(), options, resourceClients, repoResources, mockProgress)
mockProgress.AssertExpectations(t)
repoResources.AssertExpectations(t)
resourceClients.AssertExpectations(t)
return err
}
func TestExportResources_Dashboards_Success(t *testing.T) {
mockItems := []unstructured.Unstructured{
createDashboardObject("dashboard-1"),
createDashboardObject("dashboard-2"),
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-1" && result.Action == repository.FileActionCreated
})).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-1"
}), options).Return("dashboard-1.json", nil)
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-2"
}), options).Return("dashboard-2.json", nil)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_ClientError(t *testing.T) {
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, fmt.Errorf("didn't work"))
}
err := runExportTest(t, nil, setupProgress, setupResources)
require.EqualError(t, err, "get client for dashboards: didn't work")
}
func TestExportResources_Dashboards_WithErrors(t *testing.T) {
mockItems := []unstructured.Unstructured{
createDashboardObject("dashboard-1"),
createDashboardObject("dashboard-2"),
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard"
})).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-1"
}), options).Return("", fmt.Errorf("failed to export dashboard"))
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-2"
}), options).Return("dashboard-2.json", nil)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_TooManyErrors(t *testing.T) {
mockItems := []unstructured.Unstructured{
createDashboardObject("dashboard-1"),
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard"
})).Return()
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered"))
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "dashboard-1"
}), options).Return("", fmt.Errorf("failed to export dashboard"))
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.EqualError(t, err, "export dashboards: too many errors encountered")
}
func TestExportResources_Dashboards_IgnoresExisting(t *testing.T) {
mockItems := []unstructured.Unstructured{
createDashboardObject("existing-dashboard"),
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "existing-dashboard"
}), options).Return("", resources.ErrAlreadyInRepository)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_SavedVersion(t *testing.T) {
mockItems := []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "existing-dashboard",
},
"spec": map[string]interface{}{
"hello": "world",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v0xyz",
},
},
},
},
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
// Verify that the object has the expected status.conversion.storedVersion field
status, exists, err := unstructured.NestedMap(obj.Object, "status")
if !exists || err != nil {
return false
}
conversion, exists, err := unstructured.NestedMap(status, "conversion")
if !exists || err != nil {
return false
}
storedVersion, exists, err := unstructured.NestedString(conversion, "storedVersion")
if !exists || err != nil {
return false
}
if storedVersion != "v0xyz" {
return false
}
return obj.GetName() == "existing-dashboard"
}), options).Return("", fmt.Errorf("XXX"))
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_FailedConversionNoStoredVersion(t *testing.T) {
mockItems := []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "dashboard-no-stored-version",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
// No storedVersion field
},
},
},
},
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "dashboard-no-stored-version" &&
result.Action == repository.FileActionIgnored &&
result.Error != nil
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
// The value is not saved
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_V2Alpha1(t *testing.T) {
mockItems := []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2alpha1",
},
},
},
},
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
// Setup v1 client
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
// Setup v2 client
v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1")
v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}}
resourceClients.On("ForResource", resources.DashboardResourceV2alpha1).Return(v2Client, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, &v2Dashboard, options).Return("v2-dashboard.json", nil)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_V2Alpha1_ClientError(t *testing.T) {
mockItems := []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard-error",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2alpha1",
},
},
},
},
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
if result.Name != "v2-dashboard-error" {
return false
}
if result.Action != repository.FileActionIgnored {
return false
}
if result.Error == nil {
return false
}
if result.Error.Error() != "v2 client error" {
return false
}
return true
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResourceV2alpha1).Return(nil, gvk, fmt.Errorf("v2 client error"))
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_V2Alpha2(t *testing.T) {
mockItems := []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2alpha2",
},
},
},
},
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
// Setup v1 client
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
// Setup v2 client
v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha2")
v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}}
resourceClients.On("ForResource", resources.DashboardResourceV2alpha2).Return(v2Client, gvk, nil)
options := resources.WriteOptions{
Path: "grafana",
Ref: "feature/branch",
}
repoResources.On("WriteResourceFileFromObject", mock.Anything, &v2Dashboard, options).Return("v2-dashboard.json", nil)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportResources_Dashboards_V2Alpha2_ClientError(t *testing.T) {
mockItems := []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": "v2-dashboard-error",
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2alpha2",
},
},
},
},
}
setupProgress := func(progress *jobs.MockJobProgressRecorder) {
progress.On("SetMessage", mock.Anything, "start resource export").Return()
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
if result.Name != "v2-dashboard-error" {
return false
}
if result.Action != repository.FileActionIgnored {
return false
}
if result.Error == nil {
return false
}
if result.Error.Error() != "v2 client error" {
return false
}
return true
})).Return()
progress.On("TooManyErrors").Return(nil)
}
setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) {
resourceClients.On("ForResource", resources.DashboardResourceV2alpha2).Return(nil, gvk, fmt.Errorf("v2 client error"))
resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil)
}
err := runExportTest(t, mockItems, setupProgress, setupResources)
require.NoError(t, err)
}
@@ -11,7 +11,8 @@ import (
"k8s.io/client-go/dynamic"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
@@ -19,10 +20,11 @@ import (
)
var (
UserResource = iam.UserResourceInfo.GroupVersionResource()
FolderResource = folders.FolderResourceInfo.GroupVersionResource()
DashboardResource = dashboardV1.DashboardResourceInfo.GroupVersionResource()
DashboardResourceV2 = dashboardV2.DashboardResourceInfo.GroupVersionResource()
UserResource = iam.UserResourceInfo.GroupVersionResource()
FolderResource = folders.FolderResourceInfo.GroupVersionResource()
DashboardResource = dashboardV1.DashboardResourceInfo.GroupVersionResource()
DashboardResourceV2alpha1 = dashboardV2alpha1.DashboardResourceInfo.GroupVersionResource()
DashboardResourceV2alpha2 = dashboardV2alpha2.DashboardResourceInfo.GroupVersionResource()
// SupportedProvisioningResources is the list of resources that can fully managed from the UI
SupportedProvisioningResources = []schema.GroupVersionResource{FolderResource, DashboardResource}
+17 -3
View File
@@ -11,7 +11,8 @@ import (
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"google.golang.org/protobuf/types/known/wrapperspb"
dashboardalpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -87,8 +88,21 @@ func (s *Server) getContextuals(subject string) (*openfgav1.ContextualTupleKeys,
User: subject,
Relation: common.RelationSetView,
Object: common.NewGroupResourceIdent(
dashboardalpha1.DashboardResourceInfo.GroupResource().Group,
dashboardalpha1.DashboardResourceInfo.GroupResource().Resource,
dashboardV2alpha1.DashboardResourceInfo.GroupResource().Group,
dashboardV2alpha1.DashboardResourceInfo.GroupResource().Resource,
"",
),
},
)
contextuals = append(
contextuals,
&openfgav1.TupleKey{
User: subject,
Relation: common.RelationSetView,
Object: common.NewGroupResourceIdent(
dashboardV2alpha2.DashboardResourceInfo.GroupResource().Group,
dashboardV2alpha2.DashboardResourceInfo.GroupResource().Resource,
"",
),
},
+33 -6
View File
@@ -23,7 +23,8 @@ import (
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
)
func TestMain(m *testing.M) {
@@ -164,10 +165,10 @@ func TestIntegrationDashboardsAppV1(t *testing.T) {
}
}
func TestIntegrationDashboardsAppV2(t *testing.T) {
func TestIntegrationDashboardsAppV2alpha1(t *testing.T) {
gvr := schema.GroupVersionResource{
Group: dashboardV2.GROUP,
Version: dashboardV2.VERSION,
Group: dashboardV2alpha1.GROUP,
Version: dashboardV2alpha1.VERSION,
Resource: "dashboards",
}
if testing.Short() {
@@ -176,7 +177,33 @@ func TestIntegrationDashboardsAppV2(t *testing.T) {
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
for _, mode := range modes {
t.Run(fmt.Sprintf("v1beta1 with dual writer mode %d", mode), func(t *testing.T) {
t.Run(fmt.Sprintf("v2alpha1 with dual writer mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: mode,
},
},
})
runDashboardTest(t, helper, gvr)
})
}
}
func TestIntegrationDashboardsAppV2alpha2(t *testing.T) {
gvr := schema.GroupVersionResource{
Group: dashboardV2alpha2.GROUP,
Version: dashboardV2alpha2.VERSION,
Resource: "dashboards",
}
if testing.Short() {
t.Skip("skipping integration test")
}
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
for _, mode := range modes {
t.Run(fmt.Sprintf("v1alpha2 with dual writer mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
@@ -223,7 +250,7 @@ func TestIntegrationLegacySupport(t *testing.T) {
clientV2 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: dashboardV2.DashboardResourceInfo.GroupVersionResource(),
GVR: dashboardV2alpha1.DashboardResourceInfo.GroupVersionResource(),
})
obj, err = clientV2.Resource.Create(ctx,
helper.LoadYAMLOrJSONFile("testdata/dashboard-test-v2.yaml"),
@@ -16,7 +16,8 @@ import (
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
foldersV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -345,11 +346,29 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
},
{
name: "v2alpha1 dashboard with correct spec should not throw on v2",
resourceInfo: dashboardV2.DashboardResourceInfo,
resourceInfo: dashboardV2alpha1.DashboardResourceInfo,
expectSpecErr: false,
testObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardV2.DashboardResourceInfo.TypeMeta().APIVersion,
"apiVersion": dashboardV2alpha1.DashboardResourceInfo.TypeMeta().APIVersion,
"kind": "Dashboard",
"metadata": map[string]interface{}{
"generateName": "test-",
},
"spec": map[string]interface{}{
"title": "Dashboard Title",
"description": "valid description",
},
},
},
},
{
name: "v2alpha2 dashboard with correct spec should not throw on v2",
resourceInfo: dashboardV2alpha2.DashboardResourceInfo,
expectSpecErr: false,
testObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardV2alpha2.DashboardResourceInfo.TypeMeta().APIVersion,
"kind": "Dashboard",
"metadata": map[string]interface{}{
"generateName": "test-",
@@ -1,9 +1,9 @@
apiVersion: dashboard.grafana.app/v2alpha1
kind: Dashboard
metadata:
name: test-v2
name: test-v2alpha1
spec:
title: Test dashboard. Created at v2
title: Test dashboard. Created at v2alpha1
layout:
kind: GridLayout
spec:
@@ -0,0 +1,10 @@
apiVersion: dashboard.grafana.app/v2alpha2
kind: Dashboard
metadata:
name: test-v2alpha2
spec:
title: Test dashboard. Created at v2alpha2
layout:
kind: GridLayout
spec:
items: []
+29 -21
View File
@@ -21,7 +21,8 @@ import (
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardsV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardsV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
@@ -41,15 +42,16 @@ type provisioningTestHelper struct {
*apis.K8sTestHelper
ProvisioningPath string
Repositories *apis.K8sResourceClient
Jobs *apis.K8sResourceClient
Folders *apis.K8sResourceClient
DashboardsV0 *apis.K8sResourceClient
DashboardsV1 *apis.K8sResourceClient
DashboardsV2 *apis.K8sResourceClient
AdminREST *rest.RESTClient
EditorREST *rest.RESTClient
ViewerREST *rest.RESTClient
Repositories *apis.K8sResourceClient
Jobs *apis.K8sResourceClient
Folders *apis.K8sResourceClient
DashboardsV0 *apis.K8sResourceClient
DashboardsV1 *apis.K8sResourceClient
DashboardsV2alpha1 *apis.K8sResourceClient
DashboardsV2alpha2 *apis.K8sResourceClient
AdminREST *rest.RESTClient
EditorREST *rest.RESTClient
ViewerREST *rest.RESTClient
}
func (h *provisioningTestHelper) SyncAndWait(t *testing.T, repo string, options *provisioning.SyncJobOptions) {
@@ -255,10 +257,15 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
Namespace: "default", // actually org1
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
})
dashboardsV2 := helper.GetResourceClient(apis.ResourceClientArgs{
dashboardsV2alpha1 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: dashboardV2.DashboardResourceInfo.GroupVersionResource(),
GVR: dashboardsV2alpha1.DashboardResourceInfo.GroupVersionResource(),
})
dashboardsV2alpha2 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: dashboardsV2alpha2.DashboardResourceInfo.GroupVersionResource(),
})
// Repo client, but less guard rails. Useful for subresources. We'll need this later...
@@ -289,15 +296,16 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
ProvisioningPath: provisioningPath,
K8sTestHelper: helper,
Repositories: repositories,
AdminREST: adminClient,
EditorREST: editorClient,
ViewerREST: viewerClient,
Jobs: jobs,
Folders: folders,
DashboardsV0: dashboardsV0,
DashboardsV1: dashboardsV1,
DashboardsV2: dashboardsV2,
Repositories: repositories,
AdminREST: adminClient,
EditorREST: editorClient,
ViewerREST: viewerClient,
Jobs: jobs,
Folders: folders,
DashboardsV0: dashboardsV0,
DashboardsV1: dashboardsV1,
DashboardsV2alpha1: dashboardsV2alpha1,
DashboardsV2alpha2: dashboardsV2alpha2,
}
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
@@ -21,11 +22,50 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/tests/apis"
)
// printFileTree prints the directory structure as a tree for debugging purposes
func printFileTree(t *testing.T, rootPath string) {
t.Helper()
t.Logf("File tree for %s:", rootPath)
err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
depth := strings.Count(relPath, string(filepath.Separator))
indent := strings.Repeat(" ", depth)
if d.IsDir() {
t.Logf("%s├── %s/", indent, d.Name())
} else {
info, err := d.Info()
if err != nil {
t.Logf("%s├── %s (error reading info)", indent, d.Name())
} else {
t.Logf("%s├── %s (%d bytes)", indent, d.Name(), info.Size())
}
}
return nil
})
if err != nil {
t.Logf("Error walking directory: %v", err)
}
}
func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
@@ -609,9 +649,13 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) {
_, err = helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create v1 dashboard")
dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2.yaml")
_, err = helper.DashboardsV2.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create v2 dashboard")
dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha1.yaml")
_, err = helper.DashboardsV2alpha1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create v2alpha1 dashboard")
dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha2.yaml")
_, err = helper.DashboardsV2alpha2.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create v2alpha2 dashboard")
// Now for the repository.
const repo = "local-repository"
@@ -642,15 +686,19 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) {
title string
apiVersion string
name string
fileName string
}
printFileTree(t, helper.ProvisioningPath)
// Check that each file was exported with its stored version
for _, test := range []props{
{title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0"},
{title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1"},
{title: "Test dashboard. Created at v2", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2"},
{title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0", fileName: "test-dashboard-created-at-v0.json"},
{title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1", fileName: "test-dashboard-created-at-v1.json"},
{title: "Test dashboard. Created at v2alpha1", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2alpha1", fileName: "test-dashboard-created-at-v2alpha1.json"},
{title: "Test dashboard. Created at v2alpha2", apiVersion: "dashboard.grafana.app/v2alpha2", name: "test-v2alpha2", fileName: "test-dashboard-created-at-v2alpha2.json"},
} {
fpath := filepath.Join(helper.ProvisioningPath, slugify.Slugify(test.title)+".json")
fpath := filepath.Join(helper.ProvisioningPath, test.fileName)
//nolint:gosec // we are ok with reading files in testdata
body, err := os.ReadFile(fpath)
require.NoError(t, err, "exported file was not created at path %s", fpath)