mirror of https://github.com/portainer/portainer
fix(app): improve resource quota error handling [EE-5933] (#10951)
parent
488fcc7cc5
commit
6d71a28584
|
@ -1,6 +1,6 @@
|
|||
export const KubernetesApplicationQuotaDefaults = {
|
||||
CpuLimit: 0.1,
|
||||
MemoryLimit: 64, // MB
|
||||
MemoryLimit: 128, // MB
|
||||
};
|
||||
|
||||
export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack';
|
||||
|
|
|
@ -7,7 +7,6 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce
|
|||
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
|
||||
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
||||
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 { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import {
|
||||
|
@ -56,6 +55,8 @@ import {
|
|||
NameFormSection,
|
||||
appNameValidation,
|
||||
} 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';
|
||||
|
||||
|
@ -122,14 +123,6 @@ export const ngModule = angular
|
|||
'persistedFoldersUseExistingVolumes',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'appDeploymentTypeFormSection',
|
||||
r2a(AppDeploymentTypeFormSection, [
|
||||
'value',
|
||||
'onChange',
|
||||
'supportGlobalDeployment',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeYamlInspector',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [
|
||||
|
@ -333,3 +326,12 @@ withFormValidation(
|
|||
appNameValidation,
|
||||
true
|
||||
);
|
||||
|
||||
withFormValidation(
|
||||
ngModule,
|
||||
AppDeploymentTypeFormSection,
|
||||
'appDeploymentTypeFormSection',
|
||||
['supportGlobalDeployment'],
|
||||
deploymentTypeValidation,
|
||||
true
|
||||
);
|
||||
|
|
|
@ -294,16 +294,17 @@
|
|||
namespace-has-quota="ctrl.state.resourcePoolHasQuota"
|
||||
max-memory-limit="ctrl.state.sliders.memory.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-reservation-form-section>
|
||||
|
||||
<!-- deployment options -->
|
||||
<app-deployment-type-form-section
|
||||
value="ctrl.formValues.DeploymentType"
|
||||
values="ctrl.formValues.DeploymentType"
|
||||
on-change="(ctrl.onChangeDeploymentType)"
|
||||
support-global-deployment="ctrl.supportGlobalDeployment()"
|
||||
radio-name="'deploymentType'"
|
||||
validation-data="{isQuotaExceeded: ctrl.resourceReservationsOverflow()}"
|
||||
></app-deployment-type-form-section>
|
||||
|
||||
<!-- replica count -->
|
||||
|
|
|
@ -994,13 +994,28 @@ class KubernetesCreateApplicationController {
|
|||
this.state.nodes.cpu += item.CPU;
|
||||
});
|
||||
|
||||
var namespace = '';
|
||||
this.formValues.ResourcePool = this.resourcePools[0];
|
||||
|
||||
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.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.updateSliders(this.namespaceWithQuota);
|
||||
}
|
||||
this.formValues.ResourcePool = this.resourcePools[0];
|
||||
|
||||
if (!this.formValues.ResourcePool) {
|
||||
return;
|
||||
}
|
||||
|
@ -1008,7 +1023,6 @@ class KubernetesCreateApplicationController {
|
|||
this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes);
|
||||
this.nodeNumber = nodes.length;
|
||||
|
||||
const namespace = this.state.isEdit ? this.$state.params.namespace : this.formValues.ResourcePool.Namespace.Name;
|
||||
await this.refreshNamespaceData(namespace);
|
||||
|
||||
if (this.state.isEdit) {
|
||||
|
|
|
@ -97,10 +97,16 @@ export function ApplicationSummaryWidget() {
|
|||
<>
|
||||
{failedCreateCondition && (
|
||||
<div
|
||||
className="vertical-center alert alert-danger mb-2"
|
||||
className="flex gap-1 items-start alert alert-danger mb-2"
|
||||
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 className="font-semibold">
|
||||
Failed to create application
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { DeploymentType } from '../types';
|
||||
|
||||
import { getDeploymentOptions } from './deploymentOptions';
|
||||
import { DeploymentType } from '../../types';
|
||||
import { getDeploymentOptions } from '../../CreateView/deploymentOptions';
|
||||
|
||||
interface Props {
|
||||
value: DeploymentType;
|
||||
onChange(value: DeploymentType): void;
|
||||
values: DeploymentType;
|
||||
onChange(values: DeploymentType): void;
|
||||
errors: FormikErrors<DeploymentType>;
|
||||
supportGlobalDeployment: boolean;
|
||||
}
|
||||
|
||||
export function AppDeploymentTypeFormSection({
|
||||
supportGlobalDeployment,
|
||||
value,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
supportGlobalDeployment,
|
||||
}: Props) {
|
||||
const options = getDeploymentOptions(supportGlobalDeployment);
|
||||
|
||||
|
@ -27,10 +31,11 @@ export function AppDeploymentTypeFormSection({
|
|||
<BoxSelector
|
||||
slim
|
||||
options={options}
|
||||
value={value}
|
||||
value={values}
|
||||
onChange={onChange}
|
||||
radioName="deploymentType"
|
||||
/>
|
||||
{!!errors && <FormError>{errors}</FormError>}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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."
|
||||
>
|
||||
<div className="col-xs-10">
|
||||
<SliderWithInput
|
||||
value={Number(values.memoryLimit) ?? 0}
|
||||
onChange={(value) => onChange({ ...values, memoryLimit: value })}
|
||||
max={maxMemoryLimit}
|
||||
step={128}
|
||||
dataCy="k8sAppCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
/>
|
||||
{maxMemoryLimit > 0 && (
|
||||
<SliderWithInput
|
||||
value={Number(values.memoryLimit) ?? 0}
|
||||
onChange={(value) => onChange({ ...values, memoryLimit: value })}
|
||||
max={maxMemoryLimit}
|
||||
step={128}
|
||||
dataCy="k8sAppCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
/>
|
||||
)}
|
||||
{errors?.memoryLimit && (
|
||||
<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."
|
||||
>
|
||||
<div className="col-xs-10">
|
||||
<Slider
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
typeof value === 'number'
|
||||
? { ...values, cpuLimit: value }
|
||||
: { ...values, cpuLimit: value[0] ?? 0 }
|
||||
)
|
||||
}
|
||||
value={values.cpuLimit}
|
||||
min={0}
|
||||
max={maxCpuLimit}
|
||||
step={0.01}
|
||||
dataCy="k8sAppCreate-cpuLimitSlider"
|
||||
visibleTooltip
|
||||
/>
|
||||
{maxCpuLimit > 0 && (
|
||||
<Slider
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
typeof value === 'number'
|
||||
? { ...values, cpuLimit: value }
|
||||
: { ...values, cpuLimit: value[0] ?? 0 }
|
||||
)
|
||||
}
|
||||
value={values.cpuLimit}
|
||||
min={0}
|
||||
max={maxCpuLimit}
|
||||
step={0.1}
|
||||
dataCy="k8sAppCreate-cpuLimitSlider"
|
||||
visibleTooltip
|
||||
/>
|
||||
)}
|
||||
{errors?.cpuLimit && (
|
||||
<FormError className="pt-1">{errors.cpuLimit}</FormError>
|
||||
)}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { ResourceQuotaFormValues } from './types';
|
|||
type ValidationData = {
|
||||
maxMemoryLimit: number;
|
||||
maxCpuLimit: number;
|
||||
isEnvironmentAdmin: boolean;
|
||||
};
|
||||
|
||||
export function resourceReservationValidation(
|
||||
|
@ -13,16 +14,36 @@ export function resourceReservationValidation(
|
|||
return object().shape({
|
||||
memoryLimit: number()
|
||||
.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(
|
||||
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(),
|
||||
cpuLimit: number()
|
||||
.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(
|
||||
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(),
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue