diff --git a/pkg/services/cloudmigration/api/api.go b/pkg/services/cloudmigration/api/api.go index ce8b3ec8045..df7ebf1a8ff 100644 --- a/pkg/services/cloudmigration/api/api.go +++ b/pkg/services/cloudmigration/api/api.go @@ -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, + }) +} diff --git a/pkg/services/cloudmigration/api/api_test.go b/pkg/services/cloudmigration/api/api_test.go index b2d12213354..d68b4557f48 100644 --- a/pkg/services/cloudmigration/api/api_test.go +++ b/pkg/services/cloudmigration/api/api_test.go @@ -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) diff --git a/pkg/services/cloudmigration/api/dtos.go b/pkg/services/cloudmigration/api/dtos.go index 2509df64fa8..a9fe7ce855b 100644 --- a/pkg/services/cloudmigration/api/dtos.go +++ b/pkg/services/cloudmigration/api/dtos.go @@ -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"` +} diff --git a/pkg/services/cloudmigration/cloudmigration.go b/pkg/services/cloudmigration/cloudmigration.go index 15f450bcd64..7b90fb750d5 100644 --- a/pkg/services/cloudmigration/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigration.go @@ -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 diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go index 4c0079d33f1..a2342c9338e 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -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") diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go index e0e559e23eb..bb1669c8995 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go @@ -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 } diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go b/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go index 2bc0e4ea56a..3d338c934f1 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go @@ -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 } diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go index 528f038c512..d95d4cc8c14 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go @@ -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,199 +57,223 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S folderHierarchy := make(map[cloudmigration.MigrateDataType]map[string]string, 0) // Plugins - plugins, err := s.getPlugins(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get plugins", "err", err) - return nil, err - } + if resourceTypes.Has(cloudmigration.PluginDataType) { + plugins, err := s.getPlugins(ctx, signedInUser) + if err != nil { + s.log.Error("Failed to get plugins", "err", err) + return nil, err + } - for _, plugin := range plugins { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.PluginDataType, - RefID: plugin.ID, - Name: plugin.Name, - Data: plugin.SettingCmd, - }) + for _, plugin := range plugins { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.PluginDataType, + RefID: plugin.ID, + Name: plugin.Name, + Data: plugin.SettingCmd, + }) + } } // Data sources - dataSources, err := s.getDataSourceCommands(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get datasources", "err", err) - return nil, err - } + if resourceTypes.Has(cloudmigration.DatasourceDataType) { + dataSources, err := s.getDataSourceCommands(ctx, signedInUser) + if err != nil { + s.log.Error("Failed to get datasources", "err", err) + return nil, err + } - for _, ds := range dataSources { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.DatasourceDataType, - RefID: ds.UID, - Name: ds.Name, - Data: ds, - }) + for _, ds := range dataSources { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.DatasourceDataType, + RefID: ds.UID, + Name: ds.Name, + Data: ds, + }) + } } // Dashboards & Folders: linked via the schema, so we need to get both - 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) || 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 + } - folderHierarchy[cloudmigration.DashboardDataType] = make(map[string]string, 0) + if resourceTypes.Has(cloudmigration.DashboardDataType) { + folderHierarchy[cloudmigration.DashboardDataType] = make(map[string]string, 0) - for _, dashboard := range dashs { - dashboard.Data.Del("id") - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.DashboardDataType, - RefID: dashboard.UID, - Name: dashboard.Title, - Data: dashboards.SaveDashboardCommand{ - Dashboard: dashboard.Data, - Overwrite: true, // currently only intended to be a push, not a sync; revisit during the preview - Message: fmt.Sprintf("Created via the Grafana Cloud Migration Assistant by on-prem user \"%s\"", signedInUser.Login), - IsFolder: false, - FolderUID: dashboard.FolderUID, - }, - }) + for _, dashboard := range dashs { + dashboard.Data.Del("id") + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.DashboardDataType, + RefID: dashboard.UID, + Name: dashboard.Title, + Data: dashboards.SaveDashboardCommand{ + Dashboard: dashboard.Data, + Overwrite: true, // currently only intended to be a push, not a sync; revisit during the preview + Message: fmt.Sprintf("Created via the Grafana Cloud Migration Assistant by on-prem user \"%s\"", signedInUser.Login), + IsFolder: false, + FolderUID: dashboard.FolderUID, + }, + }) - folderHierarchy[cloudmigration.DashboardDataType][dashboard.UID] = dashboard.FolderUID - } + folderHierarchy[cloudmigration.DashboardDataType][dashboard.UID] = dashboard.FolderUID + } + } - folderHierarchy[cloudmigration.FolderDataType] = make(map[string]string, 0) + if resourceTypes.Has(cloudmigration.FolderDataType) { + folderHierarchy[cloudmigration.FolderDataType] = make(map[string]string, 0) - folders = sortFolders(folders) - for _, f := range folders { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.FolderDataType, - RefID: f.UID, - Name: f.Title, - Data: f, - }) + folders = sortFolders(folders) + for _, f := range folders { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.FolderDataType, + RefID: f.UID, + Name: f.Title, + Data: f, + }) - folderHierarchy[cloudmigration.FolderDataType][f.UID] = f.ParentUID + folderHierarchy[cloudmigration.FolderDataType][f.UID] = f.ParentUID + } + } } // Library Elements - libraryElements, err := s.getLibraryElementsCommands(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get library elements", "err", err) - return nil, err - } + if resourceTypes.Has(cloudmigration.LibraryElementDataType) { + libraryElements, err := s.getLibraryElementsCommands(ctx, signedInUser) + if err != nil { + s.log.Error("Failed to get library elements", "err", err) + return nil, err + } - folderHierarchy[cloudmigration.LibraryElementDataType] = make(map[string]string, 0) + folderHierarchy[cloudmigration.LibraryElementDataType] = make(map[string]string, 0) - for _, libraryElement := range libraryElements { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.LibraryElementDataType, - RefID: libraryElement.UID, - Name: libraryElement.Name, - Data: libraryElement, - }) + for _, libraryElement := range libraryElements { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.LibraryElementDataType, + RefID: libraryElement.UID, + Name: libraryElement.Name, + Data: libraryElement, + }) - if libraryElement.FolderUID != nil { - folderHierarchy[cloudmigration.LibraryElementDataType][libraryElement.UID] = *libraryElement.FolderUID + if libraryElement.FolderUID != nil { + folderHierarchy[cloudmigration.LibraryElementDataType][libraryElement.UID] = *libraryElement.FolderUID + } } } // Alerts: Mute Timings - muteTimings, err := s.getAlertMuteTimings(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get alert mute timings", "err", err) - return nil, err - } + 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) + return nil, err + } - for _, muteTiming := range muteTimings { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.MuteTimingType, - RefID: muteTiming.UID, - Name: muteTiming.Name, - Data: muteTiming, - }) + for _, muteTiming := range muteTimings { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.MuteTimingType, + RefID: muteTiming.UID, + Name: muteTiming.Name, + Data: muteTiming, + }) + } } // Alerts: Notification Templates - notificationTemplates, err := s.getNotificationTemplates(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get alert notification templates", "err", err) - return nil, err - } + 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) + return nil, err + } - for _, notificationTemplate := range notificationTemplates { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.NotificationTemplateType, - RefID: notificationTemplate.UID, - Name: notificationTemplate.Name, - Data: notificationTemplate, - }) + for _, notificationTemplate := range notificationTemplates { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.NotificationTemplateType, + RefID: notificationTemplate.UID, + Name: notificationTemplate.Name, + Data: notificationTemplate, + }) + } } // Alerts: Contact Points - contactPoints, err := s.getContactPoints(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get alert contact points", "err", err) - return nil, err - } + 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) + return nil, err + } - for _, contactPoint := range contactPoints { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.ContactPointType, - RefID: contactPoint.UID, - Name: contactPoint.Name, - Data: contactPoint, - }) + for _, contactPoint := range contactPoints { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.ContactPointType, + RefID: contactPoint.UID, + Name: contactPoint.Name, + Data: contactPoint, + }) + } } // Alerts: Notification Policies - notificationPolicies, err := s.getNotificationPolicies(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get alert notification policies", "err", err) - return nil, err - } + 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) + return nil, err + } - if len(notificationPolicies.Name) > 0 { - // Notification Policy can only be managed by updating its entire tree, so we send the whole thing as one item. - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.NotificationPolicyType, - RefID: notificationPolicies.Name, // no UID available - Name: notificationPolicies.Name, - Data: notificationPolicies.Routes, - }) + if len(notificationPolicies.Name) > 0 { + // Notification Policy can only be managed by updating its entire tree, so we send the whole thing as one item. + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.NotificationPolicyType, + RefID: notificationPolicies.Name, // no UID available + Name: notificationPolicies.Name, + Data: notificationPolicies.Routes, + }) + } } // Alerts: Alert Rule Groups - alertRuleGroups, err := s.getAlertRuleGroups(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get alert rule groups", "err", err) - return nil, err - } + 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) + return nil, err + } - for _, alertRuleGroup := range alertRuleGroups { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.AlertRuleGroupType, - RefID: alertRuleGroup.Title, // no UID available - Name: alertRuleGroup.Title, - Data: alertRuleGroup, - }) + for _, alertRuleGroup := range alertRuleGroups { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.AlertRuleGroupType, + RefID: alertRuleGroup.Title, // no UID available + Name: alertRuleGroup.Title, + Data: alertRuleGroup, + }) + } } // Alerts: Alert Rules - alertRules, err := s.getAlertRules(ctx, signedInUser) - if err != nil { - s.log.Error("Failed to get alert rules", "err", err) - return nil, err - } + if resourceTypes.Has(cloudmigration.AlertRuleType) { + alertRules, err := s.getAlertRules(ctx, signedInUser) + if err != nil { + s.log.Error("Failed to get alert rules", "err", err) + return nil, err + } - folderHierarchy[cloudmigration.AlertRuleType] = make(map[string]string, 0) + folderHierarchy[cloudmigration.AlertRuleType] = make(map[string]string, 0) - for _, alertRule := range alertRules { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.AlertRuleType, - RefID: alertRule.UID, - Name: alertRule.Title, - Data: alertRule, - }) + for _, alertRule := range alertRules { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.AlertRuleType, + RefID: alertRule.UID, + Name: alertRule.Title, + Data: alertRule, + }) - folderHierarchy[cloudmigration.AlertRuleType][alertRule.UID] = alertRule.FolderUID + folderHierarchy[cloudmigration.AlertRuleType][alertRule.UID] = alertRule.FolderUID + } } // Obtain the names of parent elements for data types that have folders. @@ -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) } diff --git a/pkg/services/cloudmigration/model.go b/pkg/services/cloudmigration/model.go index b8db19280f2..41d27e02f94 100644 --- a/pkg/services/cloudmigration/model.go +++ b/pkg/services/cloudmigration/model.go @@ -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 diff --git a/pkg/services/cloudmigration/resource_dependency.go b/pkg/services/cloudmigration/resource_dependency.go new file mode 100644 index 00000000000..fe6a947e473 --- /dev/null +++ b/pkg/services/cloudmigration/resource_dependency.go @@ -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 +} diff --git a/pkg/services/cloudmigration/resource_dependency_test.go b/pkg/services/cloudmigration/resource_dependency_test.go new file mode 100644 index 00000000000..d28a5065e97 --- /dev/null +++ b/pkg/services/cloudmigration/resource_dependency_test.go @@ -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) + }) + }) +} diff --git a/public/api-merged.json b/public/api-merged.json index 54294338e40..b7ad15a4be6 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -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": { diff --git a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts index 695f956f2ce..c6a3bae02da 100644 --- a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts +++ b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts @@ -18,7 +18,11 @@ const injectedRtkApi = api.injectEndpoints({ query: (queryArg) => ({ url: `/cloudmigration/migration/${queryArg.uid}` }), }), createSnapshot: build.mutation({ - 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({ query: (queryArg) => ({ @@ -54,6 +58,9 @@ const injectedRtkApi = api.injectEndpoints({ }, }), }), + getResourceDependencies: build.query({ + query: () => ({ url: `/cloudmigration/resources/dependencies` }), + }), getCloudMigrationToken: build.query({ 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, diff --git a/public/app/features/migrate-to-cloud/api/index.ts b/public/app/features/migrate-to-cloud/api/index.ts index 0e5c1d8b453..31934637db5 100644 --- a/public/app/features/migrate-to-cloud/api/index.ts +++ b/public/app/features/migrate-to-cloud/api/index.ts @@ -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, diff --git a/public/app/features/migrate-to-cloud/onprem/Page.tsx b/public/app/features/migrate-to-cloud/onprem/Page.tsx index bd2cd0ccf89..9a62636db55 100644 --- a/public/app/features/migrate-to-cloud/onprem/Page.tsx +++ b/public/app/features/migrate-to-cloud/onprem/Page.tsx @@ -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, + }; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c29be4881c1..088e63e3dc3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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", diff --git a/public/openapi3.json b/public/openapi3.json index c50e70605a3..b71f9a9ba7e 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -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", diff --git a/scripts/generate-rtk-apis.ts b/scripts/generate-rtk-apis.ts index efafffa05b6..23789ebb75d 100644 --- a/scripts/generate-rtk-apis.ts +++ b/scripts/generate-rtk-apis.ts @@ -30,6 +30,8 @@ const config: ConfigFile = { 'getDashboardByUid', 'getLibraryElementByUid', + + 'getResourceDependencies', ], }, '../public/app/features/preferences/api/user/endpoints.gen.ts': {