From 7165bc553ab972542eb0c0c4c08ce455c7b046dd Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 3 Apr 2025 10:34:32 +0200 Subject: [PATCH] CloudMigrations: Add Cypress happy path test case scenarios (#103250) --- e2e/various-suite/migrate-to-cloud.spec.ts | 298 ++++++++++++++++++ .../src/selectors/pages.ts | 5 + .../gmsclient/inmemory_client.go | 102 +++--- .../EmptyState/CallToAction/CallToAction.tsx | 6 +- .../EmptyState/CallToAction/ConnectModal.tsx | 7 +- .../onprem/MigrationSummary.tsx | 3 + .../migrate-to-cloud/onprem/SnapshotCTAs.tsx | 7 +- scripts/grafana-server/custom.ini | 3 + 8 files changed, 373 insertions(+), 58 deletions(-) create mode 100644 e2e/various-suite/migrate-to-cloud.spec.ts diff --git a/e2e/various-suite/migrate-to-cloud.spec.ts b/e2e/various-suite/migrate-to-cloud.spec.ts new file mode 100644 index 00000000000..fe32c2b74d5 --- /dev/null +++ b/e2e/various-suite/migrate-to-cloud.spec.ts @@ -0,0 +1,298 @@ +import { e2e } from '../utils'; + +describe.skip('Migrate to Cloud (On-prem)', () => { + // Here we are mostly testing the UI flow and can do interesting things with the backend responses to see how the UI behaves. + describe('with mocked calls to the API backend', () => { + afterEach(() => { + cy.get('[data-testid="migrate-to-cloud-summary-disconnect-button"]').should('be.visible').click(); + }); + + const SESSION_UID = 'fehq6hqd246iox'; + const SNAPSHOT_UID1 = 'cehq6vdjqbqbkx'; + + const SNAPSHOT_RESULTS = [ + { name: 'FolderA', type: 'FOLDER', refId: 'ref-id-folder-a', parentName: 'General' }, + { name: 'FolderB', type: 'FOLDER', refId: 'ref-id-folder-b', parentName: 'General' }, + { name: 'Prometheus', type: 'DATASOURCE', refId: 'prometheus' }, + { name: 'Postgres', type: 'DATASOURCE', refId: 'postgres' }, + { name: 'Loki', type: 'DATASOURCE', refId: 'loki' }, + { name: 'Alert Rule A', type: 'ALERT_RULE', refId: 'alert-rule-a', parentName: 'FolderA' }, + { name: 'Alert Rule B', type: 'ALERT_RULE', refId: 'alert-rule-b', parentName: 'FolderB' }, + { name: 'Alert Rule C', type: 'ALERT_RULE', refId: 'alert-rule-c', parentName: 'FolderB' }, + { name: 'Alert Rule Group A', type: 'ALERT_RULE_GROUP', refId: 'alert-rule-group-a', parentName: 'FolderA' }, + { name: 'Alert Rule Group B', type: 'ALERT_RULE_GROUP', refId: 'alert-rule-group-b', parentName: 'FolderB' }, + { name: 'Contact Point A', type: 'CONTACT_POINT', refId: 'contact-point-a' }, + { name: 'Contact Point B', type: 'CONTACT_POINT', refId: 'contact-point-b' }, + { name: 'Contact Point C', type: 'CONTACT_POINT', refId: 'contact-point-c' }, + { name: 'Notification Policy A', type: 'NOTIFICATION_POLICY', refId: 'notification-policy-a' }, + { name: 'Notification Template A', type: 'NOTIFICATION_TEMPLATE', refId: 'notification-template-a' }, + { name: 'Notification Template B', type: 'NOTIFICATION_TEMPLATE', refId: 'notification-template-b' }, + { name: 'Notification Template C', type: 'NOTIFICATION_TEMPLATE', refId: 'notification-template-c' }, + { name: 'Plugin A', type: 'PLUGIN', refId: 'plugin-a' }, + { name: 'Plugin B', type: 'PLUGIN', refId: 'plugin-b' }, + { name: 'Plugin C', type: 'PLUGIN', refId: 'plugin-c' }, + { name: 'Mute Timing A', type: 'MUTE_TIMING', refId: 'mute-timing-a' }, + { name: 'Mute Timing B', type: 'MUTE_TIMING', refId: 'mute-timing-b' }, + ]; + + const MIGRATION_SESSION = { + uid: SESSION_UID, + slug: 'test-slug', + created: '2025-04-02T21:36:08+02:00', + updated: '2025-04-02T21:36:08+02:00', + }; + + const STATS = { + types: SNAPSHOT_RESULTS.reduce( + (acc, r) => { + acc[r.type] = (acc[r.type] || 0) + 1; + return acc; + }, + {} as Record + ), + statuses: { + PENDING: SNAPSHOT_RESULTS.length, + }, + total: SNAPSHOT_RESULTS.length, + }; + + it('creates and uploads a snapshot sucessfully', () => { + // Login using the UI. + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + + // Visit the migrate to cloud onprem page. + e2e.pages.MigrateToCloud.visit(); + + // Open the connect modal and enter the token. + cy.get('[data-testid="migrate-to-cloud-connect-session-modal-button"]').should('be.visible').click(); + + cy.get('[data-testid="migrate-to-cloud-connect-session-modal-token-input"]') + .should('be.visible') + .focus() + .type('test'); + + cy.intercept('POST', '/api/cloudmigration/migration', { + statusCode: 200, + body: MIGRATION_SESSION, + }).as('createMigrationToken'); + + cy.intercept('GET', '/api/cloudmigration/migration', { + statusCode: 200, + body: { + sessions: [MIGRATION_SESSION], + }, + }).as('getMigrationSessionList'); + + cy.intercept('GET', `/api/cloudmigration/migration/${SESSION_UID}/snapshots?page=1&limit=1*`, { + statusCode: 200, + body: { + snapshots: [], + }, + }).as('getSnapshotListInitial'); + + // Click the connect button to create the token. + cy.get('[data-testid="migrate-to-cloud-connect-session-modal-connect-button"]').should('be.visible').click(); + + // Wait for the token to be created and the migration session list to be fetched to kickstart the UI state machine. + cy.wait(['@createMigrationToken', '@getMigrationSessionList', '@getSnapshotListInitial']); + + cy.intercept('POST', `/api/cloudmigration/migration/${SESSION_UID}/snapshot`, { + statusCode: 200, + body: { + uid: SNAPSHOT_UID1, + }, + }).as('createSnapshot'); + + cy.intercept('GET', `/api/cloudmigration/migration/${SESSION_UID}/snapshots?page=1&limit=1*`, { + statusCode: 200, + body: { + snapshots: [ + { + uid: SNAPSHOT_UID1, + sessionUid: SESSION_UID, + status: 'CREATING', + created: '2025-04-02T21:40:23+02:00', + finished: '0001-01-01T00:00:00Z', + }, + ], + }, + }).as('getSnapshotListCreating'); + + let getSnapshotCalled = false; + cy.intercept( + 'GET', + `/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}?resultPage=1&resultLimit=50`, + (req) => { + if (!getSnapshotCalled) { + getSnapshotCalled = true; + req.reply((res) => { + res.send({ + statusCode: 200, + body: { + uid: SNAPSHOT_UID1, + sessionUid: SESSION_UID, + status: 'CREATING', + created: '2025-04-02T21:40:23+02:00', + finished: '0001-01-01T00:00:00Z', + results: [], + stats: { + types: {}, + statuses: {}, + total: 0, + }, + }, + }); + }); + } else { + req.reply((res) => { + res.send({ + statusCode: 200, + body: { + uid: SNAPSHOT_UID1, + sessionUid: SESSION_UID, + status: 'PENDING_UPLOAD', + created: '2025-04-02T21:40:23+02:00', + finished: '0001-01-01T00:00:00Z', + results: SNAPSHOT_RESULTS.map((r) => ({ ...r, status: 'PENDING' })), + stats: STATS, + }, + }); + }); + } + } + ).as('getSnapshot'); + + // Build the snapshot. + cy.get('[data-testid="migrate-to-cloud-configure-snapshot-build-snapshot-button"]').should('be.visible').click(); + + // Wait for the snapshot to be created. Simulate it going from INITIALIZING to PENDING_UPLOAD. + cy.wait(['@createSnapshot', '@getSnapshotListCreating', '@getSnapshot']); + + cy.intercept('POST', `/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}/upload`, { + statusCode: 200, + }).as('uploadSnapshot'); + + cy.intercept('GET', `/api/cloudmigration/migration/${SESSION_UID}/snapshots?page=1&limit=1*`, { + statusCode: 200, + body: { + snapshots: [ + { + uid: SNAPSHOT_UID1, + sessionUid: SESSION_UID, + status: 'UPLOADING', + created: '2025-04-02T21:40:23+02:00', + finished: '0001-01-01T00:00:00Z', + }, + ], + }, + }).as('getSnapshotListUploading'); + + // Upload the snapshot. + cy.get('[data-testid="migrate-to-cloud-summary-upload-snapshot-button"]').should('be.visible').click(); + + // Simulate the snapshot being uploaded, the frontend will keep polling until the snapshot is either finished or errored. + let getSnapshotUploadingCalls = 0; + cy.intercept( + 'GET', + `/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}?resultPage=1&resultLimit=50`, + (req) => { + req.reply((res) => { + if (getSnapshotUploadingCalls <= 1) { + res.send({ + statusCode: 200, + body: { + uid: SNAPSHOT_UID1, + sessionUid: SESSION_UID, + status: getSnapshotUploadingCalls === 1 ? 'PROCESSING' : 'UPLOADING', + created: '2025-04-02T21:40:23+02:00', + finished: '0001-01-01T00:00:00Z', + results: SNAPSHOT_RESULTS.map((r) => ({ ...r, status: 'PENDING' })), + stats: STATS, + }, + }); + getSnapshotUploadingCalls++; + } else { + res.send({ + statusCode: 200, + body: { + uid: SNAPSHOT_UID1, + sessionUid: SESSION_UID, + status: 'FINISHED', + created: '2025-03-27T12:00:00Z', + finished: '2025-03-27T12:00:00Z', + results: SNAPSHOT_RESULTS.map((r) => ({ ...r, status: 'OK' })), + stats: { + types: STATS.types, + statuses: SNAPSHOT_RESULTS.reduce( + (acc, r) => { + const status = (r as { status?: string }).status || 'UNKNOWN'; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, + {} as Record + ), + total: SNAPSHOT_RESULTS.length, + }, + }, + }); + } + }); + } + ).as('getSnapshotUploading'); + + // Wait for the request to kickstart the upload and then wait until it is finished. + cy.wait(['@uploadSnapshot', '@getSnapshotListUploading', '@getSnapshotUploading']); + + // The upload button should now be hidden away. + cy.get('[data-testid="migrate-to-cloud-summary-upload-snapshot-button"]').should('be.disabled'); + + // And the rebuild button should be visible. + cy.get('[data-testid="migrate-to-cloud-summary-rebuild-snapshot-button"]').should('be.visible'); + + // At least some of the items are marked with "Uploaded to cloud" status. + cy.contains('Uploaded to cloud').should('be.visible'); + }); + }); + + // Here we are doing a more black box testing of the migration flow, without explicitly mocking the API calls, + // but we instead rely on the `[cloud_migration] developer_mode = true` to be set in the `custom.ini` file, + // which will make the service use in-memory fake implementations of 3rdparty dependencies, but we'll still + // use the real API endpoints, database and business logic. + describe('with a fake GMS backend implementation', () => { + afterEach(() => { + cy.get('[data-testid="migrate-to-cloud-summary-disconnect-button"]').should('be.visible').click(); + }); + + // Manually crafted base64 token for testing, does not contain any sensitive data. + const TEST_TOKEN = + 'eyJUb2tlbiI6ImdsY19kZXZfZXlKdklqb2lNVEl6TkNJc0ltNGlPaUpuY21GbVlXNWhMV05zYjNWa0xXMXBaM0poZEdsdmJuTXRNVEl6TkNJc0ltc2lPaUowWlhOMElpd2liU0k2ZXlKeUlqb2laR1YyTFhWekxXTmxiblJ5WVd3aWZYMEsiLCJJbnN0YW5jZSI6eyJTdGFja0lEIjoxMjM0LCJTbHVnIjoidGVzdC1zbHVnIiwiUmVnaW9uU2x1ZyI6ImRldi11cy1jZW50cmFsIiwiQ2x1c3RlclNsdWciOiJkZXYtdXMtY2VudHJhbC0wIn19Cg=='; + + it('creates a snapshot sucessfully', () => { + // Login using the UI. + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + + // Visit the migrate to cloud onprem page. + e2e.pages.MigrateToCloud.visit(); + + // Open the connect modal and enter the token. + cy.get('[data-testid="migrate-to-cloud-connect-session-modal-button"]').should('be.visible').click(); + + cy.get('[data-testid="migrate-to-cloud-connect-session-modal-token-input"]') + .should('be.visible') + .focus() + .type(TEST_TOKEN); + + // Click the connect button to create the token. + cy.get('[data-testid="migrate-to-cloud-connect-session-modal-connect-button"]').should('be.visible').click(); + + // Build the snapshot. + cy.get('[data-testid="migrate-to-cloud-configure-snapshot-build-snapshot-button"]').should('be.visible').click(); + + // And the rebuild button should be visible. + cy.get('[data-testid="migrate-to-cloud-summary-rebuild-snapshot-button"]').should('be.visible'); + + // We don't upload the snapshot yet because we need to create a mock server to validate the uploaded items, + // similarly to what the SMTP (tester) server does. + }); + }); +}); diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index bd45587a7bc..7ac2413298e 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -1049,6 +1049,11 @@ export const versionedPages = { [MIN_GRAFANA_VERSION]: (pluginId: string) => `/plugins/${pluginId}`, }, }, + MigrateToCloud: { + url: { + '11.2.0': '/admin/migrate-to-cloud', + }, + }, } satisfies VersionedSelectorGroup; export type VersionedPages = typeof versionedPages; diff --git a/pkg/services/cloudmigration/gmsclient/inmemory_client.go b/pkg/services/cloudmigration/gmsclient/inmemory_client.go index 2a9d73e0b05..9a176619e87 100644 --- a/pkg/services/cloudmigration/gmsclient/inmemory_client.go +++ b/pkg/services/cloudmigration/gmsclient/inmemory_client.go @@ -2,56 +2,34 @@ package gmsclient import ( "context" + cryptoRand "crypto/rand" "encoding/json" "fmt" - "math/rand" - - cryptoRand "crypto/rand" + "sync" "github.com/google/uuid" - "github.com/grafana/grafana/pkg/services/cloudmigration" "golang.org/x/crypto/nacl/box" + + "github.com/grafana/grafana/pkg/services/cloudmigration" ) // NewInMemoryClient returns an implementation of Client that returns canned responses func NewInMemoryClient() Client { - return &memoryClientImpl{} + return &memoryClientImpl{ + mx: &sync.Mutex{}, + snapshotInfo: make(map[string]cloudmigration.SnapshotState), + } } type memoryClientImpl struct { - snapshot *cloudmigration.StartSnapshotResponse + mx *sync.Mutex + snapshotInfo map[string]cloudmigration.SnapshotState // snapshotUID -> state } func (c *memoryClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.CloudMigrationSession) error { return nil } -func (c *memoryClientImpl) MigrateData( - ctx context.Context, - cm cloudmigration.CloudMigrationSession, - request cloudmigration.MigrateDataRequest, -) (*cloudmigration.MigrateDataResponse, error) { - result := cloudmigration.MigrateDataResponse{ - Items: make([]cloudmigration.CloudMigrationResource, len(request.Items)), - } - - for i, v := range request.Items { - result.Items[i] = cloudmigration.CloudMigrationResource{ - Type: v.Type, - RefID: v.RefID, - Status: cloudmigration.ItemStatusOK, - } - } - - // simulate flakiness on one random item - i := rand.Intn(len(result.Items)) - failedItem := result.Items[i] - failedItem.Status, failedItem.Error = cloudmigration.ItemStatusError, "simulated random error" - result.Items[i] = failedItem - - return &result, nil -} - func (c *memoryClientImpl) StartSnapshot(_ context.Context, sess cloudmigration.CloudMigrationSession) (*cloudmigration.StartSnapshotResponse, error) { publicKey, _, err := box.GenerateKey(cryptoRand.Reader) if err != nil { @@ -74,40 +52,54 @@ func (c *memoryClientImpl) StartSnapshot(_ context.Context, sess cloudmigration. return nil, fmt.Errorf("marshalling metadata: %w", err) } - c.snapshot = &cloudmigration.StartSnapshotResponse{ + c.mx.Lock() + c.snapshotInfo[snapshotUid] = cloudmigration.SnapshotStateInitialized + c.mx.Unlock() + + return &cloudmigration.StartSnapshotResponse{ EncryptionKey: publicKey[:], SnapshotID: snapshotUid, MaxItemsPerPartition: 10, Algo: "nacl", Metadata: metadataBuffer, - } - - return c.snapshot, nil + }, nil } func (c *memoryClientImpl) GetSnapshotStatus(ctx context.Context, session cloudmigration.CloudMigrationSession, snapshot cloudmigration.CloudMigrationSnapshot, offset int) (*cloudmigration.GetSnapshotStatusResponse, error) { - gmsResp := &cloudmigration.GetSnapshotStatusResponse{ - State: cloudmigration.SnapshotStateFinished, - Results: []cloudmigration.CloudMigrationResource{ - { - Type: cloudmigration.DashboardDataType, - RefID: "dash1", + c.mx.Lock() + snapshotInfo := c.snapshotInfo[snapshot.UID] + c.mx.Unlock() + + resources := make([]cloudmigration.CloudMigrationResource, 0, len(snapshot.Resources)) + for _, resource := range snapshot.Resources { + if snapshotInfo == cloudmigration.SnapshotStateFinished { + resources = append(resources, cloudmigration.CloudMigrationResource{ + Type: resource.Type, + RefID: resource.RefID, Status: cloudmigration.ItemStatusOK, - }, - { - Type: cloudmigration.DatasourceDataType, - RefID: "ds1", - Status: cloudmigration.ItemStatusError, - Error: "fake error", - }, - { - Type: cloudmigration.FolderDataType, - RefID: "folder1", - Status: cloudmigration.ItemStatusOK, - }, - }, + }) + } else { + resources = append(resources, cloudmigration.CloudMigrationResource{ + Type: resource.Type, + RefID: resource.RefID, + }) + } } + gmsResp := &cloudmigration.GetSnapshotStatusResponse{ + State: snapshotInfo, + Results: resources, + } + + c.mx.Lock() + // Next call, transition to the next state. + if c.snapshotInfo[snapshot.UID] == cloudmigration.SnapshotStateInitialized { + c.snapshotInfo[snapshot.UID] = cloudmigration.SnapshotStateProcessing + } else { + c.snapshotInfo[snapshot.UID] = cloudmigration.SnapshotStateFinished + } + c.mx.Unlock() + return gmsResp, nil } diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx index ba5816d0a22..8b8255e12fd 100644 --- a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx +++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx @@ -18,7 +18,11 @@ export const CallToAction = () => { Let us manage your Grafana stack - diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx index 37e52eb8860..72cc87fb78e 100644 --- a/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx +++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx @@ -167,6 +167,7 @@ export const ConnectModal = ({ isOpen, isLoading, error, hideModal, onConfirm }: })} id={tokenId} placeholder={t('migrate-to-cloud.connect-modal.body-token-field-placeholder', 'Paste token here')} + data-testid="migrate-to-cloud-connect-session-modal-token-input" /> @@ -176,7 +177,11 @@ export const ConnectModal = ({ isOpen, isLoading, error, hideModal, onConfirm }: - @@ -115,6 +116,7 @@ export function MigrationSummary(props: MigrationSummaryProps) { onClick={onBuildSnapshot} icon={buildSnapshotIsLoading ? 'spinner' : undefined} variant="secondary" + data-testid="migrate-to-cloud-summary-rebuild-snapshot-button" > Rebuild snapshot @@ -125,6 +127,7 @@ export function MigrationSummary(props: MigrationSummaryProps) { disabled={isBusy || uploadSnapshotIsLoading} onClick={onUploadSnapshot} icon={uploadSnapshotIsLoading ? 'spinner' : undefined} + data-testid="migrate-to-cloud-summary-upload-snapshot-button" > Upload snapshot diff --git a/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx b/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx index e20b5836417..e9b84aef4f4 100644 --- a/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx +++ b/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx @@ -31,7 +31,12 @@ export function BuildSnapshotCTA(props: SnapshotCTAProps) { - diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index ddda503e5c8..f16dfd7706f 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -22,3 +22,6 @@ max_open_conn = 2 [smtp] enabled = true host = localhost:7777 + +[cloud_migration] +developer_mode = true ; Enable developer mode to use in-memory implementations of 3rdparty services needed.