feat(k8s): Allow mix services for k8s app EE-1791 (#6198)

allow a mix of services for k8s in ui
pull/4499/merge
Richard Wei 2022-01-17 08:37:46 +13:00 committed by GitHub
parent edf048570b
commit c47e840b37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2336 additions and 1863 deletions

View File

@ -166,7 +166,7 @@
Status
</th>
<th>
Publishing mode
Published
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('CreationDate')">
@ -233,15 +233,9 @@
{{ item.Pods[0].Status }}
</td>
<td>
<span ng-if="item.PublishedPorts.length">
<span>
<a ng-click="$ctrl.onPublishingModeClick(item); $event.stopPropagation()">
<i class="fa {{ item.ServiceType | kubernetesApplicationServiceTypeIcon }}" aria-hidden="true" style="margin-right: 2px;"> </i>
{{ item.ServiceType | kubernetesApplicationServiceTypeText }}
</a>
</span>
<span>
{{ item.Services.length === 0 ? 'No' : 'Yes' }}
</span>
<span ng-if="item.PublishedPorts.length === 0">-</span>
</td>
<td>{{ item.CreationDate | getisodate }} {{ item.ApplicationOwner ? 'by ' + item.ApplicationOwner : '' }}</td>
</tr>

View File

@ -0,0 +1,83 @@
import _ from 'lodash-es';
import { KubernetesServicePort, KubernetesIngressServiceRoute } from 'Kubernetes/models/service/models';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
export default class KubeServicesItemViewController {
/* @ngInject */
constructor(EndpointProvider, Authentication) {
this.EndpointProvider = EndpointProvider;
this.Authentication = Authentication;
}
addPort() {
const p = new KubernetesServicePort();
p.nodePort = '';
p.port = '';
p.targetPort = '';
p.protocol = 'TCP';
if (this.ingressType) {
const r = new KubernetesIngressServiceRoute();
r.ServiceName = this.serviceName;
p.ingress = r;
p.Ingress = true;
}
this.servicePorts.push(p);
}
removePort(index) {
this.servicePorts.splice(index, 1);
}
servicePort(index) {
const targetPort = this.servicePorts[index].targetPort;
this.servicePorts[index].port = targetPort;
}
isAdmin() {
return this.Authentication.isAdmin();
}
onChangeContainerPort() {
const state = this.state.duplicates.targetPort;
const source = _.map(this.servicePorts, (sp) => sp.targetPort);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
}
onChangeServicePort() {
const state = this.state.duplicates.servicePort;
const source = _.map(this.servicePorts, (sp) => sp.port);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
}
onChangeNodePort() {
const state = this.state.duplicates.nodePort;
const source = _.map(this.servicePorts, (sp) => sp.nodePort);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
}
$onInit() {
if (this.servicePorts.length === 0) {
this.addPort();
}
this.KubernetesApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
this.state = {
duplicates: {
targetPort: new KubernetesFormValidationReferences(),
servicePort: new KubernetesFormValidationReferences(),
nodePort: new KubernetesFormValidationReferences(),
},
endpointId: this.EndpointProvider.endpointID(),
};
}
}

View File

@ -0,0 +1,242 @@
<form name="serviceForm">
<div ng-if="$ctrl.isAdmin()" class="small text-warning" ng-show="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<p style="margin-top: 10px">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> No Load balancer is available in this cluster, click
<a ui-sref="portainer.k8sendpoint.kubernetesConfig({id: $ctrl.state.endpointId})">here</a> to configure load balancer.
</p>
</div>
<div ng-if="!$ctrl.isAdmin()" class="small text-warning" ng-show="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<p style="margin-top: 10px"> <i class="fa fa-exclamation-circle" aria-hidden="true"></i> No Load balancer is available in this cluster, contract your administrator. </p>
</div>
<div
ng-if="
($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && $ctrl.loadbalancerEnabled) ||
$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP ||
$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT
"
>
<div ng-show="!$ctrl.multiItemDisable" style="margin-top: 5px; margin-bottom: 5px">
<label class="control-label text-left">Published ports</label>
<span class="label label-default interactive" style="margin-left: 10px" ng-click="$ctrl.addPort()" data-cy="k8sAppCreate-addNewPortButton">
<i class="fa fa-plus-circle" aria-hidden="true"></i> publish a new port
</span>
</div>
<div ng-repeat="servicePort in $ctrl.servicePorts" style="margin-top: 10px">
<div class="input-group input-group-sm">
<span class="input-group-addon">container port</span>
<input
type="number"
class="form-control"
name="container_port_{{ $index }}"
ng-model="servicePort.targetPort"
placeholder="80"
ng-min="1"
ng-max="65535"
ng-change="$ctrl.servicePort($index)"
required
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-change="$ctrl.onChangeContainerPort()"
data-cy="k8sAppCreate-containerPort_{{ $index }}"
/>
</div>
<div class="input-group input-group-sm">
<span class="input-group-addon">service port</span>
<input
type="number"
class="form-control"
name="service_port_{{ $index }}"
ng-model="servicePort.port"
placeholder="80"
ng-min="1"
ng-max="65535"
required
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-change="$ctrl.onChangeServicePort()"
data-cy="k8sAppCreate-servicePort_{{ $index }}"
/>
</div>
<div ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT" class="input-group input-group-sm">
<span class="input-group-addon">nodeport</span>
<input
type="number"
class="form-control"
name="node_port_{{ $index }}"
ng-model="servicePort.nodePort"
placeholder="30080"
ng-min="30000"
ng-max="32767"
ng-change="$ctrl.onChangeNodePort()"
data-cy="k8sAppCreate-nodeportPort_{{ $index }}"
/>
</div>
<div ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER" class="input-group input-group-sm">
<span class="input-group-addon">loadbalancer port</span>
<input
type="number"
class="form-control"
name="loadbalancer_port_{{ $index }}"
ng-model="servicePort.port"
placeholder="80"
ng-min="1"
ng-max="65535"
required
ng-disabled="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled"
data-cy="k8sAppCreate-loadbalancerPort_{{ $index }}"
/>
</div>
<div ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType" class="input-group input-group-sm">
<span class="input-group-addon">ingress</span>
<select
ng-init="servicePort.ingress.IngressName = $ctrl.originalIngresses[0].Name"
class="form-control"
name="ingress_port_{{ $index }}"
ng-model="servicePort.ingress.IngressName"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-options="ingress.Name as ingress.Name for ingress in $ctrl.originalIngresses"
data-cy="k8sAppCreate-ingressPort_{{ $index }}"
>
<option selected disabled hidden value="">Select an ingress</option>
</select>
</div>
<div ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType" class="input-group input-group-sm">
<span class="input-group-addon">hostname</span>
<select
ng-init="servicePort.ingress.Host = $ctrl.originalIngresses[0].Hosts"
class="form-control"
name="hostname_port_{{ $index }}"
ng-model="servicePort.ingress.Host"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-options="ingress.Hosts as ingress.Hosts for ingress in $ctrl.originalIngresses"
data-cy="k8sAppCreate-hostnamePort_{{ $index }}"
>
<option selected disabled hidden value="">Select a hostname</option>
</select>
</div>
<div ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType" class="input-group input-group-sm">
<span class="input-group-addon">route</span>
<input
class="form-control"
name="ingress_route_{{ $index }}"
ng-model="servicePort.ingress.Path"
placeholder="route"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
data-cy="k8sAppCreate-route_{{ $index }}"
/>
</div>
<div class="input-group col-sm-2 input-group-sm">
<div class="btn-group btn-group-sm">
<label
class="btn btn-primary"
ng-model="servicePort.protocol"
uib-btn-radio="'TCP'"
ng-change="ctrl.onChangePortProtocol($index)"
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'TCP')"
data-cy="k8sAppCreate-TCPButton_{{ $index }}"
>TCP</label
>
<label
class="btn btn-primary"
ng-model="servicePort.protocol"
uib-btn-radio="'UDP'"
ng-change="ctrl.onChangePortProtocol($index)"
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'UDP')"
data-cy="k8sAppCreate-UDPButton_{{ $index }}"
>UDP</label
>
</div>
<button
ng-disabled="$ctrl.servicePorts.length === 1"
ng-show="!$ctrl.multiItemDisable"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.removePort($index)"
data-cy="k8sAppCreate-rmPortButton_{{ $index }}"
>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
<div class="col-sm-12 input-group input-group-sm">
<div class="col-sm-2">
<div class="small text-warning" style="margin-top: 5px">
<p ng-if="$ctrl.state.duplicates.targetPort.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This container port is already used.
</p>
</div>
<div class="small text-warning" ng-messages="serviceForm['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>
</div>
<div class="col-sm-2">
<div class="small text-warning" style="margin-top: 5px">
<p ng-if="$ctrl.state.duplicates.servicePort.refs[$index] !== undefined">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This service port is already used.
</p>
</div>
<div class="small text-warning" style="margin-top: 5px">
<div ng-messages="serviceForm['service_port_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Service 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>
</div>
</div>
<div class="col-sm-6">
<div class="small text-warning" style="margin-top: 5px">
<div ng-messages="serviceForm['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 or blank for system allocated.</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 or blank for system allocated.</p
>
</div>
</div>
</div>
<div class="col-sm-2">
<div class="small text-warning" style="margin-top: 5px">
<div ng-messages="serviceForm['ingress_port_'+$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-2">
<div class="small text-warning" style="margin-top: 5px">
<div ng-messages="serviceForm['hostname_port_'+$index].$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Host is required.</p>
</div>
</div>
</div>
<div class="col-sm-8" ng-show="">
<div class="small text-warning" style="margin-top: 5px">
<div ng-messages="serviceForm['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>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,19 @@
import angular from 'angular';
import controller from './kube-services-item.controller';
angular.module('portainer.kubernetes').component('kubeServicesItemView', {
templateUrl: './kube-services-item.html',
controller,
bindings: {
serviceType: '<',
servicePorts: '=',
serviceRoutes: '=',
ingressType: '<',
originalIngresses: '<',
isEdit: '<',
serviceName: '<',
multiItemDisable: '<',
serviceIndex: '<',
loadbalancerEnabled: '<',
},
});

