mirror of
https://github.com/portainer/portainer.git
synced 2025-08-04 21:35:23 +02:00
feat(ingress): autodetect ingress controllers EE-673 (#7712)
This commit is contained in:
parent
c96551e410
commit
89eda13eb3
48 changed files with 1252 additions and 1047 deletions
|
@ -35,124 +35,35 @@
|
|||
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left col-sm-5 col-lg-4 px-0"> Allow users to use external load balancer </label>
|
||||
<label class="switch mb-0 col-sm-8">
|
||||
<label class="switch col-sm-8 mb-0">
|
||||
<input type="checkbox" ng-model="ctrl.formValues.UseLoadBalancer" /><span class="slider round" data-cy="kubeSetup-loadBalancerToggle"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 text-muted small mt-4">
|
||||
<p> Configuring ingress controllers will allow users to expose application they deploy over a HTTP route. </p>
|
||||
<p class="mt-1 vertical-center">
|
||||
<pr-icon icon="'alert-circle'" mode="'warning'" feather="true"></pr-icon>
|
||||
Ingress classes must be manually specified for each controller you want to use in the cluster. Make sure that each controller is running inside your cluster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ingress-class-datatable
|
||||
on-change-availability="(ctrl.onChangeAvailability)"
|
||||
ingress-controllers="ctrl.originalIngressControllers"
|
||||
description="'Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here.'"
|
||||
no-ingress-controller-label="'No supported ingress controllers found.'"
|
||||
view="'cluster'"
|
||||
></ingress-class-datatable>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Ingress controller</label>
|
||||
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="ctrl.addIngressClass()" data-cy="kubeSetup-congifIngressButton">
|
||||
<pr-icon icon="'plus'" feather="true" size="'sm'" class="vertical-center"></pr-icon> configure ingress controller
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
||||
<div ng-repeat-start="ingressClass in ctrl.formValues.IngressClasses" style="margin-top: 2px">
|
||||
<div class="col-sm-7 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
|
||||
<span class="input-group-addon">Ingress class</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="ingress_class_name_{{ $index }}"
|
||||
ng-model="ingressClass.Name"
|
||||
placeholder="nginx"
|
||||
ng-pattern="/^[a-z]([-a-z0-9]*[a-z0-9])?$/"
|
||||
ng-change="ctrl.onChangeIngressClassName($index)"
|
||||
required
|
||||
data-cy="kubeSetup-ingressClassName"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
|
||||
<span class="input-group-addon">Type</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_class_type_{{ $index }}"
|
||||
ng-model="ingressClass.Type"
|
||||
ng-options="value as value for (key, value) in ctrl.IngressClassTypes"
|
||||
required
|
||||
data-cy="kubeSetup-ingressType"
|
||||
>
|
||||
<option selected disabled hidden value="">Select a type</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm">
|
||||
<button
|
||||
ng-if="!ingressClass.NeedsDeletion"
|
||||
class="btn btn-dangerlight btn-only-icon"
|
||||
type="button"
|
||||
ng-click="ctrl.removeIngressClass($index)"
|
||||
data-cy="kubeSetup-deleteIngress"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" feather="true"></pr-icon>
|
||||
</button>
|
||||
<button ng-if="ingressClass.NeedsDeletion" class="btn btn-only-icon btn-light" type="button" ng-click="ctrl.restoreIngressClass($index)">
|
||||
<pr-icon icon="'rotate-ccw'" feather="true"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ng-repeat-end
|
||||
ng-show="
|
||||
kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid ||
|
||||
kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined
|
||||
"
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
|
||||
name="'ingressAvailabilityPerNamespace'"
|
||||
label="'Configure ingress controller availability per namespace'"
|
||||
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
|
||||
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8'"
|
||||
>
|
||||
<div class="col-sm-7 input-group">
|
||||
<div
|
||||
class="small"
|
||||
style="margin-top: 5px"
|
||||
ng-if="kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid || ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined"
|
||||
>
|
||||
<div ng-messages="kubernetesClusterSetupForm['ingress_class_name_'+$index].$error">
|
||||
<p ng-message="required" class="vertical-center text-warning"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress class name is required.</p
|
||||
>
|
||||
<p ng-message="pattern" class="vertical-center text-warning"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This field must consist of lower case alphanumeric characters or '-', start
|
||||
with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined" class="vertical-center text-warning">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This ingress class is already defined.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 input-group">
|
||||
<div class="small" style="margin-top: 5px" ng-if="kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid">
|
||||
<div ng-messages="kubernetesClusterSetupForm['ingress_class_type_'+$index].$error">
|
||||
<p ng-message="required" class="vertical-center text-warning"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress class type is required.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.hasTraefikIngress()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<p>
|
||||
<i class="fa fa-flask blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Traefik support is experimental.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- auto update window -->
|
||||
<div class="col-sm-12 form-section-title"> Change Window Settings </div>
|
||||
|
||||
|
@ -163,7 +74,7 @@
|
|||
name="'disableSysctlSettingForRegularUsers'"
|
||||
label="'Enable Change Window'"
|
||||
feature-id="ctrl.limitedFeatureAutoWindow"
|
||||
tooltip="'Specify a timeframe during which automatic updates can occur in this environment.'"
|
||||
tooltip="'Automatic updates to stacks or applications outside the defined change window will not occur.'"
|
||||
on-change="(ctrl.onToggleAutoUpdate)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8 text-muted'"
|
||||
|
|
|
@ -2,12 +2,12 @@ import _ from 'lodash-es';
|
|||
import angular from 'angular';
|
||||
import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
|
||||
|
||||
class KubernetesConfigureController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
||||
|
@ -41,10 +41,15 @@ class KubernetesConfigureController {
|
|||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.configureAsync = this.configureAsync.bind(this);
|
||||
this.areControllersChanged = this.areControllersChanged.bind(this);
|
||||
this.areFormValuesChanged = this.areFormValuesChanged.bind(this);
|
||||
this.onBeforeOnload = this.onBeforeOnload.bind(this);
|
||||
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
|
||||
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
|
||||
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
|
||||
this.onChangeAvailability = this.onChangeAvailability.bind(this);
|
||||
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
|
||||
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
|
||||
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
@ -66,48 +71,23 @@ class KubernetesConfigureController {
|
|||
/* #endregion */
|
||||
|
||||
/* #region INGRESS CLASSES UI MANAGEMENT */
|
||||
addIngressClass() {
|
||||
this.formValues.IngressClasses.push(new KubernetesIngressClass());
|
||||
this.onChangeIngressClass();
|
||||
}
|
||||
|
||||
restoreIngressClass(index) {
|
||||
this.formValues.IngressClasses[index].NeedsDeletion = false;
|
||||
this.onChangeIngressClass();
|
||||
}
|
||||
|
||||
removeIngressClass(index) {
|
||||
if (!this.formValues.IngressClasses[index].IsNew) {
|
||||
this.formValues.IngressClasses[index].NeedsDeletion = true;
|
||||
} else {
|
||||
this.formValues.IngressClasses.splice(index, 1);
|
||||
}
|
||||
this.onChangeIngressClass();
|
||||
}
|
||||
|
||||
onChangeIngressClass() {
|
||||
const state = this.state.duplicates.ingressClasses;
|
||||
const source = _.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic.Name));
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
}
|
||||
|
||||
onChangeIngressClassName(index) {
|
||||
const fv = this.formValues.IngressClasses[index];
|
||||
if (_.includes(fv.Name, KubernetesIngressClassTypes.NGINX)) {
|
||||
fv.Type = KubernetesIngressClassTypes.NGINX;
|
||||
} else if (_.includes(fv.Name, KubernetesIngressClassTypes.TRAEFIK)) {
|
||||
fv.Type = KubernetesIngressClassTypes.TRAEFIK;
|
||||
}
|
||||
this.onChangeIngressClass();
|
||||
onChangeAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
|
||||
hasTraefikIngress() {
|
||||
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
|
||||
}
|
||||
|
||||
onToggleIngressAvailabilityPerNamespace() {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region RESOURCES AND METRICS */
|
||||
|
||||
onChangeEnableResourceOverCommit(enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.EnableResourceOverCommit = enabled;
|
||||
|
@ -117,13 +97,19 @@ class KubernetesConfigureController {
|
|||
});
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region CONFIGURE */
|
||||
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
|
||||
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
|
||||
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
|
||||
endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
|
||||
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = this.formValues.EnableResourceOverCommit;
|
||||
endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage = this.formValues.ResourceOverCommitPercentage;
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
|
||||
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
|
||||
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
|
||||
endpoint.ChangeWindow = this.state.autoUpdateSettings;
|
||||
}
|
||||
|
||||
transformFormValues() {
|
||||
|
@ -150,11 +136,9 @@ class KubernetesConfigureController {
|
|||
|
||||
async removeIngressesAcrossNamespaces() {
|
||||
const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
|
||||
|
||||
if (!ingressesToDel.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
const oldEndpointID = this.EndpointProvider.endpointID();
|
||||
this.EndpointProvider.setEndpointID(this.endpoint.Id);
|
||||
|
@ -213,7 +197,9 @@ class KubernetesConfigureController {
|
|||
|
||||
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
|
||||
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
|
||||
|
||||
// updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed
|
||||
await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers);
|
||||
this.state.isSaving = true;
|
||||
const storagePromises = _.map(storageClasses, (storageClass) => {
|
||||
const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
|
||||
if (oldStorageClass) {
|
||||
|
@ -291,19 +277,31 @@ class KubernetesConfigureController {
|
|||
isServerRunning: false,
|
||||
userClick: false,
|
||||
},
|
||||
timeZone: '',
|
||||
isSaving: false,
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
EnableResourceOverCommit: true,
|
||||
ResourceOverCommitPercentage: 20,
|
||||
IngressClasses: [],
|
||||
RestrictDefaultNamespace: false,
|
||||
enableAutoUpdateTimeWindow: false,
|
||||
IngressAvailabilityPerNamespace: false,
|
||||
};
|
||||
|
||||
try {
|
||||
this.availableAccessModes = new KubernetesStorageClassAccessPolicies();
|
||||
|
||||
[this.StorageClasses, this.endpoint] = await Promise.all([this.KubernetesStorageService.get(this.state.endpointId), this.EndpointService.endpoint(this.state.endpointId)]);
|
||||
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId });
|
||||
this.originalIngressControllers = structuredClone(this.ingressControllers);
|
||||
|
||||
this.state.autoUpdateSettings = this.endpoint.ChangeWindow;
|
||||
|
||||
_.forEach(this.StorageClasses, (item) => {
|
||||
const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name);
|
||||
if (storage) {
|
||||
|
@ -316,12 +314,15 @@ class KubernetesConfigureController {
|
|||
|
||||
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
|
||||
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
this.formValues.EnableResourceOverCommit = this.endpoint.Kubernetes.Configuration.EnableResourceOverCommit;
|
||||
this.formValues.ResourceOverCommitPercentage = this.endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage;
|
||||
this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace;
|
||||
this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
|
||||
ic.IsNew = false;
|
||||
ic.NeedsDeletion = false;
|
||||
return ic;
|
||||
});
|
||||
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
|
||||
|
||||
this.oldFormValues = Object.assign({}, this.formValues);
|
||||
} catch (err) {
|
||||
|
@ -329,12 +330,48 @@ class KubernetesConfigureController {
|
|||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeOnload);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
$onDestroy() {
|
||||
window.removeEventListener('beforeunload', this.onBeforeOnload);
|
||||
}
|
||||
|
||||
areControllersChanged() {
|
||||
return !_.isEqual(this.ingressControllers, this.originalIngressControllers);
|
||||
}
|
||||
|
||||
areFormValuesChanged() {
|
||||
return !_.isEqual(this.formValues, this.oldFormValues);
|
||||
}
|
||||
|
||||
onBeforeOnload(event) {
|
||||
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
uiCanExit() {
|
||||
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
|
||||
return this.ModalService.confirmAsync({
|
||||
title: 'Are you sure?',
|
||||
message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes',
|
||||
className: 'btn-danger',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesConfigureController;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue