import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup'; import { useEffect, useState } from 'react'; import { SingleValue } from 'react-select'; import { List, Plus, Trash2 } from 'lucide-react'; import { FormikErrors } from 'formik'; import DataFlow from '@/assets/ico/dataflow-1.svg?c'; import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; import { useCurrentUser } from '@/react/hooks/useUser'; import { Link } from '@@/Link'; import { TextTip } from '@@/Tip/TextTip'; import { Select } from '@@/form-components/ReactSelect'; import { Button } from '@@/buttons'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { Icon } from '@@/Icon'; import { FormError } from '@@/form-components/FormError'; import { ServiceFormValues, ServicePort, ServiceTypeValue } from './types'; import { LoadBalancerForm } from './LoadBalancerForm'; import { ClusterIpForm } from './ClusterIpForm'; import { NodePortForm } from './NodePortForm'; import { newPort } from './utils'; type ServiceTypeLabel = 'ClusterIP' | 'NodePort' | 'LoadBalancer'; type ServiceTypeOption = { value: ServiceTypeValue; label: ServiceTypeLabel }; const serviceTypeOptions: ServiceTypeOption[] = [ { value: KubernetesApplicationPublishingTypes.CLUSTER_IP, label: 'ClusterIP', }, { value: KubernetesApplicationPublishingTypes.NODE_PORT, label: 'NodePort' }, { value: KubernetesApplicationPublishingTypes.LOAD_BALANCER, label: 'LoadBalancer', }, ]; const serviceFormDefaultValues: ServiceFormValues = { Headless: false, Namespace: '', Name: '', StackName: '', Ports: [], Type: 1, // clusterip type as default ClusterIP: '', ApplicationName: '', ApplicationOwner: '', Note: '', Ingress: false, Selector: {}, }; interface Props { values: ServiceFormValues[]; onChange: (loadBalancerPorts: ServiceFormValues[]) => void; errors?: FormikErrors; loadBalancerEnabled: boolean; appName: string; selector: Record; isEditMode: boolean; } export function KubeServicesForm({ values: services, onChange, errors, loadBalancerEnabled, appName, selector, isEditMode, }: Props) { const { isAdmin } = useCurrentUser(); const [selectedServiceTypeOption, setSelectedServiceTypeOption] = useState< SingleValue >(serviceTypeOptions[0]); // ClusterIP is the default value // when the appName changes, update the names for each service // and the serviceNames for each service port useEffect(() => { if (!isEditMode) { const newServiceNames = getUniqNames(appName, services); const newServices = services.map((service, index) => { const newServiceName = newServiceNames[index]; const newServicePorts = service.Ports.map((port) => ({ ...port, serviceName: newServiceName, })); return { ...service, Name: newServiceName, Ports: newServicePorts }; }); onChange(newServices); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [appName]); return ( <>
Publishing the application
Publish your application by creating a ClusterIP service for it, which you may then expose via{' '} an ingress .
options={serviceTypeOptions} value={selectedServiceTypeOption} className="w-1/4" data-cy="k8sAppCreate-publishingModeDropdown" onChange={(val) => { setSelectedServiceTypeOption(val); }} />
{selectedServiceTypeOption?.value === KubernetesApplicationPublishingTypes.LOAD_BALANCER && isAdmin && !loadBalancerEnabled && ( No Load balancer is available in this cluster, click{' '} here {' '} to configure load balancer. )} {selectedServiceTypeOption?.value === KubernetesApplicationPublishingTypes.LOAD_BALANCER && !isAdmin && !loadBalancerEnabled && ( No Load balancer is available in this cluster, contact your administrator. )} {services.map((service, index) => (
{service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP && ( <>
ClusterIP
{ const newServices = [...services]; newServices[index].Ports = servicePorts; onChange(newServices); }} /> )} {service.Type === KubernetesApplicationPublishingTypes.NODE_PORT && ( <>
NodePort
{ const newServices = [...services]; newServices[index].Ports = servicePorts; onChange(newServices); }} /> )} {service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER && ( <>
LoadBalancer
{ const newServices = [...services]; newServices[index].Ports = servicePorts; onChange(newServices); }} loadBalancerEnabled={loadBalancerEnabled} /> )}
))}
); } function generateIndexedName(appName: string, index: number) { return index === 0 ? appName : `${appName}-${index}`; } function isNameUnique(name: string, services: ServiceFormValues[]) { return services.findIndex((service) => service.Name === name) === -1; } function generateUniqueName( appName: string, index: number, services: ServiceFormValues[] ) { let initialIndex = index; let uniqueName = appName; while (!isNameUnique(uniqueName, services)) { uniqueName = generateIndexedName(appName, initialIndex); initialIndex++; } return uniqueName; } function getUniqNames(appName: string, services: ServiceFormValues[]) { const sortedServices = [...services].sort((a, b) => a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0 ); const uniqueNames = sortedServices.reduce( (acc: string[]) => { const newIndex = acc.findIndex((existingName) => existingName === appName) + 1; const uniqName = acc.includes(appName) ? generateUniqueName(appName, newIndex, services) : appName; return [...acc, uniqName]; }, [appName] ); return uniqueNames; } // 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 NodePortValidationContext = { nodePortServices: ServiceValues[]; formServices: ServiceFormValues[]; }; export function kubeServicesValidation( validationData?: NodePortValidationContext ): SchemaOf { return array( object({ Headless: boolean().required(), Namespace: string(), Name: string(), StackName: string(), Type: mixed().oneOf([ KubernetesApplicationPublishingTypes.CLUSTER_IP, KubernetesApplicationPublishingTypes.NODE_PORT, KubernetesApplicationPublishingTypes.LOAD_BALANCER, ]), ClusterIP: string(), ApplicationName: string(), ApplicationOwner: string(), Note: string(), Ingress: boolean().required(), Selector: object(), Ports: array( object({ port: number() .required('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.') .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().required(), 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 !== KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of 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 !== KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of 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 === KubernetesApplicationPublishingTypes.NODE_PORT && 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 !== KubernetesApplicationPublishingTypes.NODE_PORT ) { 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 !== KubernetesApplicationPublishingTypes.NODE_PORT ) { return true; } return nodePort <= 32767; } ), ingress: object(), }) ), }) ); } function getServiceForPort( servicePort: ServicePort, services: ServiceFormValues[] ) { return services.find((service) => service.Name === servicePort.serviceName); }