feat(k8s/ingress): create multiple ingress network per kubernetes namespace (#4464)

* feat(k8s/ingress): introduce multiple hosts per ingress

* feat(k8s/ingress): host selector in app create/edit

* feat(k8s/ingress): save empty hosts

* feat(k8s/ingress): fix empty host

* feat(k8s/ingress): rename inputs + ensure hostnames unicity + fix remove hostname and routes

* feat(k8s/ingress): fix duplicates hostname validation

* feat(k8s/application): fix rebase

* feat(k8s/resource-pool): fix error messages for ingress (wip)

* fix(k8s/resource-pool): ingress duplicates detection
pull/5019/head
Alice Groux 2021-04-27 19:51:13 +02:00 committed by GitHub
parent ca849e31a1
commit befccacc27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 308 additions and 106 deletions

View File

@ -132,4 +132,14 @@ angular
}; };
return values[text] || text; return values[text] || text;
}; };
})
.filter('kubernetesApplicationIngressEmptyHostname', function () {
'use strict';
return function (value) {
if (value === '') {
return '<use IP>';
} else {
return value;
}
};
}); });

View File

@ -16,7 +16,7 @@ class KubernetesFormValidationHelper {
const groupped = _.groupBy(names); const groupped = _.groupBy(names);
const res = {}; const res = {};
_.forEach(names, (name, index) => { _.forEach(names, (name, index) => {
if (groupped[name].length > 1 && name) { if (name && groupped[name].length > 1) {
res[index] = name; res[index] = name;
} }
}); });

View File

@ -2,22 +2,18 @@ import _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch'; import * as JsonPatch from 'fast-json-patch';
import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper';
import { KubernetesResourcePoolIngressClassAnnotationFormValue, KubernetesResourcePoolIngressClassFormValue } from 'Kubernetes/models/resource-pool/formValues'; import {
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngress, KubernetesIngressRule } from './models'; import { KubernetesIngress, KubernetesIngressRule } from './models';
import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads'; import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads';
import { KubernetesIngressClassAnnotation, KubernetesIngressClassRewriteTargetAnnotations } from './constants'; import { KubernetesIngressClassAnnotation, KubernetesIngressClassRewriteTargetAnnotations } from './constants';
export class KubernetesIngressConverter { export class KubernetesIngressConverter {
// TODO: refactor @LP
// currently only allows the first non-empty host to be used as the "configured" host.
// As we currently only allow a single host to be used for a Portianer-managed ingress
// it's working as the only non-empty host will be the one defined by the admin
// but it will take a random existing host for non Portainer ingresses (CLI deployed)
// Also won't support multiple hosts if we make it available in the future
static apiToModel(data) { static apiToModel(data) {
let host = undefined;
const paths = _.flatMap(data.spec.rules, (rule) => { const paths = _.flatMap(data.spec.rules, (rule) => {
host = host || rule.host; // TODO: refactor @LP - read above
return !rule.http return !rule.http
? [] ? []
: _.map(rule.http.paths, (path) => { : _.map(rule.http.paths, (path) => {
@ -41,7 +37,11 @@ export class KubernetesIngressConverter {
? data.metadata.annotations[KubernetesIngressClassAnnotation] ? data.metadata.annotations[KubernetesIngressClassAnnotation]
: data.spec.ingressClassName; : data.spec.ingressClassName;
res.Paths = paths; res.Paths = paths;
res.Host = host; res.Hosts = _.uniq(_.map(data.spec.rules, 'host'));
const idx = _.findIndex(res.Hosts, (h) => h === undefined);
if (idx >= 0) {
res.Hosts.splice(idx, 1, '');
}
return res; return res;
} }
@ -79,7 +79,7 @@ export class KubernetesIngressConverter {
_.extend(res.Annotations, KubernetesIngressClassRewriteTargetAnnotations[formValues.IngressClass.Type]); _.extend(res.Annotations, KubernetesIngressClassRewriteTargetAnnotations[formValues.IngressClass.Type]);
} }
res.Annotations[KubernetesIngressClassAnnotation] = formValues.IngressClass.Name; res.Annotations[KubernetesIngressClassAnnotation] = formValues.IngressClass.Name;
res.Host = formValues.Host; res.Hosts = formValues.Hosts;
res.Paths = formValues.Paths; res.Paths = formValues.Paths;
return res; return res;
} }
@ -96,7 +96,13 @@ export class KubernetesIngressConverter {
if (ingress) { if (ingress) {
fv.Selected = true; fv.Selected = true;
fv.WasSelected = true; fv.WasSelected = true;
fv.Host = ingress.Host; fv.Hosts = _.map(ingress.Hosts, (host) => {
const hfv = new KubernetesResourcePoolIngressClassHostFormValue();
hfv.Host = host;
hfv.PreviousHost = host;
hfv.IsNew = false;
return hfv;
});
const [[rewriteKey]] = _.toPairs(KubernetesIngressClassRewriteTargetAnnotations[ic.Type]); const [[rewriteKey]] = _.toPairs(KubernetesIngressClassRewriteTargetAnnotations[ic.Type]);
const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => { const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => {
if (key === rewriteKey) { if (key === rewriteKey) {
@ -128,16 +134,17 @@ export class KubernetesIngressConverter {
p.Host = ''; p.Host = '';
} }
}); });
const hostsWithRules = [];
const groups = _.groupBy(data.Paths, 'Host'); const groups = _.groupBy(data.Paths, 'Host');
const rules = _.map(groups, (paths, host) => { let rules = _.map(groups, (paths, host) => {
const updatedHost = _.find(data.Hosts, (h) => {
return h === host || h.PreviousHost === host;
});
host = updatedHost.Host || updatedHost;
if (updatedHost.NeedsDeletion) {
return;
}
const rule = new KubernetesIngressRuleCreatePayload(); const rule = new KubernetesIngressRuleCreatePayload();
if (host === 'undefined' || _.isEmpty(host)) {
host = data.Host;
}
if (host === data.PreviousHost && host !== data.Host) {
host = data.Host;
}
KubernetesCommonHelper.assignOrDeleteIfEmpty(rule, 'host', host); KubernetesCommonHelper.assignOrDeleteIfEmpty(rule, 'host', host);
rule.http.paths = _.map(paths, (p) => { rule.http.paths = _.map(paths, (p) => {
const path = new KubernetesIngressRulePathCreatePayload(); const path = new KubernetesIngressRulePathCreatePayload();
@ -146,11 +153,27 @@ export class KubernetesIngressConverter {
path.backend.servicePort = p.Port; path.backend.servicePort = p.Port;
return path; return path;
}); });
hostsWithRules.push(host);
return rule; return rule;
}); });
rules = _.without(rules, undefined);
const keptHosts = _.without(
_.map(data.Hosts, (h) => (h.NeedsDeletion ? undefined : h.Host || h)),
undefined
);
const hostsWithoutRules = _.without(keptHosts, ...hostsWithRules);
const emptyRules = _.map(hostsWithoutRules, (host) => {
return { host: host };
});
rules = _.concat(rules, emptyRules);
KubernetesCommonHelper.assignOrDeleteIfEmpty(res, 'spec.rules', rules); KubernetesCommonHelper.assignOrDeleteIfEmpty(res, 'spec.rules', rules);
} else if (data.Host) { } else if (data.Hosts) {
res.spec.rules = [{ host: data.Host }]; res.spec.rules = [];
_.forEach(data.Hosts, (host) => {
if (!host.NeedsDeletion) {
res.spec.rules.push({ host: host.Host });
}
});
} else { } else {
delete res.spec.rules; delete res.spec.rules;
} }

View File

@ -3,8 +3,9 @@ export function KubernetesIngress() {
Name: '', Name: '',
Namespace: '', Namespace: '',
Annotations: {}, Annotations: {},
Host: undefined, // Host: undefined,
PreviousHost: undefined, // only use for RP ingress host edit Hosts: [],
// PreviousHost: undefined, // only use for RP ingress host edit
Paths: [], Paths: [],
IngressClassName: '', IngressClassName: '',
}; };

View File

@ -17,7 +17,7 @@ export function KubernetesResourcePoolIngressClassFormValue(ingressClass) {
IngressClass: ingressClass, IngressClass: ingressClass,
RewriteTarget: false, RewriteTarget: false,
Annotations: [], // KubernetesResourcePoolIngressClassAnnotationFormValue Annotations: [], // KubernetesResourcePoolIngressClassAnnotationFormValue
Host: undefined, Hosts: [],
Selected: false, Selected: false,
WasSelected: false, WasSelected: false,
AdvancedConfig: false, AdvancedConfig: false,
@ -31,3 +31,12 @@ export function KubernetesResourcePoolIngressClassAnnotationFormValue() {
Value: '', Value: '',
}; };
} }
export function KubernetesResourcePoolIngressClassHostFormValue() {
return {
Host: '',
PreviousHost: '',
NeedsDeletion: false,
IsNew: true,
};
}

View File

@ -1264,7 +1264,14 @@
tooltip-enable="ctrl.disableLoadBalancerEdit()" tooltip-enable="ctrl.disableLoadBalancerEdit()"
uib-tooltip="Edition is not allowed while the Load Balancer is in 'Pending' state" uib-tooltip="Edition is not allowed while the Load Balancer is in 'Pending' state"
> >
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: publishedPort.NeedsDeletion }"> <div
class="input-group input-group-sm"
ng-class="{
striked: publishedPort.NeedsDeletion,
'col-sm-2': ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS,
'col-sm-3': ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS
}"
>
<span class="input-group-addon">container port</span> <span class="input-group-addon">container port</span>
<input <input
type="number" type="number"
@ -1327,7 +1334,7 @@
</div> </div>
<div <div
class="col-sm-3 input-group input-group-sm" class="col-sm-2 input-group input-group-sm"
ng-class="{ striked: publishedPort.NeedsDeletion }" ng-class="{ striked: publishedPort.NeedsDeletion }"
ng-if=" ng-if="
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) || (publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
@ -1349,7 +1356,28 @@
</div> </div>
<div <div
class="col-sm-3 input-group input-group-sm" class="col-sm-2 input-group input-group-sm"
ng-class="{ striked: publishedPort.NeedsDeletion }"
ng-if="
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
"
>
<span class="input-group-addon">hostname</span>
<select
class="form-control"
name="ingress_hostname_{{ $index }}"
ng-model="publishedPort.IngressHost"
ng-options="host as (host | kubernetesApplicationIngressEmptyHostname) for host in ctrl.ingressHostnames"
ng-change="ctrl.onChangePublishedPorts()"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
>
<option selected disabled hidden value="">Select a hostname</option>
</select>
</div>
<div
class="col-sm-2 input-group input-group-sm"
ng-class="{ striked: publishedPort.NeedsDeletion }" ng-class="{ striked: publishedPort.NeedsDeletion }"
ng-if=" ng-if="
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) || (publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||

View File

@ -320,7 +320,7 @@ class KubernetesCreateApplicationController {
const p = new KubernetesApplicationPublishedPortFormValue(); const p = new KubernetesApplicationPublishedPortFormValue();
const ingresses = this.filteredIngresses; const ingresses = this.filteredIngresses;
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined; p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined; p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined;
if (this.formValues.PublishedPorts.length) { if (this.formValues.PublishedPorts.length) {
p.Protocol = this.formValues.PublishedPorts[0].Protocol; p.Protocol = this.formValues.PublishedPorts[0].Protocol;
} }
@ -331,7 +331,7 @@ class KubernetesCreateApplicationController {
const ingresses = this.filteredIngresses; const ingresses = this.filteredIngresses;
_.forEach(this.formValues.PublishedPorts, (p) => { _.forEach(this.formValues.PublishedPorts, (p) => {
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined; p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined; p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined;
}); });
} }
@ -388,7 +388,8 @@ class KubernetesCreateApplicationController {
onChangePortMappingIngress(index) { onChangePortMappingIngress(index) {
const publishedPort = this.formValues.PublishedPorts[index]; const publishedPort = this.formValues.PublishedPorts[index];
const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName }); const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName });
publishedPort.IngressHost = ingress.Host; this.ingressHostnames = ingress.Hosts;
publishedPort.IngressHost = this.ingressHostnames.length ? this.ingressHostnames[0] : [];
this.onChangePublishedPorts(); this.onChangePublishedPorts();
} }
@ -780,6 +781,7 @@ class KubernetesCreateApplicationController {
refreshIngresses(namespace) { refreshIngresses(namespace) {
this.filteredIngresses = _.filter(this.ingresses, { Namespace: namespace }); this.filteredIngresses = _.filter(this.ingresses, { Namespace: namespace });
this.ingressHostnames = this.filteredIngresses.length ? this.filteredIngresses[0].Hosts : [];
if (!this.publishViaIngressEnabled()) { if (!this.publishViaIngressEnabled()) {
if (this.savedFormValues) { if (this.savedFormValues) {
this.formValues.PublishingType = this.savedFormValues.PublishingType; this.formValues.PublishingType = this.savedFormValues.PublishingType;

View File

@ -208,7 +208,7 @@
</div> </div>
</div> </div>
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses"> <div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
<div class="text-muted col-sm-12" style="width: 100%;"> <div class="text-muted col-sm-12" style="width: 100%;">
<div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;"> <div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;">
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }} <i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }}
@ -225,30 +225,58 @@
<div ng-if="ic.Selected"> <div ng-if="ic.Selected">
<div class="form-group"> <div class="form-group">
<label class="control-label text-left col-sm-2"> <div class="col-sm-12">
Hostname <label class="control-label text-left">
Hostnames
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="Hostname associated to the ingress inside this resource pool. Users will be able to expose and access their applications over the ingress via this hostname." message="Hostnames associated to the ingress inside this resource pool. Users will be able to expose and access their applications over the ingress via one of these hostname."
> >
</portainer-tooltip> </portainer-tooltip>
</label> </label>
<div class="col-sm-10"> <span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addHostname(ic)">
<input class="form-control" name="ingress_host_{{ $index }}" ng-model="ic.Host" placeholder="host.com" ng-change="ctrl.onChangeIngressHostname()" required /> <i class="fa fa-plus-circle" aria-hidden="true"></i> add hostname
</span>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<div ng-repeat="item in ic.Hosts track by $index" style="margin-top: 2px;">
<div class="form-inline">
<div class="col-sm-10 input-group input-group-sm">
<span class="input-group-addon">Hostname</span>
<input
type="text"
class="form-control"
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
ng-model="item.Host"
ng-change="ctrl.onChangeIngressHostname()"
placeholder="foo"
required
/>
</div>
<div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeHostname(ic, $index)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div> </div>
</div> </div>
<div class="form-group" ng-show="resourcePoolCreationForm['ingress_host_' + $index].$invalid || ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined"> <div
<div class="col-sm-12 small text-warning"> class="small text-warning"
<div ng-messages="resourcePoolCreationForm['ingress_host_' + $index].$error"> style="margin-top: 5px;"
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p> ng-show="
</div> resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid ||
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined"> ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> "
This host is already used. >
<ng-messages for="resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Hostname is required.</p>
</ng-messages>
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This hostname is already used.
</p> </p>
</div> </div>
</div> </div>
</div>
</div>
<div class="form-group" ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"> <div class="form-group" ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left"> <label class="control-label text-left">

View File

@ -3,7 +3,11 @@ import _ from 'lodash-es';
import filesizeParser from 'filesize-parser'; import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassAnnotationFormValue } from 'Kubernetes/models/resource-pool/formValues'; import {
KubernetesResourcePoolFormValues,
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
@ -34,17 +38,43 @@ class KubernetesCreateResourcePoolController {
onChangeIngressHostname() { onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts; const state = this.state.duplicates.ingressHosts;
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
const hosts = _.map(this.formValues.IngressClasses, 'Host'); const hostnames = _.map(hosts, 'Host');
const allHosts = _.map(this.allIngresses, 'Host'); const hostnamesWithoutRemoved = _.filter(hostnames, (h) => !h.NeedsDeletion);
const duplicates = KubernetesFormValidationHelper.getDuplicates(hosts); const allHosts = _.flatMap(this.allIngresses, 'Hosts');
_.forEach(hosts, (host, idx) => { const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnamesWithoutRemoved);
if (_.includes(allHosts, host) && host !== undefined) { _.forEach(hostnames, (host, idx) => {
duplicates[idx] = host; if (host !== undefined && _.includes(allHosts, host)) {
formDuplicates[idx] = host;
} }
}); });
const duplicates = {};
let count = 0;
_.forEach(this.formValues.IngressClasses, (ic) => {
duplicates[ic.IngressClass.Name] = {};
_.forEach(ic.Hosts, (hostFV, hostIdx) => {
if (hostFV.Host === formDuplicates[count]) {
duplicates[ic.IngressClass.Name][hostIdx] = hostFV.Host;
}
count++;
});
});
state.refs = duplicates; state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0; state.hasRefs = false;
_.forIn(duplicates, (value) => {
if (Object.keys(value).length > 0) {
state.hasRefs = true;
}
});
}
addHostname(ingressClass) {
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
removeHostname(ingressClass, index) {
ingressClass.Hosts.splice(index, 1);
this.onChangeIngressHostname();
} }
/* #region ANNOTATIONS MANAGEMENT */ /* #region ANNOTATIONS MANAGEMENT */
@ -168,6 +198,11 @@ class KubernetesCreateResourcePoolController {
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses); this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses);
} }
_.forEach(this.formValues.IngressClasses, (ic) => {
if (ic.Hosts.length === 0) {
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
});
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data'); this.Notifications.error('Failure', err, 'Unable to load view data');
} finally { } finally {

View File

@ -167,7 +167,7 @@
</div> </div>
</div> </div>
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses"> <div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
<div class="text-muted col-sm-12" style="width: 100%;"> <div class="text-muted col-sm-12" style="width: 100%;">
<div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;"> <div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;">
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }} <i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }}
@ -184,36 +184,59 @@
<div ng-if="ic.Selected"> <div ng-if="ic.Selected">
<div class="form-group"> <div class="form-group">
<label class="control-label text-left col-sm-2"> <div class="col-sm-12">
Hostname <label class="control-label text-left">
Hostnames
<portainer-tooltip <portainer-tooltip
position="bottom" position="bottom"
message="Hostname associated to the ingress inside this resource pool. Users will be able to expose and access their applications over the ingress via this hostname." message="Hostnames associated to the ingress inside this resource pool. Users will be able to expose and access their applications over the ingress via one of these hostname."
> >
</portainer-tooltip> </portainer-tooltip>
</label> </label>
<div class="col-sm-10"> <span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addHostname(ic)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add hostname
</span>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<div ng-repeat="item in ic.Hosts track by $index" style="margin-top: 2px;">
<div class="form-inline">
<div class="col-sm-10 input-group input-group-sm" ng-class="{ striked: item.NeedsDeletion }">
<span class="input-group-addon">Hostname</span>
<input <input
type="text"
class="form-control" class="form-control"
name="ingress_host_{{ $index }}" name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
ng-model="ic.Host" ng-model="item.Host"
placeholder="host.com"
ng-change="ctrl.onChangeIngressHostname()" ng-change="ctrl.onChangeIngressHostname()"
placeholder="foo"
required required
/> />
</div> </div>
<div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0">
<button ng-if="!item.NeedsDeletion" class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeHostname(ic, $index)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
<button ng-if="item.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restoreHostname(item)">
<i class="fa fa-trash-restore" aria-hidden="true"></i>
</button>
</div> </div>
<div class="form-group" ng-show="resourcePoolEditForm['ingress_host_' + $index].$invalid || ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
<div class="col-sm-12 small text-warning">
<div ng-messages="resourcePoolEditForm['ingress_host_' + $index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div> </div>
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined"> <div
class="small text-warning"
style="margin-top: 5px;"
ng-show="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid || item.Duplicate"
>
<ng-messages for="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Hostname is required.</p>
</ng-messages>
<p ng-if="item.Duplicate">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
This host is already used. This hostname is already used.
</p> </p>
</div> </div>
</div> </div>
</div>
</div>
<div class="form-group" ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"> <div class="form-group" ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX">
<div class="col-sm-12"> <div class="col-sm-12">

View File

@ -4,7 +4,11 @@ import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuota, KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; import { KubernetesResourceQuota, KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassAnnotationFormValue } from 'Kubernetes/models/resource-pool/formValues'; import {
KubernetesResourcePoolFormValues,
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
@ -62,22 +66,6 @@ class KubernetesResourcePoolController {
} }
/* #endregion */ /* #endregion */
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
const hosts = _.map(this.formValues.IngressClasses, 'Host');
const otherIngresses = _.without(this.allIngresses, ...this.ingresses);
const allHosts = _.map(otherIngresses, 'Host');
const duplicates = KubernetesFormValidationHelper.getDuplicates(hosts);
_.forEach(hosts, (host, idx) => {
if (_.includes(allHosts, host) && host !== undefined) {
duplicates[idx] = host;
}
});
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
}
/* #region ANNOTATIONS MANAGEMENT */ /* #region ANNOTATIONS MANAGEMENT */
addAnnotation(ingressClass) { addAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue()); ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
@ -85,9 +73,59 @@ class KubernetesResourcePoolController {
removeAnnotation(ingressClass, index) { removeAnnotation(ingressClass, index) {
ingressClass.Annotations.splice(index, 1); ingressClass.Annotations.splice(index, 1);
this.onChangeIngressHostname();
} }
/* #endregion */ /* #endregion */
/* #region INGRESS MANAGEMENT */
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
const otherIngresses = _.without(this.allIngresses, ...this.ingresses);
const allHosts = _.flatMap(otherIngresses, 'Hosts');
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
const hostsWithoutRemoved = _.filter(hosts, { NeedsDeletion: false });
const hostnames = _.map(hostsWithoutRemoved, 'Host');
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnames);
_.forEach(hostnames, (host, idx) => {
if (host !== undefined && _.includes(allHosts, host)) {
formDuplicates[idx] = host;
}
});
const duplicatedHostnames = Object.values(formDuplicates);
state.hasRefs = false;
_.forEach(this.formValues.IngressClasses, (ic) => {
_.forEach(ic.Hosts, (hostFV) => {
if (_.includes(duplicatedHostnames, hostFV.Host) && hostFV.NeedsDeletion === false) {
hostFV.Duplicate = true;
state.hasRefs = true;
} else {
hostFV.Duplicate = false;
}
});
});
}
addHostname(ingressClass) {
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
removeHostname(ingressClass, index) {
if (!ingressClass.Hosts[index].IsNew) {
ingressClass.Hosts[index].NeedsDeletion = true;
} else {
ingressClass.Hosts.splice(index, 1);
}
this.onChangeIngressHostname();
}
restoreHostname(host) {
if (!host.IsNew) {
host.NeedsDeletion = false;
}
}
/* #endregion*/
selectTab(index) { selectTab(index) {
this.LocalStorage.storeActiveTab('resourcePool', index); this.LocalStorage.storeActiveTab('resourcePool', index);
} }
@ -312,6 +350,11 @@ class KubernetesResourcePoolController {
await this.getIngresses(); await this.getIngresses();
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses); this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses);
_.forEach(this.formValues.IngressClasses, (ic) => {
if (ic.Hosts.length === 0) {
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
});
} }
this.savedFormValues = angular.copy(this.formValues); this.savedFormValues = angular.copy(this.formValues);
} catch (err) { } catch (err) {