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

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
4 changes: 2 additions & 2 deletions packages/manager/src/utilities/ipUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PRIVATE_IPv4_REGEX } from '@linode/validation';
import { PRIVATE_IPV4_REGEX } from '@linode/validation';
import { parseCIDR, parse as parseIP } from 'ipaddr.js';

/**
Expand All @@ -13,7 +13,7 @@ export const removePrefixLength = (ip: string) => ip.replace(/\/\d+/, '');
* @returns true if the given IPv4 address is private
*/
export const isPrivateIP = (ip: string) => {
return PRIVATE_IPv4_REGEX.test(ip);
return PRIVATE_IPV4_REGEX.test(ip);
};

export interface ExtendedIP {
Expand Down
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))
189 changes: 146 additions & 43 deletions packages/validation/src/nodebalancers.schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { array, boolean, mixed, number, object, string } from 'yup';
import { array, boolean, lazy, mixed, number, object, string } from 'yup';

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.

import { IP_EITHER_BOTH_NOT_NEITHER, vpcsValidateIP } from './vpcs.schema';

const PORT_WARNING = 'Port must be between 1 and 65535.';
const LABEL_WARNING = 'Label must be between 3 and 32 characters.';
const PRIVATE_IPV4_WARNING = 'Must be a valid private IPv4 address.';

export const PRIVATE_IPv4_REGEX = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/;
export const PRIVATE_IPV4_REGEX =
/^10\.|^172\.1[6-9]\.|^172\.2\d\.|^172\.3[0-1]\.|^192\.168\.|^fd/;
// The regex to capture private IPv6 isn't comprehensive of all possible cases. Currently, we just match for the first block.
export const PRIVATE_IPV6_REGEX = /^(fc|fd)[0-9a-f]{2}/;