View File

@ -0,0 +1,105 @@
import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
export default class KubeServicesViewController {
/* @ngInject */
constructor($async, EndpointProvider, Authentication) {
this.$async = $async;
this.EndpointProvider = EndpointProvider;
this.Authentication = Authentication;
}
addEntry(service) {
const p = new KubernetesService();
if (service === KubernetesApplicationPublishingTypes.INGRESS) {
p.Type = KubernetesApplicationPublishingTypes.CLUSTER_IP;
p.Ingress = true;
} else {
p.Type = service;
}
p.Selector = this.formValues.Selector;
p.Name = this.getUniqName();
this.state.nameIndex += 1;
this.formValues.Services.push(p);
}
getUniqName() {
let name = this.formValues.Name + '-' + this.state.nameIndex;
const services = this.formValues.Services;
services.forEach((service) => {
if (service.Name === name) {
this.state.nameIndex += 1;
name = this.formValues.Name + '-' + this.state.nameIndex;
}
});
const UniqName = this.formValues.Name + '-' + this.state.nameIndex;
return UniqName;
}
deleteService(index) {
this.formValues.Services.splice(index, 1);
this.state.nameIndex -= 1;
}
addPort(index) {
const p = new KubernetesServicePort();
this.formValues.Services[index].Ports.push(p);
}
serviceType(type) {
switch (type) {
case KubernetesApplicationPublishingTypes.CLUSTER_IP:
return KubernetesServiceTypes.CLUSTER_IP;
case KubernetesApplicationPublishingTypes.NODE_PORT:
return KubernetesServiceTypes.NODE_PORT;
case KubernetesApplicationPublishingTypes.LOAD_BALANCER:
return KubernetesServiceTypes.LOAD_BALANCER;
case KubernetesApplicationPublishingTypes.INGRESS:
return KubernetesServiceTypes.INGRESS;
}
}
isAdmin() {
return this.Authentication.isAdmin();
}
iconStyle(type) {
switch (type) {
case KubernetesApplicationPublishingTypes.CLUSTER_IP:
return 'fa fa-list-alt';
case KubernetesApplicationPublishingTypes.NODE_PORT:
return 'fa fa-list';
case KubernetesApplicationPublishingTypes.LOAD_BALANCER:
return 'fa fa-project-diagram';
case KubernetesApplicationPublishingTypes.INGRESS:
return 'fa fa-route';
}
}
$onInit() {
this.state = {
serviceType: [
{
typeName: KubernetesServiceTypes.CLUSTER_IP,
typeValue: KubernetesApplicationPublishingTypes.CLUSTER_IP,
},
{
typeName: KubernetesServiceTypes.NODE_PORT,
typeValue: KubernetesApplicationPublishingTypes.NODE_PORT,
},
{
typeName: KubernetesServiceTypes.LOAD_BALANCER,
typeValue: KubernetesApplicationPublishingTypes.LOAD_BALANCER,
},
{
typeName: KubernetesServiceTypes.INGRESS,
typeValue: KubernetesApplicationPublishingTypes.INGRESS,
},
],
selected: KubernetesApplicationPublishingTypes.CLUSTER_IP,
nameIndex: this.formValues.Services.length,
endpointId: this.EndpointProvider.endpointID(),
};
}
}

