diff --git a/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md b/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md new file mode 100644 index 00000000000..417c8d56f0f --- /dev/null +++ b/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Change range property of IPv6SLAAC to be optional ([#13209](https://github.com/linode/manager/pull/13209)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index db79101e5bc..0d298c662a9 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -192,7 +192,7 @@ export interface ConfigInterfaceIPv4 { export interface IPv6SLAAC { address?: string; - range: string; + range?: string; } // The legacy interface type - for Configuration Profile Interfaces diff --git a/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md b/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md new file mode 100644 index 00000000000..88106a0863f --- /dev/null +++ b/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add VPC IPv6 support in Linode Add/Edit Config dialog ([#13209](https://github.com/linode/manager/pull/13209)) diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx index 808a3b927b4..a66f11dfaac 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx @@ -16,7 +16,7 @@ import { FirewallCell, LKEClusterCell, } from './LinodeEntityDetailRowInterfaceFirewall'; -import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/LinodeConfigs'; +import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/constants'; import { getUnableToUpgradeTooltipText } from './LinodesDetail/LinodeConfigs/UpgradeInterfaces/utils'; import type { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx index c870d9c0c3b..6710d8f776e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx @@ -10,11 +10,8 @@ import { import 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { - LinodeConfigDialog, - padList, - unrecommendedConfigNoticeSelector, -} from './LinodeConfigDialog'; +import { LinodeConfigDialog } from './LinodeConfigDialog'; +import { padList, unrecommendedConfigNoticeSelector } from './utilities'; import type { MemoryLimit } from './LinodeConfigDialog'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 2fdd949f2cf..5795a49cadb 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -46,11 +46,6 @@ import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; import { DeviceSelection } from 'src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection'; import { titlecase } from 'src/features/Linodes/presentation'; -import { - LINODE_UNREACHABLE_HELPER_TEXT, - NATTED_PUBLIC_IP_HELPER_TEXT, - NOT_NATTED_HELPER_TEXT, -} from 'src/features/VPCs/constants'; import { handleFieldErrors, handleGeneralErrors, @@ -68,7 +63,11 @@ import { StyledFormGroup, StyledRadioGroup, } from './LinodeConfigDialog.styles'; -import { getPrimaryInterfaceIndex, useGetDeviceLimit } from './utilities'; +import { + getPrimaryInterfaceIndex, + unrecommendedConfigNoticeSelector, + useGetDeviceLimit, +} from './utilities'; import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect'; import type { @@ -92,7 +91,7 @@ type RunLevel = 'binbash' | 'default' | 'single'; type VirtMode = 'fullvirt' | 'paravirt'; export type MemoryLimit = 'no_limit' | 'set_limit'; -interface EditableFields { +export interface EditableFields { comments?: string; devices: DevicesAsStrings; helpers: Helpers; @@ -181,6 +180,7 @@ const interfacesToState = (interfaces?: Interface[] | null) => { ip_ranges, ipam_address, ipv4, + ipv6, label, primary, purpose, @@ -191,6 +191,7 @@ const interfacesToState = (interfaces?: Interface[] | null) => { ip_ranges, ipam_address, ipv4, + ipv6, label, primary, purpose, @@ -1049,7 +1050,7 @@ export const LinodeConfigDialog = (props: Props) => { /> {values.interfaces?.map((thisInterface, idx) => { - const thisInterfaceIPRanges: ExtendedIP[] = ( + const thisInterfaceIPv4Ranges: ExtendedIP[] = ( thisInterface.ip_ranges ?? [] ).map((ip_range, index) => { // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ip_ranges[1] is invalid" @@ -1069,6 +1070,26 @@ export const LinodeConfigDialog = (props: Props) => { }; }); + const thisInterfaceIPv6Ranges: ExtendedIP[] = ( + thisInterface.ipv6?.ranges ?? [] + ).map((ipv6Range, index) => { + // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ipv6.ranges[1] is invalid" + // @ts-expect-error this form intentionally breaks formik's error type + const errorString: string = formik.errors[ + `interfaces[${idx}].ipv6.ranges[${index}].range` + ]?.includes('is invalid') + ? 'Invalid IPv6 range' + : // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[ + `interfaces[${idx}].ipv6.ranges[${index}].range` + ]; + + return { + address: ipv6Range.range, + error: errorString, + }; + }); + return ( {unrecommendedConfigNoticeSelector({ @@ -1078,7 +1099,8 @@ export const LinodeConfigDialog = (props: Props) => { values, })} { publicIPv4Error: // @ts-expect-error this form intentionally breaks formik's error type formik.errors[`interfaces[${idx}].ipv4.nat_1_1`], + publicIPv6Error: + // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[ + `interfaces[${idx}].ipv6.is_public` + ], subnetError: // @ts-expect-error this form intentionally breaks formik's error type formik.errors[`interfaces[${idx}].subnet_id`], @@ -1104,6 +1131,11 @@ export const LinodeConfigDialog = (props: Props) => { vpcIPv4Error: // @ts-expect-error this form intentionally breaks formik's error type formik.errors[`interfaces[${idx}].ipv4.vpc`], + vpcIPv6Error: + // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[ + `interfaces[${idx}].ipv6.slaac[0].range` + ], }} handleChange={(newInterface: ExtendedInterface) => { handleInterfaceChange(idx, newInterface); @@ -1122,6 +1154,12 @@ export const LinodeConfigDialog = (props: Props) => { subnetId={thisInterface.subnet_id} vpcId={thisInterface.vpc_id} vpcIPv4={thisInterface.ipv4?.vpc ?? undefined} + vpcIPv6={ + thisInterface.ipv6?.slaac?.[0]?.range ?? undefined + } + vpcIPv6IsPublic={ + thisInterface.ipv6?.is_public ?? false + } /> ); @@ -1243,76 +1281,3 @@ const DialogContent = (props: ConfigFormProps) => { const isUsingCustomRoot = (value: string) => pathsOptionsLabels.includes(value) === false; - -const noticeForScenario = (scenarioText: string) => ( - -); - -/** - * Returns a JSX warning notice if the current network interface configuration - * is unrecommended and may lead to undesired or unsupported behavior. - * - * @param _interface the current config interface being passed in - * @param primaryInterfaceIndex the index of the primary interface - * @param thisIndex the index of the current config interface within the `interfaces` array of the `config` object - * @param values the values held in Formik state, having a type of `EditableFields` - * @returns JSX.Element | null - */ -export const unrecommendedConfigNoticeSelector = ({ - _interface, - primaryInterfaceIndex, - thisIndex, - values, -}: { - _interface: ExtendedInterface; - primaryInterfaceIndex: null | number; - thisIndex: number; - values: EditableFields; -}): JSX.Element | null => { - const vpcInterface = _interface.purpose === 'vpc'; - const nattedIPv4Address = Boolean(_interface.ipv4?.nat_1_1); - - const filteredInterfaces = - values.interfaces?.filter((_interface) => _interface.purpose !== 'none') ?? - []; - - // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done - const primaryInterfaceIsVPC = - primaryInterfaceIndex !== null && - values.interfaces && - values.interfaces[primaryInterfaceIndex].purpose === 'vpc'; - - /* - Scenario 1: - - the interface passed in to this function is a VPC interface - - the index of the primary interface !== the index of the interface passed in to this function - - nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" checked) - - Scenario 2: - - all of Scenario 1, except: !nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" unchecked) - - Scenario 3: - - only eth0 populated, and it is a VPC interface - - If not one of the above scenarios, do not display a warning notice re: configuration - */ - if ( - vpcInterface && - primaryInterfaceIndex !== thisIndex && - !primaryInterfaceIsVPC - ) { - return nattedIPv4Address - ? noticeForScenario(NATTED_PUBLIC_IP_HELPER_TEXT) - : noticeForScenario(LINODE_UNREACHABLE_HELPER_TEXT); - } - - if (filteredInterfaces.length === 1 && vpcInterface && !nattedIPv4Address) { - return noticeForScenario(NOT_NATTED_HELPER_TEXT); - } - - return null; -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx index b543c9048c9..9314ea4ed61 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx @@ -3,7 +3,7 @@ import { useGrants, useLinodeQuery, } from '@linode/queries'; -import { Box, Button, Typography } from '@linode/ui'; +import { Box, Button } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { useNavigate, useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -28,24 +28,11 @@ import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { useLinodeDetailContext } from '../LinodesDetailContext'; import { BootConfigDialog } from './BootConfigDialog'; import { ConfigRow } from './ConfigRow'; +import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './constants'; import { DeleteConfigDialog } from './DeleteConfigDialog'; import { LinodeConfigDialog } from './LinodeConfigDialog'; import { getUnableToUpgradeTooltipText } from './UpgradeInterfaces/utils'; -export const DEFAULT_UPGRADE_BUTTON_HELPER_TEXT = ( - <> - - Configuration Profile interfaces from a single profile can be upgraded to - Linode Interfaces. - - - After the upgrade, the Linode can only use Linode Interfaces and cannot - revert to Configuration Profile interfaces. Use the dry-run feature to - review the changes before committing. - - -); - const LinodeConfigs = () => { const theme = useTheme(); const navigate = useNavigate(); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx similarity index 87% rename from packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts rename to packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx index d1cb140afbb..4c140df6420 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx @@ -1,3 +1,6 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + export const deviceSlots = [ 'sda', 'sdb', @@ -133,3 +136,17 @@ export const pathsOptions = [ ]; export const pathsOptionsLabels = pathsOptions.map((path) => path.label); + +export const DEFAULT_UPGRADE_BUTTON_HELPER_TEXT = ( + <> + + Configuration Profile interfaces from a single profile can be upgraded to + Linode Interfaces. + + + After the upgrade, the Linode can only use Linode Interfaces and cannot + revert to Configuration Profile interfaces. Use the dry-run feature to + review the changes before committing. + + +); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.tsx similarity index 100% rename from packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts rename to packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.tsx diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts deleted file mode 100644 index faed50c71af..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isEmpty } from '@linode/api-v4'; - -import { DEFAULT_DEVICE_LIMIT } from 'src/constants'; -import { useFlags } from 'src/hooks/useFlags'; - -import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4'; - -/** - * Gets the index of the primary Linode interface - * - * The function does more than just look for `primary: true`. It will also return the index - * of the implicit primary interface. (The API does not enforce that a Linode config always - * has an interface that is marked as primary) - * - * This is the general logic we follow in this function: - * - If an interface is primary we know that's the primary - * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary. - * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface - * - * @returns the index of the primary interface or `null` if there is not a primary interface - */ -export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => { - const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary); - - // If an interface has `primary: true` we know thats the primary so just return it. - if (indexOfPrimaryInterface !== -1) { - return indexOfPrimaryInterface; - } - - // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface, - // and it will be eth0 on the Linode. This interface will be primary. - // This case isn't really nessesary because this form is built so that the interfaces state will be - // populated even if the API returns an empty interfaces array, but I'm including it for completeness. - if (isEmpty(interfaces)) { - return null; - } - - // If a config has interfaces but none of them are marked as primary, - // then the first interface in the list that’s not a VLAN will shown as the primary interface. - const inherentIndexOfPrimaryInterface = interfaces.findIndex( - (i) => i.purpose !== 'vlan' - ); - - if (inherentIndexOfPrimaryInterface !== -1) { - // If we're able to find the inherent primary interface, just return it. - return inherentIndexOfPrimaryInterface; - } - - // If we haven't been able to find the primary interface by this point, the Linode doesn't have one. - // As an example, this is the case when a Linode only has a VLAN interface. - return null; -}; - -/** - * Determines the maximum available Linodes allowed for a configuration profile - * - * returns MAX(8, MIN(ram / 1024, 64)) - * - * @param ram the Linode's available ram - * @returns the device limit allowed - */ -export const useGetDeviceLimit = (ram: number) => { - const flags = useFlags(); - if (flags.blockStorageVolumeLimit) { - return Math.max(DEFAULT_DEVICE_LIMIT, Math.min(ram / 1024, 64)); - } - - return DEFAULT_DEVICE_LIMIT; -}; - -export const isDiskDevice = ( - device: DiskDevice | VolumeDevice -): device is DiskDevice => { - return 'disk_id' in device && device.disk_id !== null; -}; - -export const isVolumeDevice = ( - device: DiskDevice | VolumeDevice -): device is VolumeDevice => { - return 'volume_id' in device && device.volume_id !== null; -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx new file mode 100644 index 00000000000..02ab3260baa --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx @@ -0,0 +1,174 @@ +import { isEmpty } from '@linode/api-v4'; +import { Notice } from '@linode/ui'; +import * as React from 'react'; +import type { JSX } from 'react'; + +import { DEFAULT_DEVICE_LIMIT } from 'src/constants'; +import { + LINODE_UNREACHABLE_HELPER_TEXT, + NATTED_PUBLIC_IP_HELPER_TEXT, + NOT_NATTED_HELPER_TEXT, +} from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; + +import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect'; +import type { EditableFields } from './LinodeConfigDialog'; +import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4'; + +/** + * Gets the index of the primary Linode interface + * + * The function does more than just look for `primary: true`. It will also return the index + * of the implicit primary interface. (The API does not enforce that a Linode config always + * has an interface that is marked as primary) + * + * This is the general logic we follow in this function: + * - If an interface is primary we know that's the primary + * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary. + * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface + * + * @returns the index of the primary interface or `null` if there is not a primary interface + */ +export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => { + const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary); + + // If an interface has `primary: true` we know thats the primary so just return it. + if (indexOfPrimaryInterface !== -1) { + return indexOfPrimaryInterface; + } + + // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface, + // and it will be eth0 on the Linode. This interface will be primary. + // This case isn't really nessesary because this form is built so that the interfaces state will be + // populated even if the API returns an empty interfaces array, but I'm including it for completeness. + if (isEmpty(interfaces)) { + return null; + } + + // If a config has interfaces but none of them are marked as primary, + // then the first interface in the list that’s not a VLAN will shown as the primary interface. + const inherentIndexOfPrimaryInterface = interfaces.findIndex( + (i) => i.purpose !== 'vlan' + ); + + if (inherentIndexOfPrimaryInterface !== -1) { + // If we're able to find the inherent primary interface, just return it. + return inherentIndexOfPrimaryInterface; + } + + // If we haven't been able to find the primary interface by this point, the Linode doesn't have one. + // As an example, this is the case when a Linode only has a VLAN interface. + return null; +}; + +/** + * Determines the maximum available Linodes allowed for a configuration profile + * + * returns MAX(8, MIN(ram / 1024, 64)) + * + * @param ram the Linode's available ram + * @returns the device limit allowed + */ +export const useGetDeviceLimit = (ram: number) => { + const flags = useFlags(); + if (flags.blockStorageVolumeLimit) { + return Math.max(DEFAULT_DEVICE_LIMIT, Math.min(ram / 1024, 64)); + } + + return DEFAULT_DEVICE_LIMIT; +}; + +export const isDiskDevice = ( + device: DiskDevice | VolumeDevice +): device is DiskDevice => { + return 'disk_id' in device && device.disk_id !== null; +}; + +export const isVolumeDevice = ( + device: DiskDevice | VolumeDevice +): device is VolumeDevice => { + return 'volume_id' in device && device.volume_id !== null; +}; + +/** + * We want to pad the interface list in the UI with purpose.none + * interfaces up to the maximum (currently 3); any purpose.none + * interfaces will be removed from the payload before submission, + * they are only used as placeholders presented to the user as empty selects. + */ +export const padList = (list: T[], filler: T, size: number = 3): T[] => { + return [...list, ...Array(Math.max(0, size - list.length)).fill(filler)]; +}; + +export const noticeForScenario = (scenarioText: string) => ( + +); + +/** + * Returns a JSX warning notice if the current network interface configuration + * is unrecommended and may lead to undesired or unsupported behavior. + * + * @param _interface the current config interface being passed in + * @param primaryInterfaceIndex the index of the primary interface + * @param thisIndex the index of the current config interface within the `interfaces` array of the `config` object + * @param values the values held in Formik state, having a type of `EditableFields` + * @returns JSX.Element | null + */ +export const unrecommendedConfigNoticeSelector = ({ + _interface, + primaryInterfaceIndex, + thisIndex, + values, +}: { + _interface: ExtendedInterface; + primaryInterfaceIndex: null | number; + thisIndex: number; + values: EditableFields; +}): JSX.Element | null => { + const vpcInterface = _interface.purpose === 'vpc'; + const nattedIPv4Address = Boolean(_interface.ipv4?.nat_1_1); + + const filteredInterfaces = + values.interfaces?.filter((_interface) => _interface.purpose !== 'none') ?? + []; + + // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done + const primaryInterfaceIsVPC = + primaryInterfaceIndex !== null && + values.interfaces && + values.interfaces[primaryInterfaceIndex].purpose === 'vpc'; + + /* + Scenario 1: + - the interface passed in to this function is a VPC interface + - the index of the primary interface !== the index of the interface passed in to this function + - nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" checked) + + Scenario 2: + - all of Scenario 1, except: !nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" unchecked) + + Scenario 3: + - only eth0 populated, and it is a VPC interface + + If not one of the above scenarios, do not display a warning notice re: configuration + */ + if ( + vpcInterface && + primaryInterfaceIndex !== thisIndex && + !primaryInterfaceIsVPC + ) { + return nattedIPv4Address + ? noticeForScenario(NATTED_PUBLIC_IP_HELPER_TEXT) + : noticeForScenario(LINODE_UNREACHABLE_HELPER_TEXT); + } + + if (filteredInterfaces.length === 1 && vpcInterface && !nattedIPv4Address) { + return noticeForScenario(NOT_NATTED_HELPER_TEXT); + } + + return null; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts index b00e20ed925..74a0ba9105f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts @@ -64,7 +64,9 @@ export function getLinodeInterfaceIPs(linodeInterface: LinodeInterface) { for (const slaacs of linodeInterface.vpc.ipv6.slaac) { if (slaacs.address) { ips.push(slaacs.address); - ips.push(slaacs.range); + if (slaacs.range) { + ips.push(slaacs.range); + } } } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx index df20f1f0b54..307469b6b06 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx @@ -27,6 +27,7 @@ describe('InterfaceSelect', () => { region: 'us-east', regionHasVLANs: true, slotNumber: 0, + vpcIPv6IsPublic: false, }; it('should display helper text regarding VPCs not being available in the region in the Linode Add/Edit Config dialog if applicable', async () => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index daac7de0eef..be8d35b2281 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -1,4 +1,4 @@ -import { useVlansQuery } from '@linode/queries'; +import { useSubnetQuery, useVlansQuery } from '@linode/queries'; import { Autocomplete, Divider, @@ -14,6 +14,7 @@ import * as React from 'react'; import type { JSX } from 'react'; import { VPCPanel } from 'src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel'; +import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; import type { @@ -27,6 +28,7 @@ interface InterfaceErrors extends VPCInterfaceErrors, OtherInterfaceErrors {} interface InterfaceSelectProps extends VPCState { additionalIPv4RangesForVPC?: ExtendedIP[]; + additionalIPv6RangesForVPC?: ExtendedIP[]; errors: InterfaceErrors; fromAddonsPanel?: boolean; handleChange: (updatedInterface: ExtendedInterface) => void; @@ -37,14 +39,17 @@ interface InterfaceSelectProps extends VPCState { regionHasVLANs?: boolean; regionHasVPCs?: boolean; slotNumber: number; + vpcIPv6IsPublic: boolean; } interface VPCInterfaceErrors { ipRangeError?: string; labelError?: string; publicIPv4Error?: string; + publicIPv6Error?: string; subnetError?: string; vpcError?: string; vpcIPv4Error?: string; + vpcIPv6Error?: string; } interface OtherInterfaceErrors { @@ -57,6 +62,7 @@ interface VPCState { subnetId?: null | number; vpcId?: null | number; vpcIPv4?: string; + vpcIPv6?: string; } // To allow for empty slots, which the API doesn't account for @@ -69,6 +75,7 @@ export interface ExtendedInterface export const InterfaceSelect = (props: InterfaceSelectProps) => { const { additionalIPv4RangesForVPC, + additionalIPv6RangesForVPC, errors, fromAddonsPanel, handleChange, @@ -82,7 +89,9 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { slotNumber, subnetId, vpcIPv4, + vpcIPv6, vpcId, + vpcIPv6IsPublic, } = props; const theme = useTheme(); @@ -90,8 +99,24 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { theme.breakpoints.down(fromAddonsPanel ? 'sm' : 1015) ); + const { isDualStackEnabled } = useVPCDualStack(); + + const { data: selectedSubnet } = useSubnetQuery( + vpcId ?? -1, + subnetId ?? -1, + isDualStackEnabled && Boolean(vpcId) && Boolean(subnetId) + ); + + // Show IPv6 content if Dual Stack is enabled and the VPC of the selected subnet is Dual Stack + const showIPv6Content = + isDualStackEnabled && + Boolean(selectedSubnet?.ipv6?.length && selectedSubnet?.ipv6?.length > 0); + const [newVlan, setNewVlan] = React.useState(''); + const [autoassignIPv6VPCAddress, setAutoassignIPv6VPCAddress] = + React.useState(false); + const purposeOptions: SelectOption[] = [ { label: 'Public Internet', @@ -131,6 +156,10 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { (ip_range) => ip_range.address ); + const _additionalIPv6RangesForVPC = additionalIPv6RangesForVPC?.map( + (ip_range) => ({ range: ip_range.address }) + ); + const handlePurposeChange = (selectedValue: ExtendedPurpose) => { const purpose = selectedValue; handleChange({ @@ -150,6 +179,12 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { purpose, }); + const slaacFieldValue = autoassignIPv6VPCAddress + ? [{ range: 'auto' }] + : vpcIPv6 + ? [{ range: vpcIPv6 }] + : undefined; + const handleVPCLabelChange = (selectedVPCId: number) => { // Only clear VPC related fields if VPC selection changes if (selectedVPCId !== vpcId) { @@ -159,6 +194,13 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { nat_1_1: nattedIPv4Address, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, purpose, vpc_id: selectedVPCId, }); @@ -172,6 +214,31 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { nat_1_1: nattedIPv4Address, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }); + }; + + const handleIPv6RangeChange = (ipv6Ranges: ExtendedIP[]) => { + handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, + ipv4: { + nat_1_1: nattedIPv4Address, + vpc: vpcIPv4, + }, + ipv6: { + is_public: vpcIPv6IsPublic, + ranges: ipv6Ranges.map((ip_range) => ({ range: ip_range.address })), + slaac: slaacFieldValue, + }, purpose, subnet_id: subnetId, vpc_id: vpcId, @@ -185,35 +252,110 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { nat_1_1: nattedIPv4Address, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, purpose, subnet_id: selectedSubnetId, vpc_id: vpcId, }); - const handleVPCIPv4Input = (vpcIPv4Input: string | undefined) => - handleChange({ + const handleVPCIPv4Input = (vpcIPv4Input: string | undefined) => { + const obj = { ip_ranges: _additionalIPv4RangesForVPC, ipv4: { nat_1_1: nattedIPv4Address, vpc: vpcIPv4Input, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }; + + handleChange(obj); + }; + + const handleVPCIPv6Input = (vpcIPv6Input: string | undefined) => { + handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, + ipv4: { + nat_1_1: nattedIPv4Address, + vpc: vpcIPv4, + }, + ipv6: { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: + vpcIPv6Input !== undefined && vpcIPv6Input !== '' + ? [{ range: vpcIPv6Input }] + : undefined, + }, purpose, subnet_id: subnetId, vpc_id: vpcId, }); + }; - const handleIPv4Input = (IPv4Input: null | string) => + const handleIPv4Input = (ipv4Input: null | string) => handleChange({ ip_ranges: _additionalIPv4RangesForVPC, ipv4: { - nat_1_1: IPv4Input, + nat_1_1: ipv4Input, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, purpose, subnet_id: subnetId, vpc_id: vpcId, }); + const handleIPv6IsPublicChange = (vpcIPv6IsPublic: boolean) => { + handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, + ipv4: { + nat_1_1: nattedIPv4Address, + vpc: vpcIPv4, + }, + ipv6: { + is_public: !vpcIPv6IsPublic, + slaac: slaacFieldValue, + ranges: _additionalIPv6RangesForVPC, + }, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }); + }; + + const handleToggleAutoassignIPv6WithinVPCEnabled = () => { + const newValue = !autoassignIPv6VPCAddress; + + setAutoassignIPv6VPCAddress(newValue); + + if (newValue) { + handleVPCIPv6Input('auto'); + } else { + handleVPCIPv6Input(undefined); + } + }; + const handleCreateOption = (_newVlan: string) => { setNewVlan(_newVlan); handleChange({ @@ -400,25 +542,42 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { + handleIPv4Input(nattedIPv4Address === undefined ? 'any' : null) + } + toggleAssignPublicIPv6Address={() => + handleIPv6IsPublicChange(vpcIPv6IsPublic) + } toggleAutoassignIPv4WithinVPCEnabled={() => handleVPCIPv4Input(vpcIPv4 === undefined ? '' : undefined) } + toggleAutoassignIPv6WithinVPCEnabled={ + handleToggleAutoassignIPv6WithinVPCEnabled + } vpcIdError={errors.vpcError} vpcIPRangesError={errors.ipRangeError} vpcIPv4AddressOfLinode={vpcIPv4} vpcIPv4Error={errors.vpcIPv4Error} + vpcIPv6AddressOfLinode={vpcIPv6} + vpcIPv6Error={errors.vpcIPv6Error} /> )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx index a9ec9ba9a1e..385a43fe9ca 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx @@ -13,18 +13,27 @@ beforeAll(() => mockMatchMedia()); const props = { additionalIPv4RangesForVPC: [], + additionalIPv6RangesForVPC: [], assignPublicIPv4Address: false, + assignPublicIPv6Address: false, autoassignIPv4WithinVPC: true, + autoassignIPv6WithinVPC: false, handleIPv4RangeChange: vi.fn(), + handleIPv6RangeChange: vi.fn(), handleSelectVPC: vi.fn(), handleSubnetChange: vi.fn(), handleVPCIPv4Change: vi.fn(), + handleVPCIPv6Change: vi.fn(), region: 'us-east', selectedSubnetId: undefined, selectedVPCId: undefined, + showIPv6Content: false, toggleAssignPublicIPv4Address: vi.fn(), + toggleAssignPublicIPv6Address: vi.fn(), toggleAutoassignIPv4WithinVPCEnabled: vi.fn(), + toggleAutoassignIPv6WithinVPCEnabled: vi.fn(), vpcIPv4AddressOfLinode: undefined, + vpcIPv6AddressOfLinode: undefined, }; const vpcPanelTestId = 'vpc-panel'; @@ -149,9 +158,7 @@ describe('VPCPanel', () => { await waitFor(() => { expect( - wrapper.getByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode in the VPC' - ) + wrapper.getByLabelText('Auto-assign VPC IPv4 address') ).not.toBeChecked(); // Using regex here to account for the "(required)" indicator. expect(wrapper.getByLabelText(/^VPC IPv4.*/)).toHaveValue('10.0.4.3'); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx index 42e9ab02fdc..d555b64b689 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx @@ -18,10 +18,11 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; -import { PublicIPv4Access } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/PublicIPv4Access'; +import { PublicAccess } from 'src/features/VPCs/components/PublicAccess'; import { REGION_CAVEAT_HELPER_TEXT, VPC_AUTO_ASSIGN_IPV4_TOOLTIP, + VPC_AUTO_ASSIGN_IPV6_TOOLTIP, } from 'src/features/VPCs/constants'; import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -30,23 +31,34 @@ import type { ExtendedIP } from 'src/utilities/ipUtils'; export interface VPCPanelProps { additionalIPv4RangesForVPC: ExtendedIP[]; + additionalIPv6RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; + assignPublicIPv6Address: boolean; autoassignIPv4WithinVPC: boolean; + autoassignIPv6WithinVPC: boolean; handleIPv4RangeChange: (ranges: ExtendedIP[]) => void; + handleIPv6RangeChange: (ranges: ExtendedIP[]) => void; handleSelectVPC: (vpcId: number) => void; handleSubnetChange: (subnetId: number | undefined) => void; handleVPCIPv4Change: (IPv4: string) => void; + handleVPCIPv6Change: (IPv6: string) => void; publicIPv4Error?: string; + publicIPv6Error?: string; region: string | undefined; selectedSubnetId: null | number | undefined; selectedVPCId: null | number | undefined; + showIPv6Content: boolean; subnetError?: string; toggleAssignPublicIPv4Address: (ipv4Access: null | string) => void; + toggleAssignPublicIPv6Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; + toggleAutoassignIPv6WithinVPCEnabled: () => void; vpcIdError?: string; vpcIPRangesError?: string; vpcIPv4AddressOfLinode: string | undefined; vpcIPv4Error?: string; + vpcIPv6AddressOfLinode: string | undefined; + vpcIPv6Error?: string; } const ERROR_GROUP_STRING = 'vpc-errors'; @@ -54,23 +66,34 @@ const ERROR_GROUP_STRING = 'vpc-errors'; export const VPCPanel = (props: VPCPanelProps) => { const { additionalIPv4RangesForVPC, + additionalIPv6RangesForVPC, assignPublicIPv4Address, + assignPublicIPv6Address, autoassignIPv4WithinVPC, + autoassignIPv6WithinVPC, handleIPv4RangeChange, + handleIPv6RangeChange, handleSelectVPC, handleSubnetChange, handleVPCIPv4Change, + handleVPCIPv6Change, publicIPv4Error, + publicIPv6Error, region, selectedSubnetId, selectedVPCId, + showIPv6Content, subnetError, toggleAssignPublicIPv4Address, + toggleAssignPublicIPv6Address, toggleAutoassignIPv4WithinVPCEnabled, + toggleAutoassignIPv6WithinVPCEnabled, vpcIPRangesError, vpcIPv4AddressOfLinode, vpcIPv4Error, vpcIdError, + vpcIPv6AddressOfLinode, + vpcIPv6Error, } = props; const theme = useTheme(); @@ -95,10 +118,10 @@ export const VPCPanel = (props: VPCPanelProps) => { }); React.useEffect(() => { - if (subnetError || vpcIPv4Error) { + if (subnetError || vpcIPv4Error || vpcIPv6Error) { scrollErrorIntoView(ERROR_GROUP_STRING); } - }, [subnetError, vpcIPv4Error]); + }, [subnetError, vpcIPv4Error, vpcIPv6Error]); const vpcs = vpcsData ?? []; @@ -204,8 +227,7 @@ export const VPCPanel = (props: VPCPanelProps) => { flexDirection="row" > - Auto-assign a VPC IPv4 address for this Linode in the - VPC + Auto-assign VPC IPv4 address { errorGroup={ERROR_GROUP_STRING} errorText={vpcIPv4Error} label="VPC IPv4" + noMarginTop={showIPv6Content} onChange={(e) => handleVPCIPv4Change(e.target.value)} required={!autoassignIPv4WithinVPC} value={vpcIPv4AddressOfLinode} /> )} - ({ - marginLeft: '2px', - marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, - })} - > - - - {assignPublicIPv4Address && publicIPv4Error && ( - ({ - color: theme.color.red, - })} - > - {publicIPv4Error} - + {showIPv6Content && ( + <> + ({ + marginLeft: '2px', + paddingTop: theme.spacingFunction(8), + })} + > + + } + data-testid="vpc-ipv6-checkbox" + label={ + + + Auto-assign VPC IPv6 address + + + + } + /> + + {!autoassignIPv6WithinVPC && ( + handleVPCIPv6Change(e.target.value)} + value={vpcIPv6AddressOfLinode} + /> + )} + )} + + ) => void // The type conversion is not ideal, but seems to be the least disruptive option + } + handleAllowPublicIPv6AccessChange={ + toggleAssignPublicIPv6Address + } + publicIPv4Error={publicIPv4Error} + publicIPv6Error={publicIPv6Error} + showIPv6Content={showIPv6Content} + sx={{ margin: `${theme.spacingFunction(16)} 0` }} + userCannotAssignLinodes={false} + /> )} diff --git a/packages/manager/src/features/VPCs/components/PublicAccess.tsx b/packages/manager/src/features/VPCs/components/PublicAccess.tsx index 3cac4a4ab50..c3727cbb20c 100644 --- a/packages/manager/src/features/VPCs/components/PublicAccess.tsx +++ b/packages/manager/src/features/VPCs/components/PublicAccess.tsx @@ -24,6 +24,8 @@ interface Props { handleAllowPublicIPv6AccessChange: ( e: React.ChangeEvent ) => void; + publicIPv4Error?: string; + publicIPv6Error?: string; showIPv6Content: boolean; sx?: SxProps; userCannotAssignLinodes: boolean; @@ -35,6 +37,8 @@ export const PublicAccess = (props: Props) => { allowPublicIPv6Access, handleAllowPublicIPv4AccessChange, handleAllowPublicIPv6AccessChange, + publicIPv4Error, + publicIPv6Error, showIPv6Content, sx, userCannotAssignLinodes, @@ -61,22 +65,42 @@ export const PublicAccess = (props: Props) => { } onChange={handleAllowPublicIPv4AccessChange} /> + {allowPublicIPv4Access && publicIPv4Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv4Error} + + )} {showIPv6Content && ( - } - disabled={userCannotAssignLinodes} - label={ - - Allow public IPv6 access - - - } - onChange={handleAllowPublicIPv6AccessChange} - /> + <> + } + disabled={userCannotAssignLinodes} + label={ + + Allow public IPv6 access + + + } + onChange={handleAllowPublicIPv6AccessChange} + /> + {allowPublicIPv6Access && publicIPv6Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv6Error} + + )} + )} ); diff --git a/packages/validation/.changeset/pr-13209-changed-1765924184891.md b/packages/validation/.changeset/pr-13209-changed-1765924184891.md new file mode 100644 index 00000000000..8f0a0aceea9 --- /dev/null +++ b/packages/validation/.changeset/pr-13209-changed-1765924184891.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Use UpdateConfigProfileInterfacesSchema in UpdateLinodeConfigSchema for interfaces property ([#13209](https://github.com/linode/manager/pull/13209)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 4de83f20225..05e4c460a5b 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -141,7 +141,7 @@ const ipv4ConfigInterface = object().when('purpose', { const slaacSchema = object().shape({ range: string() - .required('VPC IPv6 is required.') + .optional() .test({ name: 'IPv6 prefix length', message: 'Must be a /64 IPv6 network CIDR', @@ -321,6 +321,29 @@ export const ConfigProfileInterfacesSchema = array() }, ); +// This was created specifically for use in UpdateLinodeConfigSchema. +// Altering `ConfigProfileInterfaceSchema` results in issues related to the `interfaces` property +// that bubble up to `CreateLinodeSchema` in LinodeCreate/schemas.ts +export const UpdateConfigProfileInterfacesSchema = array() + .of( + ConfigProfileInterfaceSchema.clone().shape({ + ipv6: ipv6Interface.notRequired().nullable(), + }), + ) + .test( + 'unique-public-interface', + 'Only one public interface per config is allowed.', + (list?: any[] | null) => { + if (!list) { + return true; + } + + return ( + list.filter((thisSlot) => thisSlot.purpose === 'public').length <= 1 + ); + }, + ); + export const UpdateConfigInterfaceOrderSchema = object({ ids: array().of(number()).required('The list of interface IDs is required.'), }); @@ -593,7 +616,7 @@ export const UpdateLinodeConfigSchema = object({ virt_mode: mixed().oneOf(['paravirt', 'fullvirt']), helpers, root_device: string(), - interfaces: ConfigProfileInterfacesSchema, + interfaces: UpdateConfigProfileInterfacesSchema, }); export const CreateLinodeDiskSchema = object({