mirror of https://github.com/portainer/portainer
301 lines
12 KiB
TypeScript
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);
|
|
}
|