Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { applicationFactoryMock } from '@qovery/shared/factories'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { fireEvent, renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { ServiceResourcesSettings } from './service-resources-settings'

let mockAdvancedSettings: Record<string, unknown> | undefined
const mockEditAdvancedSettings = jest.fn()
const mockEditService = jest.fn()

jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useParams: () => ({
Expand All @@ -13,15 +17,41 @@ jest.mock('@tanstack/react-router', () => ({

jest.mock('@qovery/domains/services/feature', () => ({
...jest.requireActual('@qovery/domains/services/feature'),
useAdvancedSettings: () => ({ data: undefined }),
useEditAdvancedSettings: () => ({ mutateAsync: jest.fn(), isLoading: false }),
useEditService: () => ({ mutate: jest.fn(), isLoading: false }),
ApplicationSettingsResources: () => <div data-testid="application-settings-resources">Resources form</div>,
useAdvancedSettings: () => ({ data: mockAdvancedSettings }),
useEditAdvancedSettings: () => ({ mutateAsync: mockEditAdvancedSettings, isLoading: false }),
useEditService: () => ({ mutate: mockEditService, isLoading: false }),
ApplicationSettingsResources: ({ displayStableNodepoolToggle }: { displayStableNodepoolToggle?: boolean }) => {
const { setValue, watch } = jest.requireActual('react-hook-form').useFormContext()
const runOnStableNodepool = watch('run_on_stable_nodepool') ?? false

return (
<div data-testid="application-settings-resources">
Resources form
{displayStableNodepoolToggle && (
<>
<button
type="button"
onClick={() => setValue('run_on_stable_nodepool', !runOnStableNodepool, { shouldDirty: true })}
>
Run on a stable nodepool
</button>
</>
)}
</div>
)
},
}))

describe('ServiceResourcesSettings', () => {
const service = applicationFactoryMock(1)[0]

beforeEach(() => {
mockAdvancedSettings = undefined
mockEditAdvancedSettings.mockReset()
mockEditAdvancedSettings.mockResolvedValue(undefined)
mockEditService.mockReset()
})

it('should render resources heading and save action', () => {
renderWithProviders(<ServiceResourcesSettings service={service} />)

Expand All @@ -35,4 +65,42 @@ describe('ServiceResourcesSettings', () => {

expect(screen.getByTestId('application-settings-resources')).toBeInTheDocument()
})

it('should render stable nodepool control in the resources form', async () => {
renderWithProviders(<ServiceResourcesSettings service={service} />)

expect(screen.getByTestId('application-settings-resources')).toContainElement(
screen.getByText('Run on a stable nodepool')
)
})

it('should remove stable nodepool advanced settings when toggle is disabled', async () => {
mockAdvancedSettings = {
'deployment.affinity.node.required': {
'karpenter.sh/capacity-type': 'on-demand',
'karpenter.sh/nodepool': 'stable',
'kubernetes.io/arch': 'arm64',
},
}

renderWithProviders(<ServiceResourcesSettings service={service} />)

fireEvent.click(screen.getByText('Run on a stable nodepool'))
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))

await waitFor(() => {
expect(mockEditAdvancedSettings).toHaveBeenCalledWith({
serviceId: service.id,
payload: {
serviceType: 'APPLICATION',
'deployment.affinity.node.required': {
'kubernetes.io/arch': 'arm64',
},
'hpa.cpu.average_utilization_percent': 60,
'hpa.memory.average_utilization_percent': null,
},
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ import {
buildAutoscalingRequestFromForm,
buildEditServicePayload,
buildHpaAdvancedSettingsPayload,
buildStableNodepoolAdvancedSettingsPayload,
loadHpaSettingsFromAdvancedSettings,
loadStableNodepoolFromAdvancedSettings,
} from '@qovery/shared/util-services'

type ServiceResourcesService = Exclude<EditableService, Database | Helm>

interface ServiceResourcesFormData extends ApplicationResourcesData {
storage_gib?: number
run_on_stable_nodepool?: boolean
}

export interface ServiceResourcesSettingsProps {
Expand Down Expand Up @@ -112,6 +115,7 @@ function getDefaultValues(
})
.otherwise(() => undefined),
...loadHpaSettingsFromAdvancedSettings(advancedSettings),
run_on_stable_nodepool: loadStableNodepoolFromAdvancedSettings(advancedSettings),
...defaultInstances,
}
}
Expand Down Expand Up @@ -147,6 +151,7 @@ function ServiceResourcesSettingsForm({
displayWarningCpu={displayWarningCpu}
service={service}
advancedSettings={advancedSettings}
displayStableNodepoolToggle
/>
<div className="flex justify-end">
<Button type="submit" size="lg" loading={loading} disabled={!formState.isValid || isHpaToKedaMigration}>
Expand Down Expand Up @@ -195,6 +200,7 @@ export function ServiceResourcesSettings({ service }: ServiceResourcesSettingsPr
methods.setValue('hpa_metric_type', hpaSettings.hpa_metric_type)
methods.setValue('hpa_cpu_average_utilization_percent', hpaSettings.hpa_cpu_average_utilization_percent)
methods.setValue('hpa_memory_average_utilization_percent', hpaSettings.hpa_memory_average_utilization_percent)
methods.setValue('run_on_stable_nodepool', loadStableNodepoolFromAdvancedSettings(advancedSettings))
}, [advancedSettings, methods])

const onSubmit = methods.handleSubmit(async (data) => {
Expand Down Expand Up @@ -238,13 +244,26 @@ export function ServiceResourcesSettings({ service }: ServiceResourcesSettingsPr
)
.exhaustive()

if (data.autoscaling_mode === 'HPA' && advancedSettings) {
const shouldUpdateStableNodepool =
advancedSettings &&
(data.run_on_stable_nodepool ?? false) !== loadStableNodepoolFromAdvancedSettings(advancedSettings)

if (advancedSettings && (data.autoscaling_mode === 'HPA' || shouldUpdateStableNodepool)) {
const stableNodepoolPayload = buildStableNodepoolAdvancedSettingsPayload(
data.run_on_stable_nodepool ?? false,
advancedSettings
)
const advancedSettingsPayload =
data.autoscaling_mode === 'HPA'
? buildHpaAdvancedSettingsPayload(data as unknown as Record<string, unknown>, stableNodepoolPayload)
: stableNodepoolPayload

await editAdvancedSettings({
serviceId: service.id,
payload: {
serviceType: service.serviceType,
...buildHpaAdvancedSettingsPayload(data as unknown as Record<string, unknown>, advancedSettings),
},
...advancedSettingsPayload,
} as Parameters<typeof editAdvancedSettings>[0]['payload'],
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useParams } from '@tanstack/react-router'
import clsx from 'clsx'
import posthog from 'posthog-js'
import { useEffect, useRef } from 'react'
import { Controller, useFieldArray, useFormContext } from 'react-hook-form'
Expand All @@ -12,8 +13,10 @@ import {
Icon,
InputSelect,
InputText,
InputToggle,
Link,
Section,
Tooltip,
inputSizeUnitRules,
} from '@qovery/shared/ui'
import { loadHpaSettingsFromAdvancedSettings } from '@qovery/shared/util-services'
Expand All @@ -30,6 +33,7 @@ export interface ApplicationSettingsResourcesProps {
minInstances?: number
maxInstances?: number
advancedSettings?: unknown
displayStableNodepoolToggle?: boolean
}

export function ApplicationSettingsResources({
Expand All @@ -39,6 +43,7 @@ export function ApplicationSettingsResources({
minInstances = 1,
maxInstances = 1000,
advancedSettings,
displayStableNodepoolToggle = false,
}: ApplicationSettingsResourcesProps) {
const { control, watch, setValue } = useFormContext()
const { organizationId = '', environmentId = '', serviceId = '' } = useParams({ strict: false })
Expand Down Expand Up @@ -69,6 +74,8 @@ export function ApplicationSettingsResources({
const autoscalingMode = watch('autoscaling_mode') || 'NONE'
const hpaAverageUtilizationPercent = watch('hpa_cpu_average_utilization_percent') ?? 60
const hpaMemoryAverageUtilizationPercent = watch('hpa_memory_average_utilization_percent') ?? 60
const hasGpuConfigured = watch('gpu') > 0
const runOnStableNodepool = watch('run_on_stable_nodepool') ?? false
const previousAutoscalingModeRef = useRef(autoscalingMode)

useEffect(() => {
Expand Down Expand Up @@ -260,29 +267,78 @@ export function ApplicationSettingsResources({
)}
/>
{isKarpenterCluster && (
<Controller
name="gpu"
control={control}
rules={{
pattern: {
value: /^[0-9]+$/,
message: 'Please enter a number.',
},
}}
render={({ field, fieldState: { error } }) => (
<InputText
dataTestId="input-gpu"
type="number"
name={field.name}
label="GPU (units)"
value={field.value}
onChange={field.onChange}
disabled={!canSetGPU}
hint={hintGPU}
error={error?.message}
/>
<>
<Controller
name="gpu"
control={control}
rules={{
pattern: {
value: /^[0-9]+$/,
message: 'Please enter a number.',
},
}}
render={({ field, fieldState: { error } }) => (
<Tooltip
content="GPU workloads must run on GPU nodepools. Disable stable nodepool before adding GPU"
disabled={!runOnStableNodepool}
side="top"
>
<div>
<InputText
dataTestId="input-gpu"
type="number"
name={field.name}
label="GPU (units)"
value={field.value}
onChange={field.onChange}
disabled={!canSetGPU || runOnStableNodepool}
hint={hintGPU}
error={error?.message}
/>
</div>
</Tooltip>
)}
/>
{displayStableNodepoolToggle && (
<Tooltip content="Remove GPU before enabling stable nodepool" disabled={!hasGpuConfigured} side="top">
<div
className={clsx(
'flex flex-col gap-3 rounded-md border border-neutral px-3 py-4',
hasGpuConfigured && 'bg-surface-neutral-subtle text-neutral-subtle [&_p]:!text-neutral-subtle'
)}
>
<Controller
name="run_on_stable_nodepool"
control={control}
render={({ field }) => (
<InputToggle
value={field.value ?? false}
onChange={field.onChange}
name={field.name}
title="Run on a stable nodepool"
description="Reduce interruptions from node replacements and consolidation."
align="top"
small
disabled={hasGpuConfigured}
className={hasGpuConfigured ? '!opacity-100' : ''}
/>
)}
/>
{runOnStableNodepool && (
<Callout.Root color="yellow" className="rounded-md px-3 py-2" data-testid="stable-nodepool-callout">
<Callout.Icon>
<Icon iconName="triangle-exclamation" iconStyle="regular" />
</Callout.Icon>
<Callout.Text>
Only use stable nodepools for workloads that need high availability. Stable nodes are more
reliable, but usually cost more and offer less flexibility than cost-optimized capacity.
</Callout.Text>
</Callout.Root>
)}
</div>
</Tooltip>
)}
/>
</>
)}

{service?.serviceType === 'TERRAFORM' && (
Expand Down
1 change: 1 addition & 0 deletions libs/shared/util-services/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export * from './lib/get-service-state-colors'
export * from './lib/build-edit-service-payload'
export * from './lib/keda/autoscaling-payload/autoscaling-payload'
export * from './lib/keda/hpa-advanced-settings/hpa-advanced-settings'
export * from './lib/stable-nodepool-advanced-settings/stable-nodepool-advanced-settings'
export * from './lib/service-templates'
export * from './lib/is-trying-to-remove-last-public-port'
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
buildStableNodepoolAdvancedSettingsPayload,
loadStableNodepoolFromAdvancedSettings,
} from './stable-nodepool-advanced-settings'

describe('stable-nodepool-advanced-settings', () => {
describe('loadStableNodepoolFromAdvancedSettings', () => {
it('should return true when stable nodepool affinity is configured', () => {
const result = loadStableNodepoolFromAdvancedSettings({
'deployment.affinity.node.required': {
'karpenter.sh/capacity-type': 'on-demand',
'karpenter.sh/nodepool': 'stable',
},
})

expect(result).toBe(true)
})

it('should return false when stable nodepool affinity is missing', () => {
const result = loadStableNodepoolFromAdvancedSettings({
'deployment.affinity.node.required': {
'karpenter.sh/nodepool': 'default',
},
})

expect(result).toBe(false)
})
})

describe('buildStableNodepoolAdvancedSettingsPayload', () => {
it('should add stable nodepool affinity and preserve unrelated settings', () => {
const result = buildStableNodepoolAdvancedSettingsPayload(true, {
'deployment.termination_grace_period_seconds': 30,
'deployment.affinity.node.required': {
'kubernetes.io/arch': 'arm64',
},
})

expect(result).toEqual({
'deployment.termination_grace_period_seconds': 30,
'deployment.affinity.node.required': {
'kubernetes.io/arch': 'arm64',
'karpenter.sh/capacity-type': 'on-demand',
'karpenter.sh/nodepool': 'stable',
},
})
})

it('should remove stable nodepool affinity and preserve unrelated node affinity', () => {
const result = buildStableNodepoolAdvancedSettingsPayload(false, {
'deployment.affinity.node.required': {
'kubernetes.io/arch': 'arm64',
'karpenter.sh/capacity-type': 'on-demand',
'karpenter.sh/nodepool': 'stable',
},
})

expect(result).toEqual({
'deployment.affinity.node.required': {
'kubernetes.io/arch': 'arm64',
},
})
})
})
})
Loading
Loading