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:
parent
201c3ac143
commit
f91d3f1ca3
31 changed files with 1595 additions and 443 deletions
4
app/kubernetes/ingress/constants.js
Normal file
4
app/kubernetes/ingress/constants.js
Normal 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' },
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: '',
|
||||
};
|
||||
}
|
||||
|
|
33
app/kubernetes/ingress/payloads.js
Normal file
33
app/kubernetes/ingress/payloads.js
Normal 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: '',
|
||||
};
|
||||
}
|
|
@ -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' },
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue