mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
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
This commit is contained in:
parent
5f66020e42
commit
defce0cf6d
16 changed files with 579 additions and 483 deletions
|
@ -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<string>[],
|
||||
existingIngressClass?: IngressController
|
||||
) => {
|
||||
const errors: Record<string, ReactNode> = {};
|
||||
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`] = (
|
||||
<span>
|
||||
Currently set to {path.ServiceName}, which does not exist. You
|
||||
can create a service with this name for a particular deployment
|
||||
via{' '}
|
||||
<Link
|
||||
to="kubernetes.applications"
|
||||
params={{ id: environmentId }}
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
>
|
||||
Applications
|
||||
</Link>
|
||||
, and on returning here it will be picked up.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>[],
|
||||
existingIngressClass?: IngressController
|
||||
) {
|
||||
const errors: Record<string, ReactNode> = {};
|
||||
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`] = (
|
||||
<span>
|
||||
Currently set to {path.ServiceName}, which does not exist. You can
|
||||
create a service with this name for a particular deployment via{' '}
|
||||
<Link
|
||||
to="kubernetes.applications"
|
||||
params={{ id: environmentId }}
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
>
|
||||
Applications
|
||||
</Link>
|
||||
, and on returning here it will be picked up.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -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({
|
|||
</div>
|
||||
|
||||
<div className="col-sm-12 text-muted !mb-0 px-0">
|
||||
<div className="mb-2">Annotations</div>
|
||||
<p className="vertical-center text-muted small">
|
||||
<Icon icon={Info} mode="primary" />
|
||||
<span>
|
||||
You can specify{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/"
|
||||
target="_black"
|
||||
>
|
||||
annotations
|
||||
</a>{' '}
|
||||
for the object. See further Kubernetes documentation on{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/reference/labels-annotations-taints/"
|
||||
target="_black"
|
||||
>
|
||||
well-known annotations
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
<div className="control-label !mb-3 text-left font-medium">
|
||||
Annotations
|
||||
<Tooltip
|
||||
message={
|
||||
<div className="vertical-center">
|
||||
<span>
|
||||
You can specify{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/"
|
||||
target="_black"
|
||||
>
|
||||
annotations
|
||||
</a>{' '}
|
||||
for the object. See further Kubernetes documentation on{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/reference/labels-annotations-taints/"
|
||||
target="_black"
|
||||
>
|
||||
well-known annotations
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
setHtmlMessage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule?.Annotations && (
|
||||
|
@ -233,38 +240,46 @@ export function IngressForm({
|
|||
)}
|
||||
|
||||
<div className="col-sm-12 anntation-actions p-0">
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 !ml-0"
|
||||
onClick={() => addNewAnnotation()}
|
||||
icon={Plus}
|
||||
title="Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type."
|
||||
>
|
||||
{' '}
|
||||
add annotation
|
||||
</Button>
|
||||
<TooltipWithChildren message="Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type.">
|
||||
<span>
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 !ml-0"
|
||||
onClick={() => addNewAnnotation()}
|
||||
icon={Plus}
|
||||
>
|
||||
{' '}
|
||||
Add annotation
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
|
||||
{rule.IngressType === 'nginx' && (
|
||||
<>
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('rewrite')}
|
||||
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."
|
||||
data-cy="add-rewrite-annotation"
|
||||
>
|
||||
{' '}
|
||||
Add rewrite annotation
|
||||
</Button>
|
||||
<TooltipWithChildren message="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.">
|
||||
<span>
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('rewrite')}
|
||||
icon={Plus}
|
||||
data-cy="add-rewrite-annotation"
|
||||
>
|
||||
Add rewrite annotation
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('regex')}
|
||||
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."
|
||||
data-cy="add-regex-annotation"
|
||||
>
|
||||
Add regular expression annotation
|
||||
</Button>
|
||||
<TooltipWithChildren message="Enable use of regular expressions in ingress paths (set in the ingress details of an application). Use this along with rewrite-target to specify the regex capturing group to be replaced, e.g. path regex of ^/foo/(,*) and rewrite-target of /bar/$1 rewrites example.com/foo/account to example.com/bar/account.">
|
||||
<span>
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('regex')}
|
||||
icon={Plus}
|
||||
data-cy="add-regex-annotation"
|
||||
>
|
||||
Add regular expression annotation
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue