Skip to content

upcoming: [M3-9424] - Review nodebalancers validation schemas #11910

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/validation": Changed
---

Update validation schemas for the changes in POST endpoints in /v4/nodebalancers (& /v4beta/nodebalancers) for NB-VPC Integration ([#11910](https://github.com/linode/manager/pull/11910))
104 changes: 103 additions & 1 deletion packages/validation/src/nodebalancers.schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { array, boolean, mixed, number, object, string } from 'yup';
import { array, boolean, lazy, mixed, number, object, string } from 'yup';
import { IP_EITHER_BOTH_NOT_NEITHER, vpcsValidateIP } from './vpcs.schema';
Copy link
Contributor

Choose a reason for hiding this comment

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

@harsh-akamai Can you look into this ESLint warnings.


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 PRIVATE_IPv6_REGEX = /^(fc|fd)\./;

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this regex will match valid IPv6 addresses. We should test the regex expressions with a tool like regex101.com to ensure it works as intended. We'll also want it to account for the mask since it's an IPv6 range

Copy link
Contributor

Choose a reason for hiding this comment

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

The sample IPv6 range fails the regex pattern:

Screenshot 2025-04-10 at 4 38 18β€―PM

It may be that a comprehensive regex for IPv6 may be challenging or too complex for our purposes, but if that is the case we should note in a comment that we're only testing the beginning letters of it rather than the entire IP.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Noted. I've made changes to the regex and I've also added a comment. Thanks for catching this πŸ™Œ

export const CHECK_ATTEMPTS = {
MIN: 1,
Expand Down Expand Up @@ -40,6 +42,14 @@ export const nodeBalancerConfigNodeSchema = object({
.required('IP address is required.')
.matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.'),

subnet_id: number().when('vpcs', {
is: (vpcs: typeof createNodeBalancerVPCsSchema) => vpcs !== undefined,
then: (schema) =>
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need this to be:

Suggested change
is: (vpcs: typeof createNodeBalancerVPCsSchema) => vpcs !== undefined,
is: (vpcs: typeof createNodeBalancerVPCsSchema[]) => vpcs !== undefined,

since the vpcs field is an array?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops! Missed this. Thanks!

schema
.required('Subnet ID is required')
.typeError('Subnet ID must be a number'),
}),

port: number()
.typeError('Port must be a number.')
.required('Port is required.')
Expand Down Expand Up @@ -251,6 +261,76 @@ const client_udp_sess_throttle = number()
)
.typeError('UDP Session Throttle must be a number.');

const createNodeBalancerVPCsSchema = object({
subnet_id: number()
.typeError('Subnet ID must be a number.')
.required('Subnet ID is required.'),
ipv4_range: string().when('ipv6_range', {
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
schema
.required(IP_EITHER_BOTH_NOT_NEITHER)
.matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.')
.test({
Copy link
Contributor

Choose a reason for hiding this comment

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

let's store the 'Must be a valid private IPv4 address.' string in a constant since we use it several times

name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
otherwise: (schema) =>
lazy((value: string | undefined) => {
switch (typeof value) {
case 'undefined':
return schema.notRequired().nullable();

case 'string':
return schema
.notRequired()
.matches(
PRIVATE_IPv4_REGEX,
'Must be a valid private IPv4 address.'
)
.test({
name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
});

default:
return schema.notRequired().nullable();
}
}),
}),
ipv6_range: string().when('ipv6_range', {
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
schema
.required(IP_EITHER_BOTH_NOT_NEITHER)
.matches(PRIVATE_IPv6_REGEX, 'Must be a valid private IPv6 address.')
.test({
name: 'valid-ipv6-range',
message: 'Must be a valid IPv6 range, e.g. 2001:db8:abcd:0012::0/64.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
}),
});

Copy link
Contributor

Choose a reason for hiding this comment

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

Right now, Cloud Manager doesn't load and we get the following console error:

Screenshot 2025-04-04 at 1 20 29β€―PM

Suggested change
const createNodeBalancerVPCsSchema = object({
subnet_id: number()
.typeError('Subnet ID must be a number.')
.required('Subnet ID is required.'),
ipv4_range: string().when('ipv6_range', {
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
schema
.required(IP_EITHER_BOTH_NOT_NEITHER)
.matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.')
.test({
name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
otherwise: (schema) =>
lazy((value: string | undefined) => {
switch (typeof value) {
case 'undefined':
return schema.notRequired().nullable();
case 'string':
return schema
.notRequired()
.matches(
PRIVATE_IPv4_REGEX,
'Must be a valid private IPv4 address.'
)
.test({
name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
});
default:
return schema.notRequired().nullable();
}
}),
}),
ipv6_range: string().when('ipv6_range', {
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
schema
.required(IP_EITHER_BOTH_NOT_NEITHER)
.matches(PRIVATE_IPv6_REGEX, 'Must be a valid private IPv6 address.')
.test({
name: 'valid-ipv6-range',
message: 'Must be a valid IPv6 range, e.g. 2001:db8:abcd:0012::0/64.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
}),
});
const createNodeBalancerVPCsSchema = object().shape(
{
subnet_id: number()
.typeError('Subnet ID must be a number.')
.required('Subnet ID is required.'),
ipv4_range: string().when('ipv6_range', {
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
schema
.required(IP_EITHER_BOTH_NOT_NEITHER)
.matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.')
.test({
name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
otherwise: (schema) =>
lazy((value: string | undefined) => {
switch (typeof value) {
case 'undefined':
return schema.notRequired().nullable();
case 'string':
return schema
.notRequired()
.matches(
PRIVATE_IPv4_REGEX,
'Must be a valid private IPv4 address.'
)
.test({
name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
});
default:
return schema.notRequired().nullable();
}
}),
}),
ipv6_range: string().when('ipv4_range', {
is: (value: unknown) =>
value === '' || value === null || value === undefined,
then: (schema) =>
schema
.required(IP_EITHER_BOTH_NOT_NEITHER)
.matches(PRIVATE_IPv6_REGEX, 'Must be a valid private IPv6 address.')
.test({
name: 'valid-ipv6-range',
message:
'Must be a valid IPv6 range, e.g. 2001:db8:abcd:0012::0/64.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
}),
},
[
['ipv4_range', 'ipv6_range'],
['ipv6_range', 'ipv4_range'],
]
);

The array at the end prevents the cyclic dependency error.

export const NodeBalancerSchema = object({
label: string()
.required('Label is required.')
Expand Down Expand Up @@ -301,6 +381,28 @@ export const NodeBalancerSchema = object({
message: 'Port must be unique.',
});
}),

vpcs: array()
.of(createNodeBalancerVPCsSchema)
.test(
'unique subnet IDs',
'Subnet IDs must be unique.',
function (value?: any[] | null) {
if (!value) {
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to include a type annotation as yup will infer one for you!

Suggested change
function (value?: any[] | null) {
function (value) {

return true;
}
const ids: number[] = value.map((vpcs) => vpcs.subnet_id);
const duplicates: number[] = [];
ids.forEach(
(id, index) => ids.indexOf(id) !== index && duplicates.push(index)
);
const idStrings = ids.map((id: number) => `vpcs[${id}].subnet_id`);
throw this.createError({
path: idStrings.join('|'),
message: 'Subnet ID must be unique',
});
}
),
});

export const UpdateNodeBalancerSchema = object({
Expand Down
3 changes: 2 additions & 1 deletion packages/validation/src/vpcs.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const labelTestDetails = {
testMessage: 'Label must not contain two dashes in a row.',
};

const IP_EITHER_BOTH_NOT_NEITHER =
export const IP_EITHER_BOTH_NOT_NEITHER =
'A subnet must have either IPv4 or IPv6, or both, but not neither.';
// @TODO VPC IPv6 - remove below constant when IPv6 is in GA
const TEMPORARY_IPV4_REQUIRED_MESSAGE = 'A subnet must have an IPv4 range.';
Expand Down Expand Up @@ -97,6 +97,7 @@ export const vpcsValidateIP = ({
}

if (isIPv6) {
// @TODO NB-VPC: update the IPv6 prefix if required for NB-VPC integration
// VPCs must be assigned an IPv6 prefix of /52, /48, or /44
const invalidVPCIPv6Prefix = !['52', '48', '44'].includes(mask);
if (!isIPv6Subnet && invalidVPCIPv6Prefix) {
Expand Down
Loading