diff --git a/packages/api-v4/.changeset/pr-9418-added-1690303394895.md b/packages/api-v4/.changeset/pr-9418-added-1690303394895.md new file mode 100644 index 00000000000..21e3d8f0a39 --- /dev/null +++ b/packages/api-v4/.changeset/pr-9418-added-1690303394895.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New methods for Linode Configs and new/updated Linode Config and interface types ([#9418](https://github.com/linode/manager/pull/9418)) diff --git a/packages/api-v4/src/linodes/configs.ts b/packages/api-v4/src/linodes/configs.ts index 835abfd6729..353ee871da2 100644 --- a/packages/api-v4/src/linodes/configs.ts +++ b/packages/api-v4/src/linodes/configs.ts @@ -1,6 +1,9 @@ import { CreateLinodeConfigSchema, + UpdateConfigInterfaceOrderSchema, + UpdateConfigInterfaceSchema, UpdateLinodeConfigSchema, + linodeInterfaceSchema, } from '@linode/validation/lib/linodes.schema'; import { API_ROOT } from '../constants'; import Request, { @@ -10,8 +13,15 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { Config, LinodeConfigCreationData } from './types'; +import { Filter, ResourcePage as Page, Params } from '../types'; +import { + Config, + ConfigInterfaceOrderPayload, + Interface, + InterfacePayload, + LinodeConfigCreationData, + UpdateConfigInterfacePayload, +} from './types'; /** * getLinodeConfigs @@ -106,3 +116,144 @@ export const updateLinodeConfig = ( setMethod('PUT'), setData(data, UpdateLinodeConfigSchema) ); + +/** + * getConfigInterfaces + * + * Return non-paginated list in devnum order of all interfaces on the given config. + * + * @param linodeId { number } The id of a Linode. + * @param configId { number } The id of a config belonging to the specified Linode. + */ +export const getConfigInterfaces = (linodeId: number, configId: number) => + Request( + setURL( + `${API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/configs/${encodeURIComponent(configId)}/interfaces` + ), + setMethod('GET') + ); + +/** + * getConfigInterface + * + * Get a single Linode config interface object using the interface's unique ID. + * + * @param linodeId { number } The id of a Linode. + * @param configId { number } The id of a config belonging to the specified Linode. + * @param interfaceId { number } The id of an interface belonging to the specified config. + */ +export const getConfigInterface = ( + linodeId: number, + configId: number, + interfaceId: number +) => + Request( + setURL( + `${API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/configs/${encodeURIComponent( + configId + )}/interfaces/${encodeURIComponent(interfaceId)}` + ), + setMethod('GET') + ); + +/** + * appendConfigInterface + * + * Append a single new Linode config interface object to an existing config. + * + * @param linodeId { number } The id of a Linode to receive the new config interface. + * @param configId { number } The id of a config to receive the new interface. + */ +export const appendConfigInterface = ( + linodeId: number, + configId: number, + data: InterfacePayload +) => + Request( + setURL( + `${API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/configs/${encodeURIComponent(configId)}/interfaces` + ), + setMethod('POST'), + setData(data, linodeInterfaceSchema) + ); + +/** + * updateConfigInterface + * + * Change an existing interface. + * + * @param linodeId { number } The id of a Linode. + * @param configId { number } The id of a config belonging to that Linode. + * @param interfaceId { number } The id of an interface belonging to the specified config. + */ +export const updateConfigInterface = ( + linodeId: number, + configId: number, + interfaceId: number, + data: UpdateConfigInterfacePayload +) => + Request( + setURL( + `${API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/configs/${encodeURIComponent( + configId + )}/interfaces/${encodeURIComponent(interfaceId)}` + ), + setMethod('PUT'), + setData(data, UpdateConfigInterfaceSchema) + ); + +/** + * updateLinodeConfigOrder + * + * Change the order of interfaces. + * + * @param linodeId { number } The id of a Linode. + * @param configId { number } The id of a config belonging to the specified Linode. + */ +export const updateLinodeConfigOrder = ( + linodeId: number, + configId: number, + data: ConfigInterfaceOrderPayload +) => + Request<{}>( + setURL( + `${API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/configs/${encodeURIComponent(configId)}/interfaces/order` + ), + setMethod('POST'), + setData(data, UpdateConfigInterfaceOrderSchema) + ); + +/** + * deleteLinodeConfigInterface + * + * Delete a Linode config interface. + * + * @param linodeId { number } The id of a Linode the specified config is attached to. + * @param configId { number } The id of a config belonging to the specified Linode. + * @param interfaceId { number } The id of the interface to be deleted. + */ +export const deleteLinodeConfigInterface = ( + linodeId: number, + configId: number, + interfaceId: number +) => + Request<{}>( + setMethod('DELETE'), + setURL( + `${API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/configs/${encodeURIComponent( + configId + )}/interfaces/${encodeURIComponent(interfaceId)}` + ) + ); diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 9b06852046f..a5fb47e14ce 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -147,17 +147,40 @@ export type LinodeStatus = | 'restoring' | 'stopped'; -export type InterfacePurpose = 'public' | 'vlan'; +export type InterfacePurpose = 'public' | 'vlan' | 'vpc'; + +export interface ConfigInterfaceIPv4 { + vpc?: string; + nat_1_1?: string; +} + +export interface ConfigInterfaceIPv6 { + vpc?: string; +} export interface Interface { id: number; label: string | null; purpose: InterfacePurpose; ipam_address: string | null; + primary?: boolean; + subnet?: number | null; + ipv4?: ConfigInterfaceIPv4; + ipv6?: ConfigInterfaceIPv6; + ip_ranges?: string[]; } export type InterfacePayload = Omit; +export interface ConfigInterfaceOrderPayload { + ids: number[]; +} + +export type UpdateConfigInterfacePayload = Pick< + Interface, + 'primary' | 'ipv4' | 'ipv6' | 'ip_ranges' +>; + export interface Config { id: number; kernel: string; diff --git a/packages/manager/.changeset/pr-9418-tech-stories-1690303249242.md b/packages/manager/.changeset/pr-9418-tech-stories-1690303249242.md new file mode 100644 index 00000000000..7f0b7c9c990 --- /dev/null +++ b/packages/manager/.changeset/pr-9418-tech-stories-1690303249242.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +React Query queries for Linode Configs ([#9418](https://github.com/linode/manager/pull/9418)) diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 43c16b47bbc..9ace334080a 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -43,6 +43,7 @@ import { tokenEventHandler } from './queries/tokens'; import { volumeEventsHandler } from './queries/volumes'; import { ApplicationState } from './store'; import { getNextThemeValue } from './utilities/theme'; +// import { useConfigInterfacesQuery } from 'src/queries/linodes/configs'; // Ensure component's display name is 'App' export const App = () => ; @@ -63,6 +64,14 @@ const BaseApp = withDocumentTitleProvider( const { enqueueSnackbar } = useSnackbar(); + // const testLinodeID = your linode ID here + // const testConfigID = your linode config ID here + // const { data: configInterfaces } = useConfigInterfacesQuery( + // testLinodeID, + // testConfigID + // ); + // console.log(configInterfaces); + const [goToOpen, setGoToOpen] = React.useState(false); const theme = preferences?.theme; diff --git a/packages/manager/src/queries/linodes/configs.ts b/packages/manager/src/queries/linodes/configs.ts index 5bd31551ae2..c089a6c597f 100644 --- a/packages/manager/src/queries/linodes/configs.ts +++ b/packages/manager/src/queries/linodes/configs.ts @@ -1,15 +1,29 @@ import { APIError, Config, + ConfigInterfaceOrderPayload, + Interface, + InterfacePayload, LinodeConfigCreationData, + UpdateConfigInterfacePayload, + appendConfigInterface, createLinodeConfig, deleteLinodeConfig, + deleteLinodeConfigInterface, + getConfigInterface, + getConfigInterfaces, + updateConfigInterface, updateLinodeConfig, + updateLinodeConfigOrder, } from '@linode/api-v4'; -import { useMutation, useQueryClient } from 'react-query'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; import { queryKey } from './linodes'; +const configQueryKey = 'configs'; +const interfaceQueryKey = 'interfaces'; + +// Config queries export const useLinodeConfigDeleteMutation = ( linodeId: number, configId: number @@ -23,7 +37,7 @@ export const useLinodeConfigDeleteMutation = ( queryKey, 'linode', linodeId, - 'configs', + configQueryKey, ]); }, } @@ -40,7 +54,7 @@ export const useLinodeConfigCreateMutation = (linodeId: number) => { queryKey, 'linode', linodeId, - 'configs', + configQueryKey, ]); }, } @@ -60,9 +74,90 @@ export const useLinodeConfigUpdateMutation = ( queryKey, 'linode', linodeId, - 'configs', + configQueryKey, ]); }, } ); }; + +// Config Interface queries +export const useConfigInterfacesQuery = (linodeID: number, configID: number) => { + return useQuery( + [queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey], + () => getConfigInterfaces(linodeID, configID), + { keepPreviousData: true } + ); +}; + +export const useConfigInterfaceQuery = (linodeID: number, configID: number, interfaceID: number) => { + return useQuery( + [queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey, 'interface', interfaceID], + () => getConfigInterface(linodeID, configID, interfaceID), + { keepPreviousData: true } + ); +}; + +export const useConfigInterfacesOrderMutation = ( + linodeID: number, + configID: number, +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], ConfigInterfaceOrderPayload>( + (data) => updateLinodeConfigOrder(linodeID, configID, data), + { + onSuccess() { + queryClient.invalidateQueries([queryKey, 'linode', linodeID, configQueryKey, 'config', interfaceQueryKey]); + } + } + ) +}; + +export const useAppendConfigInterfaceMutation = ( + linodeID: number, + configID: number, +) => { + const queryClient = useQueryClient(); + return useMutation( + (data) => appendConfigInterface(linodeID, configID, data), + { + onSuccess() { + queryClient.invalidateQueries([queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey]); + } + } + ) +} + +export const useUpdateConfigInterfaceMutation = ( + linodeID: number, + configID: number, + interfaceID: number, +) => { + const queryClient = useQueryClient(); + return useMutation( + (data) => updateConfigInterface(linodeID, configID, interfaceID, data), + { + onSuccess: (InterfaceObj) => { + queryClient.invalidateQueries([queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey]); + queryClient.setQueryData([queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey, 'interface', InterfaceObj.id], InterfaceObj); + } + } + ) +} + +export const useDeleteConfigInterfaceMutation = ( + linodeID: number, + configID: number, + interfaceID: number, +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>( + () => deleteLinodeConfigInterface(linodeID, configID, interfaceID), + { + onSuccess() { + queryClient.invalidateQueries([queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey]); + queryClient.removeQueries([queryKey, 'linode', linodeID, configQueryKey, 'config', configID, interfaceQueryKey, 'interface', interfaceID]); + } + } + ) +} diff --git a/packages/validation/.changeset/pr-9418-added-1690303540547.md b/packages/validation/.changeset/pr-9418-added-1690303540547.md new file mode 100644 index 00000000000..7a5aeef1353 --- /dev/null +++ b/packages/validation/.changeset/pr-9418-added-1690303540547.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Linode Config and interface validation ([#9418](https://github.com/linode/manager/pull/9418)) diff --git a/packages/validation/.changeset/pr-9418-changed-1690303623040.md b/packages/validation/.changeset/pr-9418-changed-1690303623040.md new file mode 100644 index 00000000000..5708e71443a --- /dev/null +++ b/packages/validation/.changeset/pr-9418-changed-1690303623040.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Adjustments to linodeInterfaceSchema and createSubnetSchema ([#9418](https://github.com/linode/manager/pull/9418)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 92c517b6e41..f77ba21d590 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -1,8 +1,10 @@ -import { array, boolean, mixed, number, object, string } from 'yup'; +import { array, boolean, lazy, mixed, number, object, string } from 'yup'; // We must use a default export for ipaddr.js so our packages node compatability // Refer to https://github.com/linode/manager/issues/8675 import ipaddr from 'ipaddr.js'; +import { vpcsValidateIP } from './vpcs.schema'; +// Functions for test validations const validateIP = (ipAddress?: string | null) => { if (!ipAddress) { return true; @@ -18,34 +20,165 @@ const validateIP = (ipAddress?: string | null) => { return true; }; +const test_vpcsValidateIP = (value?: string | null) => { + // Since the field is optional, return true here to prevent an incorrect test failure. + if (value === undefined || value === null) { + return true; + } + + return vpcsValidateIP(value, false); +}; + +// Utils +const testnameDisallowedBasedOnPurpose = (allowedPurpose: string) => + `Disallowed for non-${allowedPurpose} interfaces`; +const testmessageDisallowedBasedOnPurpose = ( + allowedPurpose: string, + field: string +) => + `${field} is not allowed for interfaces that do not have a purpose set to ${allowedPurpose}.`; + +// Schemas const stackscript_data = array().of(object()).nullable(true); +const IPv4 = string() + .notRequired() + .nullable() + .test({ + name: 'validateIPv4', + message: 'Must be a valid IPv4 address, e.g. 192.0.2.0.', + test: (value) => test_vpcsValidateIP(value), + }); + +const IPv6 = string() + .notRequired() + .nullable() + .test({ + name: 'validateIPv6', + message: + 'Must be a valid IPv6 address, e.g. 2600:3c00::f03c:92ff:feeb:98f9.', + test: (value) => test_vpcsValidateIP(value), + }); + +const ipv4ConfigInterface = object().when('purpose', { + is: 'vpc', + then: object({ + vpc: IPv4, + nat_1_1: lazy((value) => + value === 'any' ? string().notRequired().nullable() : IPv4 + ), + }), + otherwise: object() + .nullable() + .test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'ipv4.vpc'), + /* + Workaround to get test to fail if field is populated when it should not be based + on purpose (inspired by similar approach in firewalls.schema.ts for ports field). + Similarly-structured logic (return typeof xyz === 'undefined') throughout this + file serves the same purpose. + */ + test: (value) => { + if (value?.vpc) { + return typeof value.vpc === 'undefined'; + } + + return true; + }, + }) + .test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'ipv4.nat_1_1'), + test: (value) => { + if (value?.nat_1_1) { + return typeof value.nat_1_1 === 'undefined'; + } + + return true; + }, + }), +}); + +const ipv6ConfigInterface = object().when('purpose', { + is: 'vpc', + then: object({ + vpc: IPv6, + }), + otherwise: object() + .nullable() + .test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'ipv6.vpc'), + test: (value) => { + if (value?.vpc) { + return typeof value.vpc === 'undefined'; + } + + return true; + }, + }), +}); + export const linodeInterfaceSchema = array() .of( - object({ + object().shape({ purpose: mixed().oneOf( - [null, 'public', 'vlan'], - 'Purpose must be null, public, or vlan.' + ['public', 'vlan', 'vpc'], + 'Purpose must be public, vlan, or vpc.' ), - label: string() - .when('purpose', { - is: 'vlan', - then: string() - .required('VLAN label is required.') - .min(1, 'VLAN label must be between 1 and 64 characters.') - .max(64, 'VLAN label must be between 1 and 64 characters.') - .matches( - /[a-zA-Z0-9-]+/, - 'Must include only ASCII letters, numbers, and dashes' - ), - otherwise: string().notRequired().nullable(true), - }) - .nullable(true), - ipam_address: string().nullable(true).test({ - name: 'validateIPAM', - message: 'Must be a valid IPv4 range, e.g. 192.0.2.0/24.', - test: validateIP, + label: string().when('purpose', { + is: 'vlan', + then: string() + .required('VLAN label is required.') + .min(1, 'VLAN label must be between 1 and 64 characters.') + .max(64, 'VLAN label must be between 1 and 64 characters.') + .matches( + /[a-zA-Z0-9-]+/, + 'Must include only ASCII letters, numbers, and dashes' + ), + otherwise: string().test({ + name: testnameDisallowedBasedOnPurpose('VLAN'), + message: testmessageDisallowedBasedOnPurpose('vlan', 'label'), + test: (value) => typeof value === 'undefined' || value === '', + }), + }), + ipam_address: string().when('purpose', { + is: 'vlan', + then: string().notRequired().test({ + name: 'validateIPAM', + message: 'Must be a valid IPv4 range, e.g. 192.0.2.0/24.', + test: validateIP, + }), + otherwise: string().test({ + name: testnameDisallowedBasedOnPurpose('VLAN'), + message: testmessageDisallowedBasedOnPurpose('vlan', 'ipam_address'), + test: (value) => typeof value === 'undefined' || value === '', + }), + }), + primary: boolean().notRequired(), + subnet: number().when('purpose', { + is: 'vpc', + then: number().required(), + otherwise: number().test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'subnet'), + test: (value) => typeof value === 'undefined', + }), }), + ipv4: ipv4ConfigInterface, + ipv6: ipv6ConfigInterface, + ip_ranges: array() + .of(string()) + .when('purpose', { + is: 'vpc', + then: array().of(string().test(validateIP)).max(1).notRequired(), + otherwise: array().test({ + name: testnameDisallowedBasedOnPurpose('VPC'), + message: testmessageDisallowedBasedOnPurpose('vpc', 'ip_ranges'), + test: (value) => typeof value === 'undefined', + }), + }), }) ) .test( @@ -62,6 +195,26 @@ export const linodeInterfaceSchema = array() } ); +export const UpdateConfigInterfaceOrderSchema = object({ + ids: array().of(number()).required('The list of interface IDs is required.'), +}); + +export const UpdateConfigInterfaceSchema = object({ + primary: boolean().notRequired(), + ipv4: object() + .notRequired() + .shape({ + vpc: IPv4, + nat_1_1: lazy((value) => + value === 'any' ? string().notRequired().nullable() : IPv4 + ), + }), + ipv6: object().notRequired().nullable().shape({ + vpc: IPv6, + }), + ip_ranges: array().of(string().test(validateIP)).max(1).notRequired(), +}); + // const rootPasswordValidation = string().test( // 'is-strong-password', // 'Password does not meet strength requirements.', diff --git a/packages/validation/src/vpcs.schema.ts b/packages/validation/src/vpcs.schema.ts index 3d9e5b98a4e..78b161bf40c 100644 --- a/packages/validation/src/vpcs.schema.ts +++ b/packages/validation/src/vpcs.schema.ts @@ -1,5 +1,5 @@ import ipaddr from 'ipaddr.js'; -import { array, object, string } from 'yup'; +import { array, lazy, object, string } from 'yup'; const LABEL_MESSAGE = 'VPC label must be between 1 and 64 characters.'; const LABEL_REQUIRED = 'Label is required'; @@ -11,24 +11,83 @@ const labelTestDetails = { testMessage: 'Must not contain two dashes in a row', }; -const validateIP = (value?: string): boolean => { +const IP_EITHER_BOTH_NOT_NEITHER = + 'A subnet must have either IPv4 or IPv6, or both, but not neither.'; + +/** + * VPC-related IP validation that handles for single IPv4 and IPv6 addresses as well as + * IPv4 ranges in CIDR format and IPv6 ranges with prefix lengths. + * @param value - the IP address string to be validated + * @param shouldHaveIPMask - a boolean indicating whether the value should have a mask (e.g., /32) or not + * @param mustBeIPMask - a boolean indicating whether the value MUST be an IP mask/prefix length or not + */ +export const vpcsValidateIP = ( + value?: string | null, + shouldHaveIPMask?: boolean, + mustBeIPMask?: boolean +): boolean => { if (!value) { return false; } + const [, mask] = value.trim().split('/'); + + /* + // 1. If the test specifies the value must be a mask, and the value is not, fail the test. + // 2. If the value is a mask, ensure it is a valid IPv6 mask. For MVP: the IPv6 property of + // the Subnet /POST object is just the prefix length (CIDR mask), 64-125. + // subnetMaskFromPrefixLength returns null for invalid prefix lengths + */ + if (mustBeIPMask) { + // Check that the value equals just the /mask. This is to prevent, for example, + // something like 2600:3c00::f03c:92ff:feeb:98f9/64 from falsely passing this check. + const valueIsMaskOnly = value === `/${mask}`; + + return !mask + ? false + : ipaddr.IPv6.subnetMaskFromPrefixLength(Number(mask)) !== null && + valueIsMaskOnly && + Number(mask) >= 64 && + Number(mask) <= 125; + } + try { - const addr = ipaddr.parse(value); - const type = addr.kind(); + let addr; - if (type === 'ipv4') { - ipaddr.IPv4.isValid(value); - ipaddr.IPv4.parseCIDR(value); - } else if (type === 'ipv6') { - ipaddr.IPv6.parseCIDR(value); + if (mask) { + const parsedValue = ipaddr.parseCIDR(value); + addr = parsedValue[0]; } else { + addr = ipaddr.parse(value); + } + + const type = addr.kind(); + const isIPv4 = type === 'ipv4'; + const isIPv6 = type === 'ipv6'; + + if (!isIPv4 && !isIPv6) { return false; } + // Do protocol-specific checks + if (isIPv4) { + if (shouldHaveIPMask) { + ipaddr.IPv4.parseCIDR(value); + } else { + ipaddr.IPv4.isValid(value); + ipaddr.IPv4.parse(value); // Parse again to prompt test failure if it has a mask but should not. + } + } + + if (isIPv6) { + if (shouldHaveIPMask) { + ipaddr.IPv6.parseCIDR(value); + } else { + ipaddr.IPv6.isValid(value); + ipaddr.IPv6.parse(value); // Parse again to prompt test failure if it has a mask but should not. + } + } + return true; } catch (err) { return false; @@ -60,32 +119,68 @@ export const updateVPCSchema = object({ export const createSubnetSchema = object().shape( { label: labelValidation.required(LABEL_REQUIRED), - ipv4: string() - .test({ - name: 'cidr', - message: 'The IPv4 range must be in CIDR format', - test: validateIP, - }) - .notRequired() - .ensure() - .when('ipv6', { - is: '', - then: string().required(), + ipv4: string().when('ipv6', { + is: '' || null || undefined, + then: string() + .required(IP_EITHER_BOTH_NOT_NEITHER) + .test({ + name: 'IPv4 CIDR format', + message: 'The IPv4 range must be in CIDR format', + test: (value) => vpcsValidateIP(value, true, false), + }), + otherwise: lazy((value: string | undefined) => { + switch (typeof value) { + case 'undefined': + return string().notRequired().nullable(); + + case 'string': + return string() + .notRequired() + .test({ + name: 'IPv4 CIDR format', + message: 'The IPv4 range must be in CIDR format', + test: (value) => vpcsValidateIP(value, true, false), + }); + + default: + return string().notRequired().nullable(); + } }), - ipv6: string() - .test({ - name: 'cidr mask', - message: 'Must be the subnet mask of the IP, e.g. /24', - test: validateIP, - }) - .notRequired() - .ensure() - .when('ipv4', { - is: '', - then: string().required(), + }), + ipv6: string().when('ipv4', { + is: '' || null || undefined, + then: string() + .required(IP_EITHER_BOTH_NOT_NEITHER) + .test({ + name: 'IPv6 prefix length', + message: 'Must be the prefix length (64-125) of the IP, e.g. /64', + test: (value) => vpcsValidateIP(value, true, true), + }), + otherwise: lazy((value: string | undefined) => { + switch (typeof value) { + case 'undefined': + return string().notRequired().nullable(); + + case 'string': + return string() + .notRequired() + .test({ + name: 'IPv6 prefix length', + message: + 'Must be the prefix length (64-125) of the IP, e.g. /64', + test: (value) => vpcsValidateIP(value, true, true), + }); + + default: + return string().notRequired().nullable(); + } }), + }), }, - [['ipv6', 'ipv4']] + [ + ['ipv6', 'ipv4'], + ['ipv4', 'ipv6'], + ] ); export const modifySubnetSchema = object({