fix(application): edit cluster ip services EE-4328 (#7775)

pull/7817/head
Ali 2022-10-07 16:55:11 +13:00 committed by GitHub
parent 819dc4d561
commit 315c1c7e1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 200 additions and 107 deletions

View File

@ -23,7 +23,7 @@ export default class KubeServicesItemViewController {
const route = new KubernetesIngressServiceRoute(); const route = new KubernetesIngressServiceRoute();
route.ServiceName = this.serviceName; route.ServiceName = this.serviceName;
if (this.serviceType === KubernetesApplicationPublishingTypes.CLUSTER_IP && this.originalIngresses.length > 0) { if (this.serviceType === KubernetesApplicationPublishingTypes.CLUSTER_IP && this.originalIngresses && this.originalIngresses.length > 0) {
if (!route.IngressName) { if (!route.IngressName) {
route.IngressName = this.originalIngresses[0].Name; route.IngressName = this.originalIngresses[0].Name;
} }

View File

@ -154,82 +154,7 @@
</div> </div>
</div> </div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType"> <div class="form-group !mx-0 !pl-0 col-sm-3">
<div class="input-group input-group-sm">
<span class="input-group-addon">Ingress</span>
<select
class="form-control"
name="ingress_port_{{ $index }}"
ng-model="servicePort.ingress.IngressName"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-options="ingress.Name as ingress.Name for ingress in $ctrl.originalIngresses"
data-cy="k8sAppCreate-ingressPort_{{ $index }}"
>
<option selected disabled hidden value="">Select an ingress</option>
</select>
</div>
<span>
<div class="small mt-5 text-warning">
<div ng-messages="serviceForm['ingress_port_'+$index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress selection is required.</p>
</div>
</div>
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType">
<div class="input-group input-group-sm">
<span class="input-group-addon">Hostname</span>
<select
class="form-control"
name="hostname_port_{{ $index }}"
ng-model="servicePort.ingress.Host"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-options="host as host for host in ($ctrl.originalIngresses | filter:{ Name: servicePort.ingress.IngressName })[0].Hosts"
data-cy="k8sAppCreate-hostnamePort_{{ $index }}"
>
<option selected disabled hidden value="">Select a hostname</option>
</select>
</div>
<span>
<div class="small mt-1 text-warning">
<div ng-messages="serviceForm['hostname_port_'+$index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Hostname is required.</p>
</div>
</div>
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3 clear-both" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType">
<div class="input-group input-group-sm">
<span class="input-group-addon required">Route</span>
<input
class="form-control"
name="ingress_route_{{ $index }}"
ng-model="servicePort.ingress.Path"
placeholder="route"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
data-cy="k8sAppCreate-route_{{ $index }}"
/>
</div>
<span>
<div class="small mt-1 text-warning">
<div ng-messages="serviceForm['ingress_route_'+$index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Route is required.</p>
<p class="vertical-center" ng-message="pattern"
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This field must consist of alphanumeric characters or the special characters: '-', '_'
or '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').</p
>
</div>
</div>
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-2">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<label <label

View File

@ -36,7 +36,7 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 form-inline" style="margin-top: 20px" ng-repeat="service in $ctrl.formValues.Services"> <div class="col-sm-12 form-inline" style="margin-top: 20px" ng-repeat="service in $ctrl.formValues.Services">
<div ng-if="!$ctrl.formValues.Services[$index].Ingress"> <div>
<div class="text-muted vertical-center"> <div class="text-muted vertical-center">
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'ClusterIP'" icon="'list'" feather="true"></pr-icon> <pr-icon ng-if="$ctrl.serviceType(service.Type) === 'ClusterIP'" icon="'list'" feather="true"></pr-icon>
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'LoadBalancer'" icon="'svg-dataflow'"></pr-icon> <pr-icon ng-if="$ctrl.serviceType(service.Type) === 'LoadBalancer'" icon="'svg-dataflow'"></pr-icon>

View File

@ -308,13 +308,17 @@ class KubernetesApplicationHelper {
svcport.targetPort = port.targetPort; svcport.targetPort = port.targetPort;
app.Ingresses.value.forEach((ingress) => { app.Ingresses.value.forEach((ingress) => {
const ingressMatched = _.find(ingress.Paths, { ServiceName: service.metadata.name }); const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name);
if (ingressMatched) { const ingressPortMatched = ingress.Paths.find((ingPath) => ingPath.Port === port.port);
// only add ingress info to the port if the ingress serviceport matches the port in the service
if (ingressPortMatched) {
svcport.ingress = { svcport.ingress = {
IngressName: ingressMatched.IngressName, IngressName: ingressPortMatched.IngressName,
Host: ingressMatched.Host, Host: ingressPortMatched.Host,
Path: ingressMatched.Path, Path: ingressPortMatched.Path,
}; };
}
if (ingressNameMatched) {
svc.Ingress = true; svc.Ingress = true;
} }
}); });

View File

@ -138,7 +138,9 @@ export function CreateIngressView() {
{ label: 'Select a service', value: '' }, { label: 'Select a service', value: '' },
...(servicesOptions || []), ...(servicesOptions || []),
]; ];
const servicePorts = clusterIpServices const servicePorts = useMemo(
() =>
clusterIpServices
? Object.fromEntries( ? Object.fromEntries(
clusterIpServices?.map((service) => [ clusterIpServices?.map((service) => [
service.Name, service.Name,
@ -148,7 +150,9 @@ export function CreateIngressView() {
})), })),
]) ])
) )
: {}; : {},
[clusterIpServices]
);
const existingIngressClass = useMemo( const existingIngressClass = useMemo(
() => () =>
@ -222,6 +226,32 @@ export function CreateIngressView() {
params.namespace, params.namespace,
]); ]);
useEffect(() => {
// for each path in each host, if the service port doesn't exist as an option, change it to the first option
if (ingressRule?.Hosts?.length) {
ingressRule.Hosts.forEach((host, hIndex) => {
host?.Paths?.forEach((path, pIndex) => {
const serviceName = path.ServiceName;
const currentServicePorts = servicePorts[serviceName]?.map(
(p) => p.value
);
if (
currentServicePorts?.length &&
!currentServicePorts?.includes(String(path.ServicePort))
) {
handlePathChange(
hIndex,
pIndex,
'ServicePort',
currentServicePorts[0]
);
}
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ingressRule, servicePorts]);
useEffect(() => { useEffect(() => {
if (namespace.length > 0) { if (namespace.length > 0) {
validate( validate(

View File

@ -290,7 +290,8 @@ export function IngressForm({
)} )}
<Button <Button
className="btn btn-sm btn-dangerlight ml-2" className="btn btn-sm ml-2"
color="dangerlight"
type="button" type="button"
data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`} data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`}
onClick={() => removeIngressHost(hostIndex)} onClick={() => removeIngressHost(hostIndex)}
@ -534,7 +535,8 @@ export function IngressForm({
<div className="form-group !pl-0 col-sm-1 !m-0"> <div className="form-group !pl-0 col-sm-1 !m-0">
<Button <Button
className="btn btn-sm btn-dangerlight btn-only-icon !ml-0 vertical-center" className="btn btn-sm btn-only-icon !ml-0 vertical-center"
color="dangerlight"
type="button" type="button"
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`} data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
onClick={() => removeIngressRoute(hostIndex, pathIndex)} onClick={() => removeIngressRoute(hostIndex, pathIndex)}

View File

@ -32,6 +32,8 @@ import KubernetesApplicationHelper from 'Kubernetes/helpers/application/index';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper'; import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesNodeHelper } from 'Kubernetes/node/helper'; import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
import { updateIngress, getIngresses } from '@/kubernetes/react/views/networks/ingresses/service';
import { confirmUpdateAppIngress } from '@/portainer/services/modal.service/prompt';
class KubernetesCreateApplicationController { class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -144,6 +146,8 @@ class KubernetesCreateApplicationController {
this.setPullImageValidity = this.setPullImageValidity.bind(this); this.setPullImageValidity = this.setPullImageValidity.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this); this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onServicePublishChange = this.onServicePublishChange.bind(this); this.onServicePublishChange = this.onServicePublishChange.bind(this);
this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this);
this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this);
} }
/* #endregion */ /* #endregion */
@ -1015,7 +1019,16 @@ class KubernetesCreateApplicationController {
} }
} }
async updateApplicationAsync() { async updateApplicationAsync(ingressesToUpdate, rulePlural) {
if (ingressesToUpdate.length) {
try {
await Promise.all(ingressesToUpdate.map((ing) => updateIngress(this.endpoint.Id, ing)));
this.Notifications.success('Success', `Ingress ${rulePlural} successfully updated`);
} catch (error) {
this.Notifications.error('Failure', error, 'Unable to update ingress');
}
}
try { try {
this.state.actionInProgress = true; this.state.actionInProgress = true;
await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues); await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues);
@ -1028,13 +1041,100 @@ class KubernetesCreateApplicationController {
} }
} }
deployApplication() { async confirmUpdateApplicationAsync() {
if (this.state.isEdit) { const [ingressesToUpdate, servicePortsToUpdate] = await this.checkIngressesToUpdate();
this.ModalService.confirmUpdate('Updating the application may cause a service interruption. Do you wish to continue?', (confirmed) => { // if there is an ingressesToUpdate, then show a warning modal with asking if they want to update the ingresses
if (confirmed) { if (ingressesToUpdate.length) {
return this.$async(this.updateApplicationAsync); const rulePlural = ingressesToUpdate.length > 1 ? 'rules' : 'rule';
const noMatchSentence =
servicePortsToUpdate.length > 1
? `Service ports in this application no longer match the ingress ${rulePlural}.`
: `A service port in this application no longer matches the ingress ${rulePlural} which may break ingress rule paths.`;
const message = `
<ul class="ml-3">
<li>Updating the application may cause a service interruption.</li>
<li>${noMatchSentence}</li>
</ul>
`;
const inputLabel = `Update ingress ${rulePlural} to match the service port changes`;
confirmUpdateAppIngress(`Are you sure?`, message, inputLabel, (value) => {
if (value === null) {
return;
}
if (value.length === 0) {
return this.$async(this.updateApplicationAsync, [], '');
}
if (value[0] === '1') {
return this.$async(this.updateApplicationAsync, ingressesToUpdate, rulePlural);
} }
}); });
} else {
this.ModalService.confirmUpdate('Updating the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
if (confirmed) {
return this.$async(this.updateApplicationAsync, [], '');
}
});
}
}
// check if service ports with ingresses have changed and allow the user to update the ingress to the new port values with a modal
async checkIngressesToUpdate() {
let ingressesToUpdate = [];
let servicePortsToUpdate = [];
const fullIngresses = await getIngresses(this.endpoint.Id, this.formValues.ResourcePool.Namespace.Name);
this.formValues.Services.forEach((updatedService) => {
const oldServiceIndex = this.oldFormValues.Services.findIndex((oldService) => oldService.Name === updatedService.Name);
const numberOfPortsInOldService = this.oldFormValues.Services[oldServiceIndex] && this.oldFormValues.Services[oldServiceIndex].Ports.length;
// if the service has an ingress and there is the same number of ports or more in the updated service
if (updatedService.Ingress && numberOfPortsInOldService && numberOfPortsInOldService <= updatedService.Ports.length) {
const updatedOldPorts = updatedService.Ports.slice(0, numberOfPortsInOldService);
const ingressesForService = fullIngresses.filter((ing) => {
const ingServiceNames = ing.Paths.map((path) => path.ServiceName);
if (ingServiceNames.includes(updatedService.Name)) {
return true;
}
});
ingressesForService.forEach((ingressForService) => {
updatedOldPorts.forEach((servicePort, pIndex) => {
if (servicePort.ingress) {
// if there isn't a ingress path that has a matching service name and port
const doesIngressPathMatchServicePort = ingressForService.Paths.find((ingPath) => ingPath.ServiceName === updatedService.Name && ingPath.Port === servicePort.port);
if (!doesIngressPathMatchServicePort) {
// then find the ingress path index to update by looking for the matching port in the old form values
const oldServicePort = this.oldFormValues.Services[oldServiceIndex].Ports[pIndex].port;
const newServicePort = servicePort.port;
const ingressPathIndex = ingressForService.Paths.findIndex((ingPath) => {
return ingPath.ServiceName === updatedService.Name && ingPath.Port === oldServicePort;
});
if (ingressPathIndex !== -1) {
// if the ingress to update isn't in the ingressesToUpdate list
const ingressUpdateIndex = ingressesToUpdate.findIndex((ing) => ing.Name === ingressForService.Name);
if (ingressUpdateIndex === -1) {
// then add it to the list with the new port
const ingressToUpdate = angular.copy(ingressForService);
ingressToUpdate.Paths[ingressPathIndex].Port = newServicePort;
ingressesToUpdate.push(ingressToUpdate);
} else {
// if the ingress is already in the list, then update the path with the new port
ingressesToUpdate[ingressUpdateIndex].Paths[ingressPathIndex].Port = newServicePort;
}
if (!servicePortsToUpdate.includes(newServicePort)) {
servicePortsToUpdate.push(newServicePort);
}
}
}
}
});
});
}
});
return [ingressesToUpdate, servicePortsToUpdate];
}
deployApplication() {
if (this.state.isEdit) {
return this.$async(this.confirmUpdateApplicationAsync);
} else { } else {
return this.$async(this.deployApplicationAsync); return this.$async(this.deployApplicationAsync);
} }
@ -1154,6 +1254,8 @@ class KubernetesCreateApplicationController {
this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0; this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0;
this.oldFormValues = angular.copy(this.formValues);
this.updateNamespaceLimits(); this.updateNamespaceLimits();
this.updateSliders(); this.updateSliders();
} catch (err) { } catch (err) {

View File

@ -18,6 +18,7 @@ interface InputOption {
interface PromptOptions { interface PromptOptions {
title: string; title: string;
message?: string;
inputType?: inputType?:
| 'text' | 'text'
| 'textarea' | 'textarea'
@ -45,9 +46,12 @@ export async function promptAsync(options: Omit<PromptOptions, 'callback'>) {
}); });
} }
// the ts-ignore is required because the bootbox typings are not up to date
// remove the ts-ignore when the typings are updated in
export function prompt(options: PromptOptions) { export function prompt(options: PromptOptions) {
const box = bootbox.prompt({ const box = bootbox.prompt({
title: options.title, title: options.title,
message: options.message || '',
inputType: options.inputType, inputType: options.inputType,
inputOptions: options.inputOptions, inputOptions: options.inputOptions,
buttons: options.buttons ? confirmButtons(options.buttons) : undefined, buttons: options.buttons ? confirmButtons(options.buttons) : undefined,
@ -84,6 +88,32 @@ export function confirmContainerDeletion(
}); });
} }
export function confirmUpdateAppIngress(
title: string,
message: string,
inputText: string,
callback: PromptCallback
) {
prompt({
title: buildTitle(title),
inputType: 'checkbox',
message,
inputOptions: [
{
text: `${inputText}<i></i>`,
value: '1',
},
],
buttons: {
confirm: {
label: 'Update',
className: 'btn-primary',
},
},
callback,
});
}
export function selectRegistry(options: PromptOptions) { export function selectRegistry(options: PromptOptions) {
prompt(options); prompt(options);
} }

View File

@ -165,7 +165,7 @@
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/angular": "^1.8.3", "@types/angular": "^1.8.3",
"@types/bootbox": "^5.2.2", "@types/bootbox": "^5.2.4",
"@types/file-saver": "^2.0.4", "@types/file-saver": "^2.0.4",
"@types/jest": "^27.0.3", "@types/jest": "^27.0.3",
"@types/jquery": "^3.5.10", "@types/jquery": "^3.5.10",

View File

@ -4455,10 +4455,10 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/bootbox@^5.2.2": "@types/bootbox@^5.2.4":
version "5.2.3" version "5.2.4"
resolved "https://registry.yarnpkg.com/@types/bootbox/-/bootbox-5.2.3.tgz#86aa918eb4df2499631887bb7b6b23f0195a751d" resolved "https://registry.yarnpkg.com/@types/bootbox/-/bootbox-5.2.4.tgz#b86363715f7cd2b60edcc70217ad67c919a1942a"
integrity sha512-6O9474usap0SRkRhPYhmtrAWPfQ2Kwb5WsSxVkM8uT5FwRp/TQijSrhg344r+zJb4K38b96DlXaqs/BrW4Banw== integrity sha512-YYywaPrgRtLgui/dhZujO8ZLw4vFW7eRgRbL/6MO7RG6Hah08gZmeOQv7jKZaltWafixZEPmmFKMSw9qC2rlbw==
dependencies: dependencies:
"@types/jquery" "*" "@types/jquery" "*"