diff --git a/packages/manager/.changeset/pr-11875-tech-stories-1742315053264.md b/packages/manager/.changeset/pr-11875-tech-stories-1742315053264.md new file mode 100644 index 00000000000..a59f66f0a73 --- /dev/null +++ b/packages/manager/.changeset/pr-11875-tech-stories-1742315053264.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add MSW crud support for new Linode Interface endpoints ([#11875](https://github.com/linode/manager/pull/11875)) diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts index 25cb8a1fa51..57acef660bb 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts @@ -3,6 +3,7 @@ import { linodeInterfaceFactoryPublic, linodeInterfaceFactoryVPC, linodeInterfaceFactoryVlan, + linodeInterfaceSettingsFactory, } from '@linode/utilities'; import { DateTime } from 'luxon'; import { http } from 'msw'; @@ -27,6 +28,7 @@ import { mswDB } from '../../../indexedDB'; import type { Config, + CreateLinodeInterfacePayload, Disk, Firewall, FirewallDeviceEntityType, @@ -35,6 +37,7 @@ import type { LinodeBackupsResponse, LinodeIPsResponse, LinodeInterface, + LinodeInterfaceSettings, LinodeInterfaces, RegionalNetworkUtilization, Stats, @@ -128,6 +131,25 @@ export const getLinodes = () => [ } ), + // todo: connect this to the DB eventually + http.get( + '*/v4*/linode/instances/:id/interfaces/settings', + async ({ + params, + }): Promise> => { + const linodeId = Number(params.id); + const linode = await mswDB.get('linodes', linodeId); + + if (!linode) { + return makeNotFoundResponse(); + } + + const linodeSettings = linodeInterfaceSettingsFactory.build(); + + return makeResponse(linodeSettings); + } + ), + http.get( '*/v4/linode/instances/:id/configs', async ({ @@ -156,6 +178,64 @@ export const getLinodes = () => [ ), ]; +const addFirewallDevice = async (inputs: { + entityId: number; + entityLabel: string; + firewallId: number; + interfaceType: FirewallDeviceEntityType; + mockState: MockState; +}) => { + const { + entityId, + entityLabel, + firewallId, + interfaceType, + mockState, + } = inputs; + const firewall = await mswDB.get('firewalls', firewallId); + if (firewall) { + const entity = { + id: entityId, + label: entityLabel, + type: interfaceType, + url: `/linodes/${entityId}`, + }; + + const updatedFirewall = { + ...firewall, + entities: [...firewall.entities, entity], + }; + + const firewallDevice = firewallDeviceFactory.build({ + created: DateTime.now().toISO(), + entity, + updated: DateTime.now().toISO(), + }); + + await mswDB.add( + 'firewallDevices', + [firewall.id, firewallDevice], + mockState + ); + + await mswDB.update('firewalls', firewall.id, updatedFirewall, mockState); + + queueEvents({ + event: { + action: 'firewall_device_add', + entity: { + id: firewall.id, + label: firewall.label, + type: 'firewallDevice', + url: `/v4beta/networking/firewalls/${firewall.id}/linodes`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + } +}; + export const createLinode = (mockState: MockState) => [ http.post('*/v4/linode/instances', async ({ request }) => { const payload = await request.clone().json(); @@ -170,62 +250,153 @@ export const createLinode = (mockState: MockState) => [ } if (payload.firewall_id) { - const firewall = await mswDB.get('firewalls', payload.firewall_id); - if (firewall) { - const entity = { - id: linode.id, - label: linode.label, - type: 'linode' as FirewallDeviceEntityType, - url: `/linodes/${linode.id}`, - }; + await addFirewallDevice({ + entityId: linode.id, + entityLabel: linode.label, + firewallId: payload.firewall_id, + interfaceType: 'linode', + mockState, + }); + } - const updatedFirewall = { - ...firewall, - entities: [...firewall.entities, entity], - }; + await mswDB.add('linodes', linode, mockState); + if (linode.interface_generation === 'linode') { + if ( + payload.interfaces && + payload.interfaces.some( + (iface: CreateLinodeInterfacePayload) => iface.vpc + ) + ) { + const vpcIfacePayload = payload.interfaces.find( + (iface: CreateLinodeInterfacePayload) => iface.vpc + ); - const firewallDevice = firewallDeviceFactory.build({ + const subnetFromDB = await mswDB.get( + 'subnets', + vpcIfacePayload.vpc.subnet_id ?? -1 + ); + const vpc = await mswDB.get( + 'vpcs', + vpcIfacePayload.vpc.vpc_id ?? subnetFromDB?.[0] ?? -1 + ); + + if (subnetFromDB && vpc) { + const vpcInterface = linodeInterfaceFactoryVPC.build({ + ...vpcIfacePayload, + created: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + + // update VPC/subnet to include this new interface + const updatedSubnet = { + ...subnetFromDB[1], + linodes: [ + ...subnetFromDB[1].linodes, + { + id: linode.id, + interfaces: [ + { + active: true, + config_id: null, + id: vpcInterface.id, + }, + ], + }, + ], + updated: DateTime.now().toISO(), + }; + + const updatedVPC = { + ...vpc, + subnets: vpc.subnets.map((subnet) => { + if (subnet.id === subnetFromDB[1].id) { + return updatedSubnet; + } + + return subnet; + }), + }; + + await mswDB.add( + 'linodeInterfaces', + [linode.id, vpcInterface], + mockState + ); + await mswDB.update( + 'subnets', + subnetFromDB[1].id, + [vpc.id, updatedSubnet], + mockState + ); + await mswDB.update('vpcs', vpc.id, updatedVPC, mockState); + + // if firewall given in interface payload, add a device + if (vpcIfacePayload.firewall_id) { + await addFirewallDevice({ + entityId: vpcInterface.id, + entityLabel: linode.label, + firewallId: vpcIfacePayload.firewall_id, + interfaceType: 'interface', + mockState, + }); + } + } + } + + if ( + payload.interfaces && + payload.interfaces.some( + (iface: CreateLinodeInterfacePayload) => iface.public + ) + ) { + const publicInterface = linodeInterfaceFactoryPublic.build({ created: DateTime.now().toISO(), - entity, updated: DateTime.now().toISO(), }); - await mswDB.add( - 'firewallDevices', - [firewall.id, firewallDevice], + 'linodeInterfaces', + [linode.id, publicInterface], mockState ); - await mswDB.update( - 'firewalls', - firewall.id, - updatedFirewall, - mockState + // if firewall given in interface payload, add a device + const interfacePayload = payload.interfaces.find( + (iface: CreateLinodeInterfacePayload) => iface.public ); + if (interfacePayload.firewall_id) { + await addFirewallDevice({ + entityId: publicInterface.id, + entityLabel: linode.label, + firewallId: interfacePayload.firewall_id, + interfaceType: 'interface', + mockState, + }); + } + } - queueEvents({ - event: { - action: 'firewall_device_add', - entity: { - id: firewall.id, - label: firewall.label, - type: 'firewallDevice', - url: `/v4beta/networking/firewalls/${firewall.id}/linodes`, - }, - }, - mockState, - sequence: [{ status: 'notification' }], + if ( + payload.interfaces && + payload.interfaces.some( + (iface: CreateLinodeInterfacePayload) => iface.vlan + ) + ) { + const vlanInterface = linodeInterfaceFactoryVlan.build({ + created: DateTime.now().toISO(), + updated: DateTime.now().toISO(), }); + await mswDB.add( + 'linodeInterfaces', + [linode.id, vlanInterface], + mockState + ); } + } else { + const linodeConfig = configFactory.build({ + created: DateTime.now().toISO(), + }); + await mswDB.add('linodeConfigs', [linode.id, linodeConfig], mockState); } - const linodeConfig = configFactory.build({ - created: DateTime.now().toISO(), - }); - - await mswDB.add('linodes', linode, mockState); - await mswDB.add('linodeConfigs', [linode.id, linodeConfig], mockState); - queueEvents({ event: { action: 'linode_create', @@ -557,6 +728,187 @@ export const shutDownLinode = (mockState: MockState) => [ ), ]; +export const getLinodeInterfaceFirewalls = (mockState: MockState) => [ + http.get( + '*/v4*/linode/instances/:id/interfaces/:interfaceId/firewalls', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const linodeId = Number(params.id); + const interfaceId = Number(params.interfaceId); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === linodeId + ); + const linodeInterface = mockState.linodes.find( + (stateLinode) => stateLinode.id === interfaceId + ); + const allFirewalls = await mswDB.getAll('firewalls'); + + if (!linode || !linodeInterface || !allFirewalls) { + return makeNotFoundResponse(); + } + + const linodeInterfaceFirewalls = allFirewalls.filter((firewall) => + firewall.entities.some((entity) => entity.id === interfaceId) + ); + + return makePaginatedResponse({ + data: linodeInterfaceFirewalls, + request, + }); + } + ), +]; + +export const createLinodeInterface = (mockState: MockState) => [ + http.post( + '*/v4*/linode/instances/:id/interfaces', + async ({ + params, + request, + }): Promise> => { + const linodeId = Number(params.id); + const linode = await mswDB.get('linodes', linodeId); + + if (!linode) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + let linodeInterface; + + if (payload.vpc) { + linodeInterface = linodeInterfaceFactoryVPC.build({ + ...payload, + created: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + } else if (payload.vlan) { + linodeInterface = linodeInterfaceFactoryVlan.build({ + ...payload, + created: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + } else { + linodeInterface = linodeInterfaceFactoryPublic.build({ + ...payload, + created: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + } + + await mswDB.add( + 'linodeInterfaces', + [linodeId, linodeInterface], + mockState + ); + + queueEvents({ + event: { + action: 'interface_create', + entity: { + id: linodeInterface.id, + label: linode.label, + type: 'linodeInterface', + url: `/v4beta/linodes/instances/${linode.id}/interfaces`, + }, + }, + mockState, + sequence: [{ status: 'finished' }], + }); + + return makeResponse(linodeInterface); + } + ), +]; + +export const deleteLinodeInterface = (mockState: MockState) => [ + http.delete( + '*/v4*/linodes/instances/:id/interfaces/:interfaceId', + async ({ params }): Promise> => { + const linodeId = Number(params.id); + const interfaceId = Number(params.interfaceId); + const linode = await mswDB.get('linodes', linodeId); + const linodeInterface = await mswDB.get('linodeInterfaces', interfaceId); + + if (!linode || !linodeInterface) { + return makeNotFoundResponse(); + } + + await mswDB.delete('linodeInterfaces', interfaceId, mockState); + + queueEvents({ + event: { + action: 'interface_delete', + entity: { + id: interfaceId, + label: linode.label, + type: 'interface', + url: `/v4beta/linodes/instances/${linode.id}/interfaces`, + }, + }, + mockState, + sequence: [{ status: 'finished' }], + }); + + return makeResponse({}); + } + ), +]; + +export const updateLinodeInterface = (mockState: MockState) => [ + http.put( + '*/v4*/linodes/instances/:id/interfaces/:interfaceId', + async ({ + params, + request, + }): Promise> => { + const linodeId = Number(params.id); + const interfaceId = Number(params.interfaceId); + const linode = await mswDB.get('linodes', linodeId); + const linodeInterface = await mswDB.get('linodeInterfaces', interfaceId); + + if (!linode || !linodeInterface) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + + const updatedInterface = { + ...linodeInterface[1], + ...payload, + updated: DateTime.now().toISO(), + }; + + await mswDB.update( + 'linodeInterfaces', + interfaceId, + [linodeId, updatedInterface], + mockState + ); + + queueEvents({ + event: { + action: 'interface_update', + entity: { + id: interfaceId, + label: linode.label, + type: 'subnets', + url: `/v4beta/linodes/instances/${linode.id}/interfaces`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedInterface); + } + ), +]; + const convertToLinodeInterfaces = (config: Config | undefined) => { const linodeInterfacePublic = linodeInterfaceFactoryPublic.build({ created: DateTime.now().toISO(), @@ -662,4 +1014,29 @@ export const upgradeToLinodeInterfaces = (mockState: MockState) => [ ), ]; +export const updateLinodeInterfaceSettings = () => [ + http.put( + '*/v4*/linodes/instances/:id/interfaces/settings', + async ({ + params, + request, + }): Promise> => { + const linodeId = Number(params.id); + const linode = await mswDB.get('linodes', linodeId); + + if (!linode) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + + const updatedSettings = linodeInterfaceSettingsFactory.build({ + ...payload, + }); + + return makeResponse(updatedSettings); + } + ), +]; + // TODO: ad more handlers (reboot, clone, resize, rebuild, rescue, migrate...) as needed diff --git a/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts b/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts index 6b04a0c0138..862809953fc 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts @@ -141,7 +141,22 @@ export const createVPC = (mockState: MockState) => [ ); } - await Promise.all(createSubnetPromises); + // afterwards, we have to update our newly created VPC with the recently created subnets + // so that all subnet IDs match back + const returnedSubnets = await Promise.all(createSubnetPromises); + const actualSubnets = returnedSubnets.map( + (subnetFromDB) => subnetFromDB[1] + ); + + await mswDB.update( + 'vpcs', + createdVPC.id, + { + ...createdVPC, + subnets: actualSubnets, + }, + mockState + ); queueEvents({ event: { diff --git a/packages/manager/src/mocks/presets/crud/linodes.ts b/packages/manager/src/mocks/presets/crud/linodes.ts index 61e864ce60a..dcf58e48607 100644 --- a/packages/manager/src/mocks/presets/crud/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/linodes.ts @@ -1,15 +1,20 @@ import { createLinode, + createLinodeInterface, deleteLinode, + deleteLinodeInterface, getLinodeBackups, getLinodeDisks, getLinodeFirewalls, + getLinodeInterfaceFirewalls, getLinodeIps, getLinodeStats, getLinodeTransfer, getLinodes, shutDownLinode, updateLinode, + updateLinodeInterface, + updateLinodeInterfaceSettings, upgradeToLinodeInterfaces, } from 'src/mocks/presets/crud/handlers/linodes'; @@ -30,6 +35,11 @@ export const linodeCrudPreset: MockPresetCrud = { getLinodeBackups, shutDownLinode, upgradeToLinodeInterfaces, + deleteLinodeInterface, + createLinodeInterface, + updateLinodeInterface, + updateLinodeInterfaceSettings, + getLinodeInterfaceFirewalls, ], id: 'linodes:crud', label: 'Linode CRUD', diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 9a63ace5b05..4b5c1050b63 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -126,7 +126,7 @@ export interface MockState { firewalls: Firewall[]; ipAddresses: IPAddress[]; linodeConfigs: [number, Config][]; - linodeInterfaces: [number, LinodeInterface][]; + linodeInterfaces: [number, LinodeInterface][], linodes: Linode[]; notificationQueue: Notification[]; placementGroups: PlacementGroup[]; diff --git a/packages/utilities/src/factories/linodeInterface.ts b/packages/utilities/src/factories/linodeInterface.ts index dcc6404a5b1..bfefe51c182 100644 --- a/packages/utilities/src/factories/linodeInterface.ts +++ b/packages/utilities/src/factories/linodeInterface.ts @@ -2,7 +2,19 @@ import { Factory } from './factoryProxy'; -import type { LinodeInterface } from '@linode/api-v4'; +import type { LinodeInterface, LinodeInterfaceSettings } from '@linode/api-v4'; + +export const linodeInterfaceSettingsFactory = Factory.Sync.makeFactory( + { + network_helper: false, + default_route: { + ipv4_interface_id: 1, + ipv4_eligible_interface_ids: [], + ipv6_interface_id: 1, + ipv6_eligible_interface_ids: [], + }, + } +); export const linodeInterfaceFactoryVlan = Factory.Sync.makeFactory( {