CloudMigrations: Create resource dependency map to validate snapshot request (#102594)

* CloudMigrations: Create resource dependency map to validate snapshot request

* CloudMigrations: Validate resource types dependencies in create snapshot request

* CloudMigrations: Update service interface to pass parsed resource types for creation

* CloudMigrations: Conditionally append resource to snapshot if enabled

* CloudMigrations: Add /cloudmigration/resources/dependencies endpoint

* CloudMigrations: Properly filter dashboards and folders from snapshot
pull/103643/head
Matheus Macabu 3 months ago committed by GitHub
parent 662b635ef9
commit 3fad6183aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 69
      pkg/services/cloudmigration/api/api.go
  2. 60
      pkg/services/cloudmigration/api/api_test.go
  3. 25
      pkg/services/cloudmigration/api/dtos.go
  4. 2
      pkg/services/cloudmigration/cloudmigration.go
  5. 36
      pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go
  6. 2
      pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go
  7. 4
      pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go
  8. 38
      pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go
  9. 13
      pkg/services/cloudmigration/model.go
  10. 74
      pkg/services/cloudmigration/resource_dependency.go
  11. 267
      pkg/services/cloudmigration/resource_dependency_test.go
  12. 103
      public/api-merged.json
  13. 58
      public/app/features/migrate-to-cloud/api/endpoints.gen.ts
  14. 12
      public/app/features/migrate-to-cloud/api/index.ts
  15. 77
      public/app/features/migrate-to-cloud/onprem/Page.tsx
  16. 4
      public/locales/en-US/grafana.json
  17. 110
      public/openapi3.json
  18. 2
      scripts/generate-rtk-apis.ts

@ -5,6 +5,8 @@ import (
"net/http"
"strings"
"go.opentelemetry.io/otel/codes"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
@ -14,8 +16,6 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
"go.opentelemetry.io/otel/codes"
)
type CloudMigrationAPI struct {
@ -23,6 +23,7 @@ type CloudMigrationAPI struct {
routeRegister routing.RouteRegister
log log.Logger
tracer tracing.Tracer
resourceDependencyMap cloudmigration.DependencyMap
}
func RegisterApi(
@ -30,12 +31,14 @@ func RegisterApi(
cms cloudmigration.Service,
tracer tracing.Tracer,
acHandler accesscontrol.AccessControl,
resourceDependencyMap cloudmigration.DependencyMap,
) *CloudMigrationAPI {
api := &CloudMigrationAPI{
log: log.New("cloudmigrations.api"),
routeRegister: rr,
cloudMigrationService: cms,
tracer: tracer,
resourceDependencyMap: resourceDependencyMap,
}
api.registerEndpoints(acHandler)
return api
@ -63,6 +66,9 @@ func (cma *CloudMigrationAPI) registerEndpoints(acHandler accesscontrol.AccessCo
cloudMigrationRoute.Get("/migration/:uid/snapshots", routing.Wrap(cma.GetSnapshotList))
cloudMigrationRoute.Post("/migration/:uid/snapshot/:snapshotUid/upload", routing.Wrap(cma.UploadSnapshot))
cloudMigrationRoute.Post("/migration/:uid/snapshot/:snapshotUid/cancel", routing.Wrap(cma.CancelSnapshot))
// resource dependency list
cloudMigrationRoute.Get("/resources/dependencies", routing.Wrap(cma.GetResourceDependencies))
}, authorize(cloudmigration.MigrationAssistantAccess))
}
@ -316,7 +322,6 @@ func (cma *CloudMigrationAPI) CreateSnapshot(c *contextmodel.ReqContext) respons
defer span.End()
uid := web.Params(c.Req)[":uid"]
if err := util.ValidateUID(uid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
@ -324,7 +329,35 @@ func (cma *CloudMigrationAPI) CreateSnapshot(c *contextmodel.ReqContext) respons
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
}
ss, err := cma.cloudMigrationService.CreateSnapshot(ctx, c.SignedInUser, uid)
var cmd CreateSnapshotRequestDTO
if err := web.Bind(c.Req, &cmd); err != nil {
span.SetStatus(codes.Error, "invalid request body")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid request body", err)
}
if len(cmd.ResourceTypes) == 0 {
return response.ErrOrFallback(http.StatusBadRequest, "at least one resource type is required", cloudmigration.ErrEmptyResourceTypes)
}
rawResourceTypes := make([]cloudmigration.MigrateDataType, 0, len(cmd.ResourceTypes))
for _, t := range cmd.ResourceTypes {
rawResourceTypes = append(rawResourceTypes, cloudmigration.MigrateDataType(t))
}
resourceTypes, err := cma.resourceDependencyMap.Parse(rawResourceTypes)
if err != nil {
span.SetStatus(codes.Error, "invalid resource types")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid resource types", err)
}
ss, err := cma.cloudMigrationService.CreateSnapshot(ctx, c.SignedInUser, cloudmigration.CreateSnapshotCommand{
SessionUID: uid,
ResourceTypes: resourceTypes,
})
if err != nil {
span.SetStatus(codes.Error, "error creating snapshot")
span.RecordError(err)
@ -606,3 +639,31 @@ func (cma *CloudMigrationAPI) CancelSnapshot(c *contextmodel.ReqContext) respons
return response.JSON(http.StatusOK, nil)
}
// swagger:route GET /cloudmigration/resources/dependencies migrations getResourceDependencies
//
// Get the resource dependencies graph for the current set of migratable resources.
//
// Responses:
// 200: resourceDependenciesResponse
func (cma *CloudMigrationAPI) GetResourceDependencies(c *contextmodel.ReqContext) response.Response {
_, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.GetResourceDependencies")
defer span.End()
resourceDependencies := make([]ResourceDependencyDTO, 0, len(cma.resourceDependencyMap))
for resourceType, dependencies := range cma.resourceDependencyMap {
dependencyNames := make([]MigrateDataType, 0, len(dependencies))
for _, dependency := range dependencies {
dependencyNames = append(dependencyNames, MigrateDataType(dependency))
}
resourceDependencies = append(resourceDependencies, ResourceDependencyDTO{
ResourceType: MigrateDataType(resourceType),
Dependencies: dependencyNames,
})
}
return response.JSON(http.StatusOK, ResourceDependenciesResponseDTO{
ResourceDependencies: resourceDependencies,
})
}

@ -6,6 +6,8 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@ -14,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/require"
)
type TestCase struct {
@ -322,14 +323,43 @@ func TestCloudMigrationAPI_CreateSnapshot(t *testing.T) {
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
requestBody: `{"resourceTypes":["PLUGIN"]}`,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"uid":"fake_uid"}`,
},
{
desc: "returns 400 if resource types are not provided",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
requestBody: `{}`,
user: userWithPermissions,
expectedHttpResult: http.StatusBadRequest,
expectedBody: "",
},
{
desc: "returns 400 if request body is not a valid json",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
requestBody: "asdf",
user: userWithPermissions,
expectedHttpResult: http.StatusBadRequest,
expectedBody: "",
},
{
desc: "returns 400 if resource types are invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
requestBody: `{"resourceTypes":["INVALID"]}`,
user: userWithPermissions,
expectedHttpResult: http.StatusBadRequest,
expectedBody: "",
},
{
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
requestBody: `{"resourceTypes":["PLUGIN"]}`,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
@ -338,6 +368,7 @@ func TestCloudMigrationAPI_CreateSnapshot(t *testing.T) {
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
requestBody: `{"resourceTypes":["PLUGIN"]}`,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
@ -347,6 +378,7 @@ func TestCloudMigrationAPI_CreateSnapshot(t *testing.T) {
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/***/snapshot",
requestBody: `{"resourceTypes":["PLUGIN"]}`,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
@ -566,6 +598,31 @@ func TestCloudMigrationAPI_CancelSnapshot(t *testing.T) {
}
}
func TestCloudMigrationAPI_GetResourceDependencies(t *testing.T) {
tests := []TestCase{
{
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/resources/dependencies",
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"resourceDependencies":[{"resourceType":"PLUGIN","dependencies":[]}]}`,
},
{
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/resources/dependencies",
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
}
for _, tt := range tests {
t.Run(tt.desc, runSimpleApiTest(tt))
}
}
func runSimpleApiTest(tt TestCase) func(t *testing.T) {
return func(t *testing.T) {
// setup server
@ -574,6 +631,7 @@ func runSimpleApiTest(tt TestCase) func(t *testing.T) {
fake.FakeServiceImpl{ReturnError: tt.serviceReturnError},
tracing.InitializeTracerForTest(),
acimpl.ProvideAccessControlTest(),
cloudmigration.DependencyMap{cloudmigration.PluginDataType: nil},
)
server := webtest.NewServer(t, api.routeRegister)

@ -269,6 +269,14 @@ type CreateSnapshotRequest struct {
// UID of a session
// in: path
UID string `json:"uid"`
// in:body
// required:true
Body CreateSnapshotRequestDTO `json:"body"`
}
type CreateSnapshotRequestDTO struct {
ResourceTypes []MigrateDataType `json:"resourceTypes"`
}
// swagger:response createSnapshotResponse
@ -395,3 +403,20 @@ type CancelSnapshotParams struct {
// in: path
SnapshotUID string `json:"snapshotUid"`
}
// swagger:response resourceDependenciesResponse
type ResourceDependenciesResponse struct {
// in: body
Body ResourceDependenciesResponseDTO
}
// swagger:model ResourceDependenciesResponseDTO
type ResourceDependenciesResponseDTO struct {
ResourceDependencies []ResourceDependencyDTO `json:"resourceDependencies"`
}
// swagger:model ResourceDependencyDTO
type ResourceDependencyDTO struct {
ResourceType MigrateDataType `json:"resourceType"`
Dependencies []MigrateDataType `json:"dependencies"`
}

@ -21,7 +21,7 @@ type Service interface {
DeleteSession(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, migUID string) (*CloudMigrationSession, error)
GetSessionList(ctx context.Context, orgID int64) (*CloudMigrationSessionListResponse, error)
CreateSnapshot(ctx context.Context, signedInUser *user.SignedInUser, sessionUid string) (*CloudMigrationSnapshot, error)
CreateSnapshot(ctx context.Context, signedInUser *user.SignedInUser, cmd CreateSnapshotCommand) (*CloudMigrationSnapshot, error)
GetSnapshot(ctx context.Context, query GetSnapshotsQuery) (*CloudMigrationSnapshot, error)
GetSnapshotList(ctx context.Context, query ListSnapshotsQuery) ([]CloudMigrationSnapshot, error)
UploadSnapshot(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, sessionUid string, snapshotUid string) error

@ -13,6 +13,11 @@ import (
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
@ -40,10 +45,6 @@ import (
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// Service Define the cloudmigration.Service Implementation.
@ -142,7 +143,7 @@ func ProvideService(
libraryElementsService: libraryElementsService,
ngAlert: ngAlert,
}
s.api = api.RegisterApi(routeRegister, s, tracer, accessControl)
s.api = api.RegisterApi(routeRegister, s, tracer, accessControl, cloudmigration.ResourceDependency)
httpClientS3, err := httpClientProvider.New()
if err != nil {
@ -471,31 +472,32 @@ func (s *Service) DeleteSession(ctx context.Context, orgID int64, signedInUser *
return session, nil
}
func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedInUser, sessionUid string) (*cloudmigration.CloudMigrationSnapshot, error) {
func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedInUser, cmd cloudmigration.CreateSnapshotCommand) (*cloudmigration.CloudMigrationSnapshot, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.CreateSnapshot", trace.WithAttributes(
attribute.String("sessionUid", sessionUid),
attribute.String("sessionUid", cmd.SessionUID),
))
defer span.End()
if s.cfg.CloudMigration.SnapshotFolder == "" {
return nil, fmt.Errorf("snapshot folder is not set")
}
// fetch session for the gms auth token
session, err := s.store.GetMigrationSessionByUID(ctx, signedInUser.GetOrgID(), sessionUid)
session, err := s.store.GetMigrationSessionByUID(ctx, signedInUser.GetOrgID(), cmd.SessionUID)
if err != nil {
return nil, fmt.Errorf("fetching migration session for uid %s: %w", sessionUid, err)
return nil, fmt.Errorf("fetching migration session for uid %s: %w", cmd.SessionUID, err)
}
// query gms to establish new snapshot s.cfg.CloudMigration.StartSnapshotTimeout
initResp, err := s.gmsClient.StartSnapshot(ctx, *session)
if err != nil {
return nil, fmt.Errorf("initializing snapshot with GMS for session %s: %w", sessionUid, err)
return nil, fmt.Errorf("initializing snapshot with GMS for session %s: %w", cmd.SessionUID, err)
}
if s.cfg.CloudMigration.SnapshotFolder == "" {
return nil, fmt.Errorf("snapshot folder is not set")
}
// save snapshot to the db
snapshot := cloudmigration.CloudMigrationSnapshot{
UID: util.GenerateShortUID(),
SessionUID: sessionUid,
SessionUID: cmd.SessionUID,
Status: cloudmigration.SnapshotStatusCreating,
EncryptionKey: initResp.EncryptionKey,
GMSSnapshotUID: initResp.SnapshotID,
@ -511,7 +513,7 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
// Update status to "creating" to ensure the frontend polls from now on
if err := s.updateSnapshotWithRetries(ctx, cloudmigration.UpdateSnapshotCmd{
UID: uid,
SessionID: sessionUid,
SessionID: cmd.SessionUID,
Status: cloudmigration.SnapshotStatusCreating,
}); err != nil {
return nil, err
@ -536,7 +538,7 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
s.report(asyncCtx, session, gmsclient.EventStartBuildingSnapshot, 0, nil, signedInUser.UserUID)
start := time.Now()
err := s.buildSnapshot(asyncCtx, signedInUser, initResp.MaxItemsPerPartition, initResp.Metadata, snapshot)
err := s.buildSnapshot(asyncCtx, signedInUser, initResp.MaxItemsPerPartition, initResp.Metadata, snapshot, cmd.ResourceTypes)
if err != nil {
asyncSpan.SetStatus(codes.Error, "error building snapshot")
asyncSpan.RecordError(err)
@ -545,7 +547,7 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
// Update status to error with retries
if err := s.updateSnapshotWithRetries(asyncCtx, cloudmigration.UpdateSnapshotCmd{
UID: snapshot.UID,
SessionID: sessionUid,
SessionID: cmd.SessionUID,
Status: cloudmigration.SnapshotStatusError,
}); err != nil {
s.log.Error("critical failure during snapshot creation - please report any error logs")

@ -45,7 +45,7 @@ func (s *NoopServiceImpl) DeleteSession(ctx context.Context, orgID int64, signed
return nil, cloudmigration.ErrFeatureDisabledError
}
func (s *NoopServiceImpl) CreateSnapshot(ctx context.Context, user *user.SignedInUser, sessionUid string) (*cloudmigration.CloudMigrationSnapshot, error) {
func (s *NoopServiceImpl) CreateSnapshot(ctx context.Context, user *user.SignedInUser, cmd cloudmigration.CreateSnapshotCommand) (*cloudmigration.CloudMigrationSnapshot, error) {
return nil, cloudmigration.ErrFeatureDisabledError
}

@ -83,13 +83,13 @@ func (m FakeServiceImpl) GetSessionList(_ context.Context, _ int64) (*cloudmigra
}, nil
}
func (m FakeServiceImpl) CreateSnapshot(ctx context.Context, user *user.SignedInUser, sessionUid string) (*cloudmigration.CloudMigrationSnapshot, error) {
func (m FakeServiceImpl) CreateSnapshot(ctx context.Context, user *user.SignedInUser, cmd cloudmigration.CreateSnapshotCommand) (*cloudmigration.CloudMigrationSnapshot, error) {
if m.ReturnError {
return nil, fmt.Errorf("mock error")
}
return &cloudmigration.CloudMigrationSnapshot{
UID: "fake_uid",
SessionUID: sessionUid,
SessionUID: cmd.SessionUID,
Status: cloudmigration.SnapshotStatusCreating,
}, nil
}

@ -47,7 +47,8 @@ var currentMigrationTypes = []cloudmigration.MigrateDataType{
cloudmigration.PluginDataType,
}
func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser) (*cloudmigration.MigrateDataRequest, error) {
//nolint:gocyclo
func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser, resourceTypes cloudmigration.ResourceTypes) (*cloudmigration.MigrateDataRequest, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getMigrationDataJSON")
defer span.End()
@ -56,6 +57,7 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
folderHierarchy := make(map[cloudmigration.MigrateDataType]map[string]string, 0)
// Plugins
if resourceTypes.Has(cloudmigration.PluginDataType) {
plugins, err := s.getPlugins(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get plugins", "err", err)
@ -70,8 +72,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: plugin.SettingCmd,
})
}
}
// Data sources
if resourceTypes.Has(cloudmigration.DatasourceDataType) {
dataSources, err := s.getDataSourceCommands(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get datasources", "err", err)
@ -86,14 +90,17 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: ds,
})
}
}
// Dashboards & Folders: linked via the schema, so we need to get both
if resourceTypes.Has(cloudmigration.DashboardDataType) || resourceTypes.Has(cloudmigration.FolderDataType) {
dashs, folders, err := s.getDashboardAndFolderCommands(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get dashboards and folders", "err", err)
return nil, err
}
if resourceTypes.Has(cloudmigration.DashboardDataType) {
folderHierarchy[cloudmigration.DashboardDataType] = make(map[string]string, 0)
for _, dashboard := range dashs {
@ -113,7 +120,9 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
folderHierarchy[cloudmigration.DashboardDataType][dashboard.UID] = dashboard.FolderUID
}
}
if resourceTypes.Has(cloudmigration.FolderDataType) {
folderHierarchy[cloudmigration.FolderDataType] = make(map[string]string, 0)
folders = sortFolders(folders)
@ -127,8 +136,11 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
folderHierarchy[cloudmigration.FolderDataType][f.UID] = f.ParentUID
}
}
}
// Library Elements
if resourceTypes.Has(cloudmigration.LibraryElementDataType) {
libraryElements, err := s.getLibraryElementsCommands(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get library elements", "err", err)
@ -149,8 +161,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
folderHierarchy[cloudmigration.LibraryElementDataType][libraryElement.UID] = *libraryElement.FolderUID
}
}
}
// Alerts: Mute Timings
if resourceTypes.Has(cloudmigration.MuteTimingType) {
muteTimings, err := s.getAlertMuteTimings(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get alert mute timings", "err", err)
@ -165,8 +179,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: muteTiming,
})
}
}
// Alerts: Notification Templates
if resourceTypes.Has(cloudmigration.NotificationTemplateType) {
notificationTemplates, err := s.getNotificationTemplates(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get alert notification templates", "err", err)
@ -181,8 +197,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: notificationTemplate,
})
}
}
// Alerts: Contact Points
if resourceTypes.Has(cloudmigration.ContactPointType) {
contactPoints, err := s.getContactPoints(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get alert contact points", "err", err)
@ -197,8 +215,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: contactPoint,
})
}
}
// Alerts: Notification Policies
if resourceTypes.Has(cloudmigration.NotificationPolicyType) {
notificationPolicies, err := s.getNotificationPolicies(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get alert notification policies", "err", err)
@ -214,8 +234,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: notificationPolicies.Routes,
})
}
}
// Alerts: Alert Rule Groups
if resourceTypes.Has(cloudmigration.AlertRuleGroupType) {
alertRuleGroups, err := s.getAlertRuleGroups(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get alert rule groups", "err", err)
@ -230,8 +252,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Data: alertRuleGroup,
})
}
}
// Alerts: Alert Rules
if resourceTypes.Has(cloudmigration.AlertRuleType) {
alertRules, err := s.getAlertRules(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get alert rules", "err", err)
@ -250,6 +274,7 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
folderHierarchy[cloudmigration.AlertRuleType][alertRule.UID] = alertRule.FolderUID
}
}
// Obtain the names of parent elements for data types that have folders.
parentNamesByType, err := s.getParentNames(ctx, signedInUser, folderHierarchy)
@ -508,7 +533,14 @@ func (s *Service) getPlugins(ctx context.Context, signedInUser *user.SignedInUse
}
// asynchronous process for writing the snapshot to the filesystem and updating the snapshot status
func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedInUser, maxItemsPerPartition uint32, metadata []byte, snapshotMeta cloudmigration.CloudMigrationSnapshot) error {
func (s *Service) buildSnapshot(
ctx context.Context,
signedInUser *user.SignedInUser,
maxItemsPerPartition uint32,
metadata []byte,
snapshotMeta cloudmigration.CloudMigrationSnapshot,
resourceTypes cloudmigration.ResourceTypes,
) error {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.buildSnapshot")
defer span.End()
@ -542,7 +574,7 @@ func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedIn
s.log.Debug(fmt.Sprintf("buildSnapshot: created snapshot writing in %d ms", time.Since(start).Milliseconds()))
migrationData, err := s.getMigrationDataJSON(ctx, signedInUser)
migrationData, err := s.getMigrationDataJSON(ctx, signedInUser, resourceTypes)
if err != nil {
return fmt.Errorf("fetching migration data: %w", err)
}

@ -14,6 +14,7 @@ var (
ErrMigrationNotDeleted = errutil.Internal("cloudmigrations.sessionNotDeleted").Errorf("Session not deleted")
ErrTokenNotFound = errutil.NotFound("cloudmigrations.tokenNotFound").Errorf("Token not found")
ErrSnapshotNotFound = errutil.NotFound("cloudmigrations.snapshotNotFound").Errorf("Snapshot not found")
ErrEmptyResourceTypes = errutil.BadRequest("cloudmigrations.emptyResourceTypes").Errorf("Resource types cannot be empty")
)
// CloudMigration domain structs
@ -192,6 +193,18 @@ type SnapshotResultQueryParams struct {
ErrorsOnly bool
}
type ResourceTypes map[MigrateDataType]struct{}
func (r ResourceTypes) Has(t MigrateDataType) bool {
_, ok := r[t]
return ok
}
type CreateSnapshotCommand struct {
SessionUID string
ResourceTypes ResourceTypes
}
type GetSnapshotsQuery struct {
SnapshotUID string
OrgID int64

@ -0,0 +1,74 @@
package cloudmigration
import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
)
var (
ErrDuplicateResourceType = errutil.BadRequest("cloudmigrations.duplicateResourceType")
ErrUnknownResourceType = errutil.BadRequest("cloudmigrations.unknownResourceType")
ErrMissingDependency = errutil.BadRequest("cloudmigrations.missingDependency")
)
// DependencyMap is a map of resource types to their direct dependencies.
type DependencyMap map[MigrateDataType][]MigrateDataType
// ResourceDependency is a map of resource types to their direct dependencies.
// This is used to determine which resources can be filtered out from the snapshot without breaking the dependency chain.
var ResourceDependency = DependencyMap{
PluginDataType: nil,
FolderDataType: nil,
DatasourceDataType: {PluginDataType},
LibraryElementDataType: {FolderDataType},
DashboardDataType: {FolderDataType, DatasourceDataType, LibraryElementDataType},
MuteTimingType: nil,
NotificationTemplateType: nil,
ContactPointType: {NotificationTemplateType},
NotificationPolicyType: {ContactPointType},
AlertRuleType: {DatasourceDataType, FolderDataType, DashboardDataType, MuteTimingType, ContactPointType, NotificationPolicyType},
AlertRuleGroupType: {AlertRuleType},
}
// Parse a raw slice of resource types and returns a set of them if it has all correct dependencies.
func (depMap DependencyMap) Parse(rawInput []MigrateDataType) (ResourceTypes, error) {
// Clean up any possible duplicates.
input := make(ResourceTypes, len(rawInput))
for _, resourceType := range rawInput {
if _, exists := input[resourceType]; exists {
return nil, ErrDuplicateResourceType.Errorf("duplicate resource type found: %v", resourceType)
}
input[resourceType] = struct{}{}
}
// Validate that all dependencies are present.
for resourceType := range input {
if err := depMap.validateDependencies(resourceType, input); err != nil {
return nil, err
}
}
return input, nil
}
// validateDependencies recursively checks if all dependencies for a resource type are present in the input set.
func (depMap DependencyMap) validateDependencies(resourceType MigrateDataType, input ResourceTypes) error {
// Get the direct dependencies for this resource type.
dependencies, ok := depMap[resourceType]
if !ok {
return ErrUnknownResourceType.Errorf("unknown resource type: %v", resourceType)
}
// Make sure all direct dependencies are in the input.
for _, dep := range dependencies {
if _, exists := input[dep]; !exists {
return ErrMissingDependency.Errorf("missing dependency: %v for resource type %v", dep, resourceType)
}
// Recursively validate dependencies of dependencies
if err := depMap.validateDependencies(dep, input); err != nil {
return err
}
}
return nil
}

@ -0,0 +1,267 @@
package cloudmigration
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestResourceDependencyParse(t *testing.T) {
t.Run("empty input returns an empty set", func(t *testing.T) {
result, err := ResourceDependency.Parse(nil)
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("input with duplicates returns an error", func(t *testing.T) {
_, err := ResourceDependency.Parse([]MigrateDataType{FolderDataType, FolderDataType})
require.ErrorIs(t, err, ErrDuplicateResourceType)
})
t.Run("unknown resource type returns an error", func(t *testing.T) {
_, err := ResourceDependency.Parse([]MigrateDataType{"does not exist"})
require.ErrorIs(t, err, ErrUnknownResourceType)
})
t.Run("PluginDataType has no dependencies", func(t *testing.T) {
result, err := ResourceDependency.Parse([]MigrateDataType{PluginDataType})
require.NoError(t, err)
require.Len(t, result, 1)
require.Contains(t, result, PluginDataType)
})
t.Run("FolderDataType has no dependencies", func(t *testing.T) {
result, err := ResourceDependency.Parse([]MigrateDataType{FolderDataType})
require.NoError(t, err)
require.Len(t, result, 1)
require.Contains(t, result, FolderDataType)
})
t.Run("DatasourceDataType requires PluginDataType", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
_, err := ResourceDependency.Parse([]MigrateDataType{DatasourceDataType})
require.ErrorIs(t, err, ErrMissingDependency)
require.Contains(t, err.Error(), string(PluginDataType))
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{DatasourceDataType, PluginDataType}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, 2)
})
})
t.Run("LibraryElementDataType requires FolderDataType", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
_, err := ResourceDependency.Parse([]MigrateDataType{LibraryElementDataType})
require.ErrorIs(t, err, ErrMissingDependency)
require.Contains(t, err.Error(), string(FolderDataType))
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{LibraryElementDataType, FolderDataType}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, 2)
})
})
t.Run("DashboardDataType requires multiple dependencies", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
// Missing: FolderDataType, DatasourceDataType, LibraryElementDataType, PluginDataType
_, err := ResourceDependency.Parse([]MigrateDataType{DashboardDataType})
require.ErrorIs(t, err, ErrMissingDependency)
// Missing: DatasourceDataType, PluginDataType
_, err = ResourceDependency.Parse([]MigrateDataType{
DashboardDataType,
LibraryElementDataType,
FolderDataType,
})
require.ErrorIs(t, err, ErrMissingDependency)
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{
DashboardDataType,
FolderDataType,
DatasourceDataType,
LibraryElementDataType,
PluginDataType,
}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, len(input))
})
})
t.Run("MuteTimingType has no dependencies", func(t *testing.T) {
result, err := ResourceDependency.Parse([]MigrateDataType{MuteTimingType})
require.NoError(t, err)
require.Len(t, result, 1)
require.Contains(t, result, MuteTimingType)
})
t.Run("NotificationTemplateType has no dependencies", func(t *testing.T) {
result, err := ResourceDependency.Parse([]MigrateDataType{NotificationTemplateType})
require.NoError(t, err)
require.Len(t, result, 1)
require.Contains(t, result, NotificationTemplateType)
})
t.Run("ContactPointType requires NotificationTemplateType", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
_, err := ResourceDependency.Parse([]MigrateDataType{ContactPointType})
require.ErrorIs(t, err, ErrMissingDependency)
require.Contains(t, err.Error(), string(NotificationTemplateType))
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{ContactPointType, NotificationTemplateType}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, 2)
})
})
t.Run("NotificationPolicyType requires multiple dependencies", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
// Missing: ContactPointType, NotificationTemplateType
_, err := ResourceDependency.Parse([]MigrateDataType{NotificationPolicyType})
require.ErrorIs(t, err, ErrMissingDependency)
// Missing: NotificationTemplateType
_, err = ResourceDependency.Parse([]MigrateDataType{
NotificationPolicyType,
ContactPointType,
})
require.ErrorIs(t, err, ErrMissingDependency)
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{
NotificationPolicyType,
ContactPointType,
NotificationTemplateType,
}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, 3)
})
})
t.Run("AlertRuleType requires multiple dependencies", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
// Missing all dependencies
_, err := ResourceDependency.Parse([]MigrateDataType{AlertRuleType})
require.ErrorIs(t, err, ErrMissingDependency)
// Missing some dependencies
_, err = ResourceDependency.Parse([]MigrateDataType{
AlertRuleType,
DatasourceDataType,
FolderDataType,
})
require.ErrorIs(t, err, ErrMissingDependency)
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{
AlertRuleType,
DatasourceDataType,
FolderDataType,
DashboardDataType,
MuteTimingType,
ContactPointType,
NotificationPolicyType,
PluginDataType,
LibraryElementDataType,
NotificationTemplateType,
}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, len(input))
})
})
t.Run("AlertRuleGroupType requires AlertRuleType and all its dependencies", func(t *testing.T) {
t.Run("when the dependency is missing returns an error", func(t *testing.T) {
// Missing all dependencies
_, err := ResourceDependency.Parse([]MigrateDataType{AlertRuleGroupType})
require.ErrorIs(t, err, ErrMissingDependency)
// With partial dependencies
_, err = ResourceDependency.Parse([]MigrateDataType{
AlertRuleGroupType,
AlertRuleType,
FolderDataType,
DashboardDataType,
MuteTimingType,
})
require.ErrorIs(t, err, ErrMissingDependency)
})
t.Run("when the dependency is present returns the correct set", func(t *testing.T) {
input := []MigrateDataType{
AlertRuleGroupType,
AlertRuleType,
DatasourceDataType,
FolderDataType,
DashboardDataType,
MuteTimingType,
ContactPointType,
NotificationPolicyType,
PluginDataType,
LibraryElementDataType,
NotificationTemplateType,
}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, len(input))
})
})
t.Run("multiple resources with shared dependencies", func(t *testing.T) {
t.Run("resources with no dependencies", func(t *testing.T) {
input := []MigrateDataType{
FolderDataType,
PluginDataType,
MuteTimingType,
NotificationTemplateType,
}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, 4)
})
t.Run("overlapping dependencies", func(t *testing.T) {
// DashboardDataType -> LibraryElementDataType -> FolderDataType
// \-> DatasourceDataType -> PluginDataType
// ContactPointType -> NotificationTemplateType
input := []MigrateDataType{
DashboardDataType,
LibraryElementDataType,
FolderDataType,
DatasourceDataType,
PluginDataType,
ContactPointType,
NotificationTemplateType,
}
result, err := ResourceDependency.Parse(input)
require.NoError(t, err)
require.Len(t, result, 7)
})
t.Run("missing shared dependency fails", func(t *testing.T) {
// ContactPointType -> NotificationTemplateType
// DatasourceDataType -> PluginDataType
input := []MigrateDataType{
ContactPointType,
DatasourceDataType,
}
_, err := ResourceDependency.Parse(input)
require.ErrorIs(t, err, ErrMissingDependency)
})
})
}

