feat(unified): data migration integration tests (#114418)

* feat: unified storage migrations integration tests

* chore: add comment and adjust db path name

* chore: refactor test cases into interface
This commit is contained in:
Rafael Bortolon Paulovic
2025-11-27 13:35:49 +01:00
committed by GitHub
parent cffca37999
commit 4c869a21a4
5 changed files with 476 additions and 9 deletions
+9 -3
View File
@@ -581,10 +581,16 @@ func TestMain(m *testing.M) {
// nolint:staticcheck
testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
skipTruncate := false
if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present {
skipTruncate = strings.ToLower(skip) == "true"
}
if !skipTruncate {
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
}
testSQLStore.engine.ResetSequenceGenerator()
}
testSQLStore.engine.ResetSequenceGenerator()
if err := testSQLStore.Reset(); err != nil {
return nil, err
@@ -0,0 +1,208 @@
package migrations_test
import (
"fmt"
"net/http"
"testing"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration
type foldersAndDashboardsTestCase struct {
parentFolderUID string
childFolderUID string
dashboardUID string
libPanelUID string
}
// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator
func newFoldersAndDashboardsTestCase() resourceMigratorTestCase {
return &foldersAndDashboardsTestCase{
parentFolderUID: "parent-folder-uid",
childFolderUID: "child-folder-uid",
dashboardUID: "", // Will be generated during setup
libPanelUID: "", // Will be generated during setup
}
}
func (tc *foldersAndDashboardsTestCase) name() string {
return "folders-dashboards"
}
func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource {
return []schema.GroupVersionResource{
{
Group: "folder.grafana.app",
Version: "v1beta1",
Resource: "folders",
},
{
Group: "dashboard.grafana.app",
Version: "v1beta1",
Resource: "dashboards",
},
}
}
func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) {
t.Helper()
// Create parent folder
parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "")
// Create child folder (nested under parent)
child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID)
// Create library panel in child folder
tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID)
// Create dashboard with library panel in child folder
tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel",
tc.libPanelUID, "Test LP in dashboard", child.UID)
}
func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) {
t.Helper()
// Build maps of UIDs by resource type
folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID}
dashboardUIDs := []string{tc.dashboardUID}
expectedFolderCount := 0
if shouldExist {
expectedFolderCount = len(folderUIDs)
}
orgID := helper.Org1.OrgID
namespace := authlib.OrgNamespaceFormatter(orgID)
// Verify folders
folderCli := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: namespace,
GVR: schema.GroupVersionResource{
Group: "folder.grafana.app",
Version: "v1beta1",
Resource: "folders",
},
})
verifyResourceCount(t, folderCli, expectedFolderCount)
for _, uid := range folderUIDs {
verifyResource(t, folderCli, uid, shouldExist)
}
// Verify dashboards
expectedDashboardCount := 0
if shouldExist {
expectedDashboardCount = len(dashboardUIDs)
}
dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: namespace,
GVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v1beta1",
Resource: "dashboards",
},
})
verifyResourceCount(t, dashboardCli, expectedDashboardCount)
for _, uid := range dashboardUIDs {
verifyResource(t, dashboardCli, uid, shouldExist)
}
}
// createTestFolder creates a folder with specified UID and optional parent
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder {
t.Helper()
payload := fmt.Sprintf(`{
"title": "%s",
"uid": "%s"`, title, uid)
if parentUID != "" {
payload += fmt.Sprintf(`,
"parentUid": "%s"`, parentUID)
}
payload += "}"
folderCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, folderCreate.Result)
require.Equal(t, uid, folderCreate.Result.UID)
return folderCreate.Result
}
// createTestLibraryPanel creates a library panel in a folder
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string {
t.Helper()
libPanelPayload := fmt.Sprintf(`{
"kind": 1,
"name": "%s",
"folderUid": "%s",
"model": {
"type": "text",
"title": "%s"
}
}`, name, folderUID, name)
libCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/library-elements",
Body: []byte(libPanelPayload),
}, &map[string]interface{}{})
require.NotNil(t, libCreate.Response)
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
require.NotEmpty(t, libPanelUID)
return libPanelUID
}
// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel
func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string {
t.Helper()
dashPayload := fmt.Sprintf(`{
"dashboard": {
"title": "%s",
"panels": [{
"id": 1,
"libraryPanel": {
"uid": "%s",
"name": "%s"
}
}]
},
"folderUid": "%s",
"overwrite": false
}`, dashTitle, libPanelUID, libPanelName, folderUID)
dashCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/dashboards/db",
Body: []byte(dashPayload),
}, &map[string]interface{}{})
require.NotNil(t, dashCreate.Response)
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
dashUID := (*dashCreate.Result)["uid"].(string)
require.NotEmpty(t, dashUID)
return dashUID
}
@@ -0,0 +1,210 @@
package migrations_test
import (
"context"
"fmt"
"os"
"testing"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// resourceMigratorTestCase defines the interface for testing a resource migrator.
type resourceMigratorTestCase interface {
// name returns the test case name
name() string
// resources returns the GVRs that this migrator handles
resources() []schema.GroupVersionResource
// setup creates test resources in legacy storage (Mode0)
setup(t *testing.T, helper *apis.K8sTestHelper)
// verify checks that resources exist (or don't exist) in unified storage
verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool)
}
// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage.
// The test follows a three-step process:
// Step 1: inserts legacy data (migration disabled at startup)
// Step 2: verifies that the data is not in unified storage
// Step 3: migration runs at startup, and the test verifies that the data is in unified storage
func TestIntegrationMigrations(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
migrationTestCases := []resourceMigratorTestCase{
newFoldersAndDashboardsTestCase(),
}
runMigrationTestSuite(t, migrationTestCases)
}
// runMigrationTestSuite executes the migration test suite for the given test cases
func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
if db.IsTestDbSQLite() {
// Share the same SQLite DB file between steps
tmpDir := t.TempDir()
dbPath := tmpDir + "/shared-migration-test-suite.db"
oldVal := os.Getenv("SQLITE_TEST_DB")
require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath))
t.Cleanup(func() {
if oldVal == "" {
_ = os.Unsetenv("SQLITE_TEST_DB")
} else {
_ = os.Setenv("SQLITE_TEST_DB", oldVal)
}
})
t.Logf("Using shared database path: %s", dbPath)
}
// Store UIDs created by each test case
type testCaseState struct {
tc resourceMigratorTestCase
}
testStates := make([]testCaseState, len(testCases))
for i, tc := range testCases {
testStates[i].tc = tc
}
// reuse org users throughout the tests
var org1 *apis.OrgUsers
var orgB *apis.OrgUsers
t.Run("Step 1: Create data in legacy", func(t *testing.T) {
// Enforce Mode0 for all migrated resources
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode0,
}
}
}
// Set up test environment with Mode0 (writes only to legacy)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
})
t.Cleanup(helper.Shutdown)
org1 = &helper.Org1
orgB = &helper.OrgB
for i := range testStates {
state := &testStates[i]
t.Run(state.tc.name(), func(t *testing.T) {
state.tc.setup(t, helper)
// Verify resources were created in legacy storage
state.tc.verify(t, helper, true)
})
}
})
// Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1
oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE")
require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true"))
t.Cleanup(func() {
if oldSkipTruncate == "" {
_ = os.Unsetenv("SKIP_DB_TRUNCATE")
} else {
_ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate)
}
})
t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) {
// Build unified storage config for Mode5
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode5,
}
}
}
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources don't exist in unified storage yet
state.tc.verify(t, helper, false)
})
}
})
t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) {
// Migrations will run automatically at startup and mode 5 is enforced by the config
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
// EnableLog: true,
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: false, // Run migrations at startup
APIServerStorageType: "unified",
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources now exist in unified storage after migration
state.tc.verify(t, helper, true)
})
}
})
}
// verifyResourceCount verifies that the expected number of resources exist in K8s storage
func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) {
t.Helper()
l, err := client.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
resources, err := meta.ExtractList(l)
require.NoError(t, err)
require.Equal(t, expectedCount, len(resources))
}
// verifyResource verifies that a resource with the given UID exists in K8s storage
func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) {
t.Helper()
_, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
if shouldExist {
require.NoError(t, err)
} else {
require.Error(t, err)
}
}
+34 -4
View File
@@ -93,7 +93,18 @@ type K8sTestHelper struct {
userSvc user.Service
}
type K8sTestHelperOpts struct {
testinfra.GrafanaOpts
// If provided, these users will be used instead of creating new ones
Org1Users *OrgUsers
OrgBUsers *OrgUsers
}
func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts})
}
func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper {
t.Helper()
// Use GRPC server when not configured
@@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
path = opts.DirPath
)
if opts.Dir == "" && opts.DirPath == "" {
dir, path = testinfra.CreateGrafDir(t, opts)
dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts)
}
listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path)
if !opts.DisableDBCleanup {
t.Cleanup(testDB.Cleanup)
}
listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path)
c := &K8sTestHelper{
env: *env,
@@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
_ = c.CreateOrg(Org1)
_ = c.CreateOrg(Org2)
c.Org1 = c.createTestUsers(Org1)
c.OrgB = c.createTestUsers(Org2)
if opts.Org1Users != nil {
c.Org1 = *opts.Org1Users
c.Org1.Admin.baseURL = listenerAddress
c.Org1.Editor.baseURL = listenerAddress
c.Org1.Viewer.baseURL = listenerAddress
c.Org1.None.baseURL = listenerAddress
} else {
c.Org1 = c.createTestUsers(Org1)
}
if opts.OrgBUsers != nil {
c.OrgB = *opts.OrgBUsers
c.OrgB.Admin.baseURL = listenerAddress
c.OrgB.Editor.baseURL = listenerAddress
c.OrgB.Viewer.baseURL = listenerAddress
c.OrgB.None.baseURL = listenerAddress
} else {
c.OrgB = c.createTestUsers(Org2)
}
c.loadAPIGroups()
+15 -2
View File
@@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) {
}
func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) {
addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath)
t.Cleanup(testDB.Cleanup)
return addr, env
}
func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) {
t.Helper()
ctx := context.Background()
@@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
// Use proper database type based on the environment variable GRAFANA_TEST_DB in tests
testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType())
require.NoError(t, err)
t.Cleanup(testDB.Cleanup)
dbCfg := cfg.Raw.Section("database")
dbCfg.Key("type").SetValue(testDB.DriverName)
@@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
t.Logf("Grafana is listening on %s", addr)
return addr, env
return addr, env, testDB
}
// CreateGrafDir creates the Grafana directory.
@@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes))
require.NoError(t, err)
}
if opts.DisableDataMigrations {
section, err := getOrCreateSection("unified_storage")
require.NoError(t, err)
_, err = section.NewKey("disable_data_migrations", "true")
require.NoError(t, err)
}
if opts.PermittedProvisioningPaths != "" {
_, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths)
require.NoError(t, err)
@@ -637,6 +648,8 @@ type GrafanaOpts struct {
EnableSCIM bool
APIServerRuntimeConfig string
DisableControllers bool
DisableDBCleanup bool
DisableDataMigrations bool
SecretsManagerEnableDBMigrations bool
// Allow creating grafana dir beforehand