1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-25 00:09:40 +02:00

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>
This commit is contained in:
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

@ -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;