From f91d3f1ca3d086fa118549cde43a4537469a4beb Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Thu, 13 Aug 2020 01:30:23 +0200 Subject: [PATCH] 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 --- api/kubernetes.go | 4 +- api/portainer.go | 2 + .../kubernetesConfigurationData.html | 2 +- app/kubernetes/converters/application.js | 24 +- app/kubernetes/converters/service.js | 14 +- app/kubernetes/helpers/application/index.js | 15 +- app/kubernetes/ingress/constants.js | 4 + app/kubernetes/ingress/converter.js | 118 ++++- app/kubernetes/ingress/helper.js | 5 +- app/kubernetes/ingress/models.js | 40 +- app/kubernetes/ingress/payloads.js | 33 ++ app/kubernetes/ingress/rest.js | 85 ++-- app/kubernetes/ingress/service.js | 63 +++ .../models/application/formValues.js | 26 +- .../models/application/models/constants.js | 1 + .../models/resource-pool/formValues.js | 22 + app/kubernetes/models/resource-pool/models.js | 21 +- app/kubernetes/services/applicationService.js | 75 ++- .../services/resourcePoolService.js | 39 +- .../create/createApplication.html | 435 ++++++++++++++--- .../create/createApplicationController.js | 439 ++++++++++++------ app/kubernetes/views/configure/configure.html | 36 +- .../views/configure/configureController.js | 14 +- .../create/createResourcePool.html | 93 +++- .../create/createResourcePoolController.js | 27 +- .../ingresses-datatable/controller.js | 74 +++ .../components/ingresses-datatable/index.js | 13 + .../ingresses-datatable/template.html | 131 ++++++ .../resource-pools/edit/resourcePool.html | 65 ++- .../edit/resourcePoolController.js | 117 ++++- package.json | 1 + 31 files changed, 1595 insertions(+), 443 deletions(-) create mode 100644 app/kubernetes/ingress/constants.js create mode 100644 app/kubernetes/ingress/payloads.js create mode 100644 app/kubernetes/models/resource-pool/formValues.js create mode 100644 app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/controller.js create mode 100644 app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/index.js create mode 100644 app/kubernetes/views/resource-pools/edit/components/ingresses-datatable/template.html diff --git a/api/kubernetes.go b/api/kubernetes.go index a4d1beb4f..a363a0a63 100644 --- a/api/kubernetes.go +++ b/api/kubernetes.go @@ -5,7 +5,9 @@ func KubernetesDefault() KubernetesData { Configuration: KubernetesConfiguration{ UseLoadBalancer: false, UseServerMetrics: false, - StorageClasses: []KubernetesStorageClassConfig{}, + UseIngress: false, + StorageClasses: []KubernetesStorageClassConfig{}, + IngressClasses: []string{}, }, Snapshots: []KubernetesSnapshot{}, } diff --git a/api/portainer.go b/api/portainer.go index 5e87a6a2a..f1887f28c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html index a7db2fc8f..09a4baf1f 100644 --- a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html @@ -84,7 +84,7 @@
diff --git a/app/kubernetes/converters/application.js b/app/kubernetes/converters/application.js index fa45a4a38..4fb313acc 100644 --- a/app/kubernetes/converters/application.js +++ b/app/kubernetes/converters/application.js @@ -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]; } } diff --git a/app/kubernetes/converters/service.js b/app/kubernetes/converters/service.js index a04c04ef0..44755f04b 100644 --- a/app/kubernetes/converters/service.js +++ b/app/kubernetes/converters/service.js @@ -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; } diff --git a/app/kubernetes/helpers/application/index.js b/app/kubernetes/helpers/application/index.js index 11ab8c2fd..f14d493db 100644 --- a/app/kubernetes/helpers/application/index.js +++ b/app/kubernetes/helpers/application/index.js @@ -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; } diff --git a/app/kubernetes/ingress/constants.js b/app/kubernetes/ingress/constants.js new file mode 100644 index 000000000..22af57a87 --- /dev/null +++ b/app/kubernetes/ingress/constants.js @@ -0,0 +1,4 @@ +export const KubernetesIngressClassAnnotation = 'kubernetes.io/ingress.class'; +export const KubernetesIngressClassMandatoryAnnotations = Object.freeze({ + nginx: { 'nginx.ingress.kubernetes.io/rewrite-target': '/$1' }, +}); diff --git a/app/kubernetes/ingress/converter.js b/app/kubernetes/ingress/converter.js index f931fd3ab..311d22e08 100644 --- a/app/kubernetes/ingress/converter.js +++ b/app/kubernetes/ingress/converter.js @@ -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; } } diff --git a/app/kubernetes/ingress/helper.js b/app/kubernetes/ingress/helper.js index 7090e4596..b40178733 100644 --- a/app/kubernetes/ingress/helper.js +++ b/app/kubernetes/ingress/helper.js @@ -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 }); } } diff --git a/app/kubernetes/ingress/models.js b/app/kubernetes/ingress/models.js index a8486ce94..e7181a48f 100644 --- a/app/kubernetes/ingress/models.js +++ b/app/kubernetes/ingress/models.js @@ -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: '', + }; } diff --git a/app/kubernetes/ingress/payloads.js b/app/kubernetes/ingress/payloads.js new file mode 100644 index 000000000..23e5ff317 --- /dev/null +++ b/app/kubernetes/ingress/payloads.js @@ -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: '', + }; +} diff --git a/app/kubernetes/ingress/rest.js b/app/kubernetes/ingress/rest.js index 42b87a80c..98f2eea97 100644 --- a/app/kubernetes/ingress/rest.js +++ b/app/kubernetes/ingress/rest.js @@ -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' }, + } + ); + }; +} diff --git a/app/kubernetes/ingress/service.js b/app/kubernetes/ingress/service.js index 72ce0ef5d..c31f30585 100644 --- a/app/kubernetes/ingress/service.js +++ b/app/kubernetes/ingress/service.js @@ -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; diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js index 7ca9ca1b9..8b658141a 100644 --- a/app/kubernetes/models/application/formValues.js +++ b/app/kubernetes/models/application/formValues.js @@ -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, + }; } /** diff --git a/app/kubernetes/models/application/models/constants.js b/app/kubernetes/models/application/models/constants.js index f8cfa0ff1..3be2fcdfb 100644 --- a/app/kubernetes/models/application/models/constants.js +++ b/app/kubernetes/models/application/models/constants.js @@ -24,6 +24,7 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({ INTERNAL: 1, CLUSTER: 2, LOAD_BALANCER: 3, + INGRESS: 4, }); export const KubernetesApplicationQuotaDefaults = { diff --git a/app/kubernetes/models/resource-pool/formValues.js b/app/kubernetes/models/resource-pool/formValues.js new file mode 100644 index 000000000..f59446cee --- /dev/null +++ b/app/kubernetes/models/resource-pool/formValues.js @@ -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 + }; +} diff --git a/app/kubernetes/models/resource-pool/models.js b/app/kubernetes/models/resource-pool/models.js index 57fa71df9..0e7a67d62 100644 --- a/app/kubernetes/models/resource-pool/models.js +++ b/app/kubernetes/models/resource-pool/models.js @@ -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: '', + }; } diff --git a/app/kubernetes/services/applicationService.js b/app/kubernetes/services/applicationService.js index 614267068..1afe88de5 100644 --- a/app/kubernetes/services/applicationService.js +++ b/app/kubernetes/services/applicationService.js @@ -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; diff --git a/app/kubernetes/services/resourcePoolService.js b/app/kubernetes/services/resourcePoolService.js index 7dbfb933b..7de236f21 100644 --- a/app/kubernetes/services/resourcePoolService.js +++ b/app/kubernetes/services/resourcePoolService.js @@ -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); } /** diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 540c450d7..f30fa9a95 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -16,7 +16,7 @@
- +
@@ -48,9 +48,9 @@ >
- + - +
@@ -64,13 +64,12 @@
- +
Resource pool
- - +
@@ -91,12 +90,12 @@ resource pool.
- +
Stack
- +
@@ -105,7 +104,6 @@
-
@@ -121,13 +119,12 @@ />
- +
Environment
- - +
@@ -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 @@

Environment variable name is required.

@@ -164,7 +163,7 @@ character, and end with an alphanumeric character (e.g. 'my-var', or 'MY_VAR123').

-

This environment variable is already defined.

@@ -177,22 +176,21 @@
- +
Configurations
- - +
@@ -231,7 +229,7 @@ - +
@@ -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 " >

Path is required.

-

This path is already used.

@@ -302,12 +300,12 @@
- +
Persisting data
- +
@@ -315,7 +313,6 @@
-
@@ -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 volumeExisting volume
@@ -422,10 +419,10 @@
@@ -434,22 +431,22 @@

Path is required.

-

This path is already defined.

@@ -466,12 +463,12 @@

Volume is required.

-

This volume is already used.

@@ -483,8 +480,9 @@
- + +
@@ -579,11 +577,12 @@
+
Resource reservations
- +
@@ -668,11 +667,12 @@
+
Deployment
- +
Select how you want to deploy your application inside the cluster. @@ -775,8 +775,9 @@ >. You will not be able to scale that application.
+ - +
Auto-scaling
@@ -884,12 +885,12 @@
- +
Publishing the application
- +
Select how you want to publish your application. @@ -899,9 +900,36 @@
-
- -