fix(app): improve resource quota error handling [EE-5933] (#10951)

pull/10956/head
Ali 2024-01-15 13:29:35 +13:00 committed by GitHub
parent 488fcc7cc5
commit 6d71a28584
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 122 additions and 50 deletions

View File

@ -1,6 +1,6 @@
export const KubernetesApplicationQuotaDefaults = { export const KubernetesApplicationQuotaDefaults = {
CpuLimit: 0.1, CpuLimit: 0.1,
MemoryLimit: 64, // MB MemoryLimit: 128, // MB
}; };
export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack';

View File

@ -7,7 +7,6 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector'; import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/CreateView/AppDeploymentTypeFormSection';
import { withReactQuery } from '@/react-tools/withReactQuery'; import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { import {
@ -56,6 +55,8 @@ import {
NameFormSection, NameFormSection,
appNameValidation, appNameValidation,
} from '@/react/kubernetes/applications/components/NameFormSection'; } from '@/react/kubernetes/applications/components/NameFormSection';
import { deploymentTypeValidation } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation';
import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection';
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset'; import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
@ -122,14 +123,6 @@ export const ngModule = angular
'persistedFoldersUseExistingVolumes', 'persistedFoldersUseExistingVolumes',
]) ])
) )
.component(
'appDeploymentTypeFormSection',
r2a(AppDeploymentTypeFormSection, [
'value',
'onChange',
'supportGlobalDeployment',
])
)
.component( .component(
'kubeYamlInspector', 'kubeYamlInspector',
r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [ r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [
@ -333,3 +326,12 @@ withFormValidation(
appNameValidation, appNameValidation,
true true
); );
withFormValidation(
ngModule,
AppDeploymentTypeFormSection,
'appDeploymentTypeFormSection',
['supportGlobalDeployment'],
deploymentTypeValidation,
true
);

View File

@ -294,16 +294,17 @@
namespace-has-quota="ctrl.state.resourcePoolHasQuota" namespace-has-quota="ctrl.state.resourcePoolHasQuota"
max-memory-limit="ctrl.state.sliders.memory.max" max-memory-limit="ctrl.state.sliders.memory.max"
max-cpu-limit="ctrl.state.sliders.cpu.max" max-cpu-limit="ctrl.state.sliders.cpu.max"
validation-data="{maxMemoryLimit: ctrl.state.sliders.memory.max, maxCpuLimit: ctrl.state.sliders.cpu.max}" validation-data="{maxMemoryLimit: ctrl.state.sliders.memory.max, maxCpuLimit: ctrl.state.sliders.cpu.max, isEnvironmentAdmin: ctrl.state.isEnvironmentAdmin}"
resource-quota-capacity-exceeded="ctrl.resourceQuotaCapacityExceeded()" resource-quota-capacity-exceeded="ctrl.resourceQuotaCapacityExceeded()"
></resource-reservation-form-section> ></resource-reservation-form-section>
<!-- deployment options --> <!-- deployment options -->
<app-deployment-type-form-section <app-deployment-type-form-section
value="ctrl.formValues.DeploymentType" values="ctrl.formValues.DeploymentType"
on-change="(ctrl.onChangeDeploymentType)" on-change="(ctrl.onChangeDeploymentType)"
support-global-deployment="ctrl.supportGlobalDeployment()" support-global-deployment="ctrl.supportGlobalDeployment()"
radio-name="'deploymentType'" radio-name="'deploymentType'"
validation-data="{isQuotaExceeded: ctrl.resourceReservationsOverflow()}"
></app-deployment-type-form-section> ></app-deployment-type-form-section>
<!-- replica count --> <!-- replica count -->

View File

@ -994,13 +994,28 @@ class KubernetesCreateApplicationController {
this.state.nodes.cpu += item.CPU; this.state.nodes.cpu += item.CPU;
}); });
var namespace = '';
this.formValues.ResourcePool = this.resourcePools[0];
if (this.resourcePools.length) { if (this.resourcePools.length) {
this.namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.resourcePools[0].Namespace.Name); if (this.state.isEdit) {
namespace = this.$state.params.namespace;
this.formValues.ResourcePool = _.find(this.resourcePools, ['Namespace.Name', namespace]);
}
namespace = this.formValues.ResourcePool.Namespace.Name;
this.namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespace);
this.formValues.ResourcePool.Quota = this.namespaceWithQuota.Quota; this.formValues.ResourcePool.Quota = this.namespaceWithQuota.Quota;
// this.savedFormValues is being used in updateNamespaceLimits behind a check to see isEdit
if (this.state.isEdit) {
this.savedFormValues = angular.copy(this.formValues);
}
this.updateNamespaceLimits(this.namespaceWithQuota); this.updateNamespaceLimits(this.namespaceWithQuota);
this.updateSliders(this.namespaceWithQuota); this.updateSliders(this.namespaceWithQuota);
} }
this.formValues.ResourcePool = this.resourcePools[0];
if (!this.formValues.ResourcePool) { if (!this.formValues.ResourcePool) {
return; return;
} }
@ -1008,7 +1023,6 @@ class KubernetesCreateApplicationController {
this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes); this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes);
this.nodeNumber = nodes.length; this.nodeNumber = nodes.length;
const namespace = this.state.isEdit ? this.$state.params.namespace : this.formValues.ResourcePool.Namespace.Name;
await this.refreshNamespaceData(namespace); await this.refreshNamespaceData(namespace);
if (this.state.isEdit) { if (this.state.isEdit) {

View File

@ -97,10 +97,16 @@ export function ApplicationSummaryWidget() {
<> <>
{failedCreateCondition && ( {failedCreateCondition && (
<div <div
className="vertical-center alert alert-danger mb-2" className="flex gap-1 items-start alert alert-danger mb-2"
data-cy="k8sAppDetail-failedCreateMessage" data-cy="k8sAppDetail-failedCreateMessage"
> >
<Icon icon={Info} className="mr-1" mode="danger" /> <div className="mt-0.5">
<Icon
icon={Info}
className="mr-1 shrink-0"
mode="danger"
/>
</div>
<div> <div>
<div className="font-semibold"> <div className="font-semibold">
Failed to create application Failed to create application

View File

@ -1,21 +1,25 @@
import { FormikErrors } from 'formik';
import { BoxSelector } from '@@/BoxSelector'; import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { FormError } from '@@/form-components/FormError';
import { DeploymentType } from '../types'; import { DeploymentType } from '../../types';
import { getDeploymentOptions } from '../../CreateView/deploymentOptions';
import { getDeploymentOptions } from './deploymentOptions';
interface Props { interface Props {
value: DeploymentType; values: DeploymentType;
onChange(value: DeploymentType): void; onChange(values: DeploymentType): void;
errors: FormikErrors<DeploymentType>;
supportGlobalDeployment: boolean; supportGlobalDeployment: boolean;
} }
export function AppDeploymentTypeFormSection({ export function AppDeploymentTypeFormSection({
supportGlobalDeployment, values,
value,
onChange, onChange,
errors,
supportGlobalDeployment,
}: Props) { }: Props) {
const options = getDeploymentOptions(supportGlobalDeployment); const options = getDeploymentOptions(supportGlobalDeployment);
@ -27,10 +31,11 @@ export function AppDeploymentTypeFormSection({
<BoxSelector <BoxSelector
slim slim
options={options} options={options}
value={value} value={values}
onChange={onChange} onChange={onChange}
radioName="deploymentType" radioName="deploymentType"
/> />
{!!errors && <FormError>{errors}</FormError>}
</FormSection> </FormSection>
); );
} }

View File

@ -0,0 +1,19 @@
import { SchemaOf, mixed } from 'yup';
import { DeploymentType } from '../../types';
type ValidationData = {
isQuotaExceeded: boolean;
};
export function deploymentTypeValidation(
validationData?: ValidationData
): SchemaOf<DeploymentType> {
return mixed()
.oneOf(['Replicated', 'Global'])
.test(
'exhaused',
`This application would exceed available resources. Please review resource reservations or the instance count.`,
() => !validationData?.isQuotaExceeded
);
}

View File

@ -55,14 +55,16 @@ export function ResourceReservationFormSection({
tooltip="An instance of this application will reserve this amount of memory. If the instance memory usage exceeds the reservation, it might be subject to OOM." tooltip="An instance of this application will reserve this amount of memory. If the instance memory usage exceeds the reservation, it might be subject to OOM."
> >
<div className="col-xs-10"> <div className="col-xs-10">
<SliderWithInput {maxMemoryLimit > 0 && (
value={Number(values.memoryLimit) ?? 0} <SliderWithInput
onChange={(value) => onChange({ ...values, memoryLimit: value })} value={Number(values.memoryLimit) ?? 0}
max={maxMemoryLimit} onChange={(value) => onChange({ ...values, memoryLimit: value })}
step={128} max={maxMemoryLimit}
dataCy="k8sAppCreate-memoryLimit" step={128}
visibleTooltip dataCy="k8sAppCreate-memoryLimit"
/> visibleTooltip
/>
)}
{errors?.memoryLimit && ( {errors?.memoryLimit && (
<FormError className="pt-1">{errors.memoryLimit}</FormError> <FormError className="pt-1">{errors.memoryLimit}</FormError>
)} )}
@ -74,21 +76,23 @@ export function ResourceReservationFormSection({
tooltip="An instance of this application will reserve this amount of CPU. If the instance CPU usage exceeds the reservation, it might be subject to CPU throttling." tooltip="An instance of this application will reserve this amount of CPU. If the instance CPU usage exceeds the reservation, it might be subject to CPU throttling."
> >
<div className="col-xs-10"> <div className="col-xs-10">
<Slider {maxCpuLimit > 0 && (
onChange={(value) => <Slider
onChange( onChange={(value) =>
typeof value === 'number' onChange(
? { ...values, cpuLimit: value } typeof value === 'number'
: { ...values, cpuLimit: value[0] ?? 0 } ? { ...values, cpuLimit: value }
) : { ...values, cpuLimit: value[0] ?? 0 }
} )
value={values.cpuLimit} }
min={0} value={values.cpuLimit}
max={maxCpuLimit} min={0}
step={0.01} max={maxCpuLimit}
dataCy="k8sAppCreate-cpuLimitSlider" step={0.1}
visibleTooltip dataCy="k8sAppCreate-cpuLimitSlider"
/> visibleTooltip
/>
)}
{errors?.cpuLimit && ( {errors?.cpuLimit && (
<FormError className="pt-1">{errors.cpuLimit}</FormError> <FormError className="pt-1">{errors.cpuLimit}</FormError>
)} )}

View File

@ -5,6 +5,7 @@ import { ResourceQuotaFormValues } from './types';
type ValidationData = { type ValidationData = {
maxMemoryLimit: number; maxMemoryLimit: number;
maxCpuLimit: number; maxCpuLimit: number;
isEnvironmentAdmin: boolean;
}; };
export function resourceReservationValidation( export function resourceReservationValidation(
@ -13,16 +14,36 @@ export function resourceReservationValidation(
return object().shape({ return object().shape({
memoryLimit: number() memoryLimit: number()
.min(0) .min(0)
.test(
'exhaused',
`The memory capacity for this namespace has been exhausted, so you cannot deploy the application.${
validationData?.isEnvironmentAdmin
? ''
: ' Contact your administrator to expand the memory capacity of the namespace.'
}`,
() => !!validationData && validationData.maxMemoryLimit > 0
)
.max( .max(
validationData?.maxMemoryLimit || 0, validationData?.maxMemoryLimit || 0,
`Value must be between 0 and ${validationData?.maxMemoryLimit}` ({ value }) =>
`Value must be between 0 and ${validationData?.maxMemoryLimit}MB now - the previous value of ${value} exceeds this`
) )
.required(), .required(),
cpuLimit: number() cpuLimit: number()
.min(0) .min(0)
.test(
'exhaused',
`The CPU capacity for this namespace has been exhausted, so you cannot deploy the application.${
validationData?.isEnvironmentAdmin
? ''
: ' Contact your administrator to expand the CPU capacity of the namespace.'
}`,
() => !!validationData && validationData.maxCpuLimit > 0
)
.max( .max(
validationData?.maxCpuLimit || 0, validationData?.maxCpuLimit || 0,
`Value must be between 0 and ${validationData?.maxCpuLimit}` ({ value }) =>
`Value must be between 0 and ${validationData?.maxCpuLimit} now - the previous value of ${value} exceeds this`
) )
.required(), .required(),
}); });