feat(k8s/ingresses): add more granularity to ingress configuration (#4220)

* feat(k8s/configure): separate ingress class name and ingress class type

* feat(k8s/resource-pool): ability to add custom annotations to ingress classes on RP create/edit

* feat(k8s/ingresses): remove 'allow users to use ingress' switch

* feat(k8s/configure): minor UI update

* feat(k8s/resource-pool): minor UI update

* feat(k8s/application): update ingress route form validation

* refactor(k8s/resource-pool): remove console.log statement

* feat(k8s/resource-pool): update ingress annotation placeholders

* feat(k8s/configure): add pattern form validation on ingress class

* fix(k8s/resource-pool): automatically associate ingress class to ingress

* fix(k8s/resource-pool): fix invalid ingress when updating a resource pool

* fix(k8s/resource-pool): update ingress rewrite target annotation value

* feat(k8s/application): ingress form validation

* fix(k8s/application): squash ingress rules with empty host inside a single one

* feat(k8s/resource-pool): ingress host validation

* fix(k8s/resource-pool): rewrite rewrite option and only display it for ingress of type nginx

* feat(k8s/application): do not expose ingress applications over node port

* feat(k8s/application): add specific notice for ingress

Co-authored-by: Anthony Lapenna <lapenna.anthony@gmail.com>
pull/4247/head
xAt0mZ 2020-08-20 02:51:14 +02:00 committed by GitHub
parent 68851aada4
commit d850e18ff0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 699 additions and 220 deletions

View File

@ -5,9 +5,8 @@ func KubernetesDefault() KubernetesData {
Configuration: KubernetesConfiguration{
UseLoadBalancer: false,
UseServerMetrics: false,
UseIngress: false,
StorageClasses: []KubernetesStorageClassConfig{},
IngressClasses: []string{},
IngressClasses: []KubernetesIngressClassConfig{},
},
Snapshots: []KubernetesSnapshot{},
}

View File

@ -332,9 +332,8 @@ type (
KubernetesConfiguration struct {
UseLoadBalancer bool `json:"UseLoadBalancer"`
UseServerMetrics bool `json:"UseServerMetrics"`
UseIngress bool `json:"UseIngress"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
IngressClasses []string `json:"IngressClasses"`
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
}
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration
@ -345,6 +344,12 @@ type (
AllowVolumeExpansion bool `json:"AllowVolumeExpansion"`
}
// KubernetesIngressClassConfig represents a Kubernetes Ingress Class configuration
KubernetesIngressClassConfig struct {
Name string `json:"Name"`
Type string `json:"Type"`
}
// LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server
LDAPGroupSearchSettings struct {
GroupBaseDN string `json:"GroupBaseDN"`

View File

@ -7,6 +7,7 @@ import {
KubernetesApplicationDataAccessPolicies,
KubernetesApplicationDeploymentTypes,
KubernetesApplicationPersistedFolder,
KubernetesApplicationPort,
KubernetesApplicationPublishingTypes,
KubernetesApplicationTypes,
KubernetesPortainerApplicationNameLabel,
@ -25,7 +26,6 @@ import KubernetesStatefulSetConverter from 'Kubernetes/converters/statefulSet';
import KubernetesServiceConverter from 'Kubernetes/converters/service';
import KubernetesPersistentVolumeClaimConverter from 'Kubernetes/converters/persistentVolumeClaim';
import PortainerError from 'Portainer/error';
import { KubernetesApplicationPort } from 'Kubernetes/models/application/models';
import { KubernetesIngressHelper } from 'Kubernetes/ingress/helper';
function _apiPortsToPublishedPorts(pList, pRefs) {
@ -270,9 +270,9 @@ class KubernetesApplicationConverter {
const isIngress = _.filter(res.PublishedPorts, (p) => p.IngressName).length;
if (app.ServiceType === KubernetesServiceTypes.LOAD_BALANCER) {
res.PublishingType = KubernetesApplicationPublishingTypes.LOAD_BALANCER;
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT && !isIngress) {
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT) {
res.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER;
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT && isIngress) {
} else if (app.ServiceType === KubernetesServiceTypes.CLUSTER_IP && isIngress) {
res.PublishingType = KubernetesApplicationPublishingTypes.INGRESS;
} else {
res.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL;

View File

@ -3,12 +3,12 @@ import * as JsonPatch from 'fast-json-patch';
import { KubernetesServiceCreatePayload } from 'Kubernetes/models/service/payloads';
import {
KubernetesPortainerApplicationStackNameLabel,
KubernetesApplicationPublishingTypes,
KubernetesPortainerApplicationNameLabel,
KubernetesPortainerApplicationOwnerLabel,
KubernetesPortainerApplicationStackNameLabel,
} from 'Kubernetes/models/application/models';
import { KubernetesServiceHeadlessClusterIP, KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models';
import { KubernetesService, KubernetesServiceHeadlessClusterIP, KubernetesServicePort, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
function _publishedPortToServicePort(formValues, publishedPort, type) {
@ -42,7 +42,7 @@ class KubernetesServiceConverter {
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
res.ApplicationOwner = formValues.ApplicationOwner;
res.ApplicationName = formValues.Name;
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER || formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER) {
res.Type = KubernetesServiceTypes.NODE_PORT;
} else if (formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
res.Type = KubernetesServiceTypes.LOAD_BALANCER;

View File

@ -1,4 +1,13 @@
export const KubernetesIngressClassAnnotation = 'kubernetes.io/ingress.class';
export const KubernetesIngressClassMandatoryAnnotations = Object.freeze({
nginx: { 'nginx.ingress.kubernetes.io/rewrite-target': '/$1' },
// keys must match KubernetesIngressClassTypes values to map them quickly using the ingress type
// KubernetesIngressClassRewriteTargetAnnotations[KubernetesIngressClassTypes.NGINX] for example
export const KubernetesIngressClassRewriteTargetAnnotations = Object.freeze({
nginx: { 'nginx.ingress.kubernetes.io/rewrite-target': '/' },
traefik: { 'traefik.ingress.kubernetes.io/rewrite-target': '/' },
});
export const KubernetesIngressClassTypes = Object.freeze({
NGINX: 'nginx',
TRAEFIK: 'traefik',
});

View File

@ -2,9 +2,10 @@ import * as _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch';
import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper';
import { KubernetesIngressRule, KubernetesIngress } from './models';
import { KubernetesResourcePoolIngressClassAnnotationFormValue, KubernetesResourcePoolIngressClassFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngress, KubernetesIngressRule } from './models';
import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads';
import { KubernetesIngressClassAnnotation, KubernetesIngressClassMandatoryAnnotations } from './constants';
import { KubernetesIngressClassAnnotation, KubernetesIngressClassRewriteTargetAnnotations } from './constants';
export class KubernetesIngressConverter {
// TODO: refactor @LP
@ -64,17 +65,68 @@ export class KubernetesIngressConverter {
return ingresses;
}
/**
*
* @param {KubernetesResourcePoolIngressClassFormValue} formValues
*/
static resourcePoolIngressClassFormValueToIngress(formValues) {
const res = new KubernetesIngress();
res.Name = formValues.IngressClass.Name;
res.Namespace = formValues.Namespace;
const pairs = _.map(formValues.Annotations, (a) => [a.Key, a.Value]);
res.Annotations = _.fromPairs(pairs);
if (formValues.RewriteTarget) {
_.extend(res.Annotations, KubernetesIngressClassRewriteTargetAnnotations[formValues.IngressClass.Type]);
}
res.Annotations[KubernetesIngressClassAnnotation] = formValues.IngressClass.Name;
res.Host = formValues.Host;
return res;
}
/**
*
* @param {KubernetesIngressClass} ics Ingress classes (saved in Portainer DB)
* @param {KubernetesIngress} ingresses Existing Kubernetes ingresses. Must be empty for RP CREATE VIEW and passed for RP EDIT VIEW
*/
static ingressClassesToFormValues(ics, ingresses) {
const res = _.map(ics, (ic) => {
const fv = new KubernetesResourcePoolIngressClassFormValue();
fv.IngressClass = ic;
const ingress = _.find(ingresses, { Name: ic.Name });
if (ingress) {
fv.Selected = true;
fv.WasSelected = true;
fv.Host = ingress.Host;
const [[rewriteKey]] = _.toPairs(KubernetesIngressClassRewriteTargetAnnotations[ic.Type]);
const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => {
if (key === rewriteKey) {
fv.RewriteTarget = true;
} else if (key !== KubernetesIngressClassAnnotation) {
const annotation = new KubernetesResourcePoolIngressClassAnnotationFormValue();
annotation.Key = key;
annotation.Value = value;
return annotation;
}
});
fv.Annotations = _.without(annotations, undefined);
fv.AdvancedConfig = fv.Annotations.length > 0;
}
return fv;
});
return res;
}
static createPayload(data) {
const res = new KubernetesIngressCreatePayload();
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.annotations = data.Annotations || {};
res.metadata.annotations[KubernetesIngressClassAnnotation] = data.IngressClassName;
const annotations = KubernetesIngressClassMandatoryAnnotations[data.Name];
if (annotations) {
_.extend(res.metadata.annotations, annotations);
}
res.metadata.annotations = data.Annotations;
if (data.Paths && data.Paths.length) {
_.forEach(data.Paths, (p) => {
if (p.Host === 'undefined' || p.Host === undefined) {
p.Host = '';
}
});
const groups = _.groupBy(data.Paths, 'Host');
const rules = _.map(groups, (paths, host) => {
const rule = new KubernetesIngressRuleCreatePayload();

View File

@ -24,3 +24,12 @@ export function KubernetesIngressRule() {
Path: '',
};
}
export function KubernetesIngressClass() {
return {
Name: '',
Type: undefined,
NeedsDeletion: false,
IsNew: true,
};
}

View File

@ -55,10 +55,10 @@ class KubernetesIngressService {
/**
* CREATE
*/
async createAsync(formValues) {
async createAsync(ingress) {
try {
const params = {};
const payload = KubernetesIngressConverter.createPayload(formValues);
const payload = KubernetesIngressConverter.createPayload(ingress);
const namespace = payload.metadata.namespace;
const data = await this.KubernetesIngresses(namespace).create(params, payload).$promise;
return data;
@ -67,8 +67,8 @@ class KubernetesIngressService {
}
}
create(formValues) {
return this.$async(this.createAsync, formValues);
create(ingress) {
return this.$async(this.createAsync, ingress);
}
/**
@ -100,7 +100,7 @@ class KubernetesIngressService {
async deleteAsync(ingress) {
try {
const params = new KubernetesCommonParams();
params.id = ingress.Name;
params.id = ingress.IngressClass.Name;
const namespace = ingress.Namespace;
await this.KubernetesIngresses(namespace).delete(params).$promise;
} catch (err) {

View File

@ -151,7 +151,7 @@ export class KubernetesApplicationAutoScalerFormValue {
}
}
export function KubernetesApplicationFormValidationDuplicate() {
export function KubernetesFormValueDuplicate() {
return {
refs: {},
hasDuplicates: false,

View File

@ -8,15 +8,24 @@ export function KubernetesResourcePoolFormValues(defaults) {
}
/**
* @param {string} ingressClassName
* @param {KubernetesIngressClass} ingressClass
*/
export function KubernetesResourcePoolIngressClassFormValue(ingressClassName) {
export function KubernetesResourcePoolIngressClassFormValue(ingressClass) {
return {
Name: ingressClassName,
IngressClassName: ingressClassName,
Namespace: undefined, // will be filled inside ResourcePoolService.create
IngressClass: ingressClass,
RewriteTarget: false,
Annotations: [], // KubernetesResourcePoolIngressClassAnnotationFormValue
Host: undefined,
Selected: false,
WasSelected: false,
Namespace: undefined, // will be filled inside ResourcePoolService.create
AdvancedConfig: false,
};
}
export function KubernetesResourcePoolIngressClassAnnotationFormValue() {
return {
Key: '',
Value: '',
};
}

View File

@ -6,6 +6,7 @@ import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool'
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
import { KubernetesNamespace } from 'Kubernetes/models/namespace/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
class KubernetesResourcePoolService {
/* @ngInject */
@ -89,7 +90,8 @@ class KubernetesResourcePoolService {
const ingressPromises = _.map(formValues.IngressClasses, (c) => {
if (c.Selected) {
c.Namespace = namespace.Name;
return this.KubernetesIngressService.create(c);
const ingress = KubernetesIngressConverter.resourcePoolIngressClassFormValueToIngress(c);
return this.KubernetesIngressService.create(ingress);
}
});
await Promise.all(ingressPromises);

View File

@ -1329,10 +1329,10 @@
class="form-control"
name="ingress_route_{{ $index }}"
ng-model="publishedPort.IngressRoute"
placeholder="foo"
placeholder="route"
ng-required="!publishedPort.NeedsDeletion"
ng-change="ctrl.onChangePortMappingIngressRoute()"
ng-pattern="/^\/?([a-zA-Z0-9]+[a-zA-Z0-9-/_]*[a-zA-Z0-9]|[a-zA-Z0-9]+)$/"
ng-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
/>
</div>

View File

@ -20,7 +20,7 @@ import {
KubernetesApplicationPersistedFolderFormValue,
KubernetesApplicationPublishedPortFormValue,
KubernetesApplicationPlacementFormValue,
KubernetesApplicationFormValidationDuplicate,
KubernetesFormValueDuplicate,
} from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import KubernetesApplicationConverter from 'Kubernetes/converters/application';
@ -367,14 +367,16 @@ class KubernetesCreateApplicationController {
const publishedPort = this.formValues.PublishedPorts[index];
const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName });
publishedPort.IngressHost = ingress.Host;
this.onChangePublishedPorts();
}
onChangePortMappingIngressRoute() {
const state = this.state.duplicates.publishedPorts.ingressRoutes;
if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
const newRoutes = _.map(this.formValues.PublishedPorts, (p) => (p.IsNew ? p.IngressRoute : undefined));
const toDelRoutes = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? p.IngressRoute : undefined));
const allRoutes = _.flatMapDeep(this.ingresses, (c) => _.map(c.Paths, 'Path'));
const newRoutes = _.map(this.formValues.PublishedPorts, (p) => (p.IsNew && p.IngressRoute ? (p.IngressHost || p.IngressName) + p.IngressRoute : undefined));
const toDelRoutes = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion && p.IngressRoute ? (p.IngressHost || p.IngressName) + p.IngressRoute : undefined));
const allRoutes = _.flatMap(this.ingresses, (i) => _.map(i.Paths, (p) => (p.Host || i.Name) + p.Path));
const duplicates = KubernetesFormValidationHelper.getDuplicates(newRoutes);
_.forEach(newRoutes, (route, idx) => {
if (_.includes(allRoutes, route) && !_.includes(toDelRoutes, route)) {
@ -814,7 +816,6 @@ class KubernetesCreateApplicationController {
actionInProgress: false,
useLoadBalancer: false,
useServerMetrics: false,
canUseIngress: false,
sliders: {
cpu: {
min: 0,
@ -834,17 +835,17 @@ class KubernetesCreateApplicationController {
availableSizeUnits: ['MB', 'GB', 'TB'],
alreadyExists: false,
duplicates: {
environmentVariables: new KubernetesApplicationFormValidationDuplicate(),
persistedFolders: new KubernetesApplicationFormValidationDuplicate(),
configurationPaths: new KubernetesApplicationFormValidationDuplicate(),
existingVolumes: new KubernetesApplicationFormValidationDuplicate(),
environmentVariables: new KubernetesFormValueDuplicate(),
persistedFolders: new KubernetesFormValueDuplicate(),
configurationPaths: new KubernetesFormValueDuplicate(),
existingVolumes: new KubernetesFormValueDuplicate(),
publishedPorts: {
containerPorts: new KubernetesApplicationFormValidationDuplicate(),
nodePorts: new KubernetesApplicationFormValidationDuplicate(),
ingressRoutes: new KubernetesApplicationFormValidationDuplicate(),
loadBalancerPorts: new KubernetesApplicationFormValidationDuplicate(),
containerPorts: new KubernetesFormValueDuplicate(),
nodePorts: new KubernetesFormValueDuplicate(),
ingressRoutes: new KubernetesFormValueDuplicate(),
loadBalancerPorts: new KubernetesFormValueDuplicate(),
},
placements: new KubernetesApplicationFormValidationDuplicate(),
placements: new KubernetesFormValueDuplicate(),
},
isEdit: false,
params: {

View File

@ -247,7 +247,7 @@
</div>
<!-- internal notice -->
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP">
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP && !ctrl.state.useIngress">
<div class="small text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application is only available for internal usage inside the cluster via the application name <code>{{ ctrl.application.ServiceName }}</code>
@ -261,6 +261,24 @@
</div>
</div>
<!-- ingress notice -->
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP && ctrl.state.useIngress">
<div class="small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application is available for internal usage inside the cluster via the application name <code>{{ ctrl.application.ServiceName }}</code>
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyApplicationName()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</span>
<span id="copyNotificationApplicationName" style="margin-left: 7px; display: none; color: #23ae89;" class="small"
><i class="fa fa-check" aria-hidden="true"></i> copied</span
>
</p>
<p>It can also be accessed via specific HTTP route(s).</p>
</div>
<div class="small text-muted" style="margin-top: 2px;">
<p>Refer to the below port configuration to access the application.</p>
</div>
</div>
<!-- table -->
<div style="margin-top: 15px;">
<table class="table">

View File

@ -295,6 +295,10 @@ class KubernetesApplicationController {
this.formValues.SelectedRevision = _.find(this.application.Revisions, { revision: this.application.CurrentRevision.revision });
}
this.state.useIngress = _.find(application.PublishedPorts, (p) => {
return this.portHasIngressRules(p);
});
this.placements = computePlacements(nodes, this.application);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
@ -322,6 +326,7 @@ class KubernetesApplicationController {
},
eventWarningCount: 0,
expandedNote: false,
useIngress: false,
};
this.state.activeTab = this.LocalStorage.getActiveTab('application');

View File

@ -33,42 +33,91 @@
<div class="form-group">
<span class="col-sm-12 text-muted small">
Enabling the ingress feature will allow users to expose application they deploy over a HTTP route.<br />
Adding ingress controllers will allow users to expose application they deploy over a HTTP route.<br />
<p style="margin-top: 2px;">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Ingress classes (controllers) must be manually specified for each one you want to use in the cluster. Make sure that each controller is running inside your
cluster.
Ingress classes must be manually specified for each controller you want to use in the cluster. Make sure that each controller is running inside your cluster.
</p>
</span>
<div class="col-sm-12">
<label class="control-label text-left">
Allow users to use ingress
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.UseIngress" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="ctrl.formValues.UseIngress">
<label for="ingress_classes" class="col-sm-3 col-lg-2 control-label text-left">
Ingress classes
<portainer-tooltip position="bottom" message="Provide a comma separated list of all the ingress classes available in your cluster."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="ingress_classes"
id="ingress_classes"
ng-model="ctrl.formValues.IngressClasses"
placeholder="nginx,gce,traefik"
required
/>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Ingress controller</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addIngressClass()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add ingress controller
</span>
</div>
<div class="col-sm-12 small text-warning" ng-show="kubernetesClusterSetupForm.ingress_classes.$invalid">
<div ng-messages="kubernetesClusterSetupForm.ingress_classes.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat-start="ingressClass in ctrl.formValues.IngressClasses" style="margin-top: 2px;">
<div class="col-sm-7 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
<span class="input-group-addon">Ingress class</span>
<input
type="text"
class="form-control"
name="ingress_class_name_{{ $index }}"
ng-model="ingressClass.Name"
placeholder="nginx"
ng-pattern="/^[a-z]([-a-z0-9]*[a-z0-9])?$/"
ng-change="ctrl.onChangeIngressClassName($index)"
required
/>
</div>
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
<span class="input-group-addon">Type</span>
<select
class="form-control"
name="ingress_class_type_{{ $index }}"
ng-model="ingressClass.Type"
ng-options="value as value for (key, value) in ctrl.IngressClassTypes"
required
>
<option selected disabled hidden value="">Select a type</option>
</select>
</div>
<div class="col-sm-1 input-group input-group-sm">
<button ng-if="!ingressClass.NeedsDeletion" class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeIngressClass($index)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
<button ng-if="ingressClass.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restoreIngressClass($index)">
<i class="fa fa-trash-restore" aria-hidden="true"></i>
</button>
</div>
</div>
<div
ng-repeat-end
ng-show="
kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid ||
kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid ||
ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined
"
>
<div class="col-sm-7 input-group">
<div
class="small text-warning"
style="margin-top: 5px;"
ng-if="kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid || ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined"
>
<div ng-messages="kubernetesClusterSetupForm['ingress_class_name_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Ingress class name is required.</p>
<p ng-message="pattern"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field must consist of lower case alphanumeric characters or '-', start with an
alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').</p
>
</div>
<p ng-if="ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This ingress class is already defined.
</p>
</div>
</div>
<div class="col-sm-3 input-group">
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid">
<div ng-messages="kubernetesClusterSetupForm['ingress_class_type_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Ingress class type is required.</p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,10 +1,15 @@
import _ from 'lodash-es';
import * as _ from 'lodash-es';
import angular from 'angular';
import { KubernetesStorageClassAccessPolicies, KubernetesStorageClass } from 'Kubernetes/models/storage-class/models';
import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
import { KubernetesFormValueDuplicate } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
class KubernetesConfigureController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor($async, $state, $stateParams, Notifications, KubernetesStorageService, EndpointService, EndpointProvider) {
constructor($async, $state, $stateParams, Notifications, KubernetesStorageService, EndpointService, EndpointProvider, ModalService) {
this.$async = $async;
this.$state = $state;
this.$stateParams = $stateParams;
@ -12,11 +17,16 @@ class KubernetesConfigureController {
this.KubernetesStorageService = KubernetesStorageService;
this.EndpointService = EndpointService;
this.EndpointProvider = EndpointProvider;
this.ModalService = ModalService;
this.IngressClassTypes = KubernetesIngressClassTypes;
this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this);
}
/* #endregion */
/* #region STORAGE CLASSES UI MANAGEMENT */
storageClassAvailable() {
return this.StorageClasses && this.StorageClasses.length > 0;
}
@ -28,55 +38,99 @@ class KubernetesConfigureController {
valid = false;
}
});
return valid;
}
/* #endregion */
/* #region INGRESS CLASSES UI MANAGEMENT */
addIngressClass() {
this.formValues.IngressClasses.push(new KubernetesIngressClass());
this.onChangeIngressClass();
}
restoreIngressClass(index) {
this.formValues.IngressClasses[index].NeedsDeletion = false;
this.onChangeIngressClass();
}
removeIngressClass(index) {
if (!this.formValues.IngressClasses[index].IsNew) {
this.formValues.IngressClasses[index].NeedsDeletion = true;
} else {
this.formValues.IngressClasses.splice(index, 1);
}
this.onChangeIngressClass();
}
onChangeIngressClass() {
const state = this.state.duplicates.ingressClasses;
const source = _.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic.Name));
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasDuplicates = Object.keys(duplicates).length > 0;
}
onChangeIngressClassName(index) {
const fv = this.formValues.IngressClasses[index];
if (_.includes(fv.Name, KubernetesIngressClassTypes.NGINX)) {
fv.Type = KubernetesIngressClassTypes.NGINX;
} else if (_.includes(fv.Name, KubernetesIngressClassTypes.TRAEFIK)) {
fv.Type = KubernetesIngressClassTypes.TRAEFIK;
}
this.onChangeIngressClass();
}
/* #endregion */
/* #region CONFIGURE */
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
}
transformFormValues() {
const storageClasses = _.map(this.StorageClasses, (item) => {
if (item.selected) {
const res = new KubernetesStorageClass();
res.Name = item.Name;
res.AccessModes = _.map(item.AccessModes, 'Name');
res.Provisioner = item.Provisioner;
res.AllowVolumeExpansion = item.AllowVolumeExpansion;
return res;
}
});
_.pull(storageClasses, undefined);
const ingressClasses = _.without(
_.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic)),
undefined
);
_.pull(ingressClasses, undefined);
return [storageClasses, ingressClasses];
}
async configureAsync() {
try {
this.state.actionInProgress = true;
const classes = _.without(
_.map(this.StorageClasses, (item) => {
if (item.selected) {
const res = new KubernetesStorageClass();
res.Name = item.Name;
res.AccessModes = _.map(item.AccessModes, 'Name');
res.Provisioner = item.Provisioner;
res.AllowVolumeExpansion = item.AllowVolumeExpansion;
return res;
}
}),
undefined
);
const [storageClasses, ingressClasses] = this.transformFormValues();
this.endpoint.Kubernetes.Configuration.StorageClasses = classes;
this.endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
this.endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
this.endpoint.Kubernetes.Configuration.UseIngress = this.formValues.UseIngress;
if (this.formValues.UseIngress) {
this.endpoint.Kubernetes.Configuration.IngressClasses = _.split(this.formValues.IngressClasses, ',');
}
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
const storagePromises = _.map(classes, (storageClass) => {
const storagePromises = _.map(storageClasses, (storageClass) => {
const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
if (oldStorageClass) {
return this.KubernetesStorageService.patch(this.state.endpointId, oldStorageClass, storageClass);
}
});
await Promise.all(storagePromises);
const endpoints = this.EndpointProvider.endpoints();
const modifiedEndpoint = _.find(endpoints, (item) => item.Id === this.endpoint.Id);
if (modifiedEndpoint) {
modifiedEndpoint.Kubernetes.Configuration.StorageClasses = classes;
modifiedEndpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
modifiedEndpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
modifiedEndpoint.Kubernetes.Configuration.UseIngress = this.formValues.UseIngress;
if (this.formValues.UseIngress) {
modifiedEndpoint.Kubernetes.Configuration.IngressClasses = _.split(this.formValues.IngressClasses, ',');
}
this.assignFormValuesToEndpoint(modifiedEndpoint, storageClasses, ingressClasses);
this.EndpointProvider.setEndpoints(endpoints);
}
this.Notifications.success('Configuration successfully applied');
@ -89,22 +143,38 @@ class KubernetesConfigureController {
}
configure() {
return this.$async(this.configureAsync);
const toDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
if (toDel.length) {
this.ModalService.confirmUpdate(
`Removing ingress controllers will make them unavailable for future use.<br/>Existing resources linked to these ingress controllers will continue to live in cluster but you will not be able to remove them from Portainer.<br/><br/>Do you wish to continue?`,
(confirmed) => {
if (confirmed) {
return this.$async(this.configureAsync);
}
}
);
} else {
return this.$async(this.configureAsync);
}
}
/* #endregion */
/* #region ON INIT */
async onInit() {
this.state = {
actionInProgress: false,
displayConfigureClassPanel: {},
viewReady: false,
endpointId: this.$stateParams.id,
duplicates: {
ingressClasses: new KubernetesFormValueDuplicate(),
},
};
this.formValues = {
UseLoadBalancer: false,
UseServerMetrics: false,
UseIngress: false,
IngressClasses: '',
IngressClasses: [],
};
try {
@ -127,8 +197,11 @@ class KubernetesConfigureController {
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
this.formValues.UseIngress = this.endpoint.Kubernetes.Configuration.UseIngress;
this.formValues.IngressClasses = _.join(this.endpoint.Kubernetes.Configuration.IngressClasses);
this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
ic.IsNew = false;
ic.NeedsDeletion = false;
return ic;
});
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve endpoint configuration');
} finally {
@ -139,6 +212,7 @@ class KubernetesConfigureController {
$onInit() {
return this.$async(this.onInit);
}
/* #endregion */
}
export default KubernetesConfigureController;

View File

@ -129,54 +129,126 @@
</div>
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Ingresses
</div>
<!-- #region INGRESSES -->
<div class="form-group" ng-if="!ctrl.state.canUseIngress">
<div class="col-sm-12 small text-muted">
The ingress feature must be enabled in the
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside this
resource pool.
<div ng-if="ctrl.state.canUseIngress">
<div class="col-sm-12 form-section-title">
Ingresses
</div>
</div>
<div class="form-group" ng-if="ctrl.state.canUseIngress">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You can enable one or multiple ingresses to be used when deploying an application inside this resource pool.
</p>
<!-- #region INGRESSES -->
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length === 0">
<div class="col-sm-12 small text-muted">
The ingress feature must be enabled in the
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside this
resource pool.
</div>
</div>
<div class="col-sm-12">
<table class="table" style="table-layout: fixed;">
<tbody>
<tr class="text-muted">
<td style="width: 33%;">Ingress controller</td>
<td style="width: 66%;">
Hostname
<portainer-tooltip
position="bottom"
message="Optional 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 or via IP address directly if not defined."
>
</portainer-tooltip>
</td>
</tr>
<tr ng-repeat="class in ctrl.formValues.IngressClasses">
<td style="width: 33%;">
<div style="margin: 5px;">
<label class="switch" style="margin-right: 10px;"> <input type="checkbox" ng-model="class.Selected" /><i></i> </label>
<span>{{ class.Name }}</span>
</div>
</td>
<td style="width: 66%;">
<input class="form-control" ng-model="class.Host" placeholder="host.com" />
</td>
</tr>
</tbody>
</table>
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length > 0">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Enable and configure ingresses available to users when deploying applications.
</p>
</div>
</div>
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses">
<div class="text-muted col-sm-12" style="width: 100%;">
<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 }}
</div>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<label class="control-label text-left">
Allow users to use this ingress
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ic.Selected" /><i></i> </label>
</div>
</div>
<div ng-if="ic.Selected">
<div class="form-group">
<label class="control-label text-left col-sm-2">
Hostname
<portainer-tooltip
position="bottom"
message="Optional 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 or via IP address directly if not defined."
>
</portainer-tooltip>
</label>
<div class="col-sm-10">
<input class="form-control" ng-model="ic.Host" placeholder="host.com" ng-change="ctrl.onChangeIngressHostname()" />
</div>
</div>
<div class="form-group" ng-show="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
<div class="col-sm-12 small text-warning">
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
This host is already used.
</p>
</div>
</div>
<div class="form-group" ng-if="ic.IngressClass.Type === 'nginx'">
<div class="col-sm-12">
<label class="control-label text-left">
Redirect published routes to / in application
<portainer-tooltip
position="bottom"
message="Enables the redirection of any route published via ingress to the root path of the application, e.g. /path remaps to /"
>
</portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ic.RewriteTarget" /><i></i> </label>
</div>
</div>
</div>
<div ng-repeat-end class="form-group" ng-if="ic.Selected" style="margin-bottom: 20px;">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-if="!ic.AdvancedConfig" ng-click="ic.AdvancedConfig = true">
<i class="fa fa-plus space-right" aria-hidden="true"></i> Advanced configuration
</a>
<a class="small interactive" ng-if="ic.AdvancedConfig" ng-click="ic.AdvancedConfig = false">
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide configuration
</a>
</p>
</div>
<div class="col-sm-12 small text-muted" ng-if="ic.AdvancedConfig" style="margin-top: 5px;">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You can specify a list of annotations that will be associated to the ingress.
</p>
</div>
<div class="col-sm-12" ng-if="ic.AdvancedConfig">
<label class="control-label text-left">Annotations</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addAnnotation(ic)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add annotation
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;" ng-if="ic.AdvancedConfig">
<div ng-repeat="annotation in ic.Annotations track by $index" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Key</span>
<input type="text" class="form-control" ng-model="annotation.Key" placeholder="nginx.ingress.kubernetes.io/rewrite-target" required />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Value</span>
<input type="text" class="form-control" ng-model="annotation.Value" placeholder="/$1" required />
</div>
<div class="col-sm-1 input-group input-group-sm">
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeAnnotation(ic, $index)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<!-- #endregion -->
</div>
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Actions
@ -187,7 +259,7 @@
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="!resourcePoolCreationForm.$valid || ctrl.state.actionInProgress || (ctrl.formValues.HasQuota && !ctrl.isQuotaValid()) || !ctrl.isValid()"
ng-disabled="!resourcePoolCreationForm.$valid || ctrl.isCreateButtonDisabled()"
ng-click="ctrl.createResourcePool()"
button-spinner="ctrl.state.actionInProgress"
>

View File

@ -3,11 +3,15 @@ import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassAnnotationFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesFormValueDuplicate } from 'Kubernetes/models/application/formValues';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, Authentication, EndpointProvider) {
constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointProvider) {
this.$async = $async;
this.$state = $state;
this.Notifications = Notifications;
@ -16,14 +20,42 @@ class KubernetesCreateResourcePoolController {
this.KubernetesNodeService = KubernetesNodeService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesIngressService = KubernetesIngressService;
this.onInit = this.onInit.bind(this);
this.createResourcePoolAsync = this.createResourcePoolAsync.bind(this);
this.getResourcePoolsAsync = this.getResourcePoolsAsync.bind(this);
this.getIngressesAsync = this.getIngressesAsync.bind(this);
}
/* #endregion */
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
const hosts = _.map(this.formValues.IngressClasses, 'Host');
const allHosts = _.map(this.allIngresses, 'Host');
const duplicates = KubernetesFormValidationHelper.getDuplicates(hosts);
_.forEach(hosts, (host, idx) => {
if (_.includes(allHosts, host) && host !== undefined) {
duplicates[idx] = host;
}
});
state.refs = duplicates;
state.hasDuplicates = Object.keys(duplicates).length > 0;
}
isValid() {
return !this.state.isAlreadyExist;
/* #region ANNOTATIONS MANAGEMENT */
addAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
}
removeAnnotation(ingressClass, index) {
ingressClass.Annotations.splice(index, 1);
}
/* #endregion */
isCreateButtonDisabled() {
return this.state.actionInProgress || (this.formValues.HasQuota && !this.isQuotaValid()) || this.state.isAlreadyExist || this.state.duplicates.ingressHosts.hasDuplicates;
}
onChangeName() {
@ -50,6 +82,7 @@ class KubernetesCreateResourcePoolController {
}
}
/* #region CREATE RESOURCE POOL */
async createResourcePoolAsync() {
this.state.actionInProgress = true;
try {
@ -69,7 +102,26 @@ class KubernetesCreateResourcePoolController {
createResourcePool() {
return this.$async(this.createResourcePoolAsync);
}
/* #endregion */
/* #region GET INGRESSES */
async getIngressesAsync() {
this.state.ingressesLoading = true;
try {
this.allIngresses = await this.KubernetesIngressService.get();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.');
} finally {
this.state.ingressesLoading = false;
}
}
getIngresses() {
return this.$async(this.getIngressesAsync);
}
/* #endregion */
/* #region GET RESOURCE POOLS */
async getResourcePoolsAsync() {
try {
this.resourcePools = await this.KubernetesResourcePoolService.get();
@ -81,7 +133,9 @@ class KubernetesCreateResourcePoolController {
getResourcePools() {
return this.$async(this.getResourcePoolsAsync);
}
/* #endregion */
/* #region ON INIT */
async onInit() {
try {
const endpoint = this.EndpointProvider.currentEndpoint();
@ -95,7 +149,10 @@ class KubernetesCreateResourcePoolController {
sliderMaxCpu: 0,
viewReady: false,
isAlreadyExist: false,
canUseIngress: endpoint.Kubernetes.Configuration.UseIngress,
canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length,
duplicates: {
ingressHosts: new KubernetesFormValueDuplicate(),
},
};
const nodes = await this.KubernetesNodeService.get();
@ -107,8 +164,9 @@ class KubernetesCreateResourcePoolController {
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
await this.getResourcePools();
if (this.state.canUseIngress) {
await this.getIngresses();
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
this.formValues.IngressClasses = _.map(ingressClasses, (item) => new KubernetesResourcePoolIngressClassFormValue(item));
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
@ -120,6 +178,7 @@ class KubernetesCreateResourcePoolController {
$onInit() {
return this.$async(this.onInit);
}
/* #endregion */
}
export default KubernetesCreateResourcePoolController;

View File

@ -120,45 +120,125 @@
>
</kubernetes-resource-reservation>
</div>
<div ng-if="ctrl.isAdmin && ctrl.isEditable">
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.canUseIngress">
<div class="col-sm-12 form-section-title">
Ingresses
</div>
<div class="form-group" ng-if="!ctrl.state.canUseIngress">
<!-- #region INGRESSES -->
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length === 0">
<div class="col-sm-12 small text-muted">
The ingress feature must be enabled in the
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside
this resource pool.
</div>
</div>
<div class="form-group col-sm-12" ng-if="ctrl.state.canUseIngress">
<table class="table" style="table-layout: fixed;">
<tbody>
<tr class="text-muted">
<td style="width: 33%; border-top: 0px;">Ingress controller</td>
<td style="width: 66%; border-top: 0px;">
Hostname
<portainer-tooltip
position="bottom"
message="Optional 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 or via IP address directly if not defined."
>
</portainer-tooltip>
</td>
</tr>
<tr ng-repeat="class in ctrl.formValues.IngressClasses">
<td style="width: 33%;">
<div style="margin: 5px;">
<label class="switch" style="margin-right: 10px;"> <input type="checkbox" ng-model="class.Selected" /><i></i> </label>
{{ class.Name }}
</div>
</td>
<td style="width: 66%;">
<input class="form-control" ng-model="class.Host" placeholder="host.com" />
</td>
</tr>
</tbody>
</table>
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length > 0">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Enable and configure ingresses available to users when deploying applications.
</p>
</div>
</div>
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses">
<div class="text-muted col-sm-12" style="width: 100%;">
<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 }}
</div>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<label class="control-label text-left">
Allow users to use this ingress
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ic.Selected" /><i></i> </label>
</div>
</div>
<div ng-if="ic.Selected">
<div class="form-group">
<label class="control-label text-left col-sm-2">
Hostname
<portainer-tooltip
position="bottom"
message="Optional 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 or via IP address directly if not defined."
>
</portainer-tooltip>
</label>
<div class="col-sm-10">
<input class="form-control" ng-model="ic.Host" placeholder="host.com" ng-change="ctrl.onChangeIngressHostname()" />
</div>
</div>
<div class="form-group" ng-show="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
<div class="col-sm-12 small text-warning">
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
This host is already used.
</p>
</div>
</div>
<div class="form-group" ng-if="ic.IngressClass.Type === 'nginx'">
<div class="col-sm-12">
<label class="control-label text-left">
Redirect published routes to / in application
<portainer-tooltip
position="bottom"
message="Enables the redirection of any route published via ingress to the root path of the application, e.g. /path remaps to /"
>
</portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ic.RewriteTarget" /><i></i> </label>
</div>
</div>
</div>
<div ng-repeat-end class="form-group" ng-if="ic.Selected" style="margin-bottom: 20px;">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-if="!ic.AdvancedConfig" ng-click="ic.AdvancedConfig = true">
<i class="fa fa-plus space-right" aria-hidden="true"></i> Advanced configuration
</a>
<a class="small interactive" ng-if="ic.AdvancedConfig" ng-click="ic.AdvancedConfig = false">
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide configuration
</a>
</p>
</div>
<div class="col-sm-12 small text-muted" ng-if="ic.AdvancedConfig" style="margin-top: 5px;">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You can specify a list of annotations that will be associated to the ingress.
</p>
</div>
<div class="col-sm-12" ng-if="ic.AdvancedConfig">
<label class="control-label text-left">Annotations</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addAnnotation(ic)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add annotation
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;" ng-if="ic.AdvancedConfig">
<div ng-repeat="annotation in ic.Annotations track by $index" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Key</span>
<input type="text" class="form-control" ng-model="annotation.Key" placeholder="nginx.ingress.kubernetes.io/rewrite-target" required />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Value</span>
<input type="text" class="form-control" ng-model="annotation.Value" placeholder="/$1" required />
</div>
<div class="col-sm-1 input-group input-group-sm">
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeAnnotation(ic, $index)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<!-- #endregion -->
</div>
<!-- actions -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="col-sm-12 form-section-title">
@ -169,7 +249,7 @@
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="!resourcePoolEditForm.$valid || ctrl.state.actionInProgress || (ctrl.formValues.HasQuota && !ctrl.isQuotaValid())"
ng-disabled="!resourcePoolEditForm.$valid || ctrl.isUpdateButtonDisabled()"
ng-click="ctrl.updateResourcePool()"
button-spinner="ctrl.state.actionInProgress"
>

View File

@ -4,9 +4,13 @@ import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuota, KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassAnnotationFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { KubernetesFormValueDuplicate } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor(
$async,
@ -52,11 +56,42 @@ class KubernetesResourcePoolController {
this.getIngresses = this.getIngresses.bind(this);
this.getIngressesAsync = this.getIngressesAsync.bind(this);
}
/* #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.hasDuplicates = Object.keys(duplicates).length > 0;
}
/* #region ANNOTATIONS MANAGEMENT */
addAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
}
removeAnnotation(ingressClass, index) {
ingressClass.Annotations.splice(index, 1);
}
/* #endregion */
selectTab(index) {
this.LocalStorage.storeActiveTab('resourcePool', index);
}
isUpdateButtonDisabled() {
return this.state.actionInProgress || (this.formValues.HasQuota && !this.isQuotaValid()) || this.state.duplicates.ingressHosts.hasDuplicates;
}
isQuotaValid() {
if (
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
@ -126,17 +161,16 @@ class KubernetesResourcePoolController {
const promises = _.map(this.formValues.IngressClasses, (c) => {
c.Namespace = namespace;
const original = _.find(this.savedIngressClasses, { Name: c.Name });
if (c.WasSelected === false && c.Selected === true) {
return this.KubernetesIngressService.create(c);
const ingress = KubernetesIngressConverter.resourcePoolIngressClassFormValueToIngress(c);
return this.KubernetesIngressService.create(ingress);
} else if (c.WasSelected === true && c.Selected === false) {
return this.KubernetesIngressService.delete(c);
} else if (c.Selected === true && original && original.Host !== c.Host) {
const oldIngress = _.find(this.ingresses, { Name: c.Name });
const newIngress = angular.copy(oldIngress);
newIngress.PreviousHost = original.Host;
newIngress.Host = c.Host;
} else if (c.WasSelected === true && c.Selected === true) {
const oldIngress = _.find(this.ingresses, { Name: c.IngressClass.Name });
const newIngress = KubernetesIngressConverter.resourcePoolIngressClassFormValueToIngress(c);
newIngress.Paths = angular.copy(oldIngress.Paths);
newIngress.PreviousHost = oldIngress.Host;
return this.KubernetesIngressService.patch(oldIngress, newIngress);
}
});
@ -180,6 +214,7 @@ class KubernetesResourcePoolController {
return this.state.eventWarningCount;
}
/* #region GET EVENTS */
async getEventsAsync() {
try {
this.state.eventsLoading = true;
@ -195,7 +230,9 @@ class KubernetesResourcePoolController {
getEvents() {
return this.$async(this.getEventsAsync);
}
/* #endregion */
/* #region GET APPLICATIONS */
async getApplicationsAsync() {
try {
this.state.applicationsLoading = true;
@ -216,12 +253,15 @@ class KubernetesResourcePoolController {
getApplications() {
return this.$async(this.getApplicationsAsync);
}
/* #endregion */
/* #region GET INGRESSES */
async getIngressesAsync() {
this.state.ingressesLoading = true;
try {
const namespace = this.pool.Namespace.Name;
this.ingresses = await this.KubernetesIngressService.get(namespace);
this.allIngresses = await this.KubernetesIngressService.get();
this.ingresses = _.filter(this.allIngresses, { Namespace: namespace });
_.forEach(this.ingresses, (ing) => {
ing.Namespace = namespace;
_.forEach(ing.Paths, (path) => {
@ -239,7 +279,9 @@ class KubernetesResourcePoolController {
getIngresses() {
return this.$async(this.getIngressesAsync);
}
/* #endregion */
/* #region ON INIT */
async onInit() {
try {
const endpoint = this.EndpointProvider.currentEndpoint();
@ -265,7 +307,10 @@ class KubernetesResourcePoolController {
ingressesLoading: true,
viewReady: false,
eventWarningCount: 0,
canUseIngress: endpoint.Kubernetes.Configuration.UseIngress,
canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length,
duplicates: {
ingressHosts: new KubernetesFormValueDuplicate(),
},
};
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
@ -304,17 +349,7 @@ class KubernetesResourcePoolController {
if (this.state.canUseIngress) {
await this.getIngresses();
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
this.formValues.IngressClasses = _.map(ingressClasses, (item) => {
const iClass = new KubernetesResourcePoolIngressClassFormValue(item);
const matchingIngress = _.find(this.ingresses, { Name: iClass.Name });
if (matchingIngress) {
iClass.Selected = true;
iClass.WasSelected = true;
iClass.Host = matchingIngress.Host;
}
return iClass;
});
this.savedIngressClasses = angular.copy(this.formValues.IngressClasses);
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
@ -326,6 +361,7 @@ class KubernetesResourcePoolController {
$onInit() {
return this.$async(this.onInit);
}
/* #endregion */
$onDestroy() {
if (this.state.currentName !== this.$state.$current.name) {