View File

@ -0,0 +1,94 @@
<div class="col-sm-12 form-section-title">
Publishing the application
</div>
<div class="form-group">
<div class="col-sm-12 form-inline">
<div class="col-sm-5" style="padding-left: 0px;">
<select class="form-control" ng-model="$ctrl.state.selected" ng-options="item.typeValue as item.typeName for item in $ctrl.state.serviceType"></select>
<button type="button" class="btn btn-sm btn-default" style="margin-left: 0;" ng-click="$ctrl.addEntry( $ctrl.state.selected )" data-cy="k8sConfigCreate-createEntryButton">
<i class="fa fa-plus-circle" aria-hidden="true"></i> Create service
</button>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 form-inline" style="margin-top: 20px;" ng-repeat="service in $ctrl.formValues.Services">
<div ng-if="!$ctrl.formValues.Services[$index].Ingress">
<div class="text-muted">
<i class="{{ $ctrl.iconStyle(service.Type) }}" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.serviceType(service.Type) }}
</div>
<kube-services-item-view
service-routes="$ctrl.formValues.Services[$index].IngressRoute"
ingress-type="$ctrl.formValues.Services[$index].Ingress"
service-type="$ctrl.formValues.Services[$index].Type"
service-ports="$ctrl.formValues.Services[$index].Ports"
is-edit="$ctrl.isEdit"
loadbalancer-enabled="$ctrl.loadbalancerEnabled"
></kube-services-item-view>
<button
type="button"
class="btn btn-sm btn-danger space-right"
style="margin-left: 0; margin-top: 10px;"
ng-click="$ctrl.deleteService( $index )"
data-cy="k8sConfigCreate-removeButton"
>
<i class="fa fa-trash-alt" aria-hidden="true"></i> Remove
</button>
</div>
<div ng-if="$ctrl.formValues.Services[$index].Ingress && $ctrl.formValues.OriginalIngresses.length === 0">
<div class="text-muted">
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
Ingress
</div>
<div ng-if="$ctrl.isAdmin()" class="small text-warning">
<p style="margin-top: 10px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> Ingress is not configured in this namespace, select another namespace or click
<a ui-sref="portainer.k8sendpoint.kubernetesConfig({id: $ctrl.state.endpointId})">here</a> to configure ingress.
</p>
</div>
<div ng-if="!$ctrl.isAdmin()" class="small text-warning">
<p style="margin-top: 10px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> Ingress is not configured in this namespace, select another namespace or contact your administrator.
</p>
</div>
<button
type="button"
class="btn btn-sm btn-danger space-right"
style="margin-left: 0; margin-top: 10px;"
ng-click="$ctrl.deleteService( $index )"
data-cy="k8sConfigCreate-removeButton"
>
<i class="fa fa-trash-alt" aria-hidden="true"></i> Remove
</button>
</div>
<div ng-if="$ctrl.formValues.Services[$index].Ingress && $ctrl.formValues.OriginalIngresses.length !== 0">
<div class="text-muted">
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i>
Ingress
</div>
<kube-services-item-view
original-ingresses="$ctrl.formValues.OriginalIngresses"
service-routes="$ctrl.formValues.Services[$index].IngressRoute"
ingress-type="$ctrl.formValues.Services[$index].Ingress"
service-type="$ctrl.formValues.Services[$index].Type"
service-ports="$ctrl.formValues.Services[$index].Ports"
service-name="$ctrl.formValues.Services[$index].Name"
multi-item-disable="true"
></kube-services-item-view>
<button
type="button"
class="btn btn-sm btn-danger space-right"
style="margin-left: 0; margin-top: 10px;"
ng-click="$ctrl.deleteService( $index )"
data-cy="k8sConfigCreate-removeButton"
>
<i class="fa fa-trash-alt" aria-hidden="true"></i> Remove
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
import angular from 'angular';
import controller from './kube-services.controller';
angular.module('portainer.kubernetes').component('kubeServicesView', {
templateUrl: './kube-services.html',
controller,
bindings: {
formValues: '=',
isEdit: '<',
loadbalancerEnabled: '<',
},
});

View File

