fix(ingress): allow none controller type EE-4420 (#7883)

Co-authored-by: testA113 <alex.harris@portainer.io>
pull/7917/head
Dakota Walsh 2022-10-25 09:41:30 +13:00 committed by GitHub
parent e48ceb15e9
commit 55211ef00e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 243 additions and 76 deletions

View File

@ -59,9 +59,6 @@ func (r K8sIngressInfo) Validate(request *http.Request) error {
if r.Namespace == "" { if r.Namespace == "" {
return errors.New("missing ingress Namespace from the request payload") return errors.New("missing ingress Namespace from the request payload")
} }
if r.ClassName == "" {
return errors.New("missing ingress ClassName from the request payload")
}
return nil return nil
} }

View File

@ -275,7 +275,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string) error {
// Compare the result we got with the one we wanted. // Compare the result we got with the one we wanted.
if diff := cmp.Diff(wantJSON, gotJSON); diff != "" { if diff := cmp.Diff(wantJSON, gotJSON); diff != "" {
gotPath := filepath.Join(t.TempDir(), "portainer-migrator-test-fail.json") gotPath := filepath.Join(os.TempDir(), "portainer-migrator-test-fail.json")
os.WriteFile( os.WriteFile(
gotPath, gotPath,
gotJSON, gotJSON,

View File

@ -8,7 +8,7 @@ import (
func (m *Migrator) migrateDBVersionToDB70() error { func (m *Migrator) migrateDBVersionToDB70() error {
log.Info().Msg("- add IngressAvailabilityPerNamespace field") log.Info().Msg("- add IngressAvailabilityPerNamespace field")
if err := m.addIngressAvailabilityPerNamespaceFieldDB70(); err != nil { if err := m.updateIngressFieldsForEnvDB70(); err != nil {
return err return err
} }
@ -51,7 +51,7 @@ func (m *Migrator) migrateDBVersionToDB70() error {
return nil return nil
} }
func (m *Migrator) addIngressAvailabilityPerNamespaceFieldDB70() error { func (m *Migrator) updateIngressFieldsForEnvDB70() error {
endpoints, err := m.endpointService.Endpoints() endpoints, err := m.endpointService.Endpoints()
if err != nil { if err != nil {
return err return err
@ -59,6 +59,7 @@ func (m *Migrator) addIngressAvailabilityPerNamespaceFieldDB70() error {
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = true endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = true
endpoint.Kubernetes.Configuration.AllowNoneIngressClass = false
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil { if err != nil {

View File

@ -52,6 +52,7 @@
"IsEdgeDevice": false, "IsEdgeDevice": false,
"Kubernetes": { "Kubernetes": {
"Configuration": { "Configuration": {
"AllowNoneIngressClass": false,
"EnableResourceOverCommit": false, "EnableResourceOverCommit": false,
"IngressAvailabilityPerNamespace": true, "IngressAvailabilityPerNamespace": true,
"IngressClasses": null, "IngressClasses": null,

View File

@ -57,11 +57,22 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
err, err,
) )
} }
// Add none controller if "AllowNone" is set for endpoint.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
ClassName: "none",
Type: "custom",
})
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
var updatedClasses []portainer.KubernetesIngressClassConfig var updatedClasses []portainer.KubernetesIngressClassConfig
for i := range controllers { for i := range controllers {
controllers[i].Availability = true controllers[i].Availability = true
controllers[i].New = true if controllers[i].ClassName != "none" {
controllers[i].New = true
}
var updatedClass portainer.KubernetesIngressClassConfig var updatedClass portainer.KubernetesIngressClassConfig
updatedClass.Name = controllers[i].ClassName updatedClass.Name = controllers[i].ClassName
@ -153,6 +164,14 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
err, err,
) )
} }
// Add none controller if "AllowNone" is set for endpoint.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
currentControllers = append(currentControllers, models.K8sIngressController{
Name: "none",
ClassName: "none",
Type: "custom",
})
}
kubernetesConfig := endpoint.Kubernetes.Configuration kubernetesConfig := endpoint.Kubernetes.Configuration
existingClasses := kubernetesConfig.IngressClasses existingClasses := kubernetesConfig.IngressClasses
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
@ -161,7 +180,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
for i := range currentControllers { for i := range currentControllers {
var globallyblocked bool var globallyblocked bool
currentControllers[i].Availability = true currentControllers[i].Availability = true
currentControllers[i].New = true if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
}
var updatedClass portainer.KubernetesIngressClassConfig var updatedClass portainer.KubernetesIngressClassConfig
updatedClass.Name = currentControllers[i].ClassName updatedClass.Name = currentControllers[i].ClassName
@ -258,6 +279,14 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
err, err,
) )
} }
// Add none controller if "AllowNone" is set for endpoint.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
ClassName: "none",
Type: "custom",
})
}
var updatedClasses []portainer.KubernetesIngressClassConfig var updatedClasses []portainer.KubernetesIngressClassConfig
for i := range controllers { for i := range controllers {
controllers[i].Availability = true controllers[i].Availability = true

View File

@ -90,11 +90,11 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
var infos []models.K8sIngressInfo var infos []models.K8sIngressInfo
for _, ingress := range ingressList.Items { for _, ingress := range ingressList.Items {
ingressClass := ingress.Spec.IngressClassName
var info models.K8sIngressInfo var info models.K8sIngressInfo
info.Name = ingress.Name info.Name = ingress.Name
info.UID = string(ingress.UID) info.UID = string(ingress.UID)
info.Namespace = namespace info.Namespace = namespace
ingressClass := ingress.Spec.IngressClassName
info.ClassName = "" info.ClassName = ""
if ingressClass != nil { if ingressClass != nil {
info.ClassName = *ingressClass info.ClassName = *ingressClass
@ -113,6 +113,10 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
// Gather list of paths and hosts. // Gather list of paths and hosts.
hosts := make(map[string]struct{}) hosts := make(map[string]struct{})
for _, r := range ingress.Spec.Rules { for _, r := range ingress.Spec.Rules {
// We collect all exiting hosts in a map to avoid duplicates.
// Then, later convert it to a slice for the frontend.
hosts[r.Host] = struct{}{}
if r.HTTP == nil { if r.HTTP == nil {
continue continue
} }
@ -124,12 +128,10 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
path.IngressName = info.Name path.IngressName = info.Name
path.Host = r.Host path.Host = r.Host
// We collect all exiting hosts in a map to avoid duplicates.
// Then, later convert it to a slice for the frontend.
hosts[r.Host] = struct{}{}
path.Path = p.Path path.Path = p.Path
path.PathType = string(*p.PathType) if p.PathType != nil {
path.PathType = string(*p.PathType)
}
path.ServiceName = p.Backend.Service.Name path.ServiceName = p.Backend.Service.Name
path.Port = int(p.Backend.Service.Port.Number) path.Port = int(p.Backend.Service.Port.Number)
info.Paths = append(info.Paths, path) info.Paths = append(info.Paths, path)
@ -154,7 +156,9 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf
ingress.Name = info.Name ingress.Name = info.Name
ingress.Namespace = info.Namespace ingress.Namespace = info.Namespace
ingress.Spec.IngressClassName = &info.ClassName if info.ClassName != "" {
ingress.Spec.IngressClassName = &info.ClassName
}
ingress.Annotations = info.Annotations ingress.Annotations = info.Annotations
// Store TLS information. // Store TLS information.
@ -224,7 +228,9 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
ingress.Name = info.Name ingress.Name = info.Name
ingress.Namespace = info.Namespace ingress.Namespace = info.Namespace
ingress.Spec.IngressClassName = &info.ClassName if info.ClassName != "" {
ingress.Spec.IngressClassName = &info.ClassName
}
ingress.Annotations = info.Annotations ingress.Annotations = info.Annotations
// Store TLS information. // Store TLS information.

View File

@ -555,6 +555,7 @@ type (
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"` IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"` RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"` IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"`
AllowNoneIngressClass bool `json:"AllowNoneIngressClass"`
} }
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration

View File

@ -12,9 +12,10 @@ export const componentsModule = angular
.component( .component(
'ingressClassDatatable', 'ingressClassDatatable',
r2a(IngressClassDatatable, [ r2a(IngressClassDatatable, [
'onChangeAvailability', 'onChangeControllers',
'description', 'description',
'ingressControllers', 'ingressControllers',
'allowNoneIngressClass',
'isLoading', 'isLoading',
'noIngressControllerLabel', 'noIngressControllerLabel',
'view', 'view',

View File

@ -157,7 +157,9 @@ export function CreateIngressView() {
const existingIngressClass = useMemo( const existingIngressClass = useMemo(
() => () =>
ingressControllersResults.data?.find( ingressControllersResults.data?.find(
(i) => i.ClassName === ingressRule.IngressClassName (i) =>
i.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && ingressRule.IngressClassName === '')
), ),
[ingressControllersResults.data, ingressRule.IngressClassName] [ingressControllersResults.data, ingressRule.IngressClassName]
); );
@ -177,10 +179,11 @@ export function CreateIngressView() {
ingressRule.IngressClassName && ingressRule.IngressClassName &&
!ingressControllersResults.isLoading !ingressControllersResults.isLoading
) { ) {
const optionLabel = !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND`
: `${ingressRule.IngressClassName} - DISALLOWED`;
ingressClassOptions.push({ ingressClassOptions.push({
label: !ingressRule.IngressType label: optionLabel,
? `${ingressRule.IngressClassName} - NOT FOUND`
: `${ingressRule.IngressClassName} - DISALLOWED`,
value: ingressRule.IngressClassName, value: ingressRule.IngressClassName,
}); });
} }
@ -206,6 +209,7 @@ export function CreateIngressView() {
!!params.name && !!params.name &&
ingressesResults.data && ingressesResults.data &&
!ingressRule.IngressName && !ingressRule.IngressName &&
!ingressControllersResults.isLoading &&
!ingressControllersResults.isLoading !ingressControllersResults.isLoading
) { ) {
// if it is an edit screen, prepare the rule from the ingress // if it is an edit screen, prepare the rule from the ingress
@ -214,9 +218,11 @@ export function CreateIngressView() {
); );
if (ing) { if (ing) {
const type = ingressControllersResults.data?.find( const type = ingressControllersResults.data?.find(
(c) => c.ClassName === ing.ClassName (c) =>
c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName)
)?.Type; )?.Type;
const r = prepareRuleFromIngress(ing); const r = prepareRuleFromIngress(ing, type);
r.IngressType = type || r.IngressType; r.IngressType = type || r.IngressType;
setIngressRule(r); setIngressRule(r);
} }
@ -636,7 +642,7 @@ export function CreateIngressView() {
setIngressRule(rule); setIngressRule(rule);
} }
function addNewAnnotation(type?: 'rewrite' | 'regex') { function addNewAnnotation(type?: 'rewrite' | 'regex' | 'ingressClass') {
const rule = { ...ingressRule }; const rule = { ...ingressRule };
const annotation: Annotation = { const annotation: Annotation = {
@ -644,13 +650,21 @@ export function CreateIngressView() {
Value: '', Value: '',
ID: uuidv4(), ID: uuidv4(),
}; };
if (type === 'rewrite') { switch (type) {
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target'; case 'rewrite':
annotation.Value = '/$1'; annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
} annotation.Value = '/$1';
if (type === 'regex') { break;
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex'; case 'regex':
annotation.Value = 'true'; annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
annotation.Value = 'true';
break;
case 'ingressClass':
annotation.Key = 'kubernetes.io/ingress.class';
annotation.Value = '';
break;
default:
break;
} }
rule.Annotations = rule.Annotations || []; rule.Annotations = rule.Annotations || [];
rule.Annotations?.push(annotation); rule.Annotations?.push(annotation);
@ -690,10 +704,13 @@ export function CreateIngressView() {
function handleCreateIngressRules() { function handleCreateIngressRules() {
const rule = { ...ingressRule }; const rule = { ...ingressRule };
const classNameToSend =
rule.IngressClassName === 'none' ? '' : rule.IngressClassName;
const ingress: Ingress = { const ingress: Ingress = {
Namespace: namespace, Namespace: namespace,
Name: rule.IngressName, Name: rule.IngressName,
ClassName: rule.IngressClassName, ClassName: classNameToSend,
Hosts: rule.Hosts.map((host) => host.Host), Hosts: rule.Hosts.map((host) => host.Host),
Paths: preparePaths(rule.IngressName, rule.Hosts), Paths: preparePaths(rule.IngressName, rule.Hosts),
TLS: prepareTLS(rule.Hosts), TLS: prepareTLS(rule.Hosts),

View File

@ -47,7 +47,7 @@ interface Props {
addNewIngressHost: (noHost?: boolean) => void; addNewIngressHost: (noHost?: boolean) => void;
addNewIngressRoute: (hostIndex: number) => void; addNewIngressRoute: (hostIndex: number) => void;
addNewAnnotation: (type?: 'rewrite' | 'regex') => void; addNewAnnotation: (type?: 'rewrite' | 'regex' | 'ingressClass') => void;
handleNamespaceChange: (val: string) => void; handleNamespaceChange: (val: string) => void;
handleHostChange: (hostIndex: number, val: string) => void; handleHostChange: (hostIndex: number, val: string) => void;
@ -249,9 +249,10 @@ export function IngressForm({
onClick={() => addNewAnnotation('rewrite')} onClick={() => addNewAnnotation('rewrite')}
icon={Plus} icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to." title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
data-cy="add-rewrite-annotation"
> >
{' '} {' '}
add rewrite annotation Add rewrite annotation
</Button> </Button>
<Button <Button
@ -259,11 +260,23 @@ export function IngressForm({
onClick={() => addNewAnnotation('regex')} onClick={() => addNewAnnotation('regex')}
icon={Plus} icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to." title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
data-cy="add-regex-annotation"
> >
add regular expression annotation Add regular expression annotation
</Button> </Button>
</> </>
)} )}
{rule.IngressType === 'custom' && (
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('ingressClass')}
icon={Plus}
data-cy="add-ingress-class-annotation"
>
Add kubernetes.io/ingress.class annotation
</Button>
)}
</div> </div>
<div className="col-sm-12 px-0 text-muted">Rules</div> <div className="col-sm-12 px-0 text-muted">Rules</div>

View File

@ -1,6 +1,7 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types'; import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
import { TLS, Ingress } from '../types'; import { TLS, Ingress } from '../types';
@ -62,7 +63,7 @@ export function prepareRuleHostsFromIngress(ing: Ingress) {
h.Host = host; h.Host = host;
h.Secret = getSecretByHost(host, ing.TLS); h.Secret = getSecretByHost(host, ing.TLS);
h.Paths = []; h.Paths = [];
ing.Paths.forEach((path) => { ing.Paths?.forEach((path) => {
if (path.Host === host) { if (path.Host === host) {
h.Paths.push({ h.Paths.push({
Route: path.Path, Route: path.Path,
@ -99,12 +100,15 @@ export function getAnnotationsForEdit(
return result; return result;
} }
export function prepareRuleFromIngress(ing: Ingress): Rule { export function prepareRuleFromIngress(
ing: Ingress,
type?: SupportedIngControllerTypes
): Rule {
return { return {
Key: uuidv4(), Key: uuidv4(),
IngressName: ing.Name, IngressName: ing.Name,
Namespace: ing.Namespace, Namespace: ing.Namespace,
IngressClassName: ing.ClassName, IngressClassName: type === 'custom' ? 'none' : ing.ClassName,
Hosts: prepareRuleHostsFromIngress(ing) || [], Hosts: prepareRuleHostsFromIngress(ing) || [],
Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [], Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [],
IngressType: ing.Type, IngressType: ing.Type,

View File

@ -96,8 +96,11 @@ export function useIngresses(
const serviceNamesInNamespace = servicesInNamespace?.map( const serviceNamesInNamespace = servicesInNamespace?.map(
(service) => service.Name (service) => service.Name
); );
ing.Paths.forEach((path, pIndex) => { ing.Paths?.forEach((path, pIndex) => {
if (!serviceNamesInNamespace?.includes(path.ServiceName)) { if (
!serviceNamesInNamespace?.includes(path.ServiceName) &&
filteredIngresses[iIndex].Paths
) {
filteredIngresses[iIndex].Paths[pIndex].HasService = false; filteredIngresses[iIndex].Paths[pIndex].HasService = false;
} else { } else {
filteredIngresses[iIndex].Paths[pIndex].HasService = true; filteredIngresses[iIndex].Paths[pIndex].HasService = true;
@ -186,6 +189,7 @@ export function useIngressControllers(
}, },
{ {
enabled: !!namespace, enabled: !!namespace,
cacheTime: 0,
...withError('Unable to get ingress controllers'), ...withError('Unable to get ingress controllers'),
} }
); );

View File

@ -2,6 +2,7 @@ import {
PaginationTableSettings, PaginationTableSettings,
SortableTableSettings, SortableTableSettings,
} from '@/react/components/datatables/types'; } from '@/react/components/datatables/types';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
export interface TableSettings export interface TableSettings
extends SortableTableSettings, extends SortableTableSettings,
@ -42,6 +43,6 @@ export interface IngressController {
Name: string; Name: string;
ClassName: string; ClassName: string;
Availability: string; Availability: string;
Type: string; Type: SupportedIngControllerTypes;
New: boolean; New: boolean;
} }

View File

@ -42,7 +42,8 @@
</div> </div>
<ingress-class-datatable <ingress-class-datatable
on-change-availability="(ctrl.onChangeAvailability)" on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
ingress-controllers="ctrl.originalIngressControllers" ingress-controllers="ctrl.originalIngressControllers"
is-loading="ctrl.isIngressControllersLoading" is-loading="ctrl.isIngressControllersLoading"
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.'" 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.'"
@ -50,18 +51,46 @@
view="'cluster'" view="'cluster'"
></ingress-class-datatable> ></ingress-class-datatable>
<div class="form-group"> <label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title cursor-pointer flex items-center">
<div class="col-sm-12"> <button
<por-switch-field id="foldingButtonIngControllerSettings"
checked="ctrl.formValues.IngressAvailabilityPerNamespace" type="button"
name="'ingressAvailabilityPerNamespace'" class="border-0 mx-2 bg-transparent inline-flex justify-center items-center w-2 !ml-0"
label="'Configure ingress controller availability per namespace'" ng-click="ctrl.toggleAdvancedIngSettings()"
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)" <pr-icon ng-if="!ctrl.state.isIngToggleSectionExpanded" feather="true" icon="'chevron-right'"></pr-icon>
label-class="'col-sm-5 col-lg-4 px-0 !m-0'" <pr-icon ng-if="ctrl.state.isIngToggleSectionExpanded" feather="true" icon="'chevron-down'"></pr-icon>
switch-class="'col-sm-8'" </button>
> More settings
</por-switch-field> </label>
<div ng-if="ctrl.state.isIngToggleSectionExpanded" class="ml-4">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<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'"
>
</por-switch-field>
</div>
</div> </div>
</div> </div>

View File

@ -47,9 +47,10 @@ class KubernetesConfigureController {
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT; this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW; this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeAvailability = this.onChangeAvailability.bind(this); this.onChangeControllers = this.onChangeControllers.bind(this);
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this); this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this); this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this);
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this); this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
} }
/* #endregion */ /* #endregion */
@ -71,7 +72,7 @@ class KubernetesConfigureController {
/* #endregion */ /* #endregion */
/* #region INGRESS CLASSES UI MANAGEMENT */ /* #region INGRESS CLASSES UI MANAGEMENT */
onChangeAvailability(controllerClassMap) { onChangeControllers(controllerClassMap) {
this.ingressControllers = controllerClassMap; this.ingressControllers = controllerClassMap;
} }
@ -79,6 +80,18 @@ class KubernetesConfigureController {
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK }); return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
} }
toggleAdvancedIngSettings() {
this.$scope.$evalAsync(() => {
this.state.isIngToggleSectionExpanded = !this.state.isIngToggleSectionExpanded;
});
}
onToggleAllowNoneIngressClass() {
this.$scope.$evalAsync(() => {
this.formValues.AllowNoneIngressClass = !this.formValues.AllowNoneIngressClass;
});
}
onToggleIngressAvailabilityPerNamespace() { onToggleIngressAvailabilityPerNamespace() {
this.$scope.$evalAsync(() => { this.$scope.$evalAsync(() => {
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace; this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
@ -109,6 +122,7 @@ class KubernetesConfigureController {
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses; endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace; endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace; endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
endpoint.Kubernetes.Configuration.AllowNoneIngressClass = this.formValues.AllowNoneIngressClass;
endpoint.ChangeWindow = this.state.autoUpdateSettings; endpoint.ChangeWindow = this.state.autoUpdateSettings;
} }
@ -256,6 +270,7 @@ class KubernetesConfigureController {
actionInProgress: false, actionInProgress: false,
displayConfigureClassPanel: {}, displayConfigureClassPanel: {},
viewReady: false, viewReady: false,
isIngToggleSectionExpanded: false,
endpointId: this.$state.params.endpointId, endpointId: this.$state.params.endpointId,
duplicates: { duplicates: {
ingressClasses: new KubernetesFormValidationReferences(), ingressClasses: new KubernetesFormValidationReferences(),
@ -315,6 +330,7 @@ class KubernetesConfigureController {
return ic; return ic;
}); });
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace; this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
this.formValues.AllowNoneIngressClass = this.endpoint.Kubernetes.Configuration.AllowNoneIngressClass;
this.oldFormValues = Object.assign({}, this.formValues); this.oldFormValues = Object.assign({}, this.formValues);
} catch (err) { } catch (err) {

View File

@ -185,7 +185,7 @@
<div class="col-sm-12 form-section-title"> Networking </div> <div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable <ingress-class-datatable
ng-if="$ctrl.state.ingressAvailabilityPerNamespace" ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
on-change-availability="($ctrl.onChangeIngressControllerAvailability)" on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="$ctrl.ingressControllers" ingress-controllers="$ctrl.ingressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'" description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'" no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"

View File

@ -161,7 +161,7 @@
<div class="col-sm-12 form-section-title"> Networking </div> <div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable <ingress-class-datatable
ng-if="ctrl.state.ingressAvailabilityPerNamespace" ng-if="ctrl.state.ingressAvailabilityPerNamespace"
on-change-availability="(ctrl.onChangeIngressControllerAvailability)" on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="ctrl.ingressControllers" ingress-controllers="ctrl.ingressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'" description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'" no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"

View File

@ -75,6 +75,7 @@ export function createMockEnvironment(): Environment {
Configuration: { Configuration: {
IngressClasses: [], IngressClasses: [],
IngressAvailabilityPerNamespace: false, IngressAvailabilityPerNamespace: false,
AllowNoneIngressClass: false,
}, },
}, },
EdgeKey: '', EdgeKey: '',

View File

@ -1,5 +1,7 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { Icon } from '@@/Icon';
import { FormSectionTitle } from '../FormSectionTitle'; import { FormSectionTitle } from '../FormSectionTitle';
interface Props { interface Props {
@ -22,11 +24,12 @@ export function FormSection({
id={`foldingButton${title}`} id={`foldingButton${title}`}
type="button" type="button"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="border-0 mx-2 bg-transparent inline-flex justify-center items-center w-2" className="border-0 mx-2 !ml-0 bg-transparent inline-flex justify-center items-center w-2"
> >
<i <Icon
className={`fa fa-caret-${isExpanded ? 'down' : 'right'}`} icon={isExpanded ? 'chevron-down' : 'chevron-right'}
aria-hidden="true" className="shrink-0"
feather
/> />
</button> </button>
)} )}

View File

@ -12,7 +12,7 @@ export function FormSectionTitle({
return ( return (
<label <label
htmlFor={htmlFor} htmlFor={htmlFor}
className="col-sm-12 form-section-title cursor-pointer" className="col-sm-12 form-section-title cursor-pointer flex items-center"
> >
{children} {children}
</label> </label>

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { confirmWarn } from '@/portainer/services/modal.service/confirm'; import { confirmWarn } from '@/portainer/services/modal.service/confirm';
@ -14,29 +14,64 @@ import { createStore } from './datatable-store';
const useStore = createStore('ingressClasses'); const useStore = createStore('ingressClasses');
interface Props { interface Props {
onChangeAvailability: ( onChangeControllers: (
controllerClassMap: IngressControllerClassMap[] controllerClassMap: IngressControllerClassMap[]
) => void; // angular function to save the ingress class list ) => void; // angular function to save the ingress class list
description: string; description: string;
ingressControllers: IngressControllerClassMap[] | undefined; ingressControllers: IngressControllerClassMap[] | undefined;
allowNoneIngressClass: boolean;
isLoading: boolean; isLoading: boolean;
noIngressControllerLabel: string; noIngressControllerLabel: string;
view: string; view: string;
} }
export function IngressClassDatatable({ export function IngressClassDatatable({
onChangeAvailability, onChangeControllers,
description, description,
ingressControllers, ingressControllers,
allowNoneIngressClass,
isLoading, isLoading,
noIngressControllerLabel, noIngressControllerLabel,
view, view,
}: Props) { }: Props) {
const [ingControllerFormValues, setIngControllerFormValues] = const [ingControllerFormValues, setIngControllerFormValues] = useState(
useState(ingressControllers); ingressControllers || []
);
const settings = useStore(); const settings = useStore();
const columns = useColumns(); const columns = useColumns();
useEffect(() => {
if (allowNoneIngressClass === undefined) {
return;
}
let newIngFormValues: IngressControllerClassMap[];
const isCustomTypeExist = ingControllerFormValues.some(
(ic) => ic.Type === 'custom'
);
if (allowNoneIngressClass) {
newIngFormValues = [...ingControllerFormValues];
// add the ingress controller type 'custom' with a 'none' ingress class name
if (!isCustomTypeExist) {
newIngFormValues.push({
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
});
}
} else {
newIngFormValues = ingControllerFormValues.filter(
(ingController) => ingController.ClassName !== 'none'
);
}
setIngControllerFormValues(newIngFormValues);
onChangeControllers(newIngFormValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowNoneIngressClass, onChangeControllers]);
return ( return (
<div className="-mx-[15px]"> <div className="-mx-[15px]">
<Datatable <Datatable
@ -134,7 +169,7 @@ export function IngressClassDatatable({
); );
if (view === 'namespace') { if (view === 'namespace') {
setIngControllerFormValues(updatedIngressControllers); setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers); onChangeControllers(updatedIngressControllers);
return; return;
} }
@ -180,14 +215,14 @@ export function IngressClassDatatable({
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
setIngControllerFormValues(updatedIngressControllers); setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers); onChangeControllers(updatedIngressControllers);
} }
}, },
}); });
return; return;
} }
setIngControllerFormValues(updatedIngressControllers); setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers); onChangeControllers(updatedIngressControllers);
} }
} }
} }
@ -196,10 +231,13 @@ function isUnsavedChanges(
oldIngressControllers: IngressControllerClassMap[], oldIngressControllers: IngressControllerClassMap[],
newIngressControllers: IngressControllerClassMap[] newIngressControllers: IngressControllerClassMap[]
) { ) {
for (let i = 0; i < oldIngressControllers.length; i += 1) { if (oldIngressControllers.length !== newIngressControllers.length) {
return true;
}
for (let i = 0; i < newIngressControllers.length; i += 1) {
if ( if (
oldIngressControllers[i].Availability !== oldIngressControllers[i]?.Availability !==
newIngressControllers[i].Availability newIngressControllers[i]?.Availability
) { ) {
return true; return true;
} }

View File

@ -1,9 +1,13 @@
type SupportedIngControllerNames = 'nginx' | 'traefik' | 'unknown'; export type SupportedIngControllerTypes =
| 'nginx'
| 'traefik'
| 'other'
| 'custom';
export interface IngressControllerClassMap extends Record<string, unknown> { export interface IngressControllerClassMap extends Record<string, unknown> {
Name: string; Name: string;
ClassName: string; ClassName: string;
Type: SupportedIngControllerNames; Type: SupportedIngControllerTypes;
Availability: boolean; Availability: boolean;
New: boolean; New: boolean;
Used: boolean; // if the controller is used by any ingress in the cluster Used: boolean; // if the controller is used by any ingress in the cluster

View File

@ -70,6 +70,7 @@ export interface KubernetesConfiguration {
RestrictDefaultNamespace?: boolean; RestrictDefaultNamespace?: boolean;
IngressClasses: IngressClass[]; IngressClasses: IngressClass[];
IngressAvailabilityPerNamespace: boolean; IngressAvailabilityPerNamespace: boolean;
AllowNoneIngressClass: boolean;
} }
export interface KubernetesSettings { export interface KubernetesSettings {