@ -2412,6 +2412,14 @@
"name": "uid",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/CreateSnapshotRequestDTO"
}
}
],
"responses": {
@ -2654,6 +2662,20 @@
}
}
},
"/cloudmigration/resources/dependencies": {
"get": {
"tags": [
"migrations"
],
"summary": "Get the resource dependencies graph for the current set of migratable resources.",
"operationId": "getResourceDependencies",
"responses": {
"200": {
"$ref": "#/responses/resourceDependenciesResponse"
}
}
}
},
"/cloudmigration/token": {
"get": {
"tags": [
@ -14343,6 +14365,30 @@
}
}
},
"CreateSnapshotRequestDTO": {
"type": "object",
"properties": {
"resourceTypes": {
"type": "array",
"items": {
"type": "string",
"enum": [
"DASHBOARD",
"DATASOURCE",
"FOLDER",
"LIBRARY_ELEMENT",
"ALERT_RULE",
"ALERT_RULE_GROUP",
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING",
"PLUGIN"
]
}
}
}
},
"CreateSnapshotResponseDTO": {
"type": "object",
"properties": {
@ -19690,6 +19736,57 @@
}
}
},
"ResourceDependenciesResponseDTO": {
"type": "object",
"properties": {
"resourceDependencies": {
"type": "array",
"items": {
"$ref": "#/definitions/ResourceDependencyDTO"
}
}
}
},
"ResourceDependencyDTO": {
"type": "object",
"properties": {
"dependencies": {
"type": "array",
"items": {
"type": "string",
"enum": [
"DASHBOARD",
"DATASOURCE",
"FOLDER",
"LIBRARY_ELEMENT",
"ALERT_RULE",
"ALERT_RULE_GROUP",
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING",
"PLUGIN"
]
}
},
"resourceType": {
"type": "string",
"enum": [
"DASHBOARD",
"DATASOURCE",
"FOLDER",
"LIBRARY_ELEMENT",
"ALERT_RULE",
"ALERT_RULE_GROUP",
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING",
"PLUGIN"
]
}
}
},
"ResponseDetails": {
"type": "object",
"properties": {
@ -24563,6 +24660,12 @@
"$ref": "#/definitions/ActiveUserStats"
}
},
"resourceDependenciesResponse": {
"description": "(empty)",
"schema": {
"$ref": "#/definitions/ResourceDependenciesResponseDTO"
}
},
"resourcePermissionsDescription": {
"description": "(empty)",
"schema": {

@ -18,7 +18,11 @@ const injectedRtkApi = api.injectEndpoints({
query: (queryArg) => ({ url: `/cloudmigration/migration/${queryArg.uid}` }),
}),
createSnapshot: build.mutation<CreateSnapshotApiResponse, CreateSnapshotApiArg>({
query: (queryArg) => ({ url: `/cloudmigration/migration/${queryArg.uid}/snapshot`, method: 'POST' }),
query: (queryArg) => ({
url: `/cloudmigration/migration/${queryArg.uid}/snapshot`,
method: 'POST',
body: queryArg.createSnapshotRequestDto,
}),
}),
getSnapshot: build.query<GetSnapshotApiResponse, GetSnapshotApiArg>({
query: (queryArg) => ({
@ -54,6 +58,9 @@ const injectedRtkApi = api.injectEndpoints({
},
}),
}),
getResourceDependencies: build.query<GetResourceDependenciesApiResponse, GetResourceDependenciesApiArg>({
query: () => ({ url: `/cloudmigration/resources/dependencies` }),
}),
getCloudMigrationToken: build.query<GetCloudMigrationTokenApiResponse, GetCloudMigrationTokenApiArg>({
query: () => ({ url: `/cloudmigration/token` }),
}),
@ -93,6 +100,7 @@ export type CreateSnapshotApiResponse = /** status 200 (empty) */ CreateSnapshot
export type CreateSnapshotApiArg = {
/** UID of a session */
uid: string;
createSnapshotRequestDto: CreateSnapshotRequestDto;
};
export type GetSnapshotApiResponse = /** status 200 (empty) */ GetSnapshotResponseDto;
export type GetSnapshotApiArg = {
@ -136,6 +144,8 @@ export type GetShapshotListApiArg = {
/** Sort with value latest to return results sorted in descending order. */
sort?: string;
};
export type GetResourceDependenciesApiResponse = /** status 200 (empty) */ ResourceDependenciesResponseDto;
export type GetResourceDependenciesApiArg = void;
export type GetCloudMigrationTokenApiResponse = /** status 200 (empty) */ GetAccessTokenResponseDto;
export type GetCloudMigrationTokenApiArg = void;
export type CreateCloudMigrationTokenApiResponse = /** status 200 (empty) */ CreateAccessTokenResponseDto;
@ -179,6 +189,21 @@ export type CloudMigrationSessionRequestDto = {
export type CreateSnapshotResponseDto = {
uid?: string;
};
export type CreateSnapshotRequestDto = {
resourceTypes?: (
| 'DASHBOARD'
| 'DATASOURCE'
| 'FOLDER'
| 'LIBRARY_ELEMENT'
| 'ALERT_RULE'
| 'ALERT_RULE_GROUP'
| 'CONTACT_POINT'
| 'NOTIFICATION_POLICY'
| 'NOTIFICATION_TEMPLATE'
| 'MUTE_TIMING'
| 'PLUGIN'
)[];
};
export type MigrateDataResponseItemDto = {
errorCode?:
| 'DATASOURCE_NAME_CONFLICT'
@ -258,6 +283,36 @@ export type SnapshotDto = {
export type SnapshotListResponseDto = {
snapshots?: SnapshotDto[];
};
export type ResourceDependencyDto = {
dependencies?: (
| 'DASHBOARD'
| 'DATASOURCE'
| 'FOLDER'
| 'LIBRARY_ELEMENT'
| 'ALERT_RULE'
| 'ALERT_RULE_GROUP'
| 'CONTACT_POINT'
| 'NOTIFICATION_POLICY'
| 'NOTIFICATION_TEMPLATE'
| 'MUTE_TIMING'
| 'PLUGIN'
)[];
resourceType?:
| 'DASHBOARD'
| 'DATASOURCE'
| 'FOLDER'
| 'LIBRARY_ELEMENT'
| 'ALERT_RULE'
| 'ALERT_RULE_GROUP'
| 'CONTACT_POINT'
| 'NOTIFICATION_POLICY'
| 'NOTIFICATION_TEMPLATE'
| 'MUTE_TIMING'
| 'PLUGIN';
};
export type ResourceDependenciesResponseDto = {
resourceDependencies?: ResourceDependencyDto[];
};
export type GetAccessTokenResponseDto = {
createdAt?: string;
displayName?: string;
@ -356,6 +411,7 @@ export const {
useCancelSnapshotMutation,
useUploadSnapshotMutation,
useGetShapshotListQuery,
useGetResourceDependenciesQuery,
useGetCloudMigrationTokenQuery,
useCreateCloudMigrationTokenMutation,
useDeleteCloudMigrationTokenMutation,

@ -26,7 +26,12 @@ export const cloudMigrationAPI = generatedAPI
}),
})
.enhanceEndpoints({
addTagTypes: ['cloud-migration-token', 'cloud-migration-session', 'cloud-migration-snapshot'],
addTagTypes: [
'cloud-migration-token',
'cloud-migration-session',
'cloud-migration-snapshot',
'cloud-migration-resource-dependencies',
],
endpoints: {
// Cloud-side - create token
@ -68,6 +73,11 @@ export const cloudMigrationAPI = generatedAPI
invalidatesTags: ['cloud-migration-snapshot'],
},
// Resource dependencies
getResourceDependencies: {
providesTags: ['cloud-migration-resource-dependencies'],
},
getDashboardByUid: suppressErrorsOnQuery,
getLibraryElementByUid: suppressErrorsOnQuery,
getLocalPluginList: suppressErrorsOnQuery,

@ -17,6 +17,7 @@ import {
useUploadSnapshotMutation,
useGetLocalPluginListQuery,
} from '../api';
import { maybeAPIError } from '../api/errors';
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
import { DisconnectModal } from './DisconnectModal';
@ -199,7 +200,26 @@ export const Page = () => {
const handleCreateSnapshot = useCallback(() => {
if (sessionUid) {
performCreateSnapshot({ uid: sessionUid });
performCreateSnapshot({
uid: sessionUid,
createSnapshotRequestDto: {
// TODO: For the moment, pass all resource types. Once we have a frontend for selecting resource types,
// we should pass the selected resource types instead.
resourceTypes: [
'DASHBOARD',
'DATASOURCE',
'FOLDER',
'LIBRARY_ELEMENT',
'ALERT_RULE',
'ALERT_RULE_GROUP',
'CONTACT_POINT',
'NOTIFICATION_POLICY',
'NOTIFICATION_TEMPLATE',
'MUTE_TIMING',
'PLUGIN',
],
},
});
}
}, [performCreateSnapshot, sessionUid]);
@ -367,12 +387,7 @@ function getError(props: GetErrorProps): ErrorDescription | undefined {
}
if (createSnapshotError) {
return {
severity: 'warning',
title: t('migrate-to-cloud.onprem.create-snapshot-error-title', 'Error creating snapshot'),
body: seeLogs,
error: createSnapshotError,
};
return handleCreateSnapshotError(createSnapshotError, seeLogs);
}
if (uploadSnapshotError) {
@ -431,3 +446,51 @@ function getError(props: GetErrorProps): ErrorDescription | undefined {
return undefined;
}
function handleCreateSnapshotError(createSnapshotError: unknown, seeLogs: string): ErrorDescription | undefined {
const apiError = maybeAPIError(createSnapshotError);
let severity: AlertVariant = 'warning';
let body = null;
switch (apiError?.messageId) {
case 'cloudmigrations.emptyResourceTypes':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-empty-resource-types',
'You need to provide at least one resource type for snapshot creation'
);
break;
case 'cloudmigrations.unknownResourceType':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-unknown-resource-type',
'Unknown resource type. See the Grafana server logs for more details'
);
break;
case 'cloudmigrations.duplicateResourceType':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-duplicate-resource-type',
'Duplicate resource type. See the Grafana server logs for more details'
);
break;
case 'cloudmigrations.missingDependency':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-missing-dependency',
'Missing dependency. See the Grafana server logs for more details'
);
break;
}
return {
severity,
title: t('migrate-to-cloud.onprem.create-snapshot-error-title', 'Error creating snapshot'),
body: body || seeLogs,
error: createSnapshotError,
};
}

@ -5336,7 +5336,11 @@
},
"onprem": {
"cancel-snapshot-error-title": "Error cancelling creating snapshot",
"create-snapshot-error-duplicate-resource-type": "Duplicate resource type. See the Grafana server logs for more details",
"create-snapshot-error-empty-resource-types": "You need to provide at least one resource type for snapshot creation",
"create-snapshot-error-missing-dependency": "Missing dependency. See the Grafana server logs for more details",
"create-snapshot-error-title": "Error creating snapshot",
"create-snapshot-error-unknown-resource-type": "Unknown resource type. See the Grafana server logs for more details",
"disconnect-error-title": "Error disconnecting",
"error-see-server-logs": "See the Grafana server logs for more details",
"get-session-error-title": "Error loading migration configuration",

@ -1803,6 +1803,16 @@
},
"description": "(empty)"
},
"resourceDependenciesResponse": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResourceDependenciesResponseDTO"
}
}
},
"description": "(empty)"
},
"resourcePermissionsDescription": {
"content": {
"application/json": {
@ -4417,6 +4427,30 @@
},
"type": "object"
},
"CreateSnapshotRequestDTO": {
"properties": {
"resourceTypes": {
"items": {
"enum": [
"DASHBOARD",
"DATASOURCE",
"FOLDER",
"LIBRARY_ELEMENT",
"ALERT_RULE",
"ALERT_RULE_GROUP",
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING",
"PLUGIN"
],
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"CreateSnapshotResponseDTO": {
"properties": {
"uid": {
@ -9764,6 +9798,57 @@
},
"type": "object"
},
"ResourceDependenciesResponseDTO": {
"properties": {
"resourceDependencies": {
"items": {
"$ref": "#/components/schemas/ResourceDependencyDTO"
},
"type": "array"
}
},
"type": "object"
},
"ResourceDependencyDTO": {
"properties": {
"dependencies": {
"items": {
"enum": [
"DASHBOARD",
"DATASOURCE",
"FOLDER",
"LIBRARY_ELEMENT",
"ALERT_RULE",
"ALERT_RULE_GROUP",
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING",
"PLUGIN"
],
"type": "string"
},
"type": "array"
},
"resourceType": {
"enum": [
"DASHBOARD",
"DATASOURCE",
"FOLDER",
"LIBRARY_ELEMENT",
"ALERT_RULE",
"ALERT_RULE_GROUP",
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING",
"PLUGIN"
],
"type": "string"
}
},
"type": "object"
},
"ResponseDetails": {
"properties": {
"msg": {
@ -15978,6 +16063,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateSnapshotRequestDTO"
}
}
},
"required": true,
"x-originalParamName": "body"
},
"responses": {
"200": {
"$ref": "#/components/responses/createSnapshotResponse"
@ -16252,6 +16348,20 @@
]
}
},
"/cloudmigration/resources/dependencies": {
"get": {
"operationId": "getResourceDependencies",
"responses": {
"200": {
"$ref": "#/components/responses/resourceDependenciesResponse"
}
},
"summary": "Get the resource dependencies graph for the current set of migratable resources.",
"tags": [
"migrations"
]
}
},
"/cloudmigration/token": {
"get": {
"operationId": "getCloudMigrationToken",

@ -30,6 +30,8 @@ const config: ConfigFile = {
'getDashboardByUid',
'getLibraryElementByUid',
'getResourceDependencies',
],
},
'../public/app/features/preferences/api/user/endpoints.gen.ts': {

Loading…
Cancel
Save