Skip to content
Merged
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10229-added-1708735909784.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Reintroduce NVMe Volume Upgrades ([#10229](https://github.com/linode/manager/pull/10229))
270 changes: 270 additions & 0 deletions packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for including this E2E!

Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import {
eventFactory,
linodeFactory,
notificationFactory,
volumeFactory,
} from '@src/factories';
import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events';
import {
mockGetLinodeDetails,
mockGetLinodeDisks,
mockGetLinodeVolumes,
} from 'support/intercepts/linodes';
import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes';
import { ui } from 'support/ui';

describe('volume upgrade/migration', () => {
it('can upgrade an unattached volume to NVMe', () => {
const volume = volumeFactory.build();

const migrationScheduledNotification = notificationFactory.build({
type: 'volume_migration_scheduled',
entity: { type: 'volume', id: volume.id },
});

mockGetVolumes([volume]).as('getVolumes');
mockMigrateVolumes().as('migrateVolumes');
mockGetNotifications([migrationScheduledNotification]).as(
'getNotifications'
);

cy.visitWithLogin('/volumes');

cy.wait(['@getVolumes', '@getNotifications']);

cy.findByText('UPGRADE TO NVMe')
.should('be.visible')
.should('be.enabled')
.click();

const migrationImminentNotification = notificationFactory.build({
type: 'volume_migration_imminent',
entity: { type: 'volume', id: volume.id },
});
mockGetNotifications([migrationImminentNotification]).as(
'getNotifications'
);

ui.dialog.findByTitle(`Upgrade Volume ${volume.label}`).within(() => {
ui.button
.findByTitle('Enter Upgrade Queue')
.should('be.visible')
.should('be.enabled')
.click();
});

cy.wait(['@migrateVolumes', '@getNotifications']);

cy.findByText('UPGRADE PENDING').should('be.visible');

for (const percentage of [10, 20, 50, 75]) {
const mockStartedMigrationEvent = eventFactory.build({
action: 'volume_migrate',
entity: { id: volume.id, type: 'volume' },
status: 'started',
percent_complete: percentage,
});

mockGetEvents([mockStartedMigrationEvent]).as('getEvents');

cy.wait('@getEvents');

cy.findByText(`migrating (${percentage}%)`).should('be.visible');
}

const mockFinishedMigrationEvent = eventFactory.build({
action: 'volume_migrate',
entity: { id: volume.id, type: 'volume', label: volume.label },
status: 'finished',
});

mockGetEvents([mockFinishedMigrationEvent]).as('getEvents');
mockGetNotifications([]).as('getNotifications');

cy.wait(['@getEvents', '@getVolumes', '@getNotifications']);

mockGetEvents([]);

cy.findByText('active').should('be.visible');

ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`);
});

it('can upgrade an attached volume from the volumes landing page', () => {
const linode = linodeFactory.build();
const volume = volumeFactory.build({
linode_id: linode.id,
linode_label: linode.label,
});

const migrationScheduledNotification = notificationFactory.build({
type: 'volume_migration_scheduled',
entity: { type: 'volume', id: volume.id },
});

mockGetVolumes([volume]).as('getVolumes');
mockMigrateVolumes().as('migrateVolumes');
mockGetLinodeDetails(linode.id, linode).as('getLinode');
mockGetLinodeDisks(linode.id, []);
mockGetNotifications([migrationScheduledNotification]).as(
'getNotifications'
);
mockGetLinodeVolumes(linode.id, [volume]).as('getLinodeVolumes');

cy.visitWithLogin('/volumes');

cy.wait(['@getVolumes', '@getNotifications']);

cy.findByText('UPGRADE TO NVMe')
.should('be.visible')
.should('be.enabled')
.click();

cy.url().should('contain', `/linodes/${linode.id}/storage?upgrade=true`);

cy.wait(['@getLinode', '@getLinodeVolumes']);

const migrationImminentNotification = notificationFactory.build({
type: 'volume_migration_imminent',
entity: { type: 'volume', id: volume.id },
});
mockGetNotifications([migrationImminentNotification]).as(
'getNotifications'
);

ui.dialog.findByTitle('Upgrade Volume').within(() => {
cy.findByText(
`A Volume attached to Linode ${linode.label} will be upgraded to high-performance NVMe Block Storage.`,
{ exact: false }
).should('be.visible');

ui.button
.findByTitle('Enter Upgrade Queue')
.should('be.visible')
.should('be.enabled')
.click();
});
cy.wait(['@migrateVolumes', '@getNotifications']);

cy.findByText('UPGRADE PENDING').should('be.visible');

for (const percentage of [10, 20, 50, 75]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case there was any question while developing this, just want to point out that we can use forEach in Cypress tests -- there's nothing async happening here that would cause unexpected behavior.

(Feel free to disregard this if you opted for for .. of for other reasons!)

const mockStartedMigrationEvent = eventFactory.build({
action: 'volume_migrate',
entity: { id: volume.id, type: 'volume' },
status: 'started',
percent_complete: percentage,
});

mockGetEvents([mockStartedMigrationEvent]).as('getEvents');

cy.wait('@getEvents');

cy.findByText(`migrating (${percentage}%)`).should('be.visible');
}

const mockFinishedMigrationEvent = eventFactory.build({
action: 'volume_migrate',
entity: { id: volume.id, type: 'volume', label: volume.label },
status: 'finished',
});

mockGetEvents([mockFinishedMigrationEvent]).as('getEvents');
mockGetNotifications([]).as('getNotifications');

cy.wait(['@getEvents', '@getLinodeVolumes', '@getNotifications']);

mockGetEvents([]);

cy.findByText('active').should('be.visible');

ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`);
});

it('can upgrade an attached volume from the linode details page', () => {
const linode = linodeFactory.build();
const volume = volumeFactory.build({
linode_id: linode.id,
linode_label: linode.label,
});

const migrationScheduledNotification = notificationFactory.build({
type: 'volume_migration_scheduled',
entity: { type: 'volume', id: volume.id },
});

mockMigrateVolumes().as('migrateVolumes');
mockGetLinodeDetails(linode.id, linode).as('getLinode');
mockGetLinodeDisks(linode.id, []);
mockGetNotifications([migrationScheduledNotification]).as(
'getNotifications'
);
mockGetLinodeVolumes(linode.id, [volume]).as('getLinodeVolumes');

cy.visitWithLogin(`/linodes/${linode.id}/storage`);

cy.wait(['@getLinode', '@getLinodeVolumes', '@getNotifications']);

ui.button
.findByTitle('Upgrade Volume')
.should('be.visible')
.should('be.enabled')
.click();

const migrationImminentNotification = notificationFactory.build({
type: 'volume_migration_imminent',
entity: { type: 'volume', id: volume.id },
});
mockGetNotifications([migrationImminentNotification]).as(
'getNotifications'
);

ui.dialog.findByTitle('Upgrade Volume').within(() => {
cy.findByText(
`A Volume attached to Linode ${linode.label} will be upgraded to high-performance NVMe Block Storage.`,
{ exact: false }
).should('be.visible');

ui.button
.findByTitle('Enter Upgrade Queue')
.should('be.visible')
.should('be.enabled')
.click();
});
cy.wait(['@migrateVolumes', '@getNotifications']);

cy.findByText('UPGRADE PENDING').should('be.visible');

for (const percentage of [10, 20, 50, 75]) {
const mockStartedMigrationEvent = eventFactory.build({
action: 'volume_migrate',
entity: { id: volume.id, type: 'volume' },
status: 'started',
percent_complete: percentage,
});

mockGetEvents([mockStartedMigrationEvent]).as('getEvents');

cy.wait('@getEvents');

cy.findByText(`migrating (${percentage}%)`).should('be.visible');
}

const mockFinishedMigrationEvent = eventFactory.build({
action: 'volume_migrate',
entity: { id: volume.id, type: 'volume', label: volume.label },
status: 'finished',
});

mockGetEvents([mockFinishedMigrationEvent]).as('getEvents');
mockGetNotifications([]).as('getNotifications');

cy.wait(['@getEvents', '@getLinodeVolumes', '@getNotifications']);

mockGetEvents([]);

cy.findByText('active').should('be.visible');

ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`);
});
});
9 changes: 9 additions & 0 deletions packages/manager/cypress/support/intercepts/volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,12 @@ export const interceptDeleteVolume = (
): Cypress.Chainable<null> => {
return cy.intercept('DELETE', apiMatcher(`volumes/${volumeId}`));
};

/**
* Intercepts POST request to migrate volumes and mocks response.
*
* @returns Cypress chainable.
*/
export const mockMigrateVolumes = (): Cypress.Chainable<null> => {
return cy.intercept('POST', apiMatcher(`volumes/migrate`), {});
};
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const LinodeVolumes = () => {
handleDetails: () => handleDetails(volume),
handleEdit: () => handleEdit(volume),
handleResize: () => handleResize(volume),
handleUpgrade: () => null,
}}
isDetailsPageRow
key={volume.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ import { EnableBackupsDialog } from '../LinodeBackup/EnableBackupsDialog';
import { LinodeRebuildDialog } from '../LinodeRebuild/LinodeRebuildDialog';
import { RescueDialog } from '../LinodeRescue/RescueDialog';
import { LinodeResize } from '../LinodeResize/LinodeResize';
import { VolumesUpgradeBanner } from '../VolumesUpgradeBanner';
import { HostMaintenance } from './HostMaintenance';
import { MutationNotification } from './MutationNotification';
import Notifications from './Notifications';
import { UpgradeVolumesDialog } from './UpgradeVolumesDialog';

interface TagDrawerProps {
open: boolean;
Expand Down Expand Up @@ -93,6 +95,7 @@ const LinodeDetailHeader = () => {
const [enableBackupsDialogOpen, setEnableBackupsDialogOpen] = React.useState(
false
);
const isUpgradeVolumesDialogOpen = queryParams.upgrade === 'true';

const [tagDrawer, setTagDrawer] = React.useState<TagDrawerProps>({
open: false,
Expand Down Expand Up @@ -222,20 +225,21 @@ const LinodeDetailHeader = () => {

return (
<>
<HostMaintenance linodeStatus={linode?.status ?? 'running'} />
<HostMaintenance linodeStatus={linode.status} />
<MutationNotification linodeId={matchedLinodeId} />
<Notifications />
<VolumesUpgradeBanner linodeId={linode.id} />
Comment thread
bnussman-akamai marked this conversation as resolved.
<ProductInformationBanner bannerLocation="Linodes" />
<LandingHeader
breadcrumbProps={{
onEditHandlers: {
editableTextTitle: linode?.label ?? '',
editableTextTitle: linode.label,
errorText: editableLabelError,
handleAnalyticsEvent: () => sendEditBreadcrumbEvent(),
onCancel: resetEditableLabel,
onEdit: handleLinodeLabelUpdate,
},
pathname: `/linodes/${linode?.label}`,
pathname: `/linodes/${linode.label}`,
}}
onDocsClick={() => {
sendLinodeCreateFlowDocsClickEvent('Getting Started');
Expand All @@ -251,7 +255,7 @@ const LinodeDetailHeader = () => {
openTagDrawer={openTagDrawer}
/>
<PowerActionsDialog
action={powerAction ?? 'Reboot'}
action={powerAction}
isOpen={powerDialogOpen}
linodeId={matchedLinodeId}
manuallyUpdateConfigs={showVPCs}
Expand Down Expand Up @@ -283,9 +287,14 @@ const LinodeDetailHeader = () => {
onClose={closeDialogs}
open={migrateDialogOpen}
/>
<UpgradeVolumesDialog
linode={linode}
onClose={closeDialogs}
open={isUpgradeVolumesDialogOpen}
/>
<TagDrawer
entityID={linode?.id}
entityLabel={linode?.label ?? ''}
entityID={linode.id}
entityLabel={linode.label}
onClose={closeTagDrawer}
open={tagDrawer.open}
tags={tagDrawer.tags}
Expand Down
Loading