diff --git a/packages/manager/.changeset/pr-11069-fixed-1728443895478.md b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md new file mode 100644 index 00000000000..057ac261ea2 --- /dev/null +++ b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Support Linodes with multiple private IPs in NodeBalancer configurations ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx index 8321d07ee4f..fc7673d7e3e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx @@ -24,7 +24,7 @@ import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useLinodesQuery } from 'src/queries/linodes/linodes'; import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { isPrivateIP } from 'src/utilities/ipUtils'; import { isNumeric } from 'src/utilities/stringUtils'; import { @@ -105,7 +105,7 @@ export const LinodeSelectTable = (props: Props) => { const queryClient = useQueryClient(); const handleSelect = async (linode: Linode) => { - const hasPrivateIP = linode.ipv4.some((ipv4) => privateIPRegex.test(ipv4)); + const hasPrivateIP = linode.ipv4.some(isPrivateIP); reset((prev) => ({ ...prev, backup_id: null, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 55641bee1ae..92fd7443e30 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -6,7 +6,7 @@ import { linodeQueries } from 'src/queries/linodes/linodes'; import { stackscriptQueries } from 'src/queries/stackscripts'; import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics'; import { sendLinodeCreateFormErrorEvent } from 'src/utilities/analytics/formEventAnalytics'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { isPrivateIP } from 'src/utilities/ipUtils'; import { utoa } from 'src/utilities/metadata'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; import { omitProps } from 'src/utilities/omittedProps'; @@ -299,8 +299,7 @@ export const defaultValues = async ( ? await queryClient.ensureQueryData(linodeQueries.linode(params.linodeID)) : null; - const privateIp = - linode?.ipv4.some((ipv4) => privateIPRegex.test(ipv4)) ?? false; + const privateIp = linode?.ipv4.some(isPrivateIP) ?? false; const values: LinodeCreateFormValues = { backup_id: params.backupID, diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx index a6c44f5eef3..8a0b44c0abf 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -8,49 +7,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { LinodeSelect } from './LinodeSelect'; -const fakeLinodeData = linodeFactory.build({ - id: 1, - image: 'metadata-test-image', - label: 'metadata-test-region', - region: 'eu-west', -}); +import type { Linode } from '@linode/api-v4'; + const TEXTFIELD_ID = 'textfield-input'; describe('LinodeSelect', () => { - test('renders custom options using renderOption', async () => { - // Create a mock renderOption function - const mockRenderOption = (linode: Linode, selected: boolean) => ( - - {`${linode.label} - ${selected ? 'Selected' : 'Not Selected'}`} - - ); - - // Render the component with the custom renderOption function - renderWithTheme( - - ); - - const input = screen.getByTestId(TEXTFIELD_ID); - - // Open the dropdown - await userEvent.click(input); - - // Wait for the options to load (use some unique identifier for the options) - await waitFor(() => { - const customOption = screen.getByTestId('custom-option-1'); - expect(customOption).toBeInTheDocument(); - expect(customOption).toHaveTextContent( - 'metadata-test-region - Not Selected' - ); - }); - }); test('should display custom no options message', async () => { const customNoOptionsMessage = 'Custom No Options Message'; const options: Linode[] = []; // Assuming no options are available diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index 84722abb76d..0b0de416bac 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -45,10 +45,6 @@ interface LinodeSelectProps { optionsFilter?: (linode: Linode) => boolean; /* Displayed when the input is blank. */ placeholder?: string; - /* Render a custom option. */ - renderOption?: (linode: Linode, selected: boolean) => JSX.Element; - /* Render a custom option label. */ - renderOptionLabel?: (linode: Linode) => string; /* Displays an indication that the input is required. */ required?: boolean; /* Adds custom styles to the component. */ @@ -98,8 +94,6 @@ export const LinodeSelect = ( options, optionsFilter, placeholder, - renderOption, - renderOptionLabel, sx, value, } = props; @@ -122,9 +116,6 @@ export const LinodeSelect = ( return ( - renderOptionLabel ? renderOptionLabel(linode) : linode.label - } isOptionEqualToValue={ checkIsOptionEqualToValue ? (option, value) => option.id === value.id @@ -145,17 +136,6 @@ export const LinodeSelect = ( ? 'Select Linodes' : 'Select a Linode' } - renderOption={ - renderOption - ? (props, option, { selected }) => { - return ( -
  • - {renderOption(option, selected)} -
  • - ); - } - : undefined - } value={ typeof value === 'function' ? multiple && Array.isArray(value) diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx index a7e1f3de72e..424e4f7c87f 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { isPrivateIP } from 'src/utilities/ipUtils'; import { tail } from 'src/utilities/tail'; import { @@ -55,7 +55,7 @@ export interface IPAddressProps { } export const sortIPAddress = (ip1: string, ip2: string) => - (privateIPRegex.test(ip1) ? 1 : -1) - (privateIPRegex.test(ip2) ? 1 : -1); + (isPrivateIP(ip1) ? 1 : -1) - (isPrivateIP(ip2) ? 1 : -1); export const IPAddress = (props: IPAddressProps) => { const { diff --git a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx index 1684f27b21e..be63b6a4e08 100644 --- a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx @@ -15,7 +15,7 @@ import { handleFieldErrors, handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; -import { privateIPRegex, removePrefixLength } from 'src/utilities/ipUtils'; +import { isPrivateIP, removePrefixLength } from 'src/utilities/ipUtils'; import { StyledIPGrid, @@ -168,9 +168,7 @@ const EditSSHAccessDrawer = (props: EditSSHAccessDrawerProps) => { }, ...options // Remove Private IPs - .filter( - (option) => !privateIPRegex.test(option.value) - ) + .filter((option) => !isPrivateIP(option.value)) // Remove the prefix length from each option. .map((option) => ({ label: removePrefixLength(option.value), diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 6630b7509c9..2372102a9d3 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -1,96 +1,102 @@ -import { Box } from '@mui/material'; -import * as React from 'react'; +import React from 'react'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { Box } from 'src/components/Box'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import type { Linode } from '@linode/api-v4/lib/linodes'; -import type { TextFieldProps } from 'src/components/TextField'; +import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils'; -interface ConfigNodeIPSelectProps { +interface Props { + /** + * Disables the select + */ disabled?: boolean; - errorText?: string; + /** + * Validation error text + */ + errorText: string | undefined; + /** + * Function that is called when the select's value changes + */ handleChange: (nodeIndex: number, ipAddress: null | string) => void; + /** + * Override the default input `id` for the select + */ inputId?: string; - nodeAddress?: string; + /** + * The selected private IP address + */ + nodeAddress: string | undefined; + /** + * The index of the config node in state + */ nodeIndex: number; - selectedRegion?: string; - textfieldProps: Omit; + /** + * The region for which to load Linodes and to show private IPs + * @note IPs won't load until a region is passed + */ + region: string | undefined; } -export const ConfigNodeIPSelect = React.memo( - (props: ConfigNodeIPSelectProps) => { - const { - handleChange: _handleChange, - inputId, - nodeAddress, - nodeIndex, - } = props; - const handleChange = (linode: Linode | null) => { - if (!linode) { - _handleChange(nodeIndex, null); - } +export const ConfigNodeIPSelect = React.memo((props: Props) => { + const { + disabled, + errorText, + handleChange, + inputId, + nodeAddress, + nodeIndex, + region, + } = props; - const thisLinodesPrivateIP = linode?.ipv4.find((ipv4) => - ipv4.match(privateIPRegex) - ); + const { data: linodes, error, isLoading } = useAllLinodesQuery( + {}, + { region }, + region !== undefined + ); - if (!thisLinodesPrivateIP) { - return; - } + const options = getPrivateIPOptions(linodes); - /** - * we can be sure the selection has a private IP because of the - * filterCondition prop in the render method below - */ - _handleChange(nodeIndex, thisLinodesPrivateIP); - }; - - return ( - { - /** - * if the Linode doesn't have an private IP OR if the Linode - * is in a different region that the NodeBalancer, don't show it - * in the select dropdown - */ - return ( - !!linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex)) && - linode.region === props.selectedRegion - ); - }} - renderOption={(linode, selected) => ( - <> - - - {linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex))} - -
    {linode.label}
    -
    - - - )} - renderOptionLabel={(linode) => - linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex)) ?? '' - } - clearable - disabled={props.disabled} - errorText={props.errorText} - id={inputId} - label="IP Address" - noMarginTop - onSelectionChange={handleChange} - placeholder="Enter IP Address" - value={(linode) => linode.ipv4.some((ip) => ip === nodeAddress)} - /> - ); - } -); + return ( + ( +
  • + + + theme.font.bold} + > + {option.label} + + {option.linode.label} + + {selected && } + +
  • + )} + disabled={disabled} + errorText={errorText ?? error?.[0].reason} + id={inputId} + label="IP Address" + loading={isLoading} + noMarginTop + noOptionsText="No options - please ensure you have at least 1 Linode with a private IP located in the selected region." + onChange={(e, value) => handleChange(nodeIndex, value?.label ?? null)} + options={options} + placeholder="Enter IP Address" + value={options.find((o) => o.label === nodeAddress) ?? null} + /> + ); +}); diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts new file mode 100644 index 00000000000..e95b738d915 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts @@ -0,0 +1,32 @@ +import { linodeFactory } from 'src/factories'; + +import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils'; + +describe('getPrivateIPOptions', () => { + it('returns an empty array when linodes are undefined', () => { + expect(getPrivateIPOptions(undefined)).toStrictEqual([]); + }); + + it('returns an empty array when there are no Linodes', () => { + expect(getPrivateIPOptions([])).toStrictEqual([]); + }); + + it('returns an option for each private IPv4 on a Linode', () => { + const linode = linodeFactory.build({ ipv4: ['192.168.1.1', '172.16.0.1'] }); + + expect(getPrivateIPOptions([linode])).toStrictEqual([ + { label: '192.168.1.1', linode }, + { label: '172.16.0.1', linode }, + ]); + }); + + it('does not return an option for public IPv4s on a Linode', () => { + const linode = linodeFactory.build({ + ipv4: ['143.198.125.230', '192.168.1.1'], + }); + + expect(getPrivateIPOptions([linode])).toStrictEqual([ + { label: '192.168.1.1', linode }, + ]); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts new file mode 100644 index 00000000000..1dfc830bbf8 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts @@ -0,0 +1,36 @@ +import { isPrivateIP } from 'src/utilities/ipUtils'; + +import type { Linode } from '@linode/api-v4'; + +interface PrivateIPOption { + /** + * A private IPv4 address + */ + label: string; + /** + * The Linode associated with the private IPv4 address + */ + linode: Linode; +} + +/** + * Given an array of Linodes, this function returns an array of private + * IPv4 options intended to be used in a Select component. + */ +export const getPrivateIPOptions = (linodes: Linode[] | undefined) => { + if (!linodes) { + return []; + } + + const options: PrivateIPOption[] = []; + + for (const linode of linodes) { + for (const ip of linode.ipv4) { + if (isPrivateIP(ip)) { + options.push({ label: ip, linode }); + } + } + } + + return options; +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index 05f47659c98..c97f7bc1345 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -134,18 +134,13 @@ export const NodeBalancerConfigNode = React.memo( diff --git a/packages/manager/src/utilities/ipUtils.test.ts b/packages/manager/src/utilities/ipUtils.test.ts new file mode 100644 index 00000000000..371cbf7b855 --- /dev/null +++ b/packages/manager/src/utilities/ipUtils.test.ts @@ -0,0 +1,13 @@ +import { isPrivateIP } from './ipUtils'; + +describe('isPrivateIP', () => { + it('returns true for a private IPv4', () => { + expect(isPrivateIP('192.168.1.1')).toBe(true); + expect(isPrivateIP('172.16.5.12')).toBe(true); + }); + + it('returns false for a public IPv4', () => { + expect(isPrivateIP('45.79.245.236')).toBe(false); + expect(isPrivateIP('100.78.0.8')).toBe(false); + }); +}); diff --git a/packages/manager/src/utilities/ipUtils.ts b/packages/manager/src/utilities/ipUtils.ts index 74368b21f55..06392a895a2 100644 --- a/packages/manager/src/utilities/ipUtils.ts +++ b/packages/manager/src/utilities/ipUtils.ts @@ -1,3 +1,4 @@ +import { PRIVATE_IPv4_REGEX } from '@linode/validation'; import { parseCIDR, parse as parseIP } from 'ipaddr.js'; /** @@ -8,9 +9,12 @@ import { parseCIDR, parse as parseIP } from 'ipaddr.js'; export const removePrefixLength = (ip: string) => ip.replace(/\/\d+/, ''); /** - * Regex for determining if a string is a private IP Addresses + * Determines if an IPv4 address is private + * @returns true if the given IPv4 address is private */ -export const privateIPRegex = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/; +export const isPrivateIP = (ip: string) => { + return PRIVATE_IPv4_REGEX.test(ip); +}; export interface ExtendedIP { address: string; diff --git a/packages/validation/.changeset/pr-11069-added-1728444089255.md b/packages/validation/.changeset/pr-11069-added-1728444089255.md new file mode 100644 index 00000000000..42512f52050 --- /dev/null +++ b/packages/validation/.changeset/pr-11069-added-1728444089255.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +`PRIVATE_IPv4_REGEX` for determining if an IPv4 address is private ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/validation/.changeset/pr-11069-changed-1728444173048.md b/packages/validation/.changeset/pr-11069-changed-1728444173048.md new file mode 100644 index 00000000000..286ee280e1f --- /dev/null +++ b/packages/validation/.changeset/pr-11069-changed-1728444173048.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Updated `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 46d2d889e2b..4cc2c15793c 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -3,6 +3,8 @@ import { array, boolean, mixed, number, object, string } from 'yup'; const PORT_WARNING = 'Port must be between 1 and 65535.'; const LABEL_WARNING = 'Label must be between 3 and 32 characters.'; +export const PRIVATE_IPv4_REGEX = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/; + export const nodeBalancerConfigNodeSchema = object({ label: string() .matches( @@ -16,10 +18,7 @@ export const nodeBalancerConfigNodeSchema = object({ address: string() .typeError('IP address is required.') .required('IP address is required.') - .matches( - /^192\.168\.\d{1,3}\.\d{1,3}$/, - 'Must be a valid private IPv4 address.' - ), + .matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.'), port: number() .typeError('Port must be a number.')