CloudMigrations: Add Cypress happy path test case scenarios (#103250)

pull/103341/head
Matheus Macabu 3 months ago committed by GitHub
parent 0fbb51ab08
commit 7165bc553a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 298
      e2e/various-suite/migrate-to-cloud.spec.ts
  2. 5
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  3. 100
      pkg/services/cloudmigration/gmsclient/inmemory_client.go
  4. 6
      public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/CallToAction.tsx
  5. 7
      public/app/features/migrate-to-cloud/onprem/EmptyState/CallToAction/ConnectModal.tsx
  6. 3
      public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx
  7. 7
      public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx
  8. 3
      scripts/grafana-server/custom.ini

@ -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<string, number>
),
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<string, number>
),
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.
});
});
});

@ -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;

@ -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,39 +52,53 @@ 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,
}, nil
}
return c.snapshot, nil
func (c *memoryClientImpl) GetSnapshotStatus(ctx context.Context, session cloudmigration.CloudMigrationSession, snapshot cloudmigration.CloudMigrationSnapshot, offset int) (*cloudmigration.GetSnapshotStatusResponse, error) {
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,
})
} else {
resources = append(resources, cloudmigration.CloudMigrationResource{
Type: resource.Type,
RefID: resource.RefID,
})
}
}
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",
Status: cloudmigration.ItemStatusOK,
},
{
Type: cloudmigration.DatasourceDataType,
RefID: "ds1",
Status: cloudmigration.ItemStatusError,
Error: "fake error",
},
{
Type: cloudmigration.FolderDataType,
RefID: "folder1",
Status: cloudmigration.ItemStatusOK,
},
},
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
}

@ -18,7 +18,11 @@ export const CallToAction = () => {
<Trans i18nKey="migrate-to-cloud.cta.header">Let us manage your Grafana stack</Trans>
</Text>
<Button disabled={createMigrationResponse.isLoading} onClick={() => setModalOpen(true)}>
<Button
data-testid="migrate-to-cloud-connect-session-modal-button"
disabled={createMigrationResponse.isLoading}
onClick={() => setModalOpen(true)}
>
<Trans i18nKey="migrate-to-cloud.cta.button">Migrate this instance to Cloud</Trans>
</Button>
</Box>

@ -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"
/>
</Field>
</Stack>
@ -176,7 +177,11 @@ export const ConnectModal = ({ isOpen, isLoading, error, hideModal, onConfirm }:
<Button variant="secondary" onClick={hideModal}>
<Trans i18nKey="migrate-to-cloud.connect-modal.cancel">Cancel</Trans>
</Button>
<Button type="submit" disabled={isLoading || !token}>
<Button
type="submit"
disabled={isLoading || !token}
data-testid="migrate-to-cloud-connect-session-modal-connect-button"
>
{isLoading
? t('migrate-to-cloud.connect-modal.connecting', 'Connecting to this stack...')
: t('migrate-to-cloud.connect-modal.connect', 'Connect to this stack')}

@ -96,6 +96,7 @@ export function MigrationSummary(props: MigrationSummaryProps) {
variant="secondary"
size="sm"
icon={disconnectIsLoading ? 'spinner' : undefined}
data-testid="migrate-to-cloud-summary-disconnect-button"
>
<Trans i18nKey="migrate-to-cloud.summary.disconnect">Disconnect</Trans>
</Button>
@ -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"
>
<Trans i18nKey="migrate-to-cloud.summary.rebuild-snapshot">Rebuild snapshot</Trans>
</Button>
@ -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"
>
<Trans i18nKey="migrate-to-cloud.summary.upload-migration">Upload snapshot</Trans>
</Button>

@ -31,7 +31,12 @@ export function BuildSnapshotCTA(props: SnapshotCTAProps) {
</Trans>
</Text>
<Button disabled={disabled} onClick={onClick} icon={isLoading ? 'spinner' : undefined}>
<Button
disabled={disabled}
onClick={onClick}
icon={isLoading ? 'spinner' : undefined}
data-testid="migrate-to-cloud-configure-snapshot-build-snapshot-button"
>
<Trans i18nKey="migrate-to-cloud.summary.start-migration">Build snapshot</Trans>
</Button>
</CTAInfo>

@ -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.

Loading…
Cancel
Save