Files
grafana/pkg/storage/unified/migrations/validator.go
T
Rafael Bortolon Paulovic 12c6d7e83f fix(unified): in-proc SQLite data migration (#114537)
* feat: unified storage migrations integration tests

* chore: add comment and adjust db path name

* chore: refactor test cases into interface

* fix: unified SQLite migration with SQLStore migrator

* revert changes to newResourceDBProvider
2025-11-28 13:13:35 +01:00

406 lines
12 KiB
Go

package migrations
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util/xorm"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func filterResponse(response *resourcepb.BulkResponse, resources []schema.GroupResource) *resourcepb.BulkResponse {
if len(resources) == 0 {
return response
}
// Create a resource lookup map for efficient filtering
resourceMap := make(map[string]bool)
for _, res := range resources {
key := fmt.Sprintf("%s/%s", res.Group, res.Resource)
resourceMap[key] = true
}
// Filter summaries to only include matching resources
var filteredSummaries []*resourcepb.BulkResponse_Summary
for _, summary := range response.Summary {
key := fmt.Sprintf("%s/%s", summary.Group, summary.Resource)
if resourceMap[key] {
filteredSummaries = append(filteredSummaries, summary)
}
}
// Filter rejected items to only include matching resources
var filteredRejected []*resourcepb.BulkResponse_Rejected
for _, rejected := range response.Rejected {
if rejected.Key != nil {
key := fmt.Sprintf("%s/%s", rejected.Key.Group, rejected.Key.Resource)
if resourceMap[key] {
filteredRejected = append(filteredRejected, rejected)
}
}
}
// Create filtered response, preserving original processed count for percentage calculations
return &resourcepb.BulkResponse{
Error: response.Error,
Processed: response.Processed,
Summary: filteredSummaries,
Rejected: filteredRejected,
}
}
type CountValidator struct {
name string
client resourcepb.ResourceIndexClient
resource schema.GroupResource
table string
whereClause string
driverName string
}
func NewCountValidator(
client resourcepb.ResourceIndexClient,
resource schema.GroupResource,
table string,
whereClause string,
driverName string,
) Validator {
return &CountValidator{
name: "CountValidator",
client: client,
resource: resource,
table: table,
whereClause: whereClause,
driverName: driverName,
}
}
func (v *CountValidator) Name() string {
return v.name
}
func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error {
// Filter response to only include the configured resource
response = filterResponse(response, []schema.GroupResource{v.resource})
if len(response.Rejected) > 0 {
log.Warn("Migration had rejected items", "count", len(response.Rejected))
for i, rejected := range response.Rejected {
if i < 10 { // Log first 10 rejected items
log.Warn("Rejected item",
"namespace", rejected.Key.Namespace,
"group", rejected.Key.Group,
"resource", rejected.Key.Resource,
"name", rejected.Key.Name,
"reason", rejected.Error)
}
}
// Rejections are not fatal - they may be expected for invalid data
}
// Should have at most one summary after filtering
if len(response.Summary) == 0 {
log.Debug("No summaries found for resource, skipping count validation",
"resource", fmt.Sprintf("%s.%s", v.resource.Resource, v.resource.Group))
return nil
}
if len(response.Summary) > 1 {
return fmt.Errorf("expected at most 1 summary after filtering, got %d", len(response.Summary))
}
summary := response.Summary[0]
// Get legacy count from database
orgID, err := ParseOrgIDFromNamespace(summary.Namespace)
if err != nil {
return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err)
}
legacyCount, err := sess.Table(v.table).Where(v.whereClause, orgID).Count()
if err != nil {
return fmt.Errorf("failed to count %s: %w", v.table, err)
}
var unifiedCount int64
if v.driverName == migrator.SQLite {
unifiedCount, err = sess.Table("resource").
Where("namespace = ? AND `group` = ? AND resource = ?",
summary.Namespace, summary.Group, summary.Resource).
Count()
if err != nil {
return fmt.Errorf("failed to count resource table for %s/%s in namespace %s: %w",
summary.Group, summary.Resource, summary.Namespace, err)
}
} else {
// Get unified storage count using GetStats API
statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{
Namespace: summary.Namespace,
Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)},
})
if err != nil {
return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w",
summary.Group, summary.Resource, summary.Namespace, err)
}
// Find the count for this specific resource type
for _, stat := range statsResp.Stats {
if stat.Group == summary.Group && stat.Resource == summary.Resource {
unifiedCount = stat.Count
break
}
}
}
// Account for rejected items in validation
expectedCount := unifiedCount + int64(len(response.Rejected))
log.Info("Count validation",
"resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group),
"namespace", summary.Namespace,
"legacy_count", legacyCount,
"unified_count", unifiedCount,
"migration_summary_count", summary.Count,
"rejected", len(response.Rejected),
"history", summary.History)
// Validate that we migrated all items (allowing for rejected items)
if legacyCount > expectedCount {
return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d",
summary.Resource, summary.Group, summary.Namespace,
legacyCount, unifiedCount, len(response.Rejected))
}
return nil
}
type FolderTreeValidator struct {
name string
client resourcepb.ResourceIndexClient
resource schema.GroupResource
driverName string
}
func NewFolderTreeValidator(
client resourcepb.ResourceIndexClient,
resource schema.GroupResource,
driverName string,
) Validator {
return &FolderTreeValidator{
name: "FolderTreeValidator",
client: client,
resource: resource,
driverName: driverName,
}
}
type legacyFolder struct {
ID int64 `xorm:"id"`
UID string `xorm:"uid"`
FolderUID string `xorm:"folder_uid"`
Title string `xorm:"title"`
}
type unifiedFolder struct {
GUID string `xorm:"guid"`
Name string `xorm:"name"`
Folder string `xorm:"folder"`
}
func (v *FolderTreeValidator) Name() string {
return v.name
}
func (v *FolderTreeValidator) Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error {
// Filter response to only include the configured resource (folders)
response = filterResponse(response, []schema.GroupResource{v.resource})
// Should have at most one summary after filtering
if len(response.Summary) == 0 {
log.Debug("No summaries found for folders, skipping folder tree validation")
return nil
}
if len(response.Summary) > 1 {
return fmt.Errorf("expected at most 1 summary after filtering, got %d", len(response.Summary))
}
summary := response.Summary[0]
// Get orgID from namespace
orgID, err := ParseOrgIDFromNamespace(summary.Namespace)
if err != nil {
return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err)
}
// Build legacy folder parent map
legacyParentMap, err := v.buildLegacyFolderParentMap(sess, orgID, log)
if err != nil {
return fmt.Errorf("failed to build legacy folder parent map: %w", err)
}
// Build unified storage folder parent map
var unifiedParentMap map[string]string
if v.driverName == migrator.SQLite {
unifiedParentMap, err = v.buildUnifiedFolderParentMapSQLite(sess, summary.Namespace, log)
} else {
unifiedParentMap, err = v.buildUnifiedFolderParentMap(ctx, summary.Namespace, log)
}
if err != nil {
return fmt.Errorf("failed to build unified folder parent map: %w", err)
}
// Compare the two maps
mismatches := []string{}
for uid, legacyParent := range legacyParentMap {
unifiedParent, exists := unifiedParentMap[uid]
if !exists {
// Folder exists in legacy but not in unified - might be rejected, skip
log.Debug("Folder exists in legacy but not in unified storage",
"uid", uid,
"legacy_parent", legacyParent)
continue
}
if legacyParent != unifiedParent {
mismatch := fmt.Sprintf("folder %s: legacy parent=%s, unified parent=%s",
uid, legacyParent, unifiedParent)
mismatches = append(mismatches, mismatch)
log.Warn("Folder parent mismatch",
"uid", uid,
"legacy_parent", legacyParent,
"unified_parent", unifiedParent)
}
}
// Check for folders in unified but not in legacy (shouldn't happen)
for uid := range unifiedParentMap {
if _, exists := legacyParentMap[uid]; !exists {
mismatch := fmt.Sprintf("folder %s exists in unified but not in legacy", uid)
mismatches = append(mismatches, mismatch)
log.Warn("Folder exists in unified but not in legacy", "uid", uid)
}
}
if len(mismatches) > 0 {
log.Error("Folder tree structure validation failed",
"mismatch_count", len(mismatches),
"total_legacy_folders", len(legacyParentMap),
"total_unified_folders", len(unifiedParentMap))
return fmt.Errorf("folder tree structure mismatch: %d folders have incorrect parents", len(mismatches))
}
log.Info("Folder tree structure validation passed",
"folder_count", len(legacyParentMap),
"namespace", summary.Namespace)
return nil
}
func (v *FolderTreeValidator) buildLegacyFolderParentMap(sess *xorm.Session, orgID int64, log log.Logger) (map[string]string, error) {
// Query all folders for this org
var folders []legacyFolder
err := sess.Table("dashboard").
Cols("id", "uid", "folder_uid", "title").
Where("org_id = ? AND is_folder = ?", orgID, true).
Find(&folders)
if err != nil {
return nil, fmt.Errorf("failed to query legacy folders: %w", err)
}
parentMap := make(map[string]string)
for _, folder := range folders {
parentMap[folder.UID] = folder.FolderUID
}
if len(parentMap) == 0 {
log.Debug("No legacy folders found for org", "org_id", orgID)
return make(map[string]string), nil
}
log.Debug("Built legacy folder parent map",
"folder_count", len(parentMap),
"org_id", orgID)
return parentMap, nil
}
func (v *FolderTreeValidator) buildUnifiedFolderParentMap(ctx context.Context, namespace string, log log.Logger) (map[string]string, error) {
// Search for all folders in this namespace
searchResp, err := v.client.Search(ctx, &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Namespace: namespace,
Group: v.resource.Group,
Resource: v.resource.Resource,
},
},
Limit: 100000, // Large limit to get all folders
})
if err != nil {
return nil, fmt.Errorf("failed to search folders in unified storage: %w", err)
}
if searchResp.Results == nil {
return make(map[string]string), nil
}
parentMap := make(map[string]string)
for _, row := range searchResp.Results.Rows {
if row.Key == nil {
continue
}
folderUID := row.Key.Name
parentUID := ""
folderColIdx := -1
for i, col := range searchResp.Results.Columns {
if col.Name == "folder" {
folderColIdx = i
break
}
}
if folderColIdx >= 0 && folderColIdx < len(row.Cells) {
parentUID = string(row.Cells[folderColIdx])
}
parentMap[folderUID] = parentUID
}
log.Debug("Built unified folder parent map",
"folder_count", len(parentMap),
"namespace", namespace)
return parentMap, nil
}
func (v *FolderTreeValidator) buildUnifiedFolderParentMapSQLite(sess *xorm.Session, namespace string, log log.Logger) (map[string]string, error) {
var folders []unifiedFolder
err := sess.Table("resource").
Cols("guid", "name", "folder").
Where("namespace = ? AND resource = ?", namespace, "folder").
Find(&folders)
if err != nil {
return nil, fmt.Errorf("failed to query unified folders: %w", err)
}
parentMap := make(map[string]string)
for _, folder := range folders {
parentMap[folder.Name] = folder.Folder
}
if len(parentMap) == 0 {
log.Debug("No unified folders found for namespace", "namespace", namespace)
return make(map[string]string), nil
}
log.Debug("Built unified folder parent map",
"folder_count", len(parentMap),
"namespace", namespace)
return parentMap, nil
}