mirror of https://github.com/portainer/portainer
fix(application): edit cluster ip services EE-4328 (#7775)
parent
819dc4d561
commit
315c1c7e1e
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" "*"
|
||||
|
||||
|
|
Loading…
Reference in New Issue