portainer/app/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation.ts

301 lines
12 KiB
TypeScript

import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup';
import { ServiceFormValues, ServicePort } from './types';
import { prependWithSlash } from './utils';
// values returned from the angular parent component (pascal case instead of camel case keys),
// these should match the form values, but don't. Future tech debt work to update this would be nice
// to make the converted values and formValues objects to be the same
interface NodePortValues {
Port: number;
TargetPort: number;
NodePort: number;
Name?: string;
Protocol?: string;
Ingress?: string;
}
type ServiceValues = {
Type: number;
Name: string;
Ports: NodePortValues[];
};
type AngularIngressPath = {
IngressName: string;
Host: string;
Path: string;
};
type AppServicesValidationData = {
nodePortServices: ServiceValues[];
formServices: ServiceFormValues[];
ingressPaths?: AngularIngressPath[];
originalIngressPaths?: AngularIngressPath[];
};
export function kubeServicesValidation(
validationData?: AppServicesValidationData
): SchemaOf<ServiceFormValues[]> {
return array(
object({
Headless: boolean().required(),
Namespace: string(),
Name: string(),
StackName: string(),
Type: mixed().oneOf(['ClusterIP', 'NodePort', 'LoadBalancer']),
ClusterIP: string(),
ApplicationName: string(),
ApplicationOwner: string(),
Note: string(),
Ingress: boolean().required(),
Selector: object(),
Ports: array(
object({
port: number()
.required('Service port number is required.')
.typeError('Service port number is required.')
.min(1, 'Service port number must be inside the range 1-65535.')
.max(65535, 'Service port number must be inside the range 1-65535.')
.test(
'service-port-is-unique',
'Service port number must be unique.',
(servicePort, context) => {
// test for duplicate service ports within this service.
// yup gives access to context.parent which gives one ServicePort object.
// yup also gives access to all form values through this.options.context.
// Unfortunately, it doesn't give direct access to all Ports within the current service.
// To find all ports in the service for validation, I'm filtering the services by the service name,
// that's stored in the ServicePort object, then getting all Ports in the service.
if (servicePort === undefined || validationData === undefined) {
return true;
}
const { formServices } = validationData;
const matchingService = getServiceForPort(
context.parent as ServicePort,
formServices
);
if (matchingService === undefined) {
return true;
}
const servicePorts = matchingService.Ports;
const duplicateServicePortCount = servicePorts.filter(
(port) => port.port === servicePort
).length;
return duplicateServicePortCount <= 1;
}
),
targetPort: number()
.required('Container port number is required.')
.typeError('Container port number is required.')
.min(1, 'Container port number must be inside the range 1-65535.')
.max(
65535,
'Container port number must be inside the range 1-65535.'
),
name: string(),
serviceName: string(),
protocol: string(),
nodePort: number()
.test(
'node-port-is-unique-in-service',
'Node port is already used in this service.',
(nodePort, context) => {
if (nodePort === undefined || validationData === undefined) {
return true;
}
const { formServices } = validationData;
const matchingService = getServiceForPort(
context.parent as ServicePort,
formServices
);
if (
matchingService === undefined ||
matchingService.Type !== 'NodePort'
) {
return true;
}
const servicePorts = matchingService.Ports;
const duplicateNodePortCount = servicePorts.filter(
(port) => port.nodePort === nodePort
).length;
return duplicateNodePortCount <= 1;
}
)
.test(
'node-port-is-unique-in-cluster',
'Node port is already used.',
(nodePort, context) => {
if (nodePort === undefined || validationData === undefined) {
return true;
}
const { formServices, nodePortServices } = validationData;
const matchingService = getServiceForPort(
context.parent as ServicePort,
formServices
);
if (
matchingService === undefined ||
matchingService.Type !== 'NodePort'
) {
return true;
}
// create a list of all the node ports (number[]) in the cluster, from services that aren't in the application form
const formServiceNames = formServices.map(
(formService) => formService.Name
);
const clusterNodePortsWithoutFormServices = nodePortServices
.filter(
(npService) => !formServiceNames.includes(npService.Name)
)
.flatMap((npService) => npService.Ports)
.map((npServicePorts) => npServicePorts.NodePort);
// node ports in the current form, excluding the current service
const formNodePortsWithoutCurrentService = formServices
.filter(
(formService) =>
formService.Type === 'NodePort' &&
formService.Name !== matchingService.Name
)
.flatMap((formService) => formService.Ports)
.map((formServicePorts) => formServicePorts.nodePort);
return (
!clusterNodePortsWithoutFormServices.includes(nodePort) && // node port is not in the cluster services that aren't in the application form
!formNodePortsWithoutCurrentService.includes(nodePort) // and the node port is not in the current form, excluding the current service
);
}
)
.test(
'node-port-minimum',
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
(nodePort, context) => {
if (nodePort === undefined || validationData === undefined) {
return true;
}
const { formServices } = validationData;
const matchingService = getServiceForPort(
context.parent as ServicePort,
formServices
);
if (!matchingService || matchingService.Type !== 'NodePort') {
return true;
}
return nodePort >= 30000;
}
)
.test(
'node-port-maximum',
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
(nodePort, context) => {
if (nodePort === undefined || validationData === undefined) {
return true;
}
const { formServices } = validationData;
const matchingService = getServiceForPort(
context.parent as ServicePort,
formServices
);
if (!matchingService || matchingService.Type !== 'NodePort') {
return true;
}
return nodePort <= 32767;
}
),
ingressPaths: array(
object({
IngressName: string().required(),
Host: string().required('Ingress hostname is required.'),
Path: string()
.required('Ingress path is required.')
.test(
'path-is-unique',
'Ingress path is already in use for this hostname.',
(path, context) => {
if (
path === undefined ||
validationData === undefined ||
!context.parent.Host
) {
return true;
}
const ingressHostAndPath = `${
context.parent.Host
}${prependWithSlash(path)}`;
const {
ingressPaths: ingressPathsInNamespace,
formServices,
originalIngressPaths,
} = validationData;
// get the count of the same ingressHostAndPath in the current form values
const allFormServicePortIngresses = formServices.flatMap(
(service) =>
service.Ports.flatMap((port) => port.ingressPaths)
);
const formMatchingIngressHostPathCount =
allFormServicePortIngresses
.filter((ingress) => ingress?.Host !== '')
.map(
(ingress) =>
`${ingress?.Host}${prependWithSlash(ingress?.Path)}`
)
.filter(
(formIngressHostAndPath) =>
formIngressHostAndPath === ingressHostAndPath
).length;
// get the count of the same ingressHostAndPath in the namespace and subtract the count from the original form values
const nsMatchingIngressHostPathCount = (
ingressPathsInNamespace ?? []
)
.map(
(ingressPath) =>
`${ingressPath.Host}${ingressPath.Path}`
)
.filter(
(nsIngressHostAndPath) =>
nsIngressHostAndPath === ingressHostAndPath
).length;
// get the count of the same ingressHostAndPath in the original form values
const originalMatchingIngressHostPathCount = (
originalIngressPaths ?? []
)
.map(
(ingressPath) =>
`${ingressPath.Host}${ingressPath.Path}`
)
.filter(
(originalIngressHostAndPath) =>
originalIngressHostAndPath === ingressHostAndPath
).length;
// for the current ingressHostAndPath to be unique, nsMatchingIngressHostPathCount - originalMatchingIngressHostPathCount + formMatchingIngressHostPathCount must be 1 or less.
const pathIsUnique =
formMatchingIngressHostPathCount === 1 &&
nsMatchingIngressHostPathCount -
originalMatchingIngressHostPathCount +
formMatchingIngressHostPathCount <=
1;
return pathIsUnique;
}
),
})
),
})
),
Annotations: array(),
})
);
}
function getServiceForPort(
servicePort: ServicePort,
services: ServiceFormValues[]
) {
return services.find((service) => service.Name === servicePort.serviceName);
}