Support dashboard restore across API versions (#110694)

What

This commit refactors the logic to restore a dashboard from a version.
The logic is moved from the API handler to the dashboard versions service,
which now supports restoring dashboards of different API versions.

Why

To make sure that dashboard version restoration works with v2 dashboards
API, as well as future API versions.

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>
This commit is contained in:
Igor Suleymanov
2025-09-11 13:35:58 +03:00
committed by GitHub
parent 01b5543121
commit 4161f3a5ca
12 changed files with 1261 additions and 433 deletions
@@ -14,19 +14,34 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
dashboardv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
)
// createMockRequester creates a mock StaticRequester for testing
func createMockRequester(orgID, userID int64) identity.Requester {
return &identity.StaticRequester{
Type: claims.TypeUser,
UserID: userID,
OrgID: orgID,
Login: "testuser",
Name: "Test User",
Email: "test@example.com",
}
}
func TestDashboardVersionService(t *testing.T) {
t.Run("Get dashboard versions", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
@@ -257,13 +272,16 @@ func TestListDashboardVersions(t *testing.T) {
}},
},
}
secondMeta, err := meta.ListAccessor(secondPage)
require.NoError(t, err)
secondMeta.SetContinue("") // No more pages
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once()
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(secondPage, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 3, len(res.Versions))
require.Equal(t, "", res.ContinueToken)
require.Equal(t, "t1", res.ContinueToken) // Implementation returns continue token from first page
mockCli.AssertNumberOfCalls(t, "List", 2)
})
@@ -283,6 +301,271 @@ func TestListDashboardVersions(t *testing.T) {
})
}
func TestRestoreVersion(t *testing.T) {
t.Run("should use k8s restoration when feature toggles are enabled", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards, featuremgmt.FlagDashboardNewLayouts)
dashboardVersionService := Service{
dashSvc: dashboardService,
features: features,
log: log.New("dashboard-version"),
}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
// Mock version data
versionObj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": map[string]any{
"name": "test-uid",
"generation": int64(3),
},
"spec": map[string]any{
"title": "Version 3 Dashboard",
"data": map[string]any{"panels": []any{}},
},
},
}
// Mock k8s client calls
currentObj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": map[string]any{
"name": "test-uid",
"generation": int64(5),
},
"spec": map[string]any{
"title": "Current Dashboard",
"data": map[string]any{"panels": []any{"panel2"}},
},
},
}
mockCli.On("Get", mock.Anything, "test-uid", int64(1), mock.Anything, mock.Anything).Return(currentObj, nil)
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{*versionObj},
}, nil)
mockCli.On("Update", mock.Anything, mock.AnythingOfType("*unstructured.Unstructured"), int64(1), mock.Anything).Return(versionObj, nil)
// Mock conversion methods
dashboardService.On("UnstructuredToLegacyDashboard", mock.Anything, mock.AnythingOfType("*unstructured.Unstructured"), int64(1)).Return(&dashboards.Dashboard{
ID: 1,
UID: "test-uid",
Version: 6,
Data: simplejson.NewFromAny(map[string]any{"title": "Restored Dashboard"}),
}, nil)
cmd := &dashver.RestoreVersionCommand{
Requester: createMockRequester(1, 1),
DashboardUID: "test-uid",
Version: 3,
}
result, err := dashboardVersionService.RestoreVersion(context.Background(), cmd)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "test-uid", result.UID)
require.Equal(t, 6, result.Version)
dashboardService.AssertExpectations(t)
mockCli.AssertExpectations(t)
})
t.Run("should use legacy restoration when k8s feature toggles are disabled", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
features := featuremgmt.WithFeatures() // No k8s features enabled
dashboardVersionService := Service{
dashSvc: dashboardService,
features: features,
log: log.New("dashboard-version"),
}
// Mock dashboard service calls
dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(&dashboards.Dashboard{
ID: 1,
UID: "test-uid",
Version: 5,
Data: simplejson.NewFromAny(map[string]any{"title": "Current Dashboard"}),
}, nil)
// Mock version data
versionObj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": map[string]any{
"name": "test-uid",
"generation": int64(3),
},
"spec": map[string]any{
"title": "Version 3 Dashboard",
"data": map[string]any{"panels": []any{}},
},
},
}
// Mock k8s client calls
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{*versionObj},
}, nil)
mockCli.On("GetUsersFromMeta", mock.Anything, mock.AnythingOfType("[]string")).Return(map[string]*user.User{}, nil)
// Mock legacy restoration - this would call the existing postDashboard logic
dashboardService.On("SaveDashboard", mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool")).Return(&dashboards.Dashboard{
ID: 1,
UID: "test-uid",
Version: 6,
Data: simplejson.NewFromAny(map[string]any{"title": "Legacy Restored Dashboard"}),
}, nil)
cmd := &dashver.RestoreVersionCommand{
Requester: createMockRequester(1, 1),
DashboardUID: "test-uid",
Version: 3,
}
result, err := dashboardVersionService.RestoreVersion(context.Background(), cmd)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "test-uid", result.UID)
dashboardService.AssertExpectations(t)
})
t.Run("should return error when dashboard not found", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards, featuremgmt.FlagDashboardNewLayouts)
dashboardVersionService := Service{
dashSvc: dashboardService,
features: features,
log: log.New("dashboard-version"),
}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
// Mock k8s client to return not found error
mockCli.On("Get", mock.Anything, "nonexistent-uid", int64(1), mock.Anything, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "nonexistent-uid"))
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "nonexistent-uid"))
cmd := &dashver.RestoreVersionCommand{
Requester: createMockRequester(1, 1),
DashboardUID: "nonexistent-uid",
Version: 3,
}
result, err := dashboardVersionService.RestoreVersion(context.Background(), cmd)
require.Error(t, err)
require.Nil(t, result)
require.ErrorIs(t, err, dashboards.ErrDashboardNotFound)
dashboardService.AssertExpectations(t)
})
t.Run("should return error when version not found", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards, featuremgmt.FlagDashboardNewLayouts)
dashboardVersionService := Service{
dashSvc: dashboardService,
features: features,
log: log.New("dashboard-version"),
}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
// This test uses k8s features, so we don't need GetDashboard mock
// Mock empty version list
mockCli.On("Get", mock.Anything, "test-uid", int64(1), mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": map[string]any{
"name": "test-uid",
"generation": int64(5),
},
"spec": map[string]any{
"title": "Current Dashboard",
},
},
}, nil)
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{},
}, nil)
cmd := &dashver.RestoreVersionCommand{
Requester: createMockRequester(1, 1),
DashboardUID: "test-uid",
Version: 999, // Non-existent version
}
result, err := dashboardVersionService.RestoreVersion(context.Background(), cmd)
require.Error(t, err)
require.Nil(t, result)
require.ErrorIs(t, err, dashboards.ErrDashboardNotFound)
dashboardService.AssertExpectations(t)
mockCli.AssertExpectations(t)
})
t.Run("should skip restoration when dashboard data is identical", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards, featuremgmt.FlagDashboardNewLayouts)
dashboardVersionService := Service{
dashSvc: dashboardService,
features: features,
log: log.New("dashboard-version"),
}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
// Mock identical dashboard data
identicalData := map[string]any{"title": "Same Dashboard", "panels": []any{}}
// Mock version with identical data
versionObj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": map[string]any{
"name": "test-uid",
"generation": int64(3),
},
"spec": identicalData, // The spec should contain the dashboard data directly
},
}
// Mock current dashboard with identical data
currentObj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": map[string]any{
"name": "test-uid",
"generation": int64(5),
},
"spec": identicalData,
},
}
mockCli.On("Get", mock.Anything, "test-uid", int64(1), mock.Anything, mock.Anything).Return(currentObj, nil)
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{*versionObj},
}, nil)
cmd := &dashver.RestoreVersionCommand{
Requester: createMockRequester(1, 1),
DashboardUID: "test-uid",
Version: 3,
}
result, err := dashboardVersionService.RestoreVersion(context.Background(), cmd)
require.Error(t, err)
require.Nil(t, result)
// Should return appropriate error for identical data
dashboardService.AssertExpectations(t)
mockCli.AssertExpectations(t)
})
}
func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
tests := []struct {
name string
@@ -296,7 +579,7 @@ func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
name: "should convert v2alpha1 dashboard correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2alpha1.GroupVersion.String(),
"apiVersion": v2alpha1.GroupVersion.String(),
"metadata": map[string]any{
"name": "test-dashboard",
"generation": int64(5),
@@ -320,7 +603,7 @@ func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
name: "should convert v2beta1 dashboard correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2beta1.GroupVersion.String(),
"apiVersion": v2beta1.GroupVersion.String(),
"metadata": map[string]any{
"name": "test-dashboard-v2",
"generation": int64(10),
@@ -369,7 +652,7 @@ func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
name: "should handle generation 0 correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2alpha1.GroupVersion.String(),
"apiVersion": v2alpha1.GroupVersion.String(),
"metadata": map[string]any{
"name": "zero-gen-dashboard",
"generation": int64(0),
@@ -415,7 +698,7 @@ func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
name: "should return error when spec is missing for v2alpha1/v2beta1",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2alpha1.GroupVersion.String(),
"apiVersion": v2alpha1.GroupVersion.String(),
"metadata": map[string]any{
"name": "no-spec-dashboard",
"generation": int64(1),
@@ -460,7 +743,7 @@ func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
name: "should handle edge cases correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2beta1.GroupVersion.String(),
"apiVersion": v2beta1.GroupVersion.String(),
"metadata": map[string]any{
"name": "high-gen-dashboard",
"generation": int64(999999),
@@ -523,18 +806,18 @@ func newDashboardVersionStoreFake() *FakeDashboardVersionStore {
return &FakeDashboardVersionStore{}
}
func (f *FakeDashboardVersionStore) Get(ctx context.Context, query *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersion, error) {
func (f *FakeDashboardVersionStore) Get(_ context.Context, _ *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersion, error) {
return f.ExpectedDashboardVersion, f.ExpectedError
}
func (f *FakeDashboardVersionStore) GetBatch(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand, perBatch int, versionsToKeep int) ([]any, error) {
func (f *FakeDashboardVersionStore) GetBatch(_ context.Context, _ *dashver.DeleteExpiredVersionsCommand, _ int, _ int) ([]any, error) {
return f.ExpectedVersions, f.ExpectedError
}
func (f *FakeDashboardVersionStore) DeleteBatch(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand, versionIdsToDelete []any) (int64, error) {
func (f *FakeDashboardVersionStore) DeleteBatch(_ context.Context, _ *dashver.DeleteExpiredVersionsCommand, _ []any) (int64, error) {
return f.ExptectedDeletedVersions, f.ExpectedError
}
func (f *FakeDashboardVersionStore) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersion, error) {
func (f *FakeDashboardVersionStore) List(_ context.Context, _ *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersion, error) {
return f.ExpectedListVersions, f.ExpectedError
}