@ -120,6 +120,8 @@ class KubernetesApplicationConverter {
res.ServiceType = serviceType;
res.ServiceId = service.metadata.uid;
res.ServiceName = service.metadata.name;
res.ClusterIp = service.spec.clusterIP;
res.ExternalIp = service.spec.externalIP;
if (serviceType === KubernetesServiceTypes.LOAD_BALANCER) {
if (service.status.loadBalancer.ingress && service.status.loadBalancer.ingress.length > 0) {
@ -279,6 +281,8 @@ class KubernetesApplicationConverter {
res.ApplicationType = app.ApplicationType;
res.ResourcePool = _.find(resourcePools, ['Namespace.Name', app.ResourcePool]);
res.Name = app.Name;
res.Services = KubernetesApplicationHelper.generateServicesFormValuesFromServices(app);
res.Selector = KubernetesApplicationHelper.generateSelectorFromService(app);
res.StackName = app.StackName;
res.ApplicationOwner = app.ApplicationOwner;
res.ImageModel.Image = app.Image;
@ -356,7 +360,9 @@ class KubernetesApplicationConverter {
service = undefined;
}
return [app, headlessService, service, claims];
let services = KubernetesServiceConverter.applicationFormValuesToServices(formValues);
return [app, headlessService, services, service, claims];
}
}

View File

@ -52,10 +52,59 @@ class KubernetesServiceConverter {
return res;
}
static applicationFormValuesToServices(formValues) {
let services = [];
formValues.Services.forEach(function (service) {
const res = new KubernetesService();
res.Namespace = formValues.ResourcePool.Namespace.Name;
res.Name = service.Name;
res.StackName = formValues.StackName ? formValues.StackName : formValues.Name;
res.ApplicationOwner = formValues.ApplicationOwner;
res.ApplicationName = formValues.Name;
if (service.Type === KubernetesApplicationPublishingTypes.NODE_PORT) {
res.Type = KubernetesServiceTypes.NODE_PORT;
} else if (service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
res.Type = KubernetesServiceTypes.LOAD_BALANCER;
} else if (service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP) {
res.Type = KubernetesServiceTypes.CLUSTER_IP;
}
res.Ingress = service.Ingress;
if (service.Selector !== undefined) {
res.Selector = service.Selector;
} else {
res.Selector = {
app: formValues.Name,
};
}
let ports = [];
service.Ports.forEach(function (port, index) {
const res = new KubernetesServicePort();
res.name = 'port-' + index;
res.port = port.port;
if (port.nodePort) {
res.nodePort = port.nodePort;
}
res.protocol = port.protocol;
res.targetPort = port.targetPort;
res.ingress = port.ingress;
ports.push(res);
});
res.Ports = ports;
services.push(res);
});
return services;
}
static applicationFormValuesToHeadlessService(formValues) {
const res = KubernetesServiceConverter.applicationFormValuesToService(formValues);
res.Name = KubernetesServiceHelper.generateHeadlessServiceName(formValues.Name);
res.Headless = true;
res.Selector = {
app: formValues.Name,
};
return res;
}
@ -70,8 +119,20 @@ class KubernetesServiceConverter {
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = service.StackName;
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = service.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.ApplicationOwner;
payload.spec.ports = service.Ports;
payload.spec.selector.app = service.ApplicationName;
const ports = [];
service.Ports.forEach((port) => {
const p = {};
p.name = port.name;
p.port = port.port;
p.nodePort = port.nodePort;
p.protocol = port.protocol;
p.targetPort = port.targetPort;
ports.push(p);
});
payload.spec.ports = ports;
payload.spec.selector = service.Selector;
if (service.Headless) {
payload.spec.clusterIP = KubernetesServiceHeadlessClusterIP;
delete payload.spec.ports;

View File

@ -1,6 +1,6 @@
import _ from 'lodash-es';
import { KubernetesPortMapping, KubernetesPortMappingPort } from 'Kubernetes/models/port/models';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import {
KubernetesApplicationAutoScalerFormValue,
@ -276,6 +276,61 @@ class KubernetesApplicationHelper {
}
/* #endregion */
/* #region SERVICES -> SERVICES FORM VALUES */
static generateServicesFormValuesFromServices(app) {
let services = [];
app.Services.forEach(function (service) {
const svc = new KubernetesService();
svc.Namespace = service.metadata.namespace;
svc.Name = service.metadata.name;
svc.StackName = service.StackName;
svc.ApplicationOwner = app.ApplicationOwner;
svc.ApplicationName = app.ApplicationName;
svc.Type = service.spec.type;
if (service.spec.type === KubernetesServiceTypes.CLUSTER_IP) {
svc.Type = 1;
} else if (service.spec.type === KubernetesServiceTypes.NODE_PORT) {
svc.Type = 2;
} else if (service.spec.type === KubernetesServiceTypes.LOAD_BALANCER) {
svc.Type = 3;
}
let ports = [];
service.spec.ports.forEach(function (port) {
const svcport = new KubernetesServicePort();
svcport.name = port.name;
svcport.port = port.port;
svcport.nodePort = port.nodePort;
svcport.protocol = port.protocol;
svcport.targetPort = port.targetPort;
app.Ingresses.value.forEach((ingress) => {
const ingressMatched = _.find(ingress.Paths, { ServiceName: service.metadata.name });
if (ingressMatched) {
svcport.ingress = {
IngressName: ingressMatched.IngressName,
Host: ingressMatched.Host,
Path: ingressMatched.Path,
};
svc.Ingress = true;
}
});
ports.push(svcport);
});
svc.Ports = ports;
svc.Selector = app.Raw.spec.selector.matchLabels;
services.push(svc);
});
return services;
}
/* #endregion */
static generateSelectorFromService(app) {
const selector = app.Raw.spec.selector.matchLabels;
return selector;
}
/* #region PUBLISHED PORTS FV <> PUBLISHED PORTS */
static generatePublishedPortsFormValuesFromPublishedPorts(serviceType, publishedPorts, ingress) {
const generatePort = (port, rule) => {

View File

@ -12,5 +12,12 @@ class KubernetesServiceHelper {
}
return _.find(services, (item) => item.spec.selector && _.isMatch(rawApp.spec.template.metadata.labels, item.spec.selector));
}
static findApplicationBoundServices(services, rawApp) {
if (!rawApp.spec.template) {
return undefined;
}
return _.filter(services, (item) => item.spec.selector && _.isMatch(rawApp.spec.template.metadata.labels, item.spec.selector));
}
}
export default KubernetesServiceHelper;

View File

@ -76,6 +76,69 @@ export class KubernetesIngressConverter {
return ingresses;
}
static applicationFormValuesToDeleteIngresses(formValues, application) {
const ingresses = angular.copy(formValues.OriginalIngresses);
application.Services.forEach((service) => {
ingresses.forEach((ingress) => {
const path = _.find(ingress.Paths, { ServiceName: service.metadata.name });
if (path) {
_.remove(ingress.Paths, path);
}
});
});
return ingresses;
}
static deleteIngressByServiceName(formValues, service) {
const ingresses = angular.copy(formValues.OriginalIngresses);
ingresses.forEach((ingress) => {
const path = _.find(ingress.Paths, { ServiceName: service.Name });
if (path) {
_.remove(ingress.Paths, path);
}
});
return ingresses;
}
static newApplicationFormValuesToIngresses(formValues, serviceName, servicePorts) {
const ingresses = angular.copy(formValues.OriginalIngresses);
servicePorts.forEach((port) => {
const ingress = _.find(ingresses, { Name: port.ingress.IngressName });
if (ingress) {
const rule = new KubernetesIngressRule();
rule.ServiceName = serviceName;
rule.IngressName = port.ingress.IngressName;
rule.Host = port.ingress.Host;
rule.Path = _.startsWith(port.ingress.Path, '/') ? port.ingress.Path : '/' + port.ingress.Path;
rule.Port = port.port;
ingress.Paths.push(rule);
}
});
return ingresses;
}
static editingFormValuesToIngresses(formValues, serviceName, servicePorts) {
const ingresses = angular.copy(formValues.OriginalIngresses);
servicePorts.forEach((port) => {
const ingressMatched = _.find(ingresses, { Name: port.ingress.IngressName });
if (ingressMatched) {
const pathMatched = _.find(ingressMatched.Paths, { ServiceName: serviceName });
_.remove(ingressMatched.Paths, pathMatched);
const rule = new KubernetesIngressRule();
rule.ServiceName = serviceName;
rule.IngressName = port.ingress.IngressName;
rule.Host = port.ingress.Host;
rule.Path = _.startsWith(port.ingress.Path, '/') ? port.ingress.Path : '/' + port.ingress.Path;
rule.Port = port.port;
ingressMatched.Paths.push(rule);
}
});
return ingresses;
}
/**
*
* @param {KubernetesResourcePoolIngressClassFormValue[]} formValues

View File

@ -18,6 +18,7 @@ export function KubernetesApplicationFormValues() {
this.ReplicaCount = 1;
this.AutoScaler = {};
this.Containers = [];
this.Services = [];
this.EnvironmentVariables = []; // KubernetesApplicationEnvironmentVariableFormValue lis;
this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.ISOLATED;
this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis;

View File

@ -4,6 +4,7 @@ export const KubernetesServiceTypes = Object.freeze({
LOAD_BALANCER: 'LoadBalancer',
NODE_PORT: 'NodePort',
CLUSTER_IP: 'ClusterIP',
INGRESS: 'Ingress',
});
/**
@ -20,6 +21,8 @@ const _KubernetesService = Object.freeze({
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
Selector: {},
});
export class KubernetesService {
@ -28,6 +31,40 @@ export class KubernetesService {
}
}
const _KubernetesIngressService = Object.freeze({
Headless: false,
Namespace: '',
Name: '',
StackName: '',
Ports: [],
Type: '',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: true,
IngressRoute: [],
});
export class KubernetesIngressService {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesIngressService)));
}
}
const _KubernetesIngressServiceRoute = Object.freeze({
Host: '',
IngressName: '',
Path: '',
ServiceName: '',
});
export class KubernetesIngressServiceRoute {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesIngressServiceRoute)));
}
}
/**
* KubernetesServicePort Model
*/
@ -37,6 +74,7 @@ const _KubernetesServicePort = Object.freeze({
targetPort: 0,
protocol: '',
nodePort: 0,
ingress: '',
});
export class KubernetesServicePort {

View File

@ -120,16 +120,19 @@ class KubernetesApplicationService {
const services = await this.KubernetesServiceService.get(namespace);
const boundService = KubernetesServiceHelper.findApplicationBoundService(services, rootItem.value.Raw);
const service = boundService ? await this.KubernetesServiceService.get(namespace, boundService.metadata.name) : {};
const boundServices = KubernetesServiceHelper.findApplicationBoundServices(services, rootItem.value.Raw);
const application = converterFunc(rootItem.value.Raw, pods.value, service.Raw, ingresses.value);
application.Yaml = rootItem.value.Yaml;
application.Raw = rootItem.value.Raw;
application.Pods = _.map(application.Pods, (item) => KubernetesPodConverter.apiToModel(item));
application.Containers = KubernetesApplicationHelper.associateContainersAndApplication(application);
application.Services = boundServices;
const boundScaler = KubernetesHorizontalPodAutoScalerHelper.findApplicationBoundScaler(autoScalers.value, application);
const scaler = boundScaler ? await this.KubernetesHorizontalPodAutoScalerService.get(namespace, boundScaler.Name) : undefined;
application.AutoScaler = scaler;
application.Ingresses = ingresses;
await this.KubernetesHistoryService.get(application);
@ -149,8 +152,10 @@ class KubernetesApplicationService {
const convertToApplication = (item, converterFunc, services, pods, ingresses) => {
const service = KubernetesServiceHelper.findApplicationBoundService(services, item);
const servicesFound = KubernetesServiceHelper.findApplicationBoundServices(services, item);
const application = converterFunc(item, pods, service, ingresses);
application.Containers = KubernetesApplicationHelper.associateContainersAndApplication(application);
application.Services = servicesFound;
return application;
};
@ -187,6 +192,7 @@ class KubernetesApplicationService {
const boundScaler = KubernetesHorizontalPodAutoScalerHelper.findApplicationBoundScaler(autoScalers, application);
const scaler = boundScaler ? await this.KubernetesHorizontalPodAutoScalerService.get(ns, boundScaler.Name) : undefined;
application.AutoScaler = scaler;
application.Ingresses = await this.KubernetesIngressService.get(ns);
})
);
return applications;
@ -214,7 +220,18 @@ class KubernetesApplicationService {
* also be displayed in the summary output (getCreatedApplicationResources)
*/
async createAsync(formValues) {
let [app, headlessService, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
// formValues -> Application
let [app, headlessService, services, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
if (services) {
services.forEach(async (service) => {
this.KubernetesServiceService.create(service);
if (service.Ingress) {
const ingresses = KubernetesIngressConverter.newApplicationFormValuesToIngresses(formValues, service.Name, service.Ports);
await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, ingresses));
}
});
}
if (service) {
await this.KubernetesServiceService.create(service);
@ -261,8 +278,8 @@ class KubernetesApplicationService {
* in this method should also be displayed in the summary output (getUpdatedApplicationResources)
*/
async patchAsync(oldFormValues, newFormValues) {
const [oldApp, oldHeadlessService, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues);
const [newApp, newHeadlessService, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
const [oldApp, oldHeadlessService, oldServices, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues);
const [newApp, newHeadlessService, newServices, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
const oldApiService = this._getApplicationApiService(oldApp);
const newApiService = this._getApplicationApiService(newApp);
@ -271,6 +288,9 @@ class KubernetesApplicationService {
if (oldService) {
await this.KubernetesServiceService.delete(oldService);
}
if (newService) {
return '';
}
return await this.create(newFormValues);
}
@ -290,25 +310,54 @@ class KubernetesApplicationService {
await newApiService.patch(oldApp, newApp);
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));
}
if (oldServices.length === 0 && newServices.length !== 0) {
newServices.forEach(async (service) => {
await this.KubernetesServiceService.create(service);
if (service.Ingress) {
const ingresses = KubernetesIngressConverter.newApplicationFormValuesToIngresses(oldFormValues, service.Name, service.Ports);
await Promise.all(this._generateIngressPatchPromises(oldFormValues.OriginalIngresses, ingresses));
}
});
}
if (oldServices.length !== 0 && newServices.length === 0) {
oldServices.forEach(async (oldService) => {
if (oldService.Ingress) {
const ingresses = KubernetesIngressConverter.deleteIngressByServiceName(oldFormValues, oldService);
await Promise.all(this._generateIngressPatchPromises(oldFormValues.OriginalIngresses, ingresses));
}
});
await this.KubernetesServiceService.deleteAll(oldServices);
}
if (oldServices.length !== 0 && newServices.length !== 0) {
newServices.forEach(async (newService) => {
const oldServiceMatched = _.find(oldServices, { Name: newService.Name });
if (oldServiceMatched) {
await this.KubernetesServiceService.patch(oldServiceMatched, newService);
if (newService.Ingress) {
const ingresses = KubernetesIngressConverter.editingFormValuesToIngresses(oldFormValues, newService.Name, newService.Ports);
await Promise.all(this._generateIngressPatchPromises(oldFormValues.OriginalIngresses, ingresses));
}
} else {
await this.KubernetesServiceService.create(newService);
if (newService.Ingress) {
const ingresses = KubernetesIngressConverter.newApplicationFormValuesToIngresses(oldFormValues, newService.Name, newService.Ports);
await Promise.all(this._generateIngressPatchPromises(oldFormValues.OriginalIngresses, ingresses));
}
}
});
oldServices.forEach(async (oldService) => {
const newServiceMatched = _.find(newServices, { Name: oldService.Name });
if (!newServiceMatched) {
await this.KubernetesServiceService.deleteSingle(oldService);
if (oldService.Ingress) {
const ingresses = KubernetesIngressConverter.deleteIngressByServiceName(oldFormValues, oldService);
await Promise.all(this._generateIngressPatchPromises(oldFormValues.OriginalIngresses, ingresses));
}
}
});
}
const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp);
@ -381,16 +430,16 @@ class KubernetesApplicationService {
}
if (application.ServiceType) {
await this.KubernetesServiceService.delete(servicePayload);
const isIngress = _.filter(application.PublishedPorts, (p) => p.IngressRules.length).length;
if (isIngress) {
await this.KubernetesServiceService.delete(application.Services);
if (application.Ingresses.length) {
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);
const ingresses = KubernetesIngressConverter.applicationFormValuesToDeleteIngresses(formValues, application);
await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, ingresses));
}
}

View File

@ -14,6 +14,8 @@ class KubernetesServiceService {
this.createAsync = this.createAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
this.deleteSingleAsync = this.deleteSingleAsync.bind(this);
this.deleteAllAsync = this.deleteAllAsync.bind(this);
}
/**
@ -95,7 +97,43 @@ class KubernetesServiceService {
/**
* DELETE
*/
async deleteAsync(service) {
async deleteAsync(services) {
services.forEach(async (service) => {
try {
const params = new KubernetesCommonParams();
params.id = service.metadata.name;
const namespace = service.metadata.namespace;
await this.KubernetesServices(namespace).delete(params).$promise;
} catch (err) {
// eslint-disable-next-line no-console
console.error('unable to remove service', err);
}
});
}
delete(services) {
return this.$async(this.deleteAsync, services);
}
async deleteAllAsync(formValuesServices) {
formValuesServices.forEach(async (service) => {
try {
const params = new KubernetesCommonParams();
params.id = service.Name;
const namespace = service.Namespace;
await this.KubernetesServices(namespace).delete(params).$promise;
} catch (err) {
// eslint-disable-next-line no-console
console.error('unable to remove service', err);
}
});
}
deleteAll(formValuesServices) {
return this.$async(this.deleteAllAsync, formValuesServices);
}
async deleteSingleAsync(service) {
try {
const params = new KubernetesCommonParams();
params.id = service.Name;
@ -107,8 +145,8 @@ class KubernetesServiceService {
}
}
delete(service) {
return this.$async(this.deleteAsync, service);
deleteSingle(service) {
return this.$async(this.deleteSingleAsync, service);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -720,7 +720,7 @@ class KubernetesCreateApplicationController {
}
publishViaLoadBalancerEnabled() {
return this.state.useLoadBalancer;
return this.state.useLoadBalancer && this.state.maxLoadBalancersQuota !== 0;
}
publishViaIngressEnabled() {
@ -799,6 +799,14 @@ class KubernetesCreateApplicationController {
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || isPublishingWithoutPorts;
}
isExternalApplication() {
if (this.application) {
return KubernetesApplicationHelper.isExternalApplication(this.application);
} else {
return false;
}
}
disableLoadBalancerEdit() {
return (
this.state.isEdit &&

View File

@ -208,6 +208,17 @@
>
<i class="fa fa-file-code space-right" aria-hidden="true"></i>Edit this application
</button>
<button
authorization="K8sApplicationDetailsW"
ng-if="ctrl.isExternalApplication()"
type="button"
class="btn btn-sm btn-primary"
ui-sref="kubernetes.applications.application.edit"
style="margin-left: 0;"
data-cy="k8sAppDetail-editAppButton"
>
<i class="fa fa-file-code space-right" aria-hidden="true"></i>Edit External application
</button>
<button
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
type="button"
@ -249,147 +260,27 @@
</div>
<div ng-if="ctrl.application.PublishedPorts.length > 0">
<!-- LB notice -->
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.LOAD_BALANCER">
<div class="small text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
>. Refer to the port configuration below to access it.
</div>
<div style="margin-top: 10px;" class="small text-muted">
<span ng-if="!ctrl.application.LoadBalancerIPAddress">
<p> Load balancer status: <i class="fa fa-cog fa-spin" style="margin-left: 2px;"></i> pending </p>
<p>
<u>what does the "pending" status means?</u>
<portainer-tooltip
position="bottom"
message="A pending status means that Portainer delegated the request to the provider responsible for creating the external load balancer. If it stays in pending state for long, this means that this capability might not be supported or you might have an issue with your cluster provider. Contact your cluster administrator for more information."
>
</portainer-tooltip>
</p>
</span>
<span ng-if="ctrl.application.LoadBalancerIPAddress">
<p> Load balancer status: <i class="fa fa-check green-icon" style="margin-left: 2px;"></i> available </p>
<p>
Load balancer IP address: {{ ctrl.application.LoadBalancerIPAddress }}
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyLoadBalancerIP()" style="margin-left: 5px;">
<i class="fa fa-copy space-right" aria-hidden="true"></i>Copy
</span>
<span id="copyNotificationLB" style="margin-left: 7px; display: none; color: #23ae89;" class="small">
<i class="fa fa-check" aria-hidden="true"></i> copied
</span>
</p>
</span>
</div>
</div>
<!-- NodePort notice -->
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.NODE_PORT">
<div class="small text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
>. It can be reached using the IP address of any node in your cluster using the port configuration below.
</div>
</div>
<!-- ClusterIP notice -->
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP && !ctrl.state.useIngress">
<div class="small text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
>. It can be reached via the application name <code>{{ ctrl.application.ServiceName }}</code> and the port configuration below.
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyApplicationName()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</span>
<span id="copyNotificationApplicationName" style="margin-left: 7px; display: none; color: #23ae89;" class="small"
><i class="fa fa-check" aria-hidden="true"></i> copied</span
>
</div>
</div>
<!-- Ingress notice -->
<div ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.CLUSTER_IP && ctrl.state.useIngress">
<!-- Services notice -->
<div>
<div class="small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application is exposed through a service of type <span class="bold">{{ ctrl.application.ServiceType }}</span
>. It can be reached via the application name <code>{{ ctrl.application.ServiceName }}</code> and the port configuration below.
<span class="btn btn-primary btn-xs" ng-click="ctrl.copyApplicationName()"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</span>
<span id="copyNotificationApplicationName" style="margin-left: 7px; display: none; color: #23ae89;" class="small"
><i class="fa fa-check" aria-hidden="true"></i> copied</span
>
This application is exposed through service(s) as below:
</p>
<p>It is also associated to an <span class="bold">Ingress</span> and can be accessed via specific HTTP route(s).</p>
</div>
</div>
<!-- table -->
<div style="margin-top: 15px;">
<table class="table">
<tbody>
<tr class="text-muted">
<td style="width: 25%;">Container port</td>
<td style="width: 25%;">Service port</td>
<td style="width: 50%;">HTTP route</td>
</tr>
<tr ng-repeat-start="port in ctrl.application.PublishedPorts">
<td ng-if="!ctrl.portHasIngressRules(port)" data-cy="k8sAppDetail-containerPort">{{ port.TargetPort }}/{{ port.Protocol }}</td>
<td ng-if="!ctrl.portHasIngressRules(port)">
<span ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.NODE_PORT" data-cy="k8sAppDetail-nodePort">
{{ port.NodePort }}
</span>
<span ng-if="ctrl.application.ServiceType !== ctrl.KubernetesServiceTypes.NODE_PORT" data-cy="k8sAppDetail-containerPort">
{{ port.Port }}
</span>
<a
ng-if="ctrl.application.LoadBalancerIPAddress"
ng-href="http://{{ ctrl.application.LoadBalancerIPAddress }}:{{ port.Port }}"
target="_blank"
style="margin-left: 5px;"
data-cy="k8sAppDetail-accessLink"
>
<i class="fa fa-external-link-alt" aria-hidden="true"></i> access
</a>
</td>
<td ng-if="!ctrl.portHasIngressRules(port)">-</td>
</tr>
<tr ng-repeat-end ng-repeat="rule in port.IngressRules">
<td data-cy="k8sAppDetail-httpRoute">{{ port.TargetPort }}/{{ port.Protocol }}</td>
<td>
<span ng-if="ctrl.application.ServiceType === ctrl.KubernetesServiceTypes.NODE_PORT" data-cy="k8sAppDetail-nodePort">
{{ port.NodePort }}
</span>
<span ng-if="ctrl.application.ServiceType !== ctrl.KubernetesServiceTypes.NODE_PORT" data-cy="k8sAppDetail-port">
{{ port.Port }}
</span>
<a
ng-if="ctrl.application.LoadBalancerIPAddress"
ng-href="http://{{ ctrl.application.LoadBalancerIPAddress }}:{{ port.Port }}"
target="_blank"
style="margin-left: 5px;"
>
<i class="fa fa-external-link-alt" aria-hidden="true"></i> access
</a>
</td>
<td>
<span
ng-if="!ctrl.ruleCanBeDisplayed(rule)"
class="text-muted"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Ingress controller IP address not available yet"
style="cursor: pointer;"
>pending
</span>
<span ng-if="ctrl.ruleCanBeDisplayed(rule)">
<a ng-href="{{ ctrl.buildIngressRuleURL(rule) }}" target="_blank" data-cy="k8sAppDetail-httpRouteLink">
{{ ctrl.buildIngressRuleURL(rule) | stripprotocol }}
</a>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<kubernetes-application-services-table
services="ctrl.application.Services"
application="ctrl.application"
public-url="ctrl.state.publicUrl"
></kubernetes-application-services-table>
<!-- table -->
<!-- table -->
<kubernetes-application-ingress-table application="ctrl.application" public-url="ctrl.state.publicUrl"></kubernetes-application-ingress-table>
<!-- table -->
</div>
<!-- !ACCESSING APPLICATION -->
<!-- AUTO SCALING -->

View File

@ -1,6 +1,7 @@
import angular from 'angular';
import _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch';
import {
KubernetesApplicationDataAccessPolicies,
KubernetesApplicationDeploymentTypes,
@ -112,7 +113,6 @@ class KubernetesApplicationController {
KubernetesStackService,
KubernetesPodService,
KubernetesNodeService,
StackService
) {
this.$async = $async;
@ -317,6 +317,7 @@ class KubernetesApplicationController {
this.application = application;
this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application);
this.formValues.Note = this.application.Note;
this.formValues.Services = this.application.Services;
if (this.application.Note) {
this.state.expandedNote = true;
}
@ -347,6 +348,12 @@ class KubernetesApplicationController {
}
async onInit() {
const endpointId = this.LocalStorage.getEndpointID();
const endpoints = this.LocalStorage.getEndpoints();
const endpoint = _.find(endpoints, function (item) {
return item.Id === endpointId;
});
this.state = {
activeTab: 0,
currentName: this.$state.$current.name,
@ -365,6 +372,7 @@ class KubernetesApplicationController {
expandedNote: false,
useIngress: false,
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
publicUrl: endpoint.PublicURL,
};
this.state.activeTab = this.LocalStorage.getActiveTab('application');

View File

@ -0,0 +1,29 @@
import _ from 'lodash-es';
export default class KubernetesApplicationIngressController {
/* @ngInject */
constructor($async, KubernetesIngressService) {
this.$async = $async;
this.KubernetesIngressService = KubernetesIngressService;
}
$onInit() {
return this.$async(async () => {
this.hasIngress;
this.applicationIngress = [];
const ingresses = await this.KubernetesIngressService.get(this.application.ResourcePool);
const services = this.application.Services;
_.forEach(services, (service) => {
_.forEach(ingresses, (ingress) => {
_.forEach(ingress.Paths, (path) => {
if (path.ServiceName === service.metadata.name) {
this.applicationIngress.push(path);
this.hasIngress = true;
}
});
});
});
});
}
}

View File

@ -0,0 +1,24 @@
<div style="margin-top: 15px" ng-if="$ctrl.hasIngress">
<table class="table">
<tbody>
<tr class="text-muted">
<td style="width: 15%">Ingress name</td>
<td style="width: 10%">Service name</td>
<td style="width: 10%">Host</td>
<td style="width: 10%">Port</td>
<td style="width: 10%">Path</td>
<td style="width: 15%">HTTP Route</td>
</tr>
<tr ng-repeat="ingress in $ctrl.applicationIngress">
<td>{{ ingress.IngressName }}</td>
<td>{{ ingress.ServiceName }}</td>
<td>{{ ingress.Host }}</td>
<td>{{ ingress.Port }}</td>
<td>{{ ingress.Path }}</td>
<td
><a target="_blank" href="http://{{ ingress.Host }}{{ ingress.Path }}">{{ ingress.Host }}{{ ingress.Path }}</a></td
>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,11 @@
import angular from 'angular';
import controller from './ingress-table.controller';
angular.module('portainer.kubernetes').component('kubernetesApplicationIngressTable', {
templateUrl: './ingress-table.html',
controller,
bindings: {
application: '<',
publicUrl: '<',
},
});

View File

@ -0,0 +1,56 @@
<!-- table -->
<div style="margin-top: 15px">
<table class="table">
<tbody>
<tr class="text-muted">
<td style="width: 15%">Service name</td>
<td style="width: 10%">Type</td>
<td style="width: 10%">Cluster IP</td>
<td style="width: 10%">External IP</td>
<td style="width: 10%">Container port</td>
<td style="width: 15%">Service port(s)</td>
</tr>
<tr ng-repeat="service in $ctrl.services">
<td>{{ service.metadata.name }}</td>
<td>{{ service.spec.type }}</td>
<td>{{ service.spec.clusterIP }}</td>
<td ng-show="service.spec.type === 'LoadBalancer'">
<div ng-show="service.status.loadBalancer.ingress">
<a target="_blank" ng-href="http://{{ service.status.loadBalancer.ingress[0].ip }}:{{ service.spec.ports[0].port }}">
<i class="fa fa-external-link-alt" aria-hidden="true"></i>
<span data-cy="k8sAppDetail-containerPort"> Access </span>
</a>
</div>
<div ng-show="!service.status.loadBalancer.ingress">
{{ service.spec.externalIP ? service.spec.externalIP : 'pending...' }}
</div>
</td>
<td ng-show="service.spec.type !== 'LoadBalancer'">{{ service.spec.externalIP ? service.spec.externalIP : '-' }}</td>
<td data-cy="k8sAppDetail-containerPort">
<div ng-repeat="port in service.spec.ports">{{ port.targetPort }}</div>
</td>
<td ng-if="!ctrl.portHasIngressRules(port)">
<div ng-repeat="port in service.spec.ports">
<a ng-if="$ctrl.publicUrl && port.nodePort" ng-href="http://{{ $ctrl.publicUrl }}:{{ port.nodePort }}" target="_blank" style="margin-left: 5px">
<i class="fa fa-external-link-alt" aria-hidden="true"></i>
<span data-cy="k8sAppDetail-containerPort">
{{ port.port }}
</span>
<span>{{ port.nodePort ? ':' : '' }}</span>
<span data-cy="k8sAppDetail-nodePort"> {{ port.nodePort }}/{{ port.protocol }} </span>
</a>
<div ng-if="!$ctrl.publicUrl">
<span data-cy="k8sAppDetail-servicePort">
{{ port.port }}
</span>
<span>{{ port.nodePort ? ':' : '' }}</span>
<span data-cy="k8sAppDetail-nodePort"> {{ port.nodePort }}/{{ port.protocol }} </span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,10 @@
import angular from 'angular';
angular.module('portainer.kubernetes').component('kubernetesApplicationServicesTable', {
templateUrl: './services-table.html',
bindings: {
services: '<',
application: '<',
publicUrl: '<',
},
});

View File

@ -4,7 +4,7 @@ import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/f
import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesService, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import {
KubernetesApplication,
KubernetesApplicationDeploymentTypes,
@ -40,7 +40,17 @@ export default function (formValues, oldFormValues = {}) {
function getCreatedApplicationResources(formValues) {
const resources = [];
let [app, headlessService, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
let [app, headlessService, services, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
if (services) {
services.forEach((service) => {
resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP });
if (formValues.OriginalIngresses.length !== 0) {
const ingresses = KubernetesIngressConverter.newApplicationFormValuesToIngresses(formValues, service.Name, service.Ports);
resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, ingresses));
}
});
}
if (service) {
// Service
@ -87,16 +97,15 @@ function getCreatedApplicationResources(formValues) {
function getUpdatedApplicationResources(oldFormValues, newFormValues) {
const resources = [];
const [oldApp, oldHeadlessService, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues);
const [newApp, newHeadlessService, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
const [oldApp, oldHeadlessService, oldServices, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues);
const [newApp, newHeadlessService, newServices, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
const oldAppResourceType = getApplicationResourceType(oldApp);
const newAppResourceType = getApplicationResourceType(newApp);
if (oldAppResourceType !== newAppResourceType) {
// Deployment
resources.push({ action: DELETE, kind: oldAppResourceType, name: oldApp.Name });
if (oldService) {
if (oldService && oldServices) {
// Service
resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP });
}
@ -129,11 +138,13 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
// Deployment
resources.push({ action: UPDATE, kind: oldAppResourceType, name: oldApp.Name });
if (oldService && newService) {
if (oldServices && newServices) {
// Service
const serviceUpdateResourceSummary = getServiceUpdateResourceSummary(oldService, newService);
if (serviceUpdateResourceSummary) {
resources.push(serviceUpdateResourceSummary);
const serviceUpdateResourceSummary = getServiceUpdateResourceSummary(oldServices, newServices);
if (serviceUpdateResourceSummary !== null) {
serviceUpdateResourceSummary.forEach((updateSummary) => {
resources.push(updateSummary);
});
}
if (newFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS || oldFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
@ -224,10 +235,41 @@ function getVolumeClaimUpdateResourceSummary(oldPVC, newPVC) {
}
// getServiceUpdateResourceSummary replicates KubernetesServiceService.patch
function getServiceUpdateResourceSummary(oldService, newService) {
const payload = KubernetesServiceConverter.patchPayload(oldService, newService);
if (payload.length) {
return { action: UPDATE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP };
function getServiceUpdateResourceSummary(oldServices, newServices) {
let summary = [];
newServices.forEach((newService) => {
const oldServiceMatched = _.find(oldServices, { Name: newService.Name });
if (oldServiceMatched) {
const payload = KubernetesServiceConverter.patchPayload(oldServiceMatched, newService);
if (payload.length) {
const serviceUpdate = {
action: UPDATE,
kind: KubernetesResourceTypes.SERVICE,
name: oldServiceMatched.Name,
type: oldServiceMatched.Type || KubernetesServiceTypes.CLUSTER_IP,
};
summary.push(serviceUpdate);
}
} else {
const emptyService = new KubernetesService();
const payload = KubernetesServiceConverter.patchPayload(emptyService, newService);
if (payload.length) {
const serviceCreate = { action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: newService.Name, type: newService.Type || KubernetesServiceTypes.CLUSTER_IP };
summary.push(serviceCreate);
}
}
});
oldServices.forEach((oldService) => {
const newServiceMatched = _.find(newServices, { Name: oldService.Name });
if (!newServiceMatched) {
const serviceDelete = { action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP };
summary.push(serviceDelete);
}
});
if (summary.length !== 0) {
return summary;
}
return null;
}