feat(k8s/applications): expose applications via ingress (#4136)

* feat(k8s/endpoint): expose ingress controllers on endpoints

* feat(k8s/applications): add ability to expose applications over ingress - missing RP and app edits

* feat(k8s/application): add validation for ingress routes

* feat(k8s/resource-pools): edit available ingress classes

* fix(k8s/ingress): var name refactor was partially applied

* feat(kubernetes): double validation on RP edit

* feat(k8s/application): app edit ingress update + formvalidation + UI rework

* feat(k8s/ingress): dictionary for default annotations on ingress creation

* fix(k8s/application): temporary fix + TODO dev notice

* feat(k8s/application): select default ingress of selected resource pool

* feat(k8s/ingress): revert ingressClassName removal

* feat(k8s/ingress): admins can now add an host to ingress in a resource pool

* feat(k8s/resource-pool): list applications using RP ingresses

* feat(k8s/configure): minor UI update

* feat(k8s/configure): minor UI update

* feat(k8s/configure): minor UI update

* feat(k8s/configure): minor UI update

* feat(k8s/configure): minor UI update

* fix(k8s/ingresses): remove host if undefined

* feat(k8s/resource-pool): remove the activate ingresses switch

* fix(k8s/resource-pool): edditing an ingress host was deleting all the routes of the ingress

* feat(k8s/application): prevent app deploy if no ports to publish and publishing type not internal

* feat(k8s/ingress): minor UI update

* fix(k8s/ingress): allow routes without prepending /

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

Co-authored-by: Anthony Lapenna <lapenna.anthony@gmail.com>
feat4204-banner
xAt0mZ 2020-08-13 01:30:23 +02:00 committed by GitHub
parent 201c3ac143
commit f91d3f1ca3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1595 additions and 443 deletions

View File

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

View File

@ -332,7 +332,9 @@ type (
KubernetesConfiguration struct {
UseLoadBalancer bool `json:"UseLoadBalancer"`
UseServerMetrics bool `json:"UseServerMetrics"`
UseIngress bool `json:"UseIngress"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
IngressClasses []string `json:"IngressClasses"`
}
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration

View File

@ -84,7 +84,7 @@
<div class="col-sm-1"></div>
<div class="col-sm-11">
<button type="button" class="btn btn-sm btn-danger" style="margin-left: 0;" ng-click="$ctrl.removeEntry(index)">
<i class="fa fa-trash" aria-hidden="true"></i> Remove entry
<i class="fa fa-trash-alt" aria-hidden="true"></i> Remove entry
</button>
</div>
</div>

View File

@ -49,7 +49,7 @@ function _apiPortsToPublishedPorts(pList, pRefs) {
}
class KubernetesApplicationConverter {
static applicationCommon(res, data, service, ingressRules) {
static applicationCommon(res, data, service, ingresses) {
res.Id = data.metadata.uid;
res.Name = data.metadata.name;
res.StackName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationStackNameLabel] || '-' : '-';
@ -111,7 +111,7 @@ class KubernetesApplicationConverter {
const portsRefs = _.concat(..._.map(data.spec.template.spec.containers, (container) => container.ports));
const ports = _apiPortsToPublishedPorts(service.spec.ports, portsRefs);
const rules = KubernetesIngressHelper.findSBoundServiceIngressesRules(ingressRules, service);
const rules = KubernetesIngressHelper.findSBoundServiceIngressesRules(ingresses, service.metadata.name);
_.forEach(ports, (port) => (port.IngressRules = _.filter(rules, (rule) => rule.Port === port.Port)));
res.PublishedPorts = ports;
}
@ -210,9 +210,9 @@ class KubernetesApplicationConverter {
);
}
static apiDeploymentToApplication(data, service, ingressRules) {
static apiDeploymentToApplication(data, service, ingresses) {
const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, service, ingressRules);
KubernetesApplicationConverter.applicationCommon(res, data, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.DEPLOYMENT;
res.DeploymentType = KubernetesApplicationDeploymentTypes.REPLICATED;
res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.SHARED;
@ -221,9 +221,9 @@ class KubernetesApplicationConverter {
return res;
}
static apiDaemonSetToApplication(data, service, ingressRules) {
static apiDaemonSetToApplication(data, service, ingresses) {
const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, service, ingressRules);
KubernetesApplicationConverter.applicationCommon(res, data, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.DAEMONSET;
res.DeploymentType = KubernetesApplicationDeploymentTypes.GLOBAL;
res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.SHARED;
@ -232,9 +232,9 @@ class KubernetesApplicationConverter {
return res;
}
static apiStatefulSetToapplication(data, service, ingressRules) {
static apiStatefulSetToapplication(data, service, ingresses) {
const res = new KubernetesApplication();
KubernetesApplicationConverter.applicationCommon(res, data, service, ingressRules);
KubernetesApplicationConverter.applicationCommon(res, data, service, ingresses);
res.ApplicationType = KubernetesApplicationTypes.STATEFULSET;
res.DeploymentType = KubernetesApplicationDeploymentTypes.REPLICATED;
res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.ISOLATED;
@ -261,15 +261,18 @@ class KubernetesApplicationConverter {
res.PersistedFolders = KubernetesApplicationHelper.generatePersistedFoldersFormValuesFromPersistedFolders(app.PersistedFolders, persistentVolumeClaims); // generate from PVC and app.PersistedFolders
res.Configurations = KubernetesApplicationHelper.generateConfigurationFormValuesFromEnvAndVolumes(app.Env, app.ConfigurationVolumes, configurations);
res.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(app.AutoScaler, res.ReplicaCount);
res.PublishedPorts = KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(app.ServiceType, app.PublishedPorts);
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) {
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT && !isIngress) {
res.PublishingType = KubernetesApplicationPublishingTypes.CLUSTER;
} else if (app.ServiceType === KubernetesServiceTypes.NODE_PORT && isIngress) {
res.PublishingType = KubernetesApplicationPublishingTypes.INGRESS;
} else {
res.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL;
}
res.PublishedPorts = KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(app.ServiceType, app.PublishedPorts);
return res;
}
@ -313,6 +316,7 @@ class KubernetesApplicationConverter {
if (!service.Ports.length) {
service = undefined;
}
return [app, headlessService, service, claims];
}
}

View File

@ -1,4 +1,4 @@
import _ from 'lodash-es';
import * as _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch';
import { KubernetesServiceCreatePayload } from 'Kubernetes/models/service/payloads';
@ -11,8 +11,9 @@ import { KubernetesServiceHeadlessClusterIP, KubernetesService, KubernetesServic
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models';
import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
class KubernetesServiceConverter {
static publishedPortToServicePort(name, publishedPort, type) {
function _publishedPortToServicePort(formValues, publishedPort, type) {
if (publishedPort.IsNew || !publishedPort.NeedsDeletion) {
const name = formValues.Name;
const res = new KubernetesServicePort();
res.name = _.toLower(name + '-' + publishedPort.ContainerPort + '-' + publishedPort.Protocol);
res.port = type === KubernetesServiceTypes.LOAD_BALANCER ? publishedPort.LoadBalancerPort : publishedPort.ContainerPort;
@ -27,7 +28,9 @@ class KubernetesServiceConverter {
}
return res;
}
}
class KubernetesServiceConverter {
/**
* Generate KubernetesService from KubernetesApplicationFormValues
* @param {KubernetesApplicationFormValues} formValues
@ -39,12 +42,13 @@ class KubernetesServiceConverter {
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
res.ApplicationOwner = formValues.ApplicationOwner;
res.ApplicationName = formValues.Name;
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER) {
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER || formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
res.Type = KubernetesServiceTypes.NODE_PORT;
} else if (formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
res.Type = KubernetesServiceTypes.LOAD_BALANCER;
}
res.Ports = _.map(formValues.PublishedPorts, (item) => KubernetesServiceConverter.publishedPortToServicePort(formValues.Name, item, res.Type));
const ports = _.map(formValues.PublishedPorts, (item) => _publishedPortToServicePort(formValues, item, res.Type));
res.Ports = _.uniqBy(_.without(ports, undefined), (p) => p.targetPort + p.protocol);
return res;
}

View File

@ -249,8 +249,14 @@ class KubernetesApplicationHelper {
}
static generatePublishedPortsFormValuesFromPublishedPorts(serviceType, publishedPorts) {
const finalRes = _.map(publishedPorts, (port) => {
const generatePort = (port, rule) => {
const res = new KubernetesApplicationPublishedPortFormValue();
res.IsNew = false;
if (rule) {
res.IngressName = rule.IngressName;
res.IngressRoute = rule.Path;
res.IngressHost = rule.Host;
}
res.Protocol = port.Protocol;
res.ContainerPort = port.TargetPort;
if (serviceType === KubernetesServiceTypes.LOAD_BALANCER) {
@ -260,6 +266,13 @@ class KubernetesApplicationHelper {
res.NodePort = port.NodePort;
}
return res;
};
const finalRes = _.flatMap(publishedPorts, (port) => {
if (port.IngressRules.length) {
return _.map(port.IngressRules, (rule) => generatePort(port, rule));
}
return generatePort(port);
});
return finalRes;
}

View File

@ -0,0 +1,4 @@
export const KubernetesIngressClassAnnotation = 'kubernetes.io/ingress.class';
export const KubernetesIngressClassMandatoryAnnotations = Object.freeze({
nginx: { 'nginx.ingress.kubernetes.io/rewrite-target': '/$1' },
});

View File

@ -1,19 +1,113 @@
import * as _ from 'lodash-es';
import { KubernetesIngressRule } from './models';
import * as JsonPatch from 'fast-json-patch';
import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper';
import { KubernetesIngressRule, KubernetesIngress } from './models';
import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads';
import { KubernetesIngressClassAnnotation, KubernetesIngressClassMandatoryAnnotations } from './constants';
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) {
const rules = _.flatMap(data.spec.rules, (rule) => {
return _.map(rule.http.paths, (path) => {
const ingRule = new KubernetesIngressRule();
ingRule.ServiceName = path.backend.serviceName;
ingRule.Host = rule.host;
ingRule.IP = data.status.loadBalancer.ingress ? data.status.loadBalancer.ingress[0].ip : undefined;
ingRule.Port = path.backend.servicePort;
ingRule.Path = path.path;
return ingRule;
});
let host = undefined;
const paths = _.flatMap(data.spec.rules, (rule) => {
host = host || rule.host; // TODO: refactor @LP - read above
return !rule.http
? []
: _.map(rule.http.paths, (path) => {
const ingRule = new KubernetesIngressRule();
ingRule.IngressName = data.metadata.name;
ingRule.ServiceName = path.backend.serviceName;
ingRule.Host = rule.host || '';
ingRule.IP = data.status.loadBalancer.ingress ? data.status.loadBalancer.ingress[0].ip : undefined;
ingRule.Port = path.backend.servicePort;
ingRule.Path = path.path;
return ingRule;
});
});
return rules;
const res = new KubernetesIngress();
res.Name = data.metadata.name;
res.Namespace = data.metadata.namespace;
res.Annotations = data.metadata.annotations || {};
res.IngressClassName =
data.metadata.annotations && data.metadata.annotations[KubernetesIngressClassAnnotation]
? data.metadata.annotations[KubernetesIngressClassAnnotation]
: data.spec.ingressClassName;
res.Paths = paths;
res.Host = host;
return res;
}
static applicationFormValuesToIngresses(formValues, serviceName) {
const ingresses = angular.copy(formValues.OriginalIngresses);
_.forEach(formValues.PublishedPorts, (p) => {
const ingress = _.find(ingresses, { Name: p.IngressName });
if (ingress && p.NeedsDeletion) {
const path = _.find(ingress.Paths, { Port: p.ContainerPort, ServiceName: serviceName, Path: p.IngressRoute });
_.remove(ingress.Paths, path);
} else if (ingress && p.IsNew) {
const rule = new KubernetesIngressRule();
rule.IngressName = ingress.Name;
rule.ServiceName = serviceName;
rule.Port = p.ContainerPort;
rule.Path = _.startsWith(p.IngressRoute, '/') ? p.IngressRoute : '/' + p.IngressRoute;
rule.Host = p.IngressHost;
ingress.Paths.push(rule);
}
});
return ingresses;
}
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);
}
if (data.Paths && data.Paths.length) {
const groups = _.groupBy(data.Paths, 'Host');
const rules = _.map(groups, (paths, host) => {
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);
rule.http.paths = _.map(paths, (p) => {
const path = new KubernetesIngressRulePathCreatePayload();
path.path = p.Path;
path.backend.serviceName = p.ServiceName;
path.backend.servicePort = p.Port;
return path;
});
return rule;
});
KubernetesCommonHelper.assignOrDeleteIfEmpty(res, 'spec.rules', rules);
} else if (data.Host) {
res.spec.rules = [{ host: data.Host }];
} else {
delete res.spec.rules;
}
return res;
}
static patchPayload(oldData, newData) {
const oldPayload = KubernetesIngressConverter.createPayload(oldData);
const newPayload = KubernetesIngressConverter.createPayload(newData);
const payload = JsonPatch.compare(oldPayload, newPayload);
return payload;
}
}

View File

@ -1,7 +1,8 @@
import * as _ from 'lodash-es';
export class KubernetesIngressHelper {
static findSBoundServiceIngressesRules(ingressRules, service) {
return _.filter(ingressRules, (r) => r.ServiceName === service.metadata.name);
static findSBoundServiceIngressesRules(ingresses, serviceName) {
const rules = _.flatMap(ingresses, 'Paths');
return _.filter(rules, { ServiceName: serviceName });
}
}

View File

@ -1,16 +1,26 @@
/**
* KubernetesIngressRule Model
*/
const _KubernetesIngressRule = Object.freeze({
ServiceName: '',
Host: '',
IP: '',
Port: '',
Path: '',
});
export class KubernetesIngressRule {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesIngressRule)));
}
export function KubernetesIngress() {
return {
Name: '',
Namespace: '',
Annotations: {},
Host: undefined,
PreviousHost: undefined, // only use for RP ingress host edit
Paths: [],
IngressClassName: '',
};
}
// TODO: refactor @LP
// rename this model to KubernetesIngressPath (and all it's references)
// as it's conceptually not an ingress rule (element of ingress.spec.rules)
// but a path (element of ingress.spec.rules[].paths)
export function KubernetesIngressRule() {
return {
IngressName: '',
ServiceName: '',
Host: '',
IP: '',
Port: '',
Path: '',
};
}

View File

@ -0,0 +1,33 @@
import { KubernetesCommonMetadataPayload } from 'Kubernetes/models/common/payloads';
export function KubernetesIngressCreatePayload() {
return {
metadata: new KubernetesCommonMetadataPayload(),
spec: {
backend: {
serviceName: 'portainer-empty-default-backend',
servicePort: 1,
},
rules: [],
},
};
}
export function KubernetesIngressRuleCreatePayload() {
return {
host: '',
http: {
paths: [],
},
};
}
export function KubernetesIngressRulePathCreatePayload() {
return {
backend: {
serviceName: '',
servicePort: 0,
},
path: '',
};
}

View File

@ -1,50 +1,47 @@
import { rawResponse } from 'Kubernetes/rest/response/transform';
angular.module('portainer.kubernetes').factory('KubernetesIngresses', [
'$resource',
'API_ENDPOINT_ENDPOINTS',
'EndpointProvider',
function KubernetesIngressesFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return function (namespace) {
const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/networking.k8s.io/v1beta1' + (namespace ? '/namespaces/:namespace' : '') + '/ingresses/:id/:action';
return $resource(
url,
{
endpointId: EndpointProvider.endpointID,
namespace: namespace,
angular.module('portainer.kubernetes').factory('KubernetesIngresses', factory);
function factory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return function (namespace) {
const url = `${API_ENDPOINT_ENDPOINTS}/:endpointId/kubernetes/apis/networking.k8s.io/v1beta1${namespace ? '/namespaces/:namespace' : ''}/ingresses/:id/:action`;
return $resource(
url,
{
endpointId: EndpointProvider.endpointID,
namespace: namespace,
},
{
get: {
method: 'GET',
timeout: 15000,
ignoreLoadingBar: true,
},
{
get: {
method: 'GET',
timeout: 15000,
ignoreLoadingBar: true,
getYaml: {
method: 'GET',
headers: {
Accept: 'application/yaml',
},
getYaml: {
method: 'GET',
headers: {
Accept: 'application/yaml',
},
transformResponse: rawResponse,
ignoreLoadingBar: true,
transformResponse: rawResponse,
ignoreLoadingBar: true,
},
create: { method: 'POST' },
update: { method: 'PUT' },
patch: {
method: 'PATCH',
headers: {
'Content-Type': 'application/json-patch+json',
},
create: { method: 'POST' },
update: { method: 'PUT' },
patch: {
method: 'PATCH',
headers: {
'Content-Type': 'application/json-patch+json',
},
},
rollback: {
method: 'PATCH',
headers: {
'Content-Type': 'application/json-patch+json',
},
rollback: {
method: 'PATCH',
headers: {
'Content-Type': 'application/json-patch+json',
},
},
delete: { method: 'DELETE' },
}
);
};
},
]);
},
delete: { method: 'DELETE' },
}
);
};
}

View File

@ -12,6 +12,9 @@ class KubernetesIngressService {
this.getAsync = this.getAsync.bind(this);
this.getAllAsync = this.getAllAsync.bind(this);
this.createAsync = this.createAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
}
/**
@ -48,6 +51,66 @@ class KubernetesIngressService {
}
return this.$async(this.getAllAsync, namespace);
}
/**
* CREATE
*/
async createAsync(formValues) {
try {
const params = {};
const payload = KubernetesIngressConverter.createPayload(formValues);
const namespace = payload.metadata.namespace;
const data = await this.KubernetesIngresses(namespace).create(params, payload).$promise;
return data;
} catch (err) {
throw new PortainerError('Unable to create ingress', err);
}
}
create(formValues) {
return this.$async(this.createAsync, formValues);
}
/**
* PATCH
*/
async patchAsync(oldIngress, newIngress) {
try {
const params = new KubernetesCommonParams();
params.id = newIngress.Name;
const namespace = newIngress.Namespace;
const payload = KubernetesIngressConverter.patchPayload(oldIngress, newIngress);
if (!payload.length) {
return;
}
const data = await this.KubernetesIngresses(namespace).patch(params, payload).$promise;
return data;
} catch (err) {
throw new PortainerError('Unable to patch ingress', err);
}
}
patch(oldIngress, newIngress) {
return this.$async(this.patchAsync, oldIngress, newIngress);
}
/**
* DELETE
*/
async deleteAsync(ingress) {
try {
const params = new KubernetesCommonParams();
params.id = ingress.Name;
const namespace = ingress.Namespace;
await this.KubernetesIngresses(namespace).delete(params).$promise;
} catch (err) {
throw new PortainerError('Unable to delete ingress', err);
}
}
delete(ingress) {
return this.$async(this.deleteAsync, ingress);
}
}
export default KubernetesIngressService;

View File

@ -22,6 +22,7 @@ const _KubernetesApplicationFormValues = Object.freeze({
DataAccessPolicy: KubernetesApplicationDataAccessPolicies.SHARED,
Configurations: [], // KubernetesApplicationConfigurationFormValue list
AutoScaler: {},
OriginalIngresses: undefined,
});
export class KubernetesApplicationFormValues {
@ -106,18 +107,19 @@ export class KubernetesApplicationPersistedFolderFormValue {
/**
* KubernetesApplicationPublishedPortFormValue Model
*/
const _KubernetesApplicationPublishedPortFormValue = Object.freeze({
ContainerPort: '',
NodePort: '',
LoadBalancerPort: '',
LoadBalancerNodePort: undefined, // only filled to save existing loadbalancer nodePort and drop it when moving app exposure from LB to Internal/NodePort
Protocol: 'TCP',
});
export class KubernetesApplicationPublishedPortFormValue {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPublishedPortFormValue)));
}
export function KubernetesApplicationPublishedPortFormValue() {
return {
NeedsDeletion: false,
IsNew: true,
ContainerPort: '',
NodePort: '',
LoadBalancerPort: '',
LoadBalancerNodePort: undefined, // only filled to save existing loadbalancer nodePort and drop it when moving app exposure from LB to Internal/NodePort
Protocol: 'TCP',
IngressName: undefined,
IngressRoute: undefined,
IngressHost: undefined,
};
}
/**

View File

@ -24,6 +24,7 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({
INTERNAL: 1,
CLUSTER: 2,
LOAD_BALANCER: 3,
INGRESS: 4,
});
export const KubernetesApplicationQuotaDefaults = {

View File

@ -0,0 +1,22 @@
export function KubernetesResourcePoolFormValues(defaults) {
return {
MemoryLimit: defaults.MemoryLimit,
CpuLimit: defaults.CpuLimit,
HasQuota: true,
IngressClasses: [], // KubernetesResourcePoolIngressClassFormValue
};
}
/**
* @param {string} ingressClassName
*/
export function KubernetesResourcePoolIngressClassFormValue(ingressClassName) {
return {
Name: ingressClassName,
IngressClassName: ingressClassName,
Host: undefined,
Selected: false,
WasSelected: false,
Namespace: undefined, // will be filled inside ResourcePoolService.create
};
}

View File

@ -3,18 +3,13 @@ export const KubernetesPortainerResourcePoolNameLabel = 'io.portainer.kubernetes
export const KubernetesPortainerResourcePoolOwnerLabel = 'io.portainer.kubernetes.resourcepool.owner';
/**
* KubernetesResourcePool Model (Composite)
* ResourcePool is a composite model that includes
* A Namespace and a Quota
* KubernetesResourcePool Model
*/
const _KubernetesResourcePool = Object.freeze({
Namespace: {}, // KubernetesNamespace
Quota: undefined, // KubernetesResourceQuota
Yaml: '',
});
export class KubernetesResourcePool {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesResourcePool)));
}
export function KubernetesResourcePool() {
return {
Namespace: {}, // KubernetesNamespace
Quota: undefined, // KubernetesResourceQuota,
Ingresses: [], // KubernetesIngress[]
Yaml: '',
};
}

View File

@ -1,8 +1,8 @@
import _ from 'lodash-es';
import * as _ from 'lodash-es';
import angular from 'angular';
import PortainerError from 'Portainer/error';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import { KubernetesApplicationTypes, KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesApplicationRollbackHelper from 'Kubernetes/helpers/application/rollback';
import KubernetesApplicationConverter from 'Kubernetes/converters/application';
@ -13,8 +13,10 @@ import { KubernetesApplication } from 'Kubernetes/models/application/models';
import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
class KubernetesApplicationService {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor(
$async,
@ -53,10 +55,9 @@ class KubernetesApplicationService {
this.rollbackAsync = this.rollbackAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
}
/* #endregion */
/**
* UTILS
*/
/* #region UTILS */
_getApplicationApiService(app) {
let apiService;
if (app instanceof KubernetesDeployment || (app instanceof KubernetesApplication && app.ApplicationType === KubernetesApplicationTypes.DEPLOYMENT)) {
@ -71,9 +72,15 @@ class KubernetesApplicationService {
return apiService;
}
/**
* GET
*/
_generateIngressPatchPromises(oldIngresses, newIngresses) {
return _.map(newIngresses, (newIng) => {
const oldIng = _.find(oldIngresses, { Name: newIng.Name });
return this.KubernetesIngressService.patch(oldIng, newIng);
});
}
/* #endregion */
/* #region GET */
async getAsync(namespace, name) {
try {
const [deployment, daemonSet, statefulSet, pods, autoScalers, ingresses] = await Promise.allSettled([
@ -121,7 +128,7 @@ class KubernetesApplicationService {
if (scaler && scaler.Yaml) {
application.Yaml += '---\n' + scaler.Yaml;
}
// TODO: refactor
// TODO: refactor @LP
// append ingress yaml ?
return application;
} catch (err) {
@ -185,10 +192,9 @@ class KubernetesApplicationService {
}
return this.$async(this.getAllAsync, namespace);
}
/* #endregion */
/**
* CREATE
*/
/* #region CREATE */
// TODO: review
// resource creation flow
// should we keep formValues > Resource_1 || Resource_2
@ -199,6 +205,10 @@ class KubernetesApplicationService {
if (service) {
await this.KubernetesServiceService.create(service);
if (formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(formValues, service.Name);
await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, ingresses));
}
}
const apiService = this._getApplicationApiService(app);
@ -231,10 +241,9 @@ class KubernetesApplicationService {
create(formValues) {
return this.$async(this.createAsync, formValues);
}
/* #endregion */
/**
* PATCH
*/
/* #region PATCH */
// this function accepts KubernetesApplicationFormValues as parameters
async patchAsync(oldFormValues, newFormValues) {
try {
@ -269,10 +278,23 @@ class KubernetesApplicationService {
if (oldService && newService) {
await this.KubernetesServiceService.patch(oldService, newService);
if (newFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS || oldFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name);
const newIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, newService.Name);
await Promise.all(this._generateIngressPatchPromises(oldIngresses, newIngresses));
}
} else if (!oldService && newService) {
await this.KubernetesServiceService.create(newService);
if (newFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, newService.Name);
await Promise.all(this._generateIngressPatchPromises(newFormValues.OriginalIngresses, ingresses));
}
} else if (oldService && !newService) {
await this.KubernetesServiceService.delete(oldService);
if (oldFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, oldService.Name);
await Promise.all(this._generateIngressPatchPromises(oldFormValues.OriginalIngresses, ingresses));
}
}
const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp);
@ -327,10 +349,9 @@ class KubernetesApplicationService {
}
return this.$async(this.patchAsync, oldValues, newValues);
}
/* #endregion */
/**
* DELETE
*/
/* #region DELETE */
async deleteAsync(application) {
try {
const payload = {
@ -351,8 +372,18 @@ class KubernetesApplicationService {
if (application.ServiceType) {
await this.KubernetesServiceService.delete(servicePayload);
const isIngress = _.filter(application.PublishedPorts, (p) => p.IngressRules.length).length;
if (isIngress) {
const originalIngresses = await this.KubernetesIngressService.get(payload.Namespace);
const formValues = {
OriginalIngresses: originalIngresses,
PublishedPorts: KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(application.ServiceType, application.PublishedPorts),
};
_.forEach(formValues.PublishedPorts, (p) => (p.NeedsDeletion = true));
const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(formValues, servicePayload.Name);
await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, ingresses));
}
}
if (!_.isEmpty(application.AutoScaler)) {
await this.KubernetesHorizontalPodAutoScalerService.delete(application.AutoScaler);
}
@ -364,10 +395,9 @@ class KubernetesApplicationService {
delete(application) {
return this.$async(this.deleteAsync, application);
}
/* #endregion */
/**
* ROLLBACK
*/
/* #region ROLLBACK */
async rollbackAsync(application, targetRevision) {
try {
const payload = KubernetesApplicationRollbackHelper.getPatchPayload(application, targetRevision);
@ -381,6 +411,7 @@ class KubernetesApplicationService {
rollback(application, targetRevision) {
return this.$async(this.rollbackAsync, application, targetRevision);
}
/* #endregion */
}
export default KubernetesApplicationService;

View File

@ -1,17 +1,19 @@
import _ from 'lodash-es';
import * as _ from 'lodash-es';
import { KubernetesResourceQuota } from 'Kubernetes/models/resource-quota/models';
import angular from 'angular';
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';
class KubernetesResourcePoolService {
/* @ngInject */
constructor($async, KubernetesNamespaceService, KubernetesResourceQuotaService) {
constructor($async, KubernetesNamespaceService, KubernetesResourceQuotaService, KubernetesIngressService) {
this.$async = $async;
this.KubernetesNamespaceService = KubernetesNamespaceService;
this.KubernetesResourceQuotaService = KubernetesResourceQuotaService;
this.KubernetesIngressService = KubernetesIngressService;
this.getAsync = this.getAsync.bind(this);
this.getAllAsync = this.getAllAsync.bind(this);
@ -67,30 +69,37 @@ class KubernetesResourcePoolService {
/**
* CREATE
* @param {KubernetesResourcePoolFormValues} formValues
*/
// TODO: review LimitRange future
async createAsync(name, owner, hasQuota, cpuLimit, memoryLimit) {
async createAsync(formValues) {
try {
const namespace = new KubernetesNamespace();
namespace.Name = name;
namespace.ResourcePoolName = name;
namespace.ResourcePoolOwner = owner;
namespace.Name = formValues.Name;
namespace.ResourcePoolName = formValues.Name;
namespace.ResourcePoolOwner = formValues.Owner;
await this.KubernetesNamespaceService.create(namespace);
if (hasQuota) {
const quota = new KubernetesResourceQuota(name);
quota.CpuLimit = cpuLimit;
quota.MemoryLimit = memoryLimit;
quota.ResourcePoolName = name;
quota.ResourcePoolOwner = owner;
if (formValues.HasQuota) {
const quota = new KubernetesResourceQuota(formValues.Name);
quota.CpuLimit = formValues.CpuLimit;
quota.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit);
quota.ResourcePoolName = formValues.Name;
quota.ResourcePoolOwner = formValues.Owner;
await this.KubernetesResourceQuotaService.create(quota);
}
const ingressPromises = _.map(formValues.IngressClasses, (c) => {
if (c.Selected) {
c.Namespace = namespace.Name;
return this.KubernetesIngressService.create(c);
}
});
await Promise.all(ingressPromises);
} catch (err) {
throw err;
}
}
create(name, owner, hasQuota, cpuLimit, memoryLimit) {
return this.$async(this.createAsync, name, owner, hasQuota, cpuLimit, memoryLimit);
create(formValues) {
return this.$async(this.createAsync, formValues);
}
/**

View File

@ -16,7 +16,7 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="kubernetesApplicationCreationForm" autocomplete="off">
<!-- name -->
<!-- #region NAME FIELD -->
<div class="form-group">
<label for="application_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
@ -48,9 +48,9 @@
>
</div>
</div>
<!-- !name -->
<!-- #endregion -->
<!-- image -->
<!-- #region IMAGE FIELD -->
<div class="form-group">
<label for="container_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
@ -64,13 +64,12 @@
</div>
</div>
</div>
<!-- !image -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Resource pool
</div>
<!-- resource-pool -->
<!-- #region RESOURCE POOL -->
<div class="form-group">
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Resource pool</label>
<div class="col-sm-11">
@ -91,12 +90,12 @@
resource pool.
</div>
</div>
<!-- !resource-pool -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Stack
</div>
<!-- #region STACK -->
<div class="form-group">
<div class="col-sm-12 small text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
@ -105,7 +104,6 @@
</div>
</div>
<!-- stack -->
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Stack</label>
<div class="col-sm-11">
@ -121,13 +119,12 @@
/>
</div>
</div>
<!-- !stack -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Environment
</div>
<!-- environment-variables -->
<!-- #region ENVIRONMENT VARIABLES -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Environment variables</label>
@ -146,7 +143,7 @@
name="environment_variable_name_{{ $index }}"
class="form-control"
ng-model="envVar.Name"
ng-change="ctrl.onChangeEnvironmentName($index)"
ng-change="ctrl.onChangeEnvironmentName()"
ng-pattern="/^[a-zA-Z]([-_a-zA-Z0-9]*[a-zA-Z0-9])?$/"
placeholder="foo"
required
@ -155,7 +152,9 @@
<div
class="small text-warning"
style="margin-top: 5px;"
ng-show="kubernetesApplicationCreationForm['environment_variable_name_' + $index].$invalid || ctrl.state.duplicateEnvironmentVariables[$index] !== undefined"
ng-show="
kubernetesApplicationCreationForm['environment_variable_name_' + $index].$invalid || ctrl.state.duplicates.environmentVariables.refs[$index] !== undefined
"
>
<ng-messages for="kubernetesApplicationCreationForm['environment_variable_name_' + $index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Environment variable name is required.</p>
@ -164,7 +163,7 @@
character, and end with an alphanumeric character (e.g. 'my-var', or 'MY_VAR123').</p
>
</ng-messages>
<p ng-if="ctrl.state.duplicateEnvironmentVariables[$index] !== undefined"
<p ng-if="ctrl.state.duplicates.environmentVariables.refs[$index] !== undefined"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This environment variable is already defined.</p
>
</div>
@ -177,22 +176,21 @@
<div class="input-group col-sm-2 input-group-sm">
<button ng-if="!envVar.NeedsDeletion" class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeEnvironmentVariable($index)">
<i class="fa fa-times" aria-hidden="true"></i>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
<button ng-if="envVar.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restoreEnvironmentVariable($index)">
Restore
<i class="fa fa-trash-restore" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<!-- !environment-variables -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Configurations
</div>
<!-- configurations -->
<!-- #region CONFIGURATIONS -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Configurations</label>
@ -231,7 +229,7 @@
<button class="btn btn-sm btn-primary" type="button" ng-if="config.Overriden" ng-click="ctrl.resetConfiguration(index)">
<i class="fa fa-undo" aria-hidden="true"></i> Auto
</button>
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeConfiguration(index)"> <i class="fa fa-trash" aria-hidden="true"></i> Remove </button>
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeConfiguration(index)"> <i class="fa fa-trash-alt" aria-hidden="true"></i> Remove </button>
</div>
<!-- no-override -->
<div class="col-sm-12" style="margin-top: 10px;" ng-if="config.SelectedConfiguration && !config.Overriden">
@ -277,13 +275,13 @@
style="margin-top: 5px;"
ng-show="
kubernetesApplicationCreationForm['overriden_key_path_' + index + '_' + keyIndex].$invalid ||
ctrl.state.duplicateConfigurationPaths[index + '_' + keyIndex] !== undefined
ctrl.state.duplicates.configurationPaths.refs[index + '_' + keyIndex] !== undefined
"
>
<ng-messages for="kubernetesApplicationCreationForm['overriden_key_path_' + index + '_' + keyIndex].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Path is required.</p>
</ng-messages>
<p ng-if="ctrl.state.duplicateConfigurationPaths[index + '_' + keyIndex] !== undefined"
<p ng-if="ctrl.state.duplicates.configurationPaths.refs[index + '_' + keyIndex] !== undefined"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This path is already used.</p
>
</div>
@ -302,12 +300,12 @@
<!-- !has-override -->
</div>
<!-- !config-element -->
<!-- !configurations -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Persisting data
</div>
<!-- #region PERSISTED FOLDERS -->
<div class="form-group" ng-if="!ctrl.storageClassAvailable()">
<div class="col-sm-12 small text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
@ -315,7 +313,6 @@
</div>
</div>
<!-- persisted folders -->
<div class="form-group" ng-if="ctrl.storageClassAvailable()">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Persisted folders</label>
@ -333,7 +330,7 @@
class="form-control"
name="persisted_folder_path_{{ $index }}"
ng-model="persistedFolder.ContainerPath"
ng-change="ctrl.onChangePersistedFolderPath($index)"
ng-change="ctrl.onChangePersistedFolderPath()"
ng-disabled="ctrl.isEditAndExistingPersistedFolder($index)"
placeholder="/data"
required
@ -360,7 +357,7 @@
uib-btn-radio="false"
ng-change="ctrl.useExistingVolume($index)"
ng-disabled="ctrl.availableVolumes.length === 0 || ctrl.application.ApplicationType === ctrl.ApplicationTypes.STATEFULSET"
>Use an existing volume</label
>Existing volume</label
>
</span>
</div>
@ -422,10 +419,10 @@
<div class="input-group col-sm-1 input-group-sm">
<div style="vertical-align: top;" ng-if="!ctrl.isEditAndStatefulSet()" ng-if="!ctrl.state.useExistingVolume[$index]">
<button ng-if="!persistedFolder.NeedsDeletion" class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removePersistedFolder($index)">
<i class="fa fa-times" aria-hidden="true"></i>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
<button ng-if="persistedFolder.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restorePersistedFolder($index)">
Restore
<i class="fa fa-trash-restore" aria-hidden="true"></i>
</button>
</div>
</div>
@ -434,22 +431,22 @@
<div
ng-show="
kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid ||
ctrl.state.duplicatePersistedFolderPaths[$index] !== undefined ||
ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined ||
kubernetesApplicationCreationForm['persisted_folder_size_' + $index].$invalid ||
kubernetesApplicationCreationForm['existing_volumes_' + $index].$invalid ||
ctrl.state.duplicateExistingVolumes[$index] !== undefined
ctrl.state.duplicates.existingVolumes.refs[$index] !== undefined
"
>
<div class="input-group col-sm-3 input-group-sm">
<div
class="small text-warning"
style="margin-top: 5px;"
ng-show="kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid || ctrl.state.duplicatePersistedFolderPaths[$index] !== undefined"
ng-show="kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid || ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined"
>
<ng-messages for="kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Path is required.</p>
</ng-messages>
<p ng-if="ctrl.state.duplicatePersistedFolderPaths[$index] !== undefined"
<p ng-if="ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This path is already defined.</p
>
</div>
@ -466,12 +463,12 @@
</div>
<div
class="small text-warning"
ng-show="kubernetesApplicationCreationForm['existing_volumes_' + $index].$invalid || ctrl.state.duplicateExistingVolumes[$index] !== undefined"
ng-show="kubernetesApplicationCreationForm['existing_volumes_' + $index].$invalid || ctrl.state.duplicates.existingVolumes.refs[$index] !== undefined"
>
<ng-messages for="kubernetesApplicationCreationForm['existing_volumes_' + $index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Volume is required.</p>
</ng-messages>
<p ng-if="ctrl.state.duplicateExistingVolumes[$index] !== undefined"
<p ng-if="ctrl.state.duplicates.existingVolumes.refs[$index] !== undefined"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This volume is already used.</p
>
</div>
@ -483,8 +480,9 @@
</div>
</div>
</div>
<!-- !persisted folders -->
<!-- #endregion -->
<!-- #region DATA ACCESS POLICY -->
<div ng-if="ctrl.showDataAccessPolicySection()">
<div class="form-group">
<div class="col-sm-12">
@ -579,11 +577,12 @@
</div>
<!-- !access policy options -->
</div>
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Resource reservations
</div>
<!-- #region RESOURCE RESERVATIONS -->
<div class="form-group" ng-if="!ctrl.state.resourcePoolHasQuota">
<div class="col-sm-12 small text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
@ -668,11 +667,12 @@
</div>
</div>
<!-- !cpu-limit-input -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- #region DEPLOYMENT -->
<div class="form-group">
<div class="col-sm-12 small text-muted">
Select how you want to deploy your application inside the cluster.
@ -775,8 +775,9 @@
>. You will not be able to scale that application.
</div>
</div>
<!-- #endregion -->
<!-- auto scaling -->
<!-- #region AUTO SCALING -->
<div class="col-sm-12 form-section-title" ng-if="ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.GLOBAL">
Auto-scaling
</div>
@ -884,12 +885,12 @@
</div>
</div>
</div>
<!-- !auto scaling -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Publishing the application
</div>
<!-- #region PUBLISHING OPTIONS -->
<div class="form-group">
<div class="col-sm-12 small text-muted">
Select how you want to publish your application.
@ -899,9 +900,36 @@
<!-- publishing options -->
<div class="form-group" style="margin-bottom: 0;">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="publishing_internal" ng-value="ctrl.ApplicationPublishingTypes.INTERNAL" ng-model="ctrl.formValues.PublishingType" />
<label for="publishing_internal">
<div ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
<input
type="radio"
id="publishing_internal"
ng-value="ctrl.ApplicationPublishingTypes.INTERNAL"
ng-model="ctrl.formValues.PublishingType"
ng-change="ctrl.onChangePublishedPorts()"
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
/>
<label
for="publishing_internal"
ng-if="
!ctrl.isPublishingTypeEditDisabled() || (ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INTERNAL)
"
>
<div class="boxselector_header">
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i>
Internal
</div>
<p>Internal communications inside the cluster only</p>
</label>
<label
for="publishing_internal"
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INTERNAL"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
style="cursor: pointer; border-color: #767676;"
>
<div class="boxselector_header">
<i class="fa fa-list-alt" aria-hidden="true" style="margin-right: 2px;"></i>
Internal
@ -909,9 +937,37 @@
<p>Internal communications inside the cluster only</p>
</label>
</div>
<div>
<input type="radio" id="publishing_cluster" ng-value="ctrl.ApplicationPublishingTypes.CLUSTER" ng-model="ctrl.formValues.PublishingType" />
<label for="publishing_cluster">
<div ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
<input
type="radio"
id="publishing_cluster"
ng-value="ctrl.ApplicationPublishingTypes.CLUSTER"
ng-model="ctrl.formValues.PublishingType"
ng-change="ctrl.onChangePublishedPorts()"
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
/>
<label
for="publishing_cluster"
ng-if="
!ctrl.isPublishingTypeEditDisabled() || (ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER)
"
>
<div class="boxselector_header">
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
Cluster
</div>
<p>Publish this application via a port on all nodes of the cluster</p>
</label>
<label
for="publishing_cluster"
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.CLUSTER"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
style="cursor: pointer; border-color: #767676;"
>
<div class="boxselector_header">
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
Cluster
@ -919,9 +975,74 @@
<p>Publish this application via a port on all nodes of the cluster</p>
</label>
</div>
<div ng-if="ctrl.publishViaLoadBalancerEnabled()">
<input type="radio" id="publishing_loadbalancer" ng-value="ctrl.ApplicationPublishingTypes.LOAD_BALANCER" ng-model="ctrl.formValues.PublishingType" />
<label for="publishing_loadbalancer">
<div ng-if="ctrl.publishViaIngressEnabled()" ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
<input
type="radio"
id="publishing_ingress"
ng-value="ctrl.ApplicationPublishingTypes.INGRESS"
ng-model="ctrl.formValues.PublishingType"
ng-change="ctrl.onChangePublishedPorts()"
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
/>
<label
for="publishing_ingress"
ng-if="
!ctrl.isPublishingTypeEditDisabled() || (ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
"
>
<div class="boxselector_header">
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
Ingress
</div>
<p>Publish this application via a HTTP route</p>
</label>
<label
for="publishing_ingress"
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
style="cursor: pointer; border-color: #767676;"
>
<div class="boxselector_header">
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
Ingress
</div>
<p>Publish this application via a HTTP route</p>
</label>
</div>
<div ng-if="ctrl.publishViaLoadBalancerEnabled()" ng-style="{ color: ctrl.isPublishingTypeEditDisabled() ? '#767676' : '' }">
<input
type="radio"
id="publishing_loadbalancer"
ng-value="ctrl.ApplicationPublishingTypes.LOAD_BALANCER"
ng-model="ctrl.formValues.PublishingType"
ng-change="ctrl.onChangePublishedPorts()"
ng-disabled="ctrl.isPublishingTypeEditDisabled()"
/>
<label
for="publishing_loadbalancer"
ng-if="
!ctrl.isPublishingTypeEditDisabled() ||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER)
"
>
<div class="boxselector_header">
<i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i>
Load balancer
</div>
<p>Publish this application via a load balancer</p>
</label>
<label
for="publishing_loadbalancer"
ng-if="ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.LOAD_BALANCER"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Changing the publishing mode is not allowed until you delete all previously existing ports"
style="cursor: pointer; border-color: #767676;"
>
<div class="boxselector_header">
<i class="fa fa-project-diagram" aria-hidden="true" style="margin-right: 2px;"></i>
Load balancer
@ -931,9 +1052,9 @@
</div>
</div>
</div>
<!-- !publishing options -->
<!-- #endregion -->
<!-- published ports -->
<!-- #region PUBLISHED PORTS -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Published ports</label>
@ -951,8 +1072,12 @@
When publishing a port in cluster mode, the node port is optional. If left empty Kubernetes will use a random port number. If you wish to specify a port, use a port
number inside the default range <code>30000-32767</code>.
</div>
<div ng-if="ctrl.isNotInternalAndHasNoPublishedPorts()" class="col-sm-12 small text-muted text-warning" style="margin-top: 12px;">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> At least one published port must be defined.
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<!-- #region INPUTS -->
<div
ng-repeat-start="publishedPort in ctrl.formValues.PublishedPorts"
style="margin-top: 2px;"
@ -960,9 +1085,9 @@
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
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-4 input-group input-group-sm">
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: publishedPort.NeedsDeletion }">
<span class="input-group-addon">container port</span>
<input
type="number"
@ -972,12 +1097,20 @@
placeholder="80"
ng-min="1"
ng-max="65535"
required
ng-disabled="ctrl.disableLoadBalancerEdit()"
ng-required="!publishedPort.NeedsDeletion"
ng-change="ctrl.onChangePortMappingContainerPort()"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
/>
</div>
<div class="input-group input-group-sm col-sm-4" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER">
<div
class="col-sm-3 input-group input-group-sm"
ng-class="{ striked: publishedPort.NeedsDeletion }"
ng-if="
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER) ||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER)
"
>
<span class="input-group-addon">node port</span>
<input
name="published_node_port_{{ $index }}"
@ -987,10 +1120,19 @@
placeholder="30080"
ng-min="30000"
ng-max="32767"
ng-change="ctrl.onChangePortMappingNodePort()"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
/>
</div>
<div class="col-sm-4 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER">
<div
class="col-sm-3 input-group input-group-sm"
ng-class="{ striked: publishedPort.NeedsDeletion }"
ng-if="
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER) ||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER)
"
>
<span class="input-group-addon">load balancer port</span>
<input
type="number"
@ -1001,69 +1143,206 @@
value="8080"
ng-min="1"
ng-max="65535"
required
ng-disabled="ctrl.disableLoadBalancerEdit()"
ng-required="!publishedPort.NeedsDeletion"
ng-change="ctrl.onChangePortMappingLoadBalancerPort()"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
/>
</div>
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="publishedPort.Protocol" uib-btn-radio="'TCP'" ng-disabled="ctrl.disableLoadBalancerEdit()">TCP</label>
<label class="btn btn-primary" ng-model="publishedPort.Protocol" uib-btn-radio="'UDP'" ng-disabled="ctrl.disableLoadBalancerEdit()">UDP</label>
<div
class="col-sm-3 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">ingress</span>
<select
class="form-control"
name="ingress_class_{{ $index }}"
ng-model="publishedPort.IngressName"
ng-options="ingress.Name as ingress.Name for ingress in ctrl.filteredIngresses"
ng-required="!publishedPort.NeedsDeletion"
ng-change="ctrl.onChangePortMappingIngress($index)"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
>
<option selected disabled hidden value="">Select an ingress</option>
</select>
</div>
<div
class="col-sm-3 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">route</span>
<input
class="form-control"
name="ingress_route_{{ $index }}"
ng-model="publishedPort.IngressRoute"
placeholder="foo"
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-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
/>
</div>
<div class="input-group col-sm-2 input-group-sm">
<div class="btn-group btn-group-sm" ng-class="{ striked: publishedPort.NeedsDeletion }">
<label
class="btn btn-primary"
ng-model="publishedPort.Protocol"
uib-btn-radio="'TCP'"
ng-change="ctrl.onChangePortMappingContainerPort()"
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'TCP')"
>TCP</label
>
<label
class="btn btn-primary"
ng-model="publishedPort.Protocol"
uib-btn-radio="'UDP'"
ng-change="ctrl.onChangePortMappingContainerPort()"
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'UDP')"
>UDP</label
>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removePublishedPort($index)" ng-if="!ctrl.disableLoadBalancerEdit()">
<i class="fa fa-times" aria-hidden="true"></i>
<button
ng-if="!ctrl.disableLoadBalancerEdit() && !publishedPort.NeedsDeletion"
class="btn btn-sm btn-danger"
type="button"
ng-click="ctrl.removePublishedPort($index)"
>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
<button
ng-if="publishedPort.NeedsDeletion && ctrl.formValues.PublishingType === ctrl.savedFormValues.PublishingType"
class="btn btn-sm btn-primary"
type="button"
ng-click="ctrl.restorePublishedPort($index)"
>
<i class="fa fa-trash-restore" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- #endregion -->
<!-- #region VALIDATION -->
<div
ng-repeat-end
ng-if="
ng-show="
kubernetesApplicationCreationForm['container_port_' + $index].$invalid ||
kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid ||
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid ||
kubernetesApplicationCreationForm['ingress_class_' + $index].$invalid ||
kubernetesApplicationCreationForm['ingress_route_' + $index].$invalid ||
ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined ||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER && ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined) ||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS && ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined) ||
(ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER &&
ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined)
"
>
<div class="col-sm-4 input-group input-group-sm">
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesApplicationCreationForm['container_port_' + $index].$invalid">
<div class="col-sm-3 input-group input-group-sm">
<div
class="small text-warning"
style="margin-top: 5px;"
ng-if="
kubernetesApplicationCreationForm['container_port_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined
"
>
<div ng-messages="kubernetesApplicationCreationForm['container_port_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number is required.</p>
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number must be inside the range 1-65535.</p>
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Container port number must be inside the range 1-65535.</p>
</div>
<p ng-if="ctrl.state.duplicates.publishedPorts.containerPorts.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This port is already used.
</p>
</div>
</div>
<div class="input-group input-group-sm col-sm-4" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER">
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid">
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER">
<div
class="small text-warning"
style="margin-top: 5px;"
ng-if="
kubernetesApplicationCreationForm['published_node_port_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined
"
>
<div ng-messages="kubernetesApplicationCreationForm['published_node_port_'+$index].$error">
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Node port number must be inside the range 30000-32767.</p>
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Node port number must be inside the range 30000-32767.</p>
</div>
<p ng-if="ctrl.state.duplicates.publishedPorts.nodePorts.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This port is already used.
</p>
</div>
</div>
<div class="col-sm-4 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER">
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid">
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS">
<div class="small text-warning" style="margin-top: 5px;" ng-if="kubernetesApplicationCreationForm['ingress_class_' + $index].$invalid">
<div ng-messages="kubernetesApplicationCreationForm['ingress_class_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Ingress selection is required.</p>
</div>
</div>
</div>
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS">
<div
class="small text-warning"
style="margin-top: 5px;"
ng-if="kubernetesApplicationCreationForm['ingress_route_' + $index].$invalid || ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined"
>
<div ng-messages="kubernetesApplicationCreationForm['ingress_route_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Route is required.</p>
<p ng-message="pattern"
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> 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>
<p ng-if="ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This route is already used.
</p>
</div>
</div>
<div class="col-sm-3 input-group input-group-sm" ng-if="ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.LOAD_BALANCER">
<div
class="small text-warning"
style="margin-top: 5px;"
ng-if="
kubernetesApplicationCreationForm['load_balancer_port_' + $index].$invalid ||
ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined
"
>
<div ng-messages="kubernetesApplicationCreationForm['load_balancer_port_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number is required.</p>
<p ng-message="min"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number must be inside the range 1-65535.</p>
<p ng-message="max"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Load balancer port number must be inside the range 1-65535.</p>
</div>
<p ng-if="ctrl.state.duplicates.publishedPorts.loadBalancerPorts.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
This port is already used.
</p>
</div>
</div>
<div class="input-group col-sm-1 input-group-sm"> </div>
</div>
<!-- #endregion -->
</div>
</div>
<!-- !published ports -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<!-- #region ACTIONS -->
<div class="form-group">
<div class="col-sm-12">
<button
@ -1083,10 +1362,12 @@
type="button"
class="btn btn-sm btn-default"
ui-sref="kubernetes.applications.application({ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool })"
>Cancel</button
>
Cancel
</button>
</div>
</div>
<!-- #endregion -->
</form>
</rd-widget-body>
</rd-widget>

View File

@ -1,5 +1,5 @@
import angular from 'angular';
import _ from 'lodash-es';
import * as _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import * as JsonPatch from 'fast-json-patch';
@ -27,6 +27,8 @@ import KubernetesApplicationHelper from 'Kubernetes/helpers/application/index';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */
/* @ngInject */
constructor(
$async,
@ -40,6 +42,7 @@ class KubernetesCreateApplicationController {
KubernetesStackService,
KubernetesConfigurationService,
KubernetesNodeService,
KubernetesIngressService,
KubernetesPersistentVolumeClaimService,
KubernetesNamespaceHelper,
KubernetesVolumeService
@ -56,6 +59,7 @@ class KubernetesCreateApplicationController {
this.KubernetesConfigurationService = KubernetesConfigurationService;
this.KubernetesNodeService = KubernetesNodeService;
this.KubernetesVolumeService = KubernetesVolumeService;
this.KubernetesIngressService = KubernetesIngressService;
this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService;
this.KubernetesNamespaceHelper = KubernetesNamespaceHelper;
@ -73,38 +77,25 @@ class KubernetesCreateApplicationController {
this.refreshStacksAsync = this.refreshStacksAsync.bind(this);
this.refreshConfigurationsAsync = this.refreshConfigurationsAsync.bind(this);
this.refreshApplicationsAsync = this.refreshApplicationsAsync.bind(this);
this.refreshStacksConfigsAppsAsync = this.refreshStacksConfigsAppsAsync.bind(this);
this.refreshNamespaceDataAsync = this.refreshNamespaceDataAsync.bind(this);
this.getApplicationAsync = this.getApplicationAsync.bind(this);
}
isValid() {
return (
!this.state.alreadyExists &&
!this.state.hasDuplicateEnvironmentVariables &&
!this.state.hasDuplicatePersistedFolderPaths &&
!this.state.hasDuplicateConfigurationPaths &&
!this.state.hasDuplicateExistingVolumes
);
}
/* #endregion */
onChangeName() {
const existingApplication = _.find(this.applications, { Name: this.formValues.Name });
this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication);
}
/**
* AUTO SCALER UI MANAGEMENT
*/
/* #region AUTO SCLAER UI MANAGEMENT */
unselectAutoScaler() {
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL) {
this.formValues.AutoScaler.IsUsed = false;
}
}
/* #endregion */
/**
* CONFIGURATION UI MANAGEMENT
*/
/* #region CONFIGURATION UI MANAGEMENT */
addConfiguration() {
let config = new KubernetesApplicationConfigurationFormValue();
config.SelectedConfiguration = this.configurations[0];
@ -133,8 +124,12 @@ class KubernetesCreateApplicationController {
this.onChangeConfigurationPath();
}
clearConfigurations() {
this.formValues.Configurations = [];
}
onChangeConfigurationPath() {
this.state.duplicateConfigurationPaths = [];
this.state.duplicates.configurationPaths.refs = [];
const paths = _.reduce(
this.formValues.Configurations,
@ -151,33 +146,20 @@ class KubernetesCreateApplicationController {
_.forEach(config.OverridenKeys, (overridenKey, keyIndex) => {
const findPath = _.find(duplicatePaths, (path) => path === overridenKey.Path);
if (findPath) {
this.state.duplicateConfigurationPaths[index + '_' + keyIndex] = findPath;
this.state.duplicates.configurationPaths.refs[index + '_' + keyIndex] = findPath;
}
});
});
this.state.hasDuplicateConfigurationPaths = Object.keys(this.state.duplicateConfigurationPaths).length > 0;
this.state.duplicates.configurationPaths.hasDuplicates = Object.keys(this.state.duplicates.configurationPaths.refs).length > 0;
}
/**
* !CONFIGURATION UI MANAGEMENT
*/
/* #endregion */
/**
* ENVIRONMENT UI MANAGEMENT
*/
/* #region ENVIRONMENT UI MANAGEMENT */
addEnvironmentVariable() {
this.formValues.EnvironmentVariables.push(new KubernetesApplicationEnvironmentVariableFormValue());
}
hasEnvironmentVariables() {
return this.formValues.EnvironmentVariables.length > 0;
}
onChangeEnvironmentName() {
this.state.duplicateEnvironmentVariables = KubernetesFormValidationHelper.getDuplicates(_.map(this.formValues.EnvironmentVariables, 'Name'));
this.state.hasDuplicateEnvironmentVariables = Object.keys(this.state.duplicateEnvironmentVariables).length > 0;
}
restoreEnvironmentVariable(index) {
this.formValues.EnvironmentVariables[index].NeedsDeletion = false;
}
@ -190,13 +172,14 @@ class KubernetesCreateApplicationController {
}
this.onChangeEnvironmentName();
}
/**
* !ENVIRONMENT UI MANAGEMENT
*/
/**
* PERSISTENT FOLDERS UI MANAGEMENT
*/
onChangeEnvironmentName() {
this.state.duplicates.environmentVariables.refs = KubernetesFormValidationHelper.getDuplicates(_.map(this.formValues.EnvironmentVariables, 'Name'));
this.state.duplicates.environmentVariables.hasDuplicates = Object.keys(this.state.duplicates.environmentVariables.refs).length > 0;
}
/* #endregion */
/* #region PERSISTENT FOLDERS UI MANAGEMENT */
addPersistedFolder() {
let storageClass = {};
if (this.storageClasses.length > 0) {
@ -207,22 +190,17 @@ class KubernetesCreateApplicationController {
this.resetDeploymentType();
}
onChangePersistedFolderPath() {
this.state.duplicatePersistedFolderPaths = KubernetesFormValidationHelper.getDuplicates(
_.map(this.formValues.PersistedFolders, (persistedFolder) => {
if (persistedFolder.NeedsDeletion) {
return undefined;
}
return persistedFolder.ContainerPath;
})
);
this.state.hasDuplicatePersistedFolderPaths = Object.keys(this.state.duplicatePersistedFolderPaths).length > 0;
}
restorePersistedFolder(index) {
this.formValues.PersistedFolders[index].NeedsDeletion = false;
}
resetPersistedFolders() {
this.formValues.PersistedFolders = _.forEach(this.formValues.PersistedFolders, (persistedFolder) => {
persistedFolder.ExistingVolume = null;
persistedFolder.UseNewVolume = true;
});
}
removePersistedFolder(index) {
if (this.state.isEdit && this.formValues.PersistedFolders[index].PersistentVolumeClaimName) {
this.formValues.PersistedFolders[index].NeedsDeletion = true;
@ -233,10 +211,25 @@ class KubernetesCreateApplicationController {
this.onChangeExistingVolumeSelection();
}
onChangeExistingVolume(index) {
if (this.formValues.PersistedFolders[index].UseNewVolume) {
this.formValues.PersistedFolders[index].ExistingVolume = null;
}
onChangePersistedFolderPath() {
this.state.duplicates.persistedFolders.refs = KubernetesFormValidationHelper.getDuplicates(
_.map(this.formValues.PersistedFolders, (persistedFolder) => {
if (persistedFolder.NeedsDeletion) {
return undefined;
}
return persistedFolder.ContainerPath;
})
);
this.state.duplicates.persistedFolders.hasDuplicates = Object.keys(this.state.duplicates.persistedFolders.refs).length > 0;
}
onChangeExistingVolumeSelection() {
this.state.duplicates.existingVolumes.refs = KubernetesFormValidationHelper.getDuplicates(
_.map(this.formValues.PersistedFolders, (persistedFolder) => {
return persistedFolder.ExistingVolume ? persistedFolder.ExistingVolume.PersistentVolumeClaim.Name : '';
})
);
this.state.duplicates.existingVolumes.hasDuplicates = Object.keys(this.state.duplicates.existingVolumes.refs).length > 0;
}
useNewVolume(index) {
@ -253,46 +246,129 @@ class KubernetesCreateApplicationController {
this.resetDeploymentType();
}
}
/* #endregion */
onChangeExistingVolumeSelection() {
this.state.duplicateExistingVolumes = KubernetesFormValidationHelper.getDuplicates(
_.map(this.formValues.PersistedFolders, (persistedFolder) => {
return persistedFolder.ExistingVolume ? persistedFolder.ExistingVolume.PersistentVolumeClaim.Name : '';
})
);
this.state.hasDuplicateExistingVolumes = Object.keys(this.state.duplicateExistingVolumes).length > 0;
}
filterAvailableVolumes() {
const filteredVolumes = _.filter(this.volumes, (volume) => {
const isSameNamespace = volume.ResourcePool.Namespace.Name === this.formValues.ResourcePool.Namespace.Name;
const isUnused = !KubernetesVolumeHelper.isUsed(volume);
const isRWX = _.find(volume.PersistentVolumeClaim.StorageClass.AccessModes, (am) => am === 'RWX');
return isSameNamespace && (isUnused || isRWX);
});
this.availableVolumes = filteredVolumes;
}
/**
* !PERSISTENT FOLDERS UI MANAGEMENT
*/
/**
* PUBLISHED PORTS UI MANAGEMENT
*/
/* #region PUBLISHED PORTS UI MANAGEMENT */
addPublishedPort() {
this.formValues.PublishedPorts.push(new KubernetesApplicationPublishedPortFormValue());
const p = new KubernetesApplicationPublishedPortFormValue();
const ingresses = this.filteredIngresses;
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined;
this.formValues.PublishedPorts.push(p);
}
resetPublishedPorts() {
const ingresses = this.filteredIngresses;
_.forEach(this.formValues.PublishedPorts, (p) => {
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined;
});
}
restorePublishedPort(index) {
this.formValues.PublishedPorts[index].NeedsDeletion = false;
this.onChangePublishedPorts();
}
removePublishedPort(index) {
this.formValues.PublishedPorts.splice(index, 1);
if (this.state.isEdit && !this.formValues.PublishedPorts[index].IsNew) {
this.formValues.PublishedPorts[index].NeedsDeletion = true;
} else {
this.formValues.PublishedPorts.splice(index, 1);
}
this.onChangePublishedPorts();
}
/* #endregion */
/* #region PUBLISHED PORTS ON CHANGE VALIDATION */
onChangePublishedPorts() {
this.onChangePortMappingContainerPort();
this.onChangePortMappingNodePort();
this.onChangePortMappingIngressRoute();
this.onChangePortMappingLoadBalancer();
}
onChangePortMappingContainerPort() {
const state = this.state.duplicates.publishedPorts.containerPorts;
if (this.formValues.PublishingType !== KubernetesApplicationPublishingTypes.INGRESS) {
const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.ContainerPort + p.Protocol));
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasDuplicates = Object.keys(duplicates).length > 0;
} else {
state.refs = {};
state.hasDuplicates = false;
}
}
onChangePortMappingNodePort() {
const state = this.state.duplicates.publishedPorts.nodePorts;
if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.CLUSTER) {
const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.NodePort));
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasDuplicates = Object.keys(duplicates).length > 0;
} else {
state.refs = {};
state.hasDuplicates = false;
}
}
onChangePortMappingIngress(index) {
const publishedPort = this.formValues.PublishedPorts[index];
const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName });
publishedPort.IngressHost = ingress.Host;
}
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 duplicates = KubernetesFormValidationHelper.getDuplicates(newRoutes);
_.forEach(newRoutes, (route, idx) => {
if (_.includes(allRoutes, route) && !_.includes(toDelRoutes, route)) {
duplicates[idx] = route;
}
});
state.refs = duplicates;
state.hasDuplicates = Object.keys(duplicates).length > 0;
} else {
state.refs = {};
state.hasDuplicates = false;
}
}
onChangePortMappingLoadBalancer() {
const state = this.state.duplicates.publishedPorts.loadBalancerPorts;
if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.LoadBalancerPort));
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasDuplicates = Object.keys(duplicates).length > 0;
} else {
state.refs = {};
state.hasDuplicates = false;
}
}
/* #endregion */
/* #region STATE VALIDATION FUNCTIONS */
isValid() {
return (
!this.state.alreadyExists &&
!this.state.duplicates.environmentVariables.hasDuplicates &&
!this.state.duplicates.persistedFolders.hasDuplicates &&
!this.state.duplicates.configurationPaths.hasDuplicates &&
!this.state.duplicates.existingVolumes.hasDuplicates &&
!this.state.duplicates.publishedPorts.containerPorts.hasDuplicates &&
!this.state.duplicates.publishedPorts.nodePorts.hasDuplicates &&
!this.state.duplicates.publishedPorts.ingressRoutes.hasDuplicates &&
!this.state.duplicates.publishedPorts.loadBalancerPorts.hasDuplicates
);
}
/**
* !PUBLISHED PORTS UI MANAGEMENT
*/
/**
* STATE VALIDATION FUNCTIONS
*/
storageClassAvailable() {
return this.storageClasses && this.storageClasses.length > 0;
}
@ -411,6 +487,10 @@ class KubernetesCreateApplicationController {
return this.state.useLoadBalancer;
}
publishViaIngressEnabled() {
return this.filteredIngresses.length;
}
isEditAndNoChangesMade() {
if (!this.state.isEdit) return false;
const changes = JsonPatch.compare(this.savedFormValues, this.formValues);
@ -422,6 +502,16 @@ class KubernetesCreateApplicationController {
return this.state.isEdit && this.formValues.PersistedFolders[index].PersistentVolumeClaimName;
}
isEditAndNotNewPublishedPort(index) {
return this.state.isEdit && !this.formValues.PublishedPorts[index].IsNew;
}
isNotInternalAndHasNoPublishedPorts() {
const toDelPorts = _.filter(this.formValues.PublishedPorts, { NeedsDeletion: true });
const toKeepPorts = _.without(this.formValues.PublishedPorts, ...toDelPorts);
return this.formValues.PublishingType !== KubernetesApplicationPublishingTypes.INTERNAL && toKeepPorts.length === 0;
}
isNonScalable() {
const scalable = this.supportScalableReplicaDeployment();
const global = this.supportGlobalDeployment();
@ -438,8 +528,8 @@ class KubernetesCreateApplicationController {
const invalid = !this.isValid();
const hasNoChanges = this.isEditAndNoChangesMade();
const nonScalable = this.isNonScalable();
const res = overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
return res;
const notInternalNoPorts = this.isNotInternalAndHasNoPublishedPorts();
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || notInternalNoPorts;
}
disableLoadBalancerEdit() {
@ -450,13 +540,19 @@ class KubernetesCreateApplicationController {
this.formValues.PublishingType === this.ApplicationPublishingTypes.LOAD_BALANCER
);
}
/**
* !STATE VALIDATION FUNCTIONS
*/
/**
* DATA AUTO REFRESH
*/
isPublishingTypeEditDisabled() {
const ports = _.filter(this.formValues.PublishedPorts, { IsNew: false, NeedsDeletion: false });
return this.state.isEdit && this.formValues.PublishedPorts.length > 0 && ports.length > 0;
}
isProtocolOptionDisabled(index, protocol) {
return this.disableLoadBalancerEdit() || (this.isEditAndNotNewPublishedPort(index) && this.formValues.PublishedPorts[index].Protocol !== protocol);
}
/* #endregion */
/* #region DATA AUTO REFRESH */
async updateSlidersAsync() {
try {
const quota = this.formValues.ResourcePool.Quota;
@ -546,33 +642,54 @@ class KubernetesCreateApplicationController {
return this.$async(this.refreshApplicationsAsync, namespace);
}
async refreshStacksConfigsAppsAsync(namespace) {
await Promise.all([this.refreshStacks(namespace), this.refreshConfigurations(namespace), this.refreshApplications(namespace)]);
refreshVolumes(namespace) {
const filteredVolumes = _.filter(this.volumes, (volume) => {
const isSameNamespace = volume.ResourcePool.Namespace.Name === namespace;
const isUnused = !KubernetesVolumeHelper.isUsed(volume);
const isRWX = volume.PersistentVolumeClaim.StorageClass && _.find(volume.PersistentVolumeClaim.StorageClass.AccessModes, (am) => am === 'RWX');
return isSameNamespace && (isUnused || isRWX);
});
this.availableVolumes = filteredVolumes;
}
refreshIngresses(namespace) {
this.filteredIngresses = _.filter(this.ingresses, { Namespace: namespace });
if (!this.publishViaIngressEnabled()) {
this.formValues.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL;
}
this.formValues.OriginalIngresses = this.filteredIngresses;
}
async refreshNamespaceDataAsync(namespace) {
await Promise.all([
this.refreshStacks(namespace),
this.refreshConfigurations(namespace),
this.refreshApplications(namespace),
this.refreshIngresses(namespace),
this.refreshVolumes(namespace),
]);
this.onChangeName();
}
refreshStacksConfigsApps(namespace) {
return this.$async(this.refreshStacksConfigsAppsAsync, namespace);
refreshNamespaceData(namespace) {
return this.$async(this.refreshNamespaceDataAsync, namespace);
}
resetFormValues() {
this.clearConfigurations();
this.resetPersistedFolders();
this.resetPublishedPorts();
}
onResourcePoolSelectionChange() {
const namespace = this.formValues.ResourcePool.Namespace.Name;
this.updateSliders();
this.refreshStacksConfigsApps(namespace);
this.filterAvailableVolumes();
this.formValues.Configurations = [];
this.formValues.PersistedFolders = _.forEach(this.formValues.PersistedFolders, (persistedFolder) => {
persistedFolder.ExistingVolume = null;
persistedFolder.UseNewVolume = true;
});
this.refreshNamespaceData(namespace);
this.resetFormValues();
}
/**
* !DATA AUTO REFRESH
*/
/* #endregion */
/**
* ACTIONS
*/
/* #region ACTIONS */
async deployApplicationAsync() {
this.state.actionInProgress = true;
try {
@ -595,7 +712,7 @@ class KubernetesCreateApplicationController {
this.Notifications.success('Application successfully updated');
this.$state.go('kubernetes.applications.application', { name: this.application.Name, namespace: this.application.ResourcePool });
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application related events');
this.Notifications.error('Failure', err, 'Unable to update application');
} finally {
this.state.actionInProgress = false;
}
@ -612,13 +729,9 @@ class KubernetesCreateApplicationController {
return this.$async(this.deployApplicationAsync);
}
}
/**
* !ACTIONS
*/
/* #endregion */
/**
* APPLICATION - used on edit context only
*/
/* #region APPLICATION - used on edit context only */
async getApplicationAsync() {
try {
const namespace = this.state.params.namespace;
@ -634,16 +747,16 @@ class KubernetesCreateApplicationController {
getApplication() {
return this.$async(this.getApplicationAsync);
}
/**
* !APPLICATION
*/
/* #endregion */
/* #region ON INIT */
async onInit() {
try {
this.state = {
actionInProgress: false,
useLoadBalancer: false,
useServerMetrics: false,
canUseIngress: false,
sliders: {
cpu: {
min: 0,
@ -662,14 +775,42 @@ class KubernetesCreateApplicationController {
viewReady: false,
availableSizeUnits: ['MB', 'GB', 'TB'],
alreadyExists: false,
duplicateEnvironmentVariables: {},
hasDuplicateEnvironmentVariables: false,
duplicatePersistedFolderPaths: {},
hasDuplicatePersistedFolderPaths: false,
duplicateConfigurationPaths: {},
hasDuplicateConfigurationPaths: false,
duplicateExistingVolumes: {},
hasDuplicateExistingVolumes: false,
duplicates: {
environmentVariables: {
refs: {},
hasDuplicates: false,
},
persistedFolders: {
refs: {},
hasDuplicates: false,
},
configurationPaths: {
refs: {},
hasDuplicates: false,
},
existingVolumes: {
refs: {},
hasDuplicates: false,
},
publishedPorts: {
containerPorts: {
refs: {},
hasDuplicates: false,
},
nodePorts: {
refs: {},
hasDuplicates: false,
},
ingressRoutes: {
refs: {},
hasDuplicates: false,
},
loadBalancerPorts: {
refs: {},
hasDuplicates: false,
},
},
},
isEdit: false,
params: {
namespace: this.$transition$.params().namespace,
@ -694,20 +835,27 @@ class KubernetesCreateApplicationController {
this.formValues = new KubernetesApplicationFormValues();
const [resourcePools, nodes, volumes, applications] = await Promise.all([
const [resourcePools, nodes, ingresses] = await Promise.all([
this.KubernetesResourcePoolService.get(),
this.KubernetesNodeService.get(),
this.KubernetesVolumeService.get(),
this.KubernetesApplicationService.get(),
this.KubernetesIngressService.get(),
]);
this.ingresses = ingresses;
this.resourcePools = _.filter(resourcePools, (resourcePool) => !this.KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.formValues.ResourcePool = this.resourcePools[0];
this.volumes = volumes;
_.forEach(this.volumes, (volume) => {
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications);
});
this.filterAvailableVolumes();
// TODO: refactor @Max
// Don't pull all volumes and applications across all namespaces
// Use refreshNamespaceData flow (triggered on Init + on Namespace change)
// and query only accross the selected namespace
if (this.storageClassAvailable()) {
const [applications, volumes] = await Promise.all([this.KubernetesApplicationService.get(), this.KubernetesVolumeService.get()]);
this.volumes = volumes;
_.forEach(this.volumes, (volume) => {
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications);
});
}
_.forEach(nodes, (item) => {
this.state.nodes.memory += filesizeParser(item.Memory);
@ -715,11 +863,12 @@ class KubernetesCreateApplicationController {
});
const namespace = this.state.isEdit ? this.state.params.namespace : this.formValues.ResourcePool.Namespace.Name;
await this.refreshStacksConfigsApps(namespace);
await this.refreshNamespaceData(namespace);
if (this.state.isEdit) {
await this.getApplication();
this.formValues = KubernetesApplicationConverter.applicationToFormValues(this.application, this.resourcePools, this.configurations, this.persistentVolumeClaims);
this.formValues.OriginalIngresses = this.filteredIngresses;
this.savedFormValues = angular.copy(this.formValues);
delete this.formValues.ApplicationType;
@ -734,6 +883,7 @@ class KubernetesCreateApplicationController {
}
} else {
this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount);
this.formValues.OriginalIngressClasses = angular.copy(this.ingresses);
}
await this.updateSliders();
@ -747,6 +897,7 @@ class KubernetesCreateApplicationController {
$onInit() {
return this.$async(this.onInit);
}
/* #endregion */
}
export default KubernetesCreateApplicationController;

View File

@ -11,12 +11,12 @@
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Expose applications over external IP addresses
Networking
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Enabling this feature will allow users to expose application they deploy over an external IP address assigned by cloud provider.
Enabling the load balancer feature will allow users to expose application they deploy over an external IP address assigned by cloud provider.
<p style="margin-top: 2px;">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Ensure that your cloud provider allows you to create load balancers if you want to use this feature. Might incur costs.
@ -32,6 +32,37 @@
</div>
</div>
<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 />
<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.
</p>
</span>
</div>
<div class="form-group">
<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 controllers
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="ingress_classes" ng-model="ctrl.formValues.IngressClasses" placeholder="nginx,gce,traefik" />
</div>
<span class="col-sm-12 text-muted small" style="margin-top: 12px;">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Once configured, ingresses can be created at the resource pool level.
</span>
</div>
<div class="col-sm-12 form-section-title">
Metrics
</div>
@ -46,7 +77,6 @@
</p>
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">

View File

@ -52,6 +52,10 @@ class KubernetesConfigureController {
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, ',');
}
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
const storagePromises = _.map(classes, (storageClass) => {
@ -69,6 +73,10 @@ class KubernetesConfigureController {
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.EndpointProvider.setEndpoints(endpoints);
}
this.Notifications.success('Configuration successfully applied');
@ -95,6 +103,8 @@ class KubernetesConfigureController {
this.formValues = {
UseLoadBalancer: false,
UseServerMetrics: false,
UseIngress: false,
IngressClasses: '',
};
try {
@ -117,8 +127,10 @@ 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);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve storage classes');
this.Notifications.error('Failure', err, 'Unable to retrieve endpoint configuration');
} finally {
this.state.viewReady = true;
}

View File

@ -10,7 +10,7 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" autocomplete="off" name="resourcePoolCreationForm">
<!-- name-input -->
<!-- #region NAME INPUT -->
<div class="form-group">
<label for="pool_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
@ -39,10 +39,12 @@
<p ng-if="ctrl.state.isAlreadyExist"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A resource pool with the same name already exists.</p>
</div>
</div>
<!-- !name-input -->
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Quota
</div>
<!-- #region QUOTA -->
<!-- quotas-switch -->
<div class="form-group">
<div class="col-sm-12 small text-muted">
@ -56,16 +58,16 @@
<label class="control-label text-left">
Resource assignment
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.hasQuota" /><i></i> </label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.HasQuota" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="ctrl.formValues.hasQuota && !ctrl.isQuotaValid()">
<div class="form-group" ng-if="ctrl.formValues.HasQuota && !ctrl.isQuotaValid()">
<span class="col-sm-12 text-warning small">
<p> <i class="fa fa-exclamation-triangle" aria-hidden="true" style="margin-right: 2px;"></i> At least a single limit must be set for the quota to be valid. </p>
</span>
</div>
<!-- !quotas-switch -->
<div ng-if="ctrl.formValues.hasQuota">
<div ng-if="ctrl.formValues.HasQuota">
<div class="col-sm-12 form-section-title">
Resource limits
</div>
@ -76,13 +78,8 @@
Memory
</label>
<div class="col-sm-3">
<slider
model="ctrl.formValues.MemoryLimit"
floor="ctrl.defaults.MemoryLimit"
ceil="ctrl.state.sliderMaxMemory"
step="128"
ng-if="ctrl.state.sliderMaxMemory"
></slider>
<slider model="ctrl.formValues.MemoryLimit" floor="ctrl.defaults.MemoryLimit" ceil="ctrl.state.sliderMaxMemory" step="128" ng-if="ctrl.state.sliderMaxMemory">
</slider>
</div>
<div class="col-sm-2">
<input
@ -106,8 +103,8 @@
<div class="col-sm-12 small text-warning">
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
<p
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Value must be between {{ ctrl.defaults.MemoryLimit }} and {{ ctrl.state.sliderMaxMemory }}</p
>
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Value must be between {{ ctrl.defaults.MemoryLimit }} and {{ ctrl.state.sliderMaxMemory }}
</p>
</div>
</div>
</div>
@ -118,14 +115,8 @@
CPU
</label>
<div class="col-sm-5">
<slider
model="ctrl.formValues.CpuLimit"
floor="ctrl.defaults.CpuLimit"
ceil="ctrl.state.sliderMaxCpu"
step="0.1"
precision="2"
ng-if="ctrl.state.sliderMaxCpu"
></slider>
<slider model="ctrl.formValues.CpuLimit" floor="ctrl.defaults.CpuLimit" ceil="ctrl.state.sliderMaxCpu" step="0.1" precision="2" ng-if="ctrl.state.sliderMaxCpu">
</slider>
</div>
<div class="col-sm-4" style="margin-top: 20px;">
<p class="small text-muted">
@ -136,16 +127,67 @@
<!-- !cpu-limit-input -->
</div>
</div>
<!-- actions -->
<!-- #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>
</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>
</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>
</div>
<!-- #endregion -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<!-- #region ACTIONS -->
<div class="form-group">
<div class="col-sm-12">
<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.state.actionInProgress || (ctrl.formValues.HasQuota && !ctrl.isQuotaValid()) || !ctrl.isValid()"
ng-click="ctrl.createResourcePool()"
button-spinner="ctrl.state.actionInProgress"
>
@ -154,7 +196,8 @@
</button>
</div>
</div>
<!-- !actions -->
<!-- #endregion -->
</form>
</rd-widget-body>
</rd-widget>

View File

@ -3,14 +3,16 @@ 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';
class KubernetesCreateResourcePoolController {
/* @ngInject */
constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, Authentication) {
constructor($async, $state, Notifications, KubernetesNodeService, KubernetesResourcePoolService, Authentication, EndpointProvider) {
this.$async = $async;
this.$state = $state;
this.Notifications = Notifications;
this.Authentication = Authentication;
this.EndpointProvider = EndpointProvider;
this.KubernetesNodeService = KubernetesNodeService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
@ -53,13 +55,8 @@ class KubernetesCreateResourcePoolController {
try {
this.checkDefaults();
const owner = this.Authentication.getUserDetails().username;
await this.KubernetesResourcePoolService.create(
this.formValues.Name,
owner,
this.formValues.hasQuota,
this.formValues.CpuLimit,
KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit)
);
this.formValues.Owner = owner;
await this.KubernetesResourcePoolService.create(this.formValues);
this.Notifications.success('Resource pool successfully created', this.formValues.Name);
this.$state.go('kubernetes.resourcePools');
} catch (err) {
@ -87,13 +84,10 @@ class KubernetesCreateResourcePoolController {
async onInit() {
try {
const endpoint = this.EndpointProvider.currentEndpoint();
this.endpoint = endpoint;
this.defaults = KubernetesResourceQuotaDefaults;
this.formValues = {
MemoryLimit: this.defaults.MemoryLimit,
CpuLimit: this.defaults.CpuLimit,
hasQuota: true,
};
this.formValues = new KubernetesResourcePoolFormValues(this.defaults);
this.state = {
actionInProgress: false,
@ -101,6 +95,7 @@ class KubernetesCreateResourcePoolController {
sliderMaxCpu: 0,
viewReady: false,
isAlreadyExist: false,
canUseIngress: endpoint.Kubernetes.Configuration.UseIngress,
};
const nodes = await this.KubernetesNodeService.get();
@ -111,6 +106,10 @@ class KubernetesCreateResourcePoolController {
});
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
await this.getResourcePools();
if (this.state.canUseIngress) {
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
this.formValues.IngressClasses = _.map(ingressClasses, (item) => new KubernetesResourcePoolIngressClassFormValue(item));
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {

View File

@ -0,0 +1,74 @@
import _ from 'lodash-es';
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolIngressesDatatableController', function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.state = Object.assign(this.state, {
expandedItems: [],
expandAll: false,
});
this.onSettingsRepeaterChange = function () {
DatatableService.setDataTableSettings(this.tableKey, this.settings);
};
this.expandItem = function (item, expanded) {
if (!this.itemCanExpand(item)) {
return;
}
item.Expanded = expanded;
if (!expanded) {
item.Highlighted = false;
}
};
this.itemCanExpand = function (item) {
return item.Paths.length > 0;
};
this.hasExpandableItems = function () {
return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length;
};
this.expandAll = function () {
this.state.expandAll = !this.state.expandAll;
_.forEach(this.state.filteredDataSet, (item) => {
if (this.itemCanExpand(item)) {
this.expandItem(item, this.state.expandAll);
}
});
};
this.$onInit = function () {
this.setDefaults();
this.prepareTableFromDataset();
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
};
});

View File

@ -0,0 +1,13 @@
angular.module('portainer.kubernetes').component('kubernetesResourcePoolIngressesDatatable', {
templateUrl: './template.html',
controller: 'KubernetesResourcePoolIngressesDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
refreshCallback: '<',
},
});

View File

@ -0,0 +1,131 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Table settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th style="width: 10%;">
<a ng-click="$ctrl.expandAll()" ng-if="$ctrl.hasExpandableItems()">
<i ng-class="{ 'fas fa-angle-down': $ctrl.state.expandAll, 'fas fa-angle-right': !$ctrl.state.expandAll }" aria-hidden="true"></i>
</a>
</th>
<th style="width: 45%;">
<a ng-click="$ctrl.changeOrderBy('Name')">
Ingress
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th style="width: 45%;"></th>
</tr>
</thead>
<tbody>
<tr
dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))"
ng-class="{ active: item.Checked }"
ng-style="{ background: item.Highlighted ? '#d5e8f3' : '' }"
ng-click="$ctrl.expandItem(item, !item.Expanded)"
pagination-id="$ctrl.tableKey"
>
<td>
<a ng-if="$ctrl.itemCanExpand(item)">
<i ng-class="{ 'fas fa-angle-down': item.Expanded, 'fas fa-angle-right': !item.Expanded }" class="space-right" aria-hidden="true"></i>
</a>
</td>
<td colspan="3">{{ item.Name }}</td>
</tr>
<tr dir-paginate-end ng-show="item.Expanded" ng-repeat="path in item.Paths" ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }">
<td colspan="3">
<a style="margin-left: 25px;" ng-href="http://{{ path.Host ? path.Host : path.IP }}{{ path.Path }}" target="_blank">
{{ path.Host ? path.Host : path.IP }}{{ path.Path }}
</a>
<i class="fas fa-long-arrow-alt-right" style="margin: 2px;"></i>
<a ui-sref="kubernetes.applications.application({ name: path.ApplicationName, namespace: item.Namespace })">{{ path.ApplicationName }}</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="4" class="text-center text-muted">No ingresses available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5" pagination-id="$ctrl.tableKey"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -28,16 +28,16 @@
<label class="control-label text-left">
Resource assignment
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.hasQuota" /><i></i> </label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.HasQuota" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="ctrl.formValues.hasQuota && !ctrl.isQuotaValid()">
<div class="form-group" ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
<span class="col-sm-12 text-warning small">
<p> <i class="fa fa-exclamation-triangle" aria-hidden="true" style="margin-right: 2px;"></i> At least a single limit must be set for the quota to be valid. </p>
</span>
</div>
<!-- !quotas-switch -->
<div ng-if="ctrl.formValues.hasQuota && ctrl.isAdmin && ctrl.isEditable">
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
<div class="col-sm-12 form-section-title">
Resource limits
</div>
@ -109,7 +109,7 @@
<!-- !cpu-limit-input -->
</div>
</div>
<div ng-if="ctrl.formValues.hasQuota">
<div ng-if="ctrl.formValues.HasQuota">
<kubernetes-resource-reservation
ng-if="ctrl.pool.Quota"
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this resource pool."
@ -120,6 +120,46 @@
>
</kubernetes-resource-reservation>
</div>
<div ng-if="ctrl.isAdmin && ctrl.isEditable">
<div class="col-sm-12 form-section-title">
Ingresses
</div>
<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>
</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>
</div>
<!-- actions -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="col-sm-12 form-section-title">
Actions
@ -129,7 +169,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.state.actionInProgress || (ctrl.formValues.HasQuota && !ctrl.isQuotaValid())"
ng-click="ctrl.updateResourcePool()"
button-spinner="ctrl.state.actionInProgress"
>
@ -186,4 +226,19 @@
</kubernetes-resource-pool-applications-datatable>
</div>
</div>
<div class="row" ng-if="ctrl.ingresses && ctrl.ingresses.length > 0">
<div class="col-sm-12">
<kubernetes-resource-pool-ingresses-datatable
dataset="ctrl.ingresses"
table-key="kubernetes.resourcepool.ingresses"
order-by="Name"
refresh-callback="ctrl.getIngresses"
loading="ctrl.state.ingressesLoading"
title-text="Ingress routes and applications"
title-icon="fa-route"
>
</kubernetes-resource-pool-ingresses-datatable>
</div>
</div>
</div>

View File

@ -1,9 +1,10 @@
import angular from 'angular';
import _ from 'lodash-es';
import * as _ from 'lodash-es';
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';
class KubernetesResourcePoolController {
/* @ngInject */
@ -13,6 +14,8 @@ class KubernetesResourcePoolController {
Authentication,
Notifications,
LocalStorage,
EndpointProvider,
ModalService,
KubernetesNodeService,
KubernetesResourceQuotaService,
KubernetesResourcePoolService,
@ -20,13 +23,15 @@ class KubernetesResourcePoolController {
KubernetesPodService,
KubernetesApplicationService,
KubernetesNamespaceHelper,
ModalService
KubernetesIngressService
) {
this.$async = $async;
this.$state = $state;
this.Notifications = Notifications;
this.Authentication = Authentication;
this.LocalStorage = LocalStorage;
this.EndpointProvider = EndpointProvider;
this.ModalService = ModalService;
this.KubernetesNodeService = KubernetesNodeService;
this.KubernetesResourceQuotaService = KubernetesResourceQuotaService;
@ -35,14 +40,17 @@ class KubernetesResourcePoolController {
this.KubernetesPodService = KubernetesPodService;
this.KubernetesApplicationService = KubernetesApplicationService;
this.KubernetesNamespaceHelper = KubernetesNamespaceHelper;
this.ModalService = ModalService;
this.KubernetesIngressService = KubernetesIngressService;
this.onInit = this.onInit.bind(this);
this.createResourceQuotaAsync = this.createResourceQuotaAsync.bind(this);
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
this.getEventsAsync = this.getEventsAsync.bind(this);
this.getApplications = this.getApplications.bind(this);
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
this.getIngresses = this.getIngresses.bind(this);
this.getIngressesAsync = this.getIngressesAsync.bind(this);
}
selectTab(index) {
@ -83,8 +91,8 @@ class KubernetesResourcePoolController {
await this.KubernetesResourceQuotaService.create(quota);
}
hasResourceQuotaBeenReduce() {
if (this.formValues.hasQuota) {
hasResourceQuotaBeenReduced() {
if (this.formValues.HasQuota && this.oldQuota) {
const cpuLimit = this.formValues.CpuLimit;
const memoryLimit = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
if (cpuLimit < this.oldQuota.CpuLimit || memoryLimit < this.oldQuota.MemoryLimit) {
@ -104,7 +112,7 @@ class KubernetesResourcePoolController {
const owner = this.pool.Namespace.ResourcePoolOwner;
const quota = this.pool.Quota;
if (this.formValues.hasQuota) {
if (this.formValues.HasQuota) {
if (quota) {
quota.CpuLimit = cpuLimit;
quota.MemoryLimit = memoryLimit;
@ -115,6 +123,25 @@ class KubernetesResourcePoolController {
} else if (quota) {
await this.KubernetesResourceQuotaService.delete(quota);
}
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);
} 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;
return this.KubernetesIngressService.patch(oldIngress, newIngress);
}
});
await Promise.all(promises);
this.Notifications.success('Resource pool successfully updated', this.pool.Namespace.Name);
this.$state.reload();
} catch (err) {
@ -125,15 +152,25 @@ class KubernetesResourcePoolController {
}
updateResourcePool() {
if (this.hasResourceQuotaBeenReduce()) {
this.ModalService.confirmUpdate(
'Reducing the quota assigned to an "in-use" resource pool may have unintended consequences, including preventing running applications from functioning correctly and potentially even blocking them from running at all.',
(confirmed) => {
if (confirmed) {
return this.$async(this.updateResourcePoolAsync);
}
const willBeDeleted = _.filter(this.formValues.IngressClasses, { WasSelected: true, Selected: false });
const warnings = {
quota: this.hasResourceQuotaBeenReduced(),
ingress: willBeDeleted.length !== 0,
};
if (warnings.quota || warnings.ingress) {
const messages = {
quota:
'Reducing the quota assigned to an "in-use" resource pool may have unintended consequences, including preventing running applications from functioning correctly and potentially even blocking them from running at all.',
ingress: 'Deactivating ingresses may cause applications to be unaccessible. All ingress configurations from affected applications will be removed.',
};
const displayedMessage = `${warnings.quota ? messages.quota : ''}${warnings.quota && warnings.ingress ? '<br/><br/>' : ''}
${warnings.ingress ? messages.ingress : ''}<br/><br/>Do you wish to continue?`;
this.ModalService.confirmUpdate(displayedMessage, (confirmed) => {
if (confirmed) {
return this.$async(this.updateResourcePoolAsync);
}
);
});
} else {
return this.$async(this.updateResourcePoolAsync);
}
@ -180,16 +217,36 @@ class KubernetesResourcePoolController {
return this.$async(this.getApplicationsAsync);
}
async getIngressesAsync() {
this.state.ingressesLoading = true;
try {
const namespace = this.pool.Namespace.Name;
this.ingresses = await this.KubernetesIngressService.get(namespace);
_.forEach(this.ingresses, (ing) => {
ing.Namespace = namespace;
_.forEach(ing.Paths, (path) => {
const application = _.find(this.applications, { ServiceName: path.ServiceName });
path.ApplicationName = application && application.Name ? application.Name : '-';
});
});
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.');
} finally {
this.state.ingressesLoading = false;
}
}
getIngresses() {
return this.$async(this.getIngressesAsync);
}
async onInit() {
try {
const endpoint = this.EndpointProvider.currentEndpoint();
this.endpoint = endpoint;
this.isAdmin = this.Authentication.isAdmin();
this.defaults = KubernetesResourceQuotaDefaults;
this.formValues = {
MemoryLimit: this.defaults.MemoryLimit,
CpuLimit: this.defaults.CpuLimit,
hasQuota: false,
};
this.formValues = new KubernetesResourcePoolFormValues(this.defaults);
this.state = {
actionInProgress: false,
@ -204,8 +261,10 @@ class KubernetesResourcePoolController {
showEditorTab: false,
eventsLoading: true,
applicationsLoading: true,
ingressesLoading: true,
viewReady: false,
eventWarningCount: 0,
canUseIngress: endpoint.Kubernetes.Configuration.UseIngress,
};
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
@ -225,7 +284,7 @@ class KubernetesResourcePoolController {
const quota = pool.Quota;
if (quota) {
this.oldQuota = angular.copy(quota);
this.formValues.hasQuota = true;
this.formValues.HasQuota = true;
this.formValues.CpuLimit = quota.CpuLimit;
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimit);
@ -240,6 +299,22 @@ class KubernetesResourcePoolController {
await this.getEvents();
await this.getApplications();
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);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {

View File

@ -26,6 +26,7 @@
"start:server": "grunt clean:server && grunt start:server",
"start:client": "grunt clean:client && grunt start:client",
"dev:client": "grunt clean:client && webpack-dev-server --config=./webpack/webpack.develop.js",
"dev:nodl": "grunt clean:server && grunt clean:client && grunt build:server && grunt copy:assets && grunt start:client",
"start:toolkit": "grunt start:toolkit",
"build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer",
"clean:all": "grunt clean:all",