From defce0cf6da0d9f6a9d100f71473443af262927e Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Wed, 1 Mar 2023 13:11:12 +1300 Subject: [PATCH] feat(kuberenetes): add annotations to kube objects EE-4089 (#8499) * add annotations BE teaser * fix settings icon click on home screen for kube env * add debouce to namespace validation * ingress button tooltip fixed * fix tooltip text --- .../imageRegistry/por-image-registry.html | 2 +- .../kube-services-item.html | 8 +- .../create/createApplication.html | 512 +++++++++--------- .../create/createConfiguration.html | 62 ++- .../configurations/edit/configuration.html | 4 + .../create/createResourcePool.html | 4 + .../resource-pools/edit/resourcePool.html | 5 + app/portainer/react/components/index.ts | 1 + app/react/components/BETeaserButton.tsx | 3 + .../annotations/AnnotationsBeTeaser.tsx | 51 ++ .../CreateIngressView/CreateIngressView.tsx | 292 +++++----- .../CreateIngressView/IngressForm.tsx | 113 ++-- .../EnvironmentItem/EditButtons.tsx | 2 +- app/react/portainer/feature-flags/enums.ts | 1 + .../feature-flags/feature-flags.service.ts | 1 + .../portainer/feature-flags/feature-ids.js | 1 + 16 files changed, 579 insertions(+), 483 deletions(-) create mode 100644 app/react/kubernetes/annotations/AnnotationsBeTeaser.tsx diff --git a/app/docker/components/imageRegistry/por-image-registry.html b/app/docker/components/imageRegistry/por-image-registry.html index 7b1b5ddcb..142b3139a 100644 --- a/app/docker/components/imageRegistry/por-image-registry.html +++ b/app/docker/components/imageRegistry/por-image-registry.html @@ -1,7 +1,7 @@
- +
-
+
value
+ +
+ + Add environment variable + +
-
Configurations
-
- - - add configuration - +
+
- Portainer will automatically expose all the keys of a configuration as environment variables. This behavior can be overridden to filesystem mounts for each - key via the override button. + Portainer will automatically expose all the keys of a ConfigMap or Secret as environment variables. This behavior can be overridden to filesystem mounts for + each key via the override option.
-
- -
- -
-
+
+
+
+ name + +
+ +
+ + +
+ - -
-
-
-
- The following keys will be loaded from the {{ config.SelectedConfiguration.Name }} configuration as environment variables: +
+
+ The following keys will be loaded from the {{ config.SelectedConfiguration.Name }} + configuration as environment variables: {{ key }}{{ $last ? '' : ', ' }} @@ -426,66 +424,56 @@ -
-
-
-
 
-
-
- configuration key - -
-
+
+
+ key + +
-
-
- path on disk - -
- + + +
+ +
+
+ path on disk + +
+
+
+
-
-
- -

Path is required.

-
-

This path is already used.

-
-
- -
- -
-
- - + +

Path is required.

+
+

+ This path is already used. +

@@ -494,9 +482,18 @@
+
+ + Add ConfigMap and Secret + +
-
Persisting data
@@ -508,15 +505,6 @@
- - add persisted folder -
@@ -543,16 +531,15 @@ />
-
- +
+
-
+
+ +
+ + Add persisted folder + +
@@ -856,7 +854,7 @@
- +
- The following storage option(s) do not support concurrent access from multiples instances: {{ ctrl.getNonScalableStorage() }}{{ ctrl.getNonScalableStorage() }}. You will not be able to scale that application.
@@ -923,9 +922,18 @@ -
Auto-scaling
-
+
+
+

This feature is currently disabled and must be enabled by an administrator user.

+

+ Server metrics features must be enabled in the + environment configuration view. +

+
+
+ +
@@ -937,22 +945,13 @@ name="enable_auto_scaling" ng-model="ctrl.formValues.AutoScaler.IsUsed" data-cy="k8sAppCreate-autoScaleCheckbox" + ng-disabled="!(ctrl.formValues.DeploymentType !== ctrl.ApplicationDeploymentTypes.GLOBAL && ctrl.state.useServerMetrics)" />
-
-
-

This feature is currently disabled and must be enabled by an administrator user.

-

- Server metrics features must be enabled in the - environment configuration view. -

-
-
-
@@ -1048,118 +1047,117 @@
-
-
Placement preferences and constraints
+
+
Placement preferences and constraints
-
- - - add rule - -
- -
+
Deploy this application on nodes that respect ALL of the following placement rules. Placement rules are based on node labels.
-
-
-
- -
-
- -
- -
- - -
+
+
+
+
-
-
-
-

- This label is already defined. -

-
+
+ +
+ +
+ + +
+
+
+
+
+

+ This label is already defined. +

-
-
-
- -
-
-
-
Specify the policy associated to the placement rules.
-
- - +
+ + Add rule +
-
+
+
+
+ +
+
- - - +
+
Specify the policy associated to the placement rules.
+
- - + +
+
+ + + + + + +
diff --git a/app/kubernetes/views/configurations/create/createConfiguration.html b/app/kubernetes/views/configurations/create/createConfiguration.html index 4e19f6e79..8d905b2a1 100644 --- a/app/kubernetes/views/configurations/create/createConfiguration.html +++ b/app/kubernetes/views/configurations/create/createConfiguration.html @@ -13,8 +13,37 @@
+ +
+ +
+ +
+
+
+
+ + This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. Contact your administrator to expand the capacity of the + namespace. +
+
+
+
+ + You do not have access to any namespace. Contact your administrator to get access to a namespace. +
+
+ + -
+
-
Namespace
- - -
- -
- -
+
+
-
-
- - This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. Contact your administrator to expand the capacity of the - namespace. -
-
-
-
- - You do not have access to any namespace. Contact your administrator to get access to a namespace. -
-
-
Configuration kind
diff --git a/app/kubernetes/views/configurations/edit/configuration.html b/app/kubernetes/views/configurations/edit/configuration.html index ecf7657b9..c7a6efe52 100644 --- a/app/kubernetes/views/configurations/edit/configuration.html +++ b/app/kubernetes/views/configurations/edit/configuration.html @@ -102,6 +102,10 @@ +
+ +
+
+
+ +
+
Quota
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index dde251d99..af7ca5e4d 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -32,6 +32,11 @@
+ +
+ +
+
Resource quota
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index c014c7315..0ecd4289a 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -57,6 +57,7 @@ export const componentsModule = angular 'buttonText', 'className', 'icon', + 'buttonClassName', ]) ) .component( diff --git a/app/react/components/BETeaserButton.tsx b/app/react/components/BETeaserButton.tsx index 9400ba3c6..78467113d 100644 --- a/app/react/components/BETeaserButton.tsx +++ b/app/react/components/BETeaserButton.tsx @@ -12,6 +12,7 @@ interface Props { buttonText: string; className?: string; icon?: ReactNode; + buttonClassName?: string; } export function BETeaserButton({ @@ -21,6 +22,7 @@ export function BETeaserButton({ buttonText, className, icon, + buttonClassName, }: Props) { return (
+
+ +
+
+ ); +} diff --git a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx index 96b7ba8f4..1edee571c 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useMemo, ReactNode } from 'react'; +import { useState, useEffect, useMemo, ReactNode, useCallback } from 'react'; import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; import { v4 as uuidv4 } from 'uuid'; +import { debounce } from 'lodash'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useConfigurations } from '@/react/kubernetes/configs/queries'; @@ -286,9 +287,155 @@ export function CreateIngressView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ingressRule, servicePorts]); + const validate = useCallback( + ( + ingressRule: Rule, + ingressNames: string[], + serviceOptions: Option[], + existingIngressClass?: IngressController + ) => { + const errors: Record = {}; + const rule = { ...ingressRule }; + + // User cannot edit the namespace and the ingress name + if (!isEdit) { + if (!rule.Namespace) { + errors.namespace = 'Namespace is required'; + } + + const nameRegex = /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/; + if (!rule.IngressName) { + errors.ingressName = 'Ingress name is required'; + } else if (!nameRegex.test(rule.IngressName)) { + errors.ingressName = + "This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123')."; + } else if (ingressNames.includes(rule.IngressName)) { + errors.ingressName = 'Ingress name already exists'; + } + + if (!rule.IngressClassName) { + errors.className = 'Ingress class is required'; + } + } + + if (isEdit && !ingressRule.IngressClassName) { + errors.className = + 'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.'; + } + + if ( + isEdit && + (!existingIngressClass || + (existingIngressClass && !existingIngressClass.Availability)) && + ingressRule.IngressClassName + ) { + if (!rule.IngressType) { + errors.className = + 'Currently set to an ingress class that cannot be found in the cluster - you must select a valid class.'; + } else { + errors.className = + 'Currently set to an ingress class that you do not have access to - you must select a valid class.'; + } + } + + const duplicatedAnnotations: string[] = []; + rule.Annotations?.forEach((a, i) => { + if (!a.Key) { + errors[`annotations.key[${i}]`] = 'Annotation key is required'; + } else if (duplicatedAnnotations.includes(a.Key)) { + errors[`annotations.key[${i}]`] = 'Annotation cannot be duplicated'; + } + if (!a.Value) { + errors[`annotations.value[${i}]`] = 'Annotation value is required'; + } + duplicatedAnnotations.push(a.Key); + }); + + const duplicatedHosts: string[] = []; + // Check if the paths are duplicates + rule.Hosts?.forEach((host, hi) => { + if (!host.NoHost) { + if (!host.Host) { + errors[`hosts[${hi}].host`] = 'Host is required'; + } else if (duplicatedHosts.includes(host.Host)) { + errors[`hosts[${hi}].host`] = 'Host cannot be duplicated'; + } + duplicatedHosts.push(host.Host); + } + + // Validate service + host.Paths?.forEach((path, pi) => { + if (!path.ServiceName) { + errors[`hosts[${hi}].paths[${pi}].servicename`] = + 'Service name is required'; + } + + if ( + isEdit && + path.ServiceName && + !serviceOptions.find((s) => s.value === path.ServiceName) + ) { + errors[`hosts[${hi}].paths[${pi}].servicename`] = ( + + Currently set to {path.ServiceName}, which does not exist. You + can create a service with this name for a particular deployment + via{' '} + + Applications + + , and on returning here it will be picked up. + + ); + } + + if (!path.ServicePort) { + errors[`hosts[${hi}].paths[${pi}].serviceport`] = + 'Service port is required'; + } + }); + // Validate paths + const paths = host.Paths.map((path) => path.Route); + paths.forEach((item, idx) => { + if (!item) { + errors[`hosts[${hi}].paths[${idx}].path`] = 'Path cannot be empty'; + } else if (paths.indexOf(item) !== idx) { + errors[`hosts[${hi}].paths[${idx}].path`] = + 'Paths cannot be duplicated'; + } else { + // Validate host and path combination globally + const isExists = checkIfPathExistsWithHost( + ingresses, + host.Host, + item, + params.name + ); + if (isExists) { + errors[`hosts[${hi}].paths[${idx}].path`] = + 'Path is already in use with the same host'; + } + } + }); + }); + + setErrors(errors); + if (Object.keys(errors).length > 0) { + return false; + } + return true; + }, + [ingresses, environmentId, isEdit, params.name] + ); + + const debouncedValidate = useMemo(() => debounce(validate, 300), [validate]); + useEffect(() => { if (namespace.length > 0) { - validate( + debouncedValidate( ingressRule, ingressNames || [], servicesOptions || [], @@ -302,6 +449,7 @@ export function CreateIngressView() { ingressNames, servicesOptions, existingIngressClass, + debouncedValidate, ]); return ( @@ -361,146 +509,6 @@ export function CreateIngressView() { ); - function validate( - ingressRule: Rule, - ingressNames: string[], - serviceOptions: Option[], - existingIngressClass?: IngressController - ) { - const errors: Record = {}; - const rule = { ...ingressRule }; - - // User cannot edit the namespace and the ingress name - if (!isEdit) { - if (!rule.Namespace) { - errors.namespace = 'Namespace is required'; - } - - const nameRegex = /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/; - if (!rule.IngressName) { - errors.ingressName = 'Ingress name is required'; - } else if (!nameRegex.test(rule.IngressName)) { - errors.ingressName = - "This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123')."; - } else if (ingressNames.includes(rule.IngressName)) { - errors.ingressName = 'Ingress name already exists'; - } - - if (!rule.IngressClassName) { - errors.className = 'Ingress class is required'; - } - } - - if (isEdit && !ingressRule.IngressClassName) { - errors.className = - 'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.'; - } - - if ( - isEdit && - (!existingIngressClass || - (existingIngressClass && !existingIngressClass.Availability)) && - ingressRule.IngressClassName - ) { - if (!rule.IngressType) { - errors.className = - 'Currently set to an ingress class that cannot be found in the cluster - you must select a valid class.'; - } else { - errors.className = - 'Currently set to an ingress class that you do not have access to - you must select a valid class.'; - } - } - - const duplicatedAnnotations: string[] = []; - rule.Annotations?.forEach((a, i) => { - if (!a.Key) { - errors[`annotations.key[${i}]`] = 'Annotation key is required'; - } else if (duplicatedAnnotations.includes(a.Key)) { - errors[`annotations.key[${i}]`] = 'Annotation cannot be duplicated'; - } - if (!a.Value) { - errors[`annotations.value[${i}]`] = 'Annotation value is required'; - } - duplicatedAnnotations.push(a.Key); - }); - - const duplicatedHosts: string[] = []; - // Check if the paths are duplicates - rule.Hosts?.forEach((host, hi) => { - if (!host.NoHost) { - if (!host.Host) { - errors[`hosts[${hi}].host`] = 'Host is required'; - } else if (duplicatedHosts.includes(host.Host)) { - errors[`hosts[${hi}].host`] = 'Host cannot be duplicated'; - } - duplicatedHosts.push(host.Host); - } - - // Validate service - host.Paths?.forEach((path, pi) => { - if (!path.ServiceName) { - errors[`hosts[${hi}].paths[${pi}].servicename`] = - 'Service name is required'; - } - - if ( - isEdit && - path.ServiceName && - !serviceOptions.find((s) => s.value === path.ServiceName) - ) { - errors[`hosts[${hi}].paths[${pi}].servicename`] = ( - - Currently set to {path.ServiceName}, which does not exist. You can - create a service with this name for a particular deployment via{' '} - - Applications - - , and on returning here it will be picked up. - - ); - } - - if (!path.ServicePort) { - errors[`hosts[${hi}].paths[${pi}].serviceport`] = - 'Service port is required'; - } - }); - // Validate paths - const paths = host.Paths.map((path) => path.Route); - paths.forEach((item, idx) => { - if (!item) { - errors[`hosts[${hi}].paths[${idx}].path`] = 'Path cannot be empty'; - } else if (paths.indexOf(item) !== idx) { - errors[`hosts[${hi}].paths[${idx}].path`] = - 'Paths cannot be duplicated'; - } else { - // Validate host and path combination globally - const isExists = checkIfPathExistsWithHost( - ingresses, - host.Host, - item, - params.name - ); - if (isExists) { - errors[`hosts[${hi}].paths[${idx}].path`] = - 'Path is already in use with the same host'; - } - } - }); - }); - - setErrors(errors); - if (Object.keys(errors).length > 0) { - return false; - } - return true; - } - function handleNamespaceChange(ns: string) { setNamespace(ns); if (!isEdit) { diff --git a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx index 89b06a4ef..5178641c9 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx @@ -10,6 +10,7 @@ import { FormError } from '@@/form-components/FormError'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; import { Tooltip } from '@@/Tip/Tooltip'; import { Button } from '@@/buttons'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { Annotations } from './Annotations'; import { Rule, ServicePorts } from './types'; @@ -199,27 +200,33 @@ export function IngressForm({
-
Annotations
-

- - - You can specify{' '} - - annotations - {' '} - for the object. See further Kubernetes documentation on{' '} - - well-known annotations - - . - -

+
+ Annotations + + + You can specify{' '} + + annotations + {' '} + for the object. See further Kubernetes documentation on{' '} + + well-known annotations + + . + +
+ } + setHtmlMessage + /> +
{rule?.Annotations && ( @@ -233,38 +240,46 @@ export function IngressForm({ )}
- + + + + + {rule.IngressType === 'nginx' && ( <> - + + + + + - + + + + + )} diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EditButtons.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EditButtons.tsx index 9ede23238..e15eefc57 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EditButtons.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EditButtons.tsx @@ -60,7 +60,7 @@ function getConfigRoute(environment: Environment) { case PlatformType.Docker: return getDockerConfigRoute(environment); case PlatformType.Kubernetes: - return 'kubernetes.cluster'; + return 'kubernetes.cluster.setup'; default: return ''; } diff --git a/app/react/portainer/feature-flags/enums.ts b/app/react/portainer/feature-flags/enums.ts index dd2dcf300..840e532e3 100644 --- a/app/react/portainer/feature-flags/enums.ts +++ b/app/react/portainer/feature-flags/enums.ts @@ -37,4 +37,5 @@ export enum FeatureId { ENFORCE_DEPLOYMENT_OPTIONS = 'k8s-enforce-deployment-options', K8S_ADM_ONLY_USR_INGRESS_DEPLY = 'k8s-admin-only-ingress-deploy', K8S_ROLLING_RESTART = 'k8s-rolling-restart', + K8S_ANNOTATIONS = 'k8s-annotations', } diff --git a/app/react/portainer/feature-flags/feature-flags.service.ts b/app/react/portainer/feature-flags/feature-flags.service.ts index 1e6646589..dbde682ee 100644 --- a/app/react/portainer/feature-flags/feature-flags.service.ts +++ b/app/react/portainer/feature-flags/feature-flags.service.ts @@ -42,6 +42,7 @@ export async function init(edition: Edition) { [FeatureId.ENFORCE_DEPLOYMENT_OPTIONS]: Edition.BE, [FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY]: Edition.BE, [FeatureId.K8S_ROLLING_RESTART]: Edition.BE, + [FeatureId.K8S_ANNOTATIONS]: Edition.BE, }; state.currentEdition = currentEdition; diff --git a/app/react/portainer/feature-flags/feature-ids.js b/app/react/portainer/feature-flags/feature-ids.js index c9aa5180b..27608b6bc 100644 --- a/app/react/portainer/feature-flags/feature-ids.js +++ b/app/react/portainer/feature-flags/feature-ids.js @@ -14,3 +14,4 @@ export const FORCE_REDEPLOYMENT = 'force-redeployment'; export const STACK_PULL_IMAGE = 'stack-pull-image'; export const STACK_WEBHOOK = 'stack-webhook'; export const CONTAINER_WEBHOOK = 'container-webhook'; +export const K8S_ANNOTATIONS = 'k8s-annotations';