fix(ing): nodeport validate and show errors [EE-4373] (#7801)

pull/7854/head
Ali 2022-10-12 10:06:57 +13:00 committed by GitHub
parent fd91de3571
commit 7a6ff10268
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 135 additions and 62 deletions

View File

@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { KubernetesServicePort, KubernetesIngressServiceRoute } from 'Kubernetes/models/service/models';
import { KubernetesServicePort } from 'Kubernetes/models/service/models';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
@ -18,34 +18,17 @@ export default class KubeServicesItemViewController {
port.port = '';
port.targetPort = '';
port.protocol = 'TCP';
if (this.ingressType) {
const route = new KubernetesIngressServiceRoute();
route.ServiceName = this.serviceName;
if (this.serviceType === KubernetesApplicationPublishingTypes.CLUSTER_IP && this.originalIngresses && this.originalIngresses.length > 0) {
if (!route.IngressName) {
route.IngressName = this.originalIngresses[0].Name;
}
if (!route.Host) {
route.Host = this.originalIngresses[0].Hosts[0];
}
}
port.ingress = route;
port.Ingress = true;
}
this.servicePorts.push(port);
this.service.Ports.push(port);
}
removePort(index) {
this.servicePorts.splice(index, 1);
this.service.Ports.splice(index, 1);
}
servicePort(index) {
const targetPort = this.servicePorts[index].targetPort;
this.servicePorts[index].port = targetPort;
const targetPort = this.service.Ports[index].targetPort;
this.service.Ports[index].port = targetPort;
this.onChangeServicePort();
}
isAdmin() {
@ -54,7 +37,7 @@ export default class KubeServicesItemViewController {
onChangeContainerPort() {
const state = this.state.duplicates.targetPort;
const source = _.map(this.servicePorts, (sp) => sp.targetPort);
const source = _.map(this.service.Ports, (sp) => sp.targetPort);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
@ -62,22 +45,41 @@ export default class KubeServicesItemViewController {
onChangeServicePort() {
const state = this.state.duplicates.servicePort;
const source = _.map(this.servicePorts, (sp) => sp.port);
const source = _.map(this.service.Ports, (sp) => sp.port);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
this.service.servicePortError = state.hasRefs;
}
onChangeNodePort() {
const state = this.state.duplicates.nodePort;
const source = _.map(this.servicePorts, (sp) => sp.nodePort);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
// create a list of all the node ports (number[]) in the cluster and current form
const clusterNodePortsWithoutCurrentService = this.nodePortServices
.filter((npService) => npService.Name !== this.service.Name)
.map((npService) => npService.Ports)
.flat()
.map((npServicePorts) => npServicePorts.NodePort);
const formNodePortsWithoutCurrentService = this.formServices
.filter((formService) => formService.Type === KubernetesApplicationPublishingTypes.NODE_PORT && formService.Name !== this.service.Name)
.map((formService) => formService.Ports)
.flat()
.map((formServicePorts) => formServicePorts.nodePort);
const serviceNodePorts = this.service.Ports.map((sp) => sp.nodePort);
// getDuplicates cares about the index, so put the serviceNodePorts at the start
const allNodePortsWithoutCurrentService = [...clusterNodePortsWithoutCurrentService, ...formNodePortsWithoutCurrentService];
const duplicates = KubernetesFormValidationHelper.getDuplicateNodePorts(serviceNodePorts, allNodePortsWithoutCurrentService);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
this.service.nodePortError = state.hasRefs;
}
$onInit() {
if (this.servicePorts.length === 0) {
if (this.service.Ports.length === 0) {
this.addPort();
}

View File

@ -1,11 +1,11 @@
<ng-form name="serviceForm">
<div ng-if="$ctrl.isAdmin()" class="small" ng-show="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<div ng-if="$ctrl.isAdmin()" class="small" ng-show="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<p class="text-warning pt-2 vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> No Load balancer is available in this cluster, click
<a class="hyperlink" ui-sref="kubernetes.cluster.setup">here</a> to configure load balancer.
</p>
</div>
<div ng-if="!$ctrl.isAdmin()" class="small" ng-show="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<div ng-if="!$ctrl.isAdmin()" class="small" ng-show="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<p class="text-warning pt-2 vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> No Load balancer is available in this cluster, contact your administrator.
</p>
@ -13,9 +13,9 @@
<div
ng-if="
($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && $ctrl.loadbalancerEnabled) ||
$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP ||
$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT
($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && $ctrl.loadbalancerEnabled) ||
$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP ||
$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT
"
>
<div ng-show="!$ctrl.multiItemDisable" class="mt-5 mb-5 vertical-center">
@ -24,7 +24,7 @@
<pr-icon icon="'plus'" mode="'alt'" size="'sm'" feather="true"></pr-icon> publish a new port
</span>
</div>
<div ng-repeat="servicePort in $ctrl.servicePorts" class="mt-5 service-form row">
<div ng-repeat="servicePort in $ctrl.service.Ports" class="mt-5 service-form row">
<div class="form-group !mx-0 !pl-0 col-sm-3">
<div class="input-group input-group-sm">
<span class="input-group-addon required">Container port</span>
@ -40,7 +40,7 @@
max="65535"
ng-change="$ctrl.servicePort($index)"
required
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-change="$ctrl.onChangeContainerPort()"
data-cy="k8sAppCreate-containerPort_{{ $index }}"
/>
@ -75,7 +75,7 @@
min="1"
max="65535"
required
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-change="$ctrl.onChangeServicePort()"
data-cy="k8sAppCreate-servicePort_{{ $index }}"
/>
@ -98,7 +98,7 @@
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT">
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT">
<div class="input-group input-group-sm">
<span class="input-group-addon required">Nodeport</span>
<input
@ -129,12 +129,15 @@
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Nodeport number must be inside the range 30000-32767 or blank for system
allocated.</p
>
<div class="mt-1 text-warning" ng-if="$ctrl.state.duplicates.nodePort.refs[$index] !== undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This node port is already used.
</div>
</div>
</div>
</span>
</div>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER">
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER">
<div class="input-group input-group-sm">
<span class="input-group-addon">Loadbalancer port</span>
<input
@ -148,7 +151,7 @@
min="1"
max="65535"
required
ng-disabled="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled"
ng-disabled="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled"
data-cy="k8sAppCreate-loadbalancerPort_{{ $index }}"
/>
</div>
@ -177,7 +180,7 @@
>
</div>
<button
ng-disabled="$ctrl.servicePorts.length === 1"
ng-disabled="$ctrl.service.Ports.length === 1"
ng-show="!$ctrl.multiItemDisable"
class="btn btn-sm btn-dangerlight btn-only-icon"
type="button"

View File

@ -5,15 +5,10 @@ angular.module('portainer.kubernetes').component('kubeServicesItemView', {
templateUrl: './kube-services-item.html',
controller,
bindings: {
serviceType: '<',
servicePorts: '=',
serviceRoutes: '=',
ingressType: '<',
originalIngresses: '<',
nodePortServices: '<',
formServices: '<',
service: '=',
isEdit: '<',
serviceName: '<',
multiItemDisable: '<',
serviceIndex: '<',
loadbalancerEnabled: '<',
},
});

View File

@ -1,5 +1,7 @@
import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
import { getServices } from 'Kubernetes/react/views/networks/services/service';
import { notifyError } from '@/portainer/services/notifications';
export default class KubeServicesViewController {
/* @ngInject */
@ -7,6 +9,7 @@ export default class KubeServicesViewController {
this.$async = $async;
this.EndpointProvider = EndpointProvider;
this.Authentication = Authentication;
this.asyncOnInit = this.asyncOnInit.bind(this);
}
addEntry(service) {
@ -74,6 +77,21 @@ export default class KubeServicesViewController {
return 'fa fa-project-diagram';
}
}
async asyncOnInit() {
try {
// get all nodeport services in the cluster, to validate unique nodeports in the form
const allSettledServices = await Promise.allSettled(this.namespaces.map((namespace) => getServices(this.state.endpointId, namespace)));
const allServices = allSettledServices
.filter((settledService) => settledService.status === 'fulfilled' && settledService.value)
.map((fulfilledService) => fulfilledService.value)
.flat();
this.nodePortServices = allServices.filter((service) => service.Type === 'NodePort');
} catch (error) {
notifyError('Failure', error, 'Failed getting services');
}
}
$onInit() {
this.state = {
serviceType: [
@ -93,5 +111,6 @@ export default class KubeServicesViewController {
selected: KubernetesApplicationPublishingTypes.CLUSTER_IP,
endpointId: this.EndpointProvider.endpointID(),
};
return this.$async(this.asyncOnInit);
}
}

View File

@ -44,10 +44,9 @@
{{ $ctrl.serviceType(service.Type) }}
</div>
<kube-services-item-view
service-routes="$ctrl.formValues.Services[$index].IngressRoute"
ingress-type="$ctrl.formValues.Services[$index].Ingress"
service-type="$ctrl.formValues.Services[$index].Type"
service-ports="$ctrl.formValues.Services[$index].Ports"
node-port-services="$ctrl.nodePortServices"
form-services="$ctrl.formValues.Services"
service="$ctrl.formValues.Services[$index]"
is-edit="$ctrl.isEdit"
loadbalancer-enabled="$ctrl.loadbalancerEnabled"
></kube-services-item-view>

View File

@ -7,6 +7,7 @@ angular.module('portainer.kubernetes').component('kubeServicesView', {
bindings: {
formValues: '=',
isEdit: '<',
namespaces: '<',
loadbalancerEnabled: '<',
},
});

View File

@ -13,14 +13,24 @@ class KubernetesFormValidationHelper {
}
static getDuplicates(names) {
const groupped = _.groupBy(names);
const grouped = _.groupBy(names);
const res = {};
_.forEach(names, (name, index) => {
if (name && groupped[name].length > 1) {
if (name && grouped[name].length > 1) {
res[index] = name;
}
});
return res;
}
static getDuplicateNodePorts(serviceNodePorts, allOtherNodePorts) {
const res = {};
serviceNodePorts.forEach((sNodePort, index) => {
if (allOtherNodePorts.includes(sNodePort) || serviceNodePorts.filter((snp) => snp === sNodePort).length > 1) {
res[index] = sNodePort;
}
});
return res;
}
}
export default KubernetesFormValidationHelper;

View File

@ -23,6 +23,8 @@ const _KubernetesService = Object.freeze({
Note: '',
Ingress: false,
Selector: {},
nodePortError: false,
servicePortError: false,
});
export class KubernetesService {

View File

@ -13,6 +13,7 @@ import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
import KubernetesPodConverter from 'Kubernetes/pod/converter';
import { notifyError } from '@/portainer/services/notifications';
class KubernetesApplicationService {
/* #region CONSTRUCTOR */
@ -213,7 +214,11 @@ class KubernetesApplicationService {
if (services) {
services.forEach(async (service) => {
await this.KubernetesServiceService.create(service);
try {
await this.KubernetesServiceService.create(service);
} catch (error) {
notifyError('Unable to create service', error);
}
});
}
@ -221,7 +226,11 @@ class KubernetesApplicationService {
if (app instanceof KubernetesStatefulSet) {
app.VolumeClaims = claims;
headlessService = await this.KubernetesServiceService.create(headlessService);
try {
headlessService = await this.KubernetesServiceService.create(headlessService);
} catch (error) {
notifyError('Unable to create service', error);
}
app.ServiceName = headlessService.metadata.name;
} else {
const claimPromises = _.map(claims, (item) => {
@ -276,7 +285,11 @@ class KubernetesApplicationService {
}
if (newApp instanceof KubernetesStatefulSet) {
await this.KubernetesServiceService.patch(oldHeadlessService, newHeadlessService);
try {
await this.KubernetesServiceService.patch(oldHeadlessService, newHeadlessService);
} catch (error) {
notifyError('Unable to update service', error);
}
} else {
const claimPromises = _.map(newClaims, (newClaim) => {
if (!newClaim.PreviousName && !newClaim.Id) {
@ -294,7 +307,11 @@ class KubernetesApplicationService {
// Create services
if (oldServices.length === 0 && newServices.length !== 0) {
newServices.forEach(async (service) => {
await this.KubernetesServiceService.create(service);
try {
await this.KubernetesServiceService.create(service);
} catch (error) {
notifyError('Unable to create service', error);
}
});
}
@ -315,9 +332,17 @@ class KubernetesApplicationService {
newServices.forEach(async (newService) => {
const oldServiceMatched = _.find(oldServices, { Name: newService.Name });
if (oldServiceMatched) {
await this.KubernetesServiceService.patch(oldServiceMatched, newService);
try {
await this.KubernetesServiceService.patch(oldServiceMatched, newService);
} catch (error) {
notifyError('Unable to update service', error);
}
} else {
await this.KubernetesServiceService.create(newService);
try {
await this.KubernetesServiceService.create(newService);
} catch (error) {
notifyError('Unable to create service', error);
}
}
});
}

View File

@ -1291,7 +1291,12 @@
</div>
<!-- kubernetes services options -->
<kube-services-view form-values="ctrl.formValues" is-edit="ctrl.state.isEdit" loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"></kube-services-view>
<kube-services-view
form-values="ctrl.formValues"
is-edit="ctrl.state.isEdit"
namespaces="ctrl.allNamespaces"
loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
></kube-services-view>
<!-- kubernetes services options -->
<!-- summary -->
@ -1349,7 +1354,7 @@
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid()"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors()"
ng-click="ctrl.deployApplication()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppCreate-deployButton"

View File

@ -680,6 +680,11 @@ class KubernetesCreateApplicationController {
return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL ? this.nodeNumber : this.formValues.ReplicaCount;
}
hasPortErrors() {
const portError = this.formValues.Services.some((service) => service.nodePortError || service.servicePortError);
return portError;
}
resourceReservationsOverflow() {
const instances = this.effectiveInstances();
const cpu = this.formValues.CpuLimit;
@ -1187,6 +1192,7 @@ class KubernetesCreateApplicationController {
const nonSystemNamespaces = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name);
this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
this.formValues.ResourcePool = this.resourcePools[0];

View File

@ -284,6 +284,7 @@
<!-- table -->
<kubernetes-application-services-table
services="ctrl.application.Services"
namespaces="ctrl.allNamespaces"
application="ctrl.application"
public-url="ctrl.state.publicUrl"
></kubernetes-application-services-table>

View File

@ -108,6 +108,7 @@ class KubernetesApplicationController {
Notifications,
LocalStorage,
ModalService,
KubernetesResourcePoolService,
KubernetesApplicationService,
KubernetesEventService,
KubernetesStackService,
@ -121,6 +122,7 @@ class KubernetesApplicationController {
this.Notifications = Notifications;
this.LocalStorage = LocalStorage;
this.ModalService = ModalService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService;
this.KubernetesApplicationService = KubernetesApplicationService;
@ -376,6 +378,9 @@ class KubernetesApplicationController {
SelectedRevision: undefined,
};
const resourcePools = await this.KubernetesResourcePoolService.get();
this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name);
await this.getApplication();
await this.getEvents();
this.updateApplicationKindText();