export const CHECK_ATTEMPTS = {
MIN: 1,
Expand All @@ -29,7 +35,7 @@ export const nodeBalancerConfigNodeSchema = object({
label: string()
.matches(
/^[a-zA-Z0-9.\-_]+$/,
'Label may only contain letters, numbers, periods, dashes, and underscores.'
'Label may only contain letters, numbers, periods, dashes, and underscores.',
)
.min(3, 'Label should be between 3 and 32 characters.')
.max(32, 'Label should be between 3 and 32 characters.')
Expand All @@ -38,7 +44,15 @@ export const nodeBalancerConfigNodeSchema = object({
address: string()
.typeError('IP address is required.')
.required('IP address is required.')
.matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.'),
.matches(PRIVATE_IPV4_REGEX, PRIVATE_IPV4_WARNING),

subnet_id: number().when('vpcs', {
is: (vpcs: typeof createNodeBalancerVPCsSchema) => vpcs !== undefined,
then: (schema) =>
schema
.required('Subnet ID is required')
.typeError('Subnet ID must be a number'),
}),

port: number()
.typeError('Port must be a number.')
Expand All @@ -63,11 +77,11 @@ export const createNodeBalancerConfigSchema = object({
check_attempts: number()
.min(
CHECK_ATTEMPTS.MIN,
`Attempts should be greater than or equal to ${CHECK_ATTEMPTS.MIN}.`
`Attempts should be greater than or equal to ${CHECK_ATTEMPTS.MIN}.`,
)
.max(
CHECK_ATTEMPTS.MAX,
`Attempts should be less than or equal to ${CHECK_ATTEMPTS.MAX}.`
`Attempts should be less than or equal to ${CHECK_ATTEMPTS.MAX}.`,
)
.integer(),
check_body: string().when('check', {
Expand All @@ -77,11 +91,11 @@ export const createNodeBalancerConfigSchema = object({
check_interval: number()
.min(
CHECK_INTERVAL.MIN,
`Interval should be greater than or equal to ${CHECK_INTERVAL.MIN}.`
`Interval should be greater than or equal to ${CHECK_INTERVAL.MIN}.`,
)
.max(
CHECK_INTERVAL.MAX,
`Interval should be less than or equal to ${CHECK_INTERVAL.MAX}.`
`Interval should be less than or equal to ${CHECK_INTERVAL.MAX}.`,
)
.typeError('Interval must be a number.')
.integer(),
Expand All @@ -107,11 +121,11 @@ export const createNodeBalancerConfigSchema = object({
check_timeout: number()
.min(
CHECK_TIMEOUT.MIN,
`Timeout should be greater than or equal to ${CHECK_TIMEOUT.MIN}.`
`Timeout should be greater than or equal to ${CHECK_TIMEOUT.MIN}.`,
)
.max(
CHECK_TIMEOUT.MAX,
`Timeout should be less than or equal to ${CHECK_TIMEOUT.MAX}.`
`Timeout should be less than or equal to ${CHECK_TIMEOUT.MAX}.`,
)
.typeError('Timeout must be a number.')
.integer(),
Expand Down Expand Up @@ -153,11 +167,11 @@ export const UpdateNodeBalancerConfigSchema = object({
check_attempts: number()
.min(
CHECK_ATTEMPTS.MIN,
`Attempts should be greater than or equal to ${CHECK_ATTEMPTS.MIN}.`
`Attempts should be greater than or equal to ${CHECK_ATTEMPTS.MIN}.`,
)
.max(
CHECK_ATTEMPTS.MAX,
`Attempts should be less than or equal to ${CHECK_ATTEMPTS.MAX}.`
`Attempts should be less than or equal to ${CHECK_ATTEMPTS.MAX}.`,
)
.integer(),
check_body: string().when('check', {
Expand All @@ -167,11 +181,11 @@ export const UpdateNodeBalancerConfigSchema = object({
check_interval: number()
.min(
CHECK_INTERVAL.MIN,
`Interval should be greater than or equal to ${CHECK_INTERVAL.MIN}.`
`Interval should be greater than or equal to ${CHECK_INTERVAL.MIN}.`,
)
.max(
CHECK_INTERVAL.MAX,
`Interval should be less than or equal to ${CHECK_INTERVAL.MAX}.`
`Interval should be less than or equal to ${CHECK_INTERVAL.MAX}.`,
)
.typeError('Interval must be a number.')
.integer(),
Expand All @@ -197,11 +211,11 @@ export const UpdateNodeBalancerConfigSchema = object({
check_timeout: number()
.min(
CHECK_TIMEOUT.MIN,
`Timeout should be greater than or equal to ${CHECK_TIMEOUT.MIN}.`
`Timeout should be greater than or equal to ${CHECK_TIMEOUT.MIN}.`,
)
.max(
CHECK_TIMEOUT.MAX,
`Timeout should be less than or equal to ${CHECK_TIMEOUT.MAX}.`
`Timeout should be less than or equal to ${CHECK_TIMEOUT.MAX}.`,
)
.typeError('Timeout must be a number.')
.integer(),
Expand Down Expand Up @@ -229,41 +243,115 @@ export const UpdateNodeBalancerConfigSchema = object({
}),
});

const client_conn_throttle = number()
const clientConnThrottle = number()
.min(
CONNECTION_THROTTLE.MIN,
`Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`
`Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`,
)
.max(
CONNECTION_THROTTLE.MAX,
`Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`
`Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`,
)
.typeError('Client Connection Throttle must be a number.');

const client_udp_sess_throttle = number()
const clientUdpSessThrottle = number()
.min(
CONNECTION_THROTTLE.MIN,
`UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`
`UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`,
)
.max(
CONNECTION_THROTTLE.MAX,
`UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`
`UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.`,
)
.typeError('UDP Session Throttle must be a number.');

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, PRIVATE_IPV4_WARNING)
.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 'string':
return schema
.notRequired()
.matches(PRIVATE_IPV4_REGEX, PRIVATE_IPV4_WARNING)
.test({
name: 'IPv4 CIDR format',
message: 'The IPv4 range must be in CIDR format.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
});

case 'undefined':
return schema.notRequired().nullable();

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 private IPv6 range, e.g. fd12:3456:789a:1::1/64.',
test: (value) =>
vpcsValidateIP({
value,
shouldHaveIPMask: true,
mustBeIPMask: false,
}),
}),
}),
},
[
['ipv4_range', 'ipv6_range'],
['ipv6_range', 'ipv4_range'],
],
);

export const NodeBalancerSchema = object({
label: string()
.required('Label is required.')
.min(3, LABEL_WARNING)
.max(32, LABEL_WARNING)
.matches(
/^[a-zA-Z0-9-_]+$/,
"Label can't contain special characters or spaces."
"Label can't contain special characters or spaces.",
),

client_conn_throttle,
clientConnThrottle,

client_udp_sess_throttle,
clientUdpSessThrottle,

tags: array(string()),

Expand All @@ -272,35 +360,50 @@ export const NodeBalancerSchema = object({
configs: array()
.of(createNodeBalancerConfigSchema)
/* @todo there must be an easier way */
.test('unique', 'Port must be unique.', function (value?: any[] | null) {
.test('unique', 'Port must be unique.', function (value) {
if (!value) {
return true;
}
const ports: number[] = [];
const configs = value.reduce(
(prev: number[], value: any, idx: number) => {
if (!value.port) {
return prev;
}
if (!ports.includes(value.port)) {
ports.push(value.port);
return prev;
}
return [...prev, idx];
},
[]
);
const configs = value.reduce((prev: number[], value, idx: number) => {
if (!value.port) {
return prev;
}
if (!ports.includes(value.port)) {
ports.push(value.port);
return prev;
}
return [...prev, idx];
}, []);
if (configs.length === 0) {
return true;
} // No ports were duplicates
const configStrings = configs.map(
(config: number) => `configs[${config}].port`
(config: number) => `configs[${config}].port`,
);
throw this.createError({
path: configStrings.join('|'),
message: 'Port must be unique.',
});
}),

vpcs: array()
.of(createNodeBalancerVPCsSchema)
.test('unique subnet IDs', 'Subnet IDs must be unique.', function (value) {
if (!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 All @@ -309,9 +412,9 @@ export const UpdateNodeBalancerSchema = object({
.max(32, LABEL_WARNING)
.matches(
/^[a-zA-Z0-9-_]+$/,
"Label can't contain special characters or spaces."
"Label can't contain special characters or spaces.",
),
client_conn_throttle,
client_udp_sess_throttle,
clientConnThrottle,
clientUdpSessThrottle,
tags: array(string()),
});
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 @@
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 @@
}

if (isIPv6) {
// @TODO NB-VPC: update the IPv6 prefix if required for NB-VPC integration

Check warning on line 100 in packages/validation/src/vpcs.schema.ts

View workflow job for this annotation

GitHub Actions / ESLint Review (validation)

[eslint] reported by reviewdog 🐢 Complete the task associated to this "TODO" comment. Raw Output: {"ruleId":"sonarjs/todo-tag","severity":1,"message":"Complete the task associated to this \"TODO\" comment.","line":100,"column":11,"nodeType":null,"messageId":"completeTODO","endLine":100,"endColumn":15}
// VPCs must be assigned an IPv6 prefix of /52, /48, or /44
const invalidVPCIPv6Prefix = !['52', '48', '44'].includes(mask);
if (!isIPv6Subnet && invalidVPCIPv6Prefix) {
Expand Down