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();
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) {
route.IngressName = this.originalIngresses[0].Name;
}

View File

@ -154,82 +154,7 @@
</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="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="form-group !mx-0 !pl-0 col-sm-3">
<div class="input-group input-group-sm">
<div class="btn-group btn-group-sm">
<label

View File

@ -36,7 +36,7 @@
<div class="form-group">
<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">
<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>

View File

@ -308,13 +308,17 @@ class KubernetesApplicationHelper {
svcport.targetPort = port.targetPort;
app.Ingresses.value.forEach((ingress) => {
const ingressMatched = _.find(ingress.Paths, { ServiceName: service.metadata.name });
if (ingressMatched) {
const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name);
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 = {
IngressName: ingressMatched.IngressName,
Host: ingressMatched.Host,
Path: ingressMatched.Path,
IngressName: ingressPortMatched.IngressName,
Host: ingressPortMatched.Host,
Path: ingressPortMatched.Path,
};
}
if (ingressNameMatched) {
svc.Ingress = true;
}
});

View File

@ -138,17 +138,21 @@ export function CreateIngressView() {
{ label: 'Select a service', value: '' },
...(servicesOptions || []),
];
const servicePorts = clusterIpServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
value: String(port.Port),
})),
])
)
: {};
const servicePorts = useMemo(
() =>
clusterIpServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
value: String(port.Port),
})),
])
)
: {},
[clusterIpServices]
);
const existingIngressClass = useMemo(
() =>
@ -222,6 +226,32 @@ export function CreateIngressView() {
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(() => {
if (namespace.length > 0) {
validate(

View File

@ -290,7 +290,8 @@ export function IngressForm({
)}
<Button
className="btn btn-sm btn-dangerlight ml-2"
className="btn btn-sm ml-2"
color="dangerlight"
type="button"
data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`}
onClick={() => removeIngressHost(hostIndex)}
@ -534,7 +535,8 @@ export function IngressForm({
<div className="form-group !pl-0 col-sm-1 !m-0">
<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"
data-cy={`k8sAppCreate-rmPortButton_${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 KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
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 {
/* #region CONSTRUCTOR */
@ -144,6 +146,8 @@ class KubernetesCreateApplicationController {
this.setPullImageValidity = this.setPullImageValidity.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onServicePublishChange = this.onServicePublishChange.bind(this);
this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this);
this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this);
}
/* #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 {
this.state.actionInProgress = true;
await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues);
@ -1028,13 +1041,100 @@ class KubernetesCreateApplicationController {
}
}
deployApplication() {
if (this.state.isEdit) {
this.ModalService.confirmUpdate('Updating the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
if (confirmed) {
return this.$async(this.updateApplicationAsync);
async confirmUpdateApplicationAsync() {
const [ingressesToUpdate, servicePortsToUpdate] = await this.checkIngressesToUpdate();
// if there is an ingressesToUpdate, then show a warning modal with asking if they want to update the ingresses
if (ingressesToUpdate.length) {
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 {
return this.$async(this.deployApplicationAsync);
}
@ -1154,6 +1254,8 @@ class KubernetesCreateApplicationController {
this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0;
this.oldFormValues = angular.copy(this.formValues);
this.updateNamespaceLimits();
this.updateSliders();
} catch (err) {

View File

@ -18,6 +18,7 @@ interface InputOption {
interface PromptOptions {
title: string;
message?: string;
inputType?:
| 'text'
| '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) {
const box = bootbox.prompt({
title: options.title,
message: options.message || '',
inputType: options.inputType,
inputOptions: options.inputOptions,
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) {
prompt(options);
}

View File

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

View File

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