diff --git a/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.spec.tsx index 22c031168b5..bca0124b255 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.spec.tsx @@ -56,4 +56,58 @@ describe('ClusterCardFeature', () => { const toggle = screen.getByTestId('feature') expect(toggle).toBeInTheDocument() }) + + it('should show NAT_GATEWAY as false when gcp nested static_ips_enabled is false', () => { + renderWithProviders( + wrapWithReactHookForm( + + ) + ) + + expect(screen.getByDisplayValue('false')).toBeInTheDocument() + }) + + it('should read NAT_GATEWAY nested nat_gateway_type static_ips_enabled', () => { + renderWithProviders( + wrapWithReactHookForm( + + ) + ) + + expect(screen.getByDisplayValue('true')).toBeInTheDocument() + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.tsx b/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.tsx index ef15aa9e0bf..dedd25d42b2 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-card-feature/cluster-card-feature.tsx @@ -2,6 +2,7 @@ import { type CloudVendorEnum, type ClusterFeatureResponse } from 'qovery-typesc import { type PropsWithChildren, type ReactNode, useEffect, useState } from 'react' import { type Control, Controller, type FieldValues, type UseFormSetValue, type UseFormWatch } from 'react-hook-form' import { ExternalLink, Icon, InputSelect, InputToggle, Tooltip } from '@qovery/shared/ui' +import { getGcpNatGatewaySettings } from '../utils/get-gcp-nat-gateway-settings' export interface ClusterCardFeatureProps extends PropsWithChildren { feature: ClusterFeatureResponse @@ -27,11 +28,21 @@ export function ClusterCardFeature({ const name = watch && watch(`features.${feature.id}.value`) - const getValue = (value: boolean | string) => { + const getFeatureToggleValue = (feature: ClusterFeatureResponse) => { + const value = feature.value_object?.value + + if (feature.id === 'NAT_GATEWAY' && cloudProvider === 'GCP') { + const gcpNatGatewaySettings = getGcpNatGatewaySettings(feature) + if (gcpNatGatewaySettings) { + return gcpNatGatewaySettings.static_ips_enabled + } + } + if (typeof value === 'string') { return true } - return value + + return Boolean(value) } useEffect(() => { @@ -69,12 +80,7 @@ export function ClusterCardFeature({ ) : ( - + )} diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx index 234e41143e3..8ace069c4fe 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx @@ -108,7 +108,7 @@ describe('StepFeatures', () => { }) }) - it('should hide NAT_GATEWAY feature for GCP cluster creation', async () => { + it('should merge STATIC_IP and NAT_GATEWAY in GCP network configuration', async () => { useCloudProviderFeaturesMockSpy.mockReturnValue({ data: [ { @@ -119,7 +119,12 @@ describe('StepFeatures', () => { { id: 'NAT_GATEWAY', title: 'NAT Gateway', - value_object: { value: false }, + value_object: { + value: { + static_ips_enabled: false, + static_ips_count: 2, + }, + }, }, { id: 'PRIVATE_CLUSTER', @@ -145,9 +150,11 @@ describe('StepFeatures', () => { renderWithProviders(, { wrapper: getWrapper(gcpContextValue) }) await waitFor(() => { + expect(screen.getAllByText('Static IP / Nat Gateways').length).toBeGreaterThan(0) + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + expect(screen.queryByText('Static IP count')).not.toBeInTheDocument() expect(screen.getByText('Private Cluster')).toBeInTheDocument() - expect(screen.getByText('Static IP')).toBeInTheDocument() - expect(screen.queryByText('NAT Gateway')).not.toBeInTheDocument() + expect(screen.queryByText(/^NAT Gateway$/)).not.toBeInTheDocument() }) }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.tsx index a17c3443c5f..99606d1bd6e 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.tsx @@ -25,13 +25,13 @@ import { } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' import { ClusterCardFeature } from '../../cluster-card-feature/cluster-card-feature' +import { GcpStaticIp } from '../../gcp-static-ip/gcp-static-ip' import { ScalewayStaticIp } from '../../scaleway-static-ip/scaleway-static-ip' import { steps, useClusterContainerCreateContext } from '../cluster-creation-flow' import AWSVpcFeature from './aws-vpc-feature/aws-vpc-feature' import GCPVpcFeature from './gcp-vpc-feature/gcp-vpc-feature' const Qovery = '/assets/logos/logo-icon.svg' -const GCP_HIDDEN_FEATURE_IDS = new Set(['NAT_GATEWAY']) const removeEmptySubnet = (objects?: Subnets[]) => objects?.filter((field) => field.A !== '' || field.B !== '' || field.C !== '') @@ -212,11 +212,32 @@ function StepFeaturesForm({ {cloudProvider === 'GCP' && (
{match(watchVpcMode) - .with('DEFAULT', () => - features && features.length > 0 ? ( - features - .filter((feature) => !GCP_HIDDEN_FEATURE_IDS.has(feature.id ?? '')) - .map((feature) => ( + .with('DEFAULT', () => { + if (!features || features.length === 0) { + return ( +
+ +
+ ) + } + + const staticIpFeature = features.find(({ id }) => id === 'STATIC_IP') + const natGatewayFeature = features.find(({ id }) => id === 'NAT_GATEWAY') + const hasMergedStaticIpNatGateway = Boolean(staticIpFeature && natGatewayFeature) + const remainingFeatures = hasMergedStaticIpNatGateway + ? features.filter(({ id }) => id !== 'STATIC_IP' && id !== 'NAT_GATEWAY') + : features + + return ( + <> + {hasMergedStaticIpNatGateway && ( + + )} + {remainingFeatures.map((feature) => ( - )) - ) : ( -
- -
+ ))} + ) - ) + }) .with('EXISTING_VPC', () => ) .otherwise(() => null)}
diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx index 01b4172759e..0fe33d5c39c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx @@ -45,6 +45,20 @@ function SubnetsList({ title, index, subnets }: { title: string; index: string; ) } +function formatFeatureValue(feature: ClusterFeaturesData['features'][string]) { + if (typeof feature.extendedValue === 'string') { + return feature.extendedValue + } + + if (feature.extendedValue && typeof feature.extendedValue === 'object') { + const staticIpsEnabled = feature.extendedValue.static_ips_enabled + const staticIpsCount = feature.extendedValue.static_ips_count + return `static_ips_enabled=${staticIpsEnabled}, static_ips_count=${staticIpsCount}` + } + + return feature.value.toString() +} + export function StepSummaryPresentation(props: StepSummaryPresentationProps) { const clusterBackup = props.resourcesData.infrastructure_charts_parameters?.eks_anywhere_parameters?.cluster_backup const showClusterBackup = Boolean(clusterBackup?.enabled) @@ -595,7 +609,7 @@ export function StepSummaryPresentation(props: StepSummaryPresentationProps) { return (
  • {currentFeature.title}: - {currentFeature.extendedValue ? currentFeature.extendedValue : currentFeature.value.toString()} + {formatFeatureValue(currentFeature)}
  • ) })} diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx index 4a12a943b45..9fe172a51b3 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.spec.tsx @@ -143,4 +143,163 @@ describe('StepSummary', () => { expect(mockNavigate).toHaveBeenCalledWith({ to: '/organization/org-123/clusters' }) }) }) + + it('should send GCP NAT_GATEWAY using nat_gateway_type format on create', async () => { + mockContextValue.generalData = { + name: 'test-gcp-cluster', + description: 'description', + cloud_provider: CloudProviderEnum.GCP, + region: 'europe-west1', + installation_type: 'MANAGED', + production: false, + credentials: 'cred-id', + credentials_name: 'cred-name', + } + mockContextValue.featuresData = { + vpc_mode: 'DEFAULT', + features: { + STATIC_IP: { + id: 'STATIC_IP', + title: 'Static IP / Nat Gateways', + value: true, + }, + NAT_GATEWAY: { + id: 'NAT_GATEWAY', + title: 'NAT Gateway', + value: true, + extendedValue: { + static_ips_enabled: false, + static_ips_count: 2, + }, + }, + }, + } + + mockCreateCluster.mockResolvedValue({ id: 'cluster-123' }) + mockEditCloudProviderInfo.mockResolvedValue({}) + + const { userEvent } = renderWithProviders(, { wrapper: Wrapper }) + + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateCluster).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-123', + clusterRequest: expect.objectContaining({ + cloud_provider: 'GCP', + features: expect.arrayContaining([ + expect.objectContaining({ + id: 'NAT_GATEWAY', + value: { + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: false, + static_ips_count: 2, + }, + }, + }), + ]), + }), + }) + ) + }) + }) + + it('should emit default NAT_GATEWAY for GCP when only STATIC_IP is in form data', async () => { + mockContextValue.generalData = { + name: 'test-gcp-cluster', + description: '', + cloud_provider: CloudProviderEnum.GCP, + region: 'europe-west1', + installation_type: 'MANAGED', + production: false, + credentials: 'cred-id', + credentials_name: 'cred-name', + } + mockContextValue.featuresData = { + vpc_mode: 'DEFAULT', + features: { + STATIC_IP: { id: 'STATIC_IP', title: 'Static IP / Nat Gateways', value: true }, + // NAT_GATEWAY intentionally absent (race / quick navigation scenario) + }, + } + + mockCreateCluster.mockResolvedValue({ id: 'cluster-123' }) + mockEditCloudProviderInfo.mockResolvedValue({}) + + const { userEvent } = renderWithProviders(, { wrapper: Wrapper }) + + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateCluster).toHaveBeenCalledWith( + expect.objectContaining({ + clusterRequest: expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ + id: 'NAT_GATEWAY', + value: expect.objectContaining({ + nat_gateway_type: expect.objectContaining({ provider: 'gcp', static_ips_enabled: false }), + }), + }), + ]), + }), + }) + ) + }) + }) + + it('should send SCW NAT_GATEWAY using nat_gateway_type format when extendedValue is string', async () => { + mockContextValue.generalData = { + name: 'test-scw-cluster', + description: '', + cloud_provider: CloudProviderEnum.SCW, + region: 'fr-par', + installation_type: 'MANAGED', + production: false, + credentials: 'cred-id', + credentials_name: 'cred-name', + } + mockContextValue.featuresData = { + vpc_mode: 'DEFAULT', + features: { + NAT_GATEWAY: { + id: 'NAT_GATEWAY', + title: 'NAT Gateway', + value: true, + extendedValue: 'VPC-GW-S', + }, + STATIC_IP: { + id: 'STATIC_IP', + title: 'Static IP', + value: true, + }, + }, + } + + mockCreateCluster.mockResolvedValue({ id: 'cluster-123' }) + mockEditCloudProviderInfo.mockResolvedValue({}) + + const { userEvent } = renderWithProviders(, { wrapper: Wrapper }) + + await userEvent.click(screen.getByTestId('button-create')) + + await waitFor(() => { + expect(mockCreateCluster).toHaveBeenCalledWith( + expect.objectContaining({ + clusterRequest: expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ + id: 'NAT_GATEWAY', + value: expect.objectContaining({ + nat_gateway_type: expect.objectContaining({ provider: 'scaleway', type: 'VPC-GW-S' }), + }), + }), + ]), + }), + }) + ) + }) + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx index c27e6e5d97b..087062c62de 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary.tsx @@ -1,6 +1,9 @@ import { useNavigate } from '@tanstack/react-router' import { type ClusterCloudProviderInfoRequest, + type ClusterFeatureNatGatewayParameters, + type ClusterFeatureNatGatewayTypeGcp, + type ClusterFeatureNatGatewayTypeScalewayTypeEnum, type ClusterRequest, type ClusterRequestFeaturesInner, type KubernetesEnum, @@ -180,13 +183,51 @@ export function StepSummary({ organizationId }: StepSummaryProps) { if (generalData.cloud_provider === 'AWS' || generalData.cloud_provider === 'GCP') { if (featuresData && featuresData.vpc_mode === 'DEFAULT') { formatFeatures = Object.keys(featuresData.features) - .map( - (id: string) => - featuresData.features[id]?.value && { + .map((id: string) => { + const feature = featuresData.features[id] + + if (!feature?.value) return null + + if (generalData.cloud_provider === 'GCP' && id === 'STATIC_IP') { + // NAT_GATEWAY is the canonical GCP egress feature. If it is already present + // in the form it will be serialised by its own branch below; skip STATIC_IP. + // If it is absent (race / quick navigation), emit a safe default here so the + // payload is never silently empty. + if ('NAT_GATEWAY' in featuresData.features) return null + return { + id: 'NAT_GATEWAY', + value: { + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: false, + static_ips_count: 2, + } as ClusterFeatureNatGatewayTypeGcp, + } as ClusterFeatureNatGatewayParameters, + } + } + + if (generalData.cloud_provider === 'GCP' && id === 'NAT_GATEWAY') { + const gcpNatGatewayType = + feature.extendedValue && typeof feature.extendedValue === 'object' + ? feature.extendedValue + : { static_ips_enabled: false, static_ips_count: 2 } + + return { id, - value: featuresData.features[id].extendedValue || featuresData.features[id].value, + value: { + nat_gateway_type: { + provider: 'gcp', + ...gcpNatGatewayType, + } as ClusterFeatureNatGatewayTypeGcp, + } as ClusterFeatureNatGatewayParameters, } - ) + } + + return { + id, + value: feature.extendedValue || feature.value, + } + }) .filter(Boolean) as ClusterRequestFeaturesInner[] } else if (generalData.cloud_provider === 'AWS') { formatFeatures = [ @@ -274,15 +315,20 @@ export function StepSummary({ organizationId }: StepSummaryProps) { Object.keys(featuresData.features).forEach((featureId) => { if (featureId === SCW_CONTROL_PLANE_FEATURE_ID) return const featureData = featuresData.features[featureId] - if (featureId === 'NAT_GATEWAY' && featureData.extendedValue) { + if (featureId === 'NAT_GATEWAY' && typeof featureData.extendedValue === 'string') { scwFeatures.push({ id: featureId, value: { - nat_gateway_type: { provider: 'scaleway', type: featureData.extendedValue }, - } as unknown as ClusterRequestFeaturesInner['value'], + nat_gateway_type: { + provider: 'scaleway', + type: featureData.extendedValue as ClusterFeatureNatGatewayTypeScalewayTypeEnum, + }, + } as ClusterFeatureNatGatewayParameters, }) } else if (featureData.value) { - scwFeatures.push({ id: featureId, value: featureData.extendedValue || featureData.value }) + const scwFeatureValue = + typeof featureData.extendedValue === 'string' ? featureData.extendedValue : featureData.value + scwFeatures.push({ id: featureId, value: scwFeatureValue }) } }) } diff --git a/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.spec.tsx index 151b2d9488b..0ccddc9433a 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.spec.tsx @@ -16,6 +16,7 @@ const mockUseCluster = useCluster as jest.MockedFunction const mockUseClusterRoutingTable = useClusterRoutingTable as jest.MockedFunction const mockUseEditCluster = useEditCluster as jest.MockedFunction const mockUseEditRoutingTable = useEditRoutingTable as jest.MockedFunction +const mockEditClusterMutate = jest.fn() const mockCluster: Cluster = { id: 'cluster-id', @@ -46,9 +47,11 @@ const mockClusterScaleway: Cluster = { { id: 'NAT_GATEWAY', value_object: { + type: 'NAT_GATEWAY', value: { nat_gateway_type: { - type: 'sbn', + provider: 'scaleway', + type: 'VPC-GW-S', }, }, }, @@ -68,7 +71,7 @@ describe('ClusterNetworkSettings', () => { beforeEach(() => { jest.clearAllMocks() mockUseEditCluster.mockReturnValue({ - mutateAsync: jest.fn(), + mutateAsync: mockEditClusterMutate, isLoading: false, } as unknown as ReturnType) mockUseEditRoutingTable.mockReturnValue({ @@ -132,6 +135,74 @@ describe('ClusterNetworkSettings', () => { expect(screen.getByTestId('submit-button')).toBeInTheDocument() }) + it('should read SCW NAT_GATEWAY from legacy plain-string shape', () => { + const legacyStringCluster: Cluster = { + ...mockCluster, + cloud_provider: 'SCW', + region: 'fr-par', + features: [ + { + id: 'NAT_GATEWAY', + value_object: { + value: 'VPC-GW-S', + }, + }, + ], + } as Cluster + + mockUseCluster.mockReturnValue({ + data: legacyStringCluster, + isLoading: false, + } as unknown as ReturnType) + mockUseClusterRoutingTable.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + renderWithProviders( + wrapWithReactHookForm() + ) + + expect(screen.getByTestId('submit-button')).toBeInTheDocument() + expect(screen.getByDisplayValue('VPC-GW-S')).toBeInTheDocument() + }) + + it('should read SCW NAT_GATEWAY from legacy nested-object shape without type discriminator', () => { + const legacyObjectCluster: Cluster = { + ...mockCluster, + cloud_provider: 'SCW', + region: 'fr-par', + features: [ + { + id: 'NAT_GATEWAY', + value_object: { + value: { + nat_gateway_type: { + type: 'VPC-GW-M', + }, + }, + }, + }, + ], + } as Cluster + + mockUseCluster.mockReturnValue({ + data: legacyObjectCluster, + isLoading: false, + } as unknown as ReturnType) + mockUseClusterRoutingTable.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + renderWithProviders( + wrapWithReactHookForm() + ) + + expect(screen.getByTestId('submit-button')).toBeInTheDocument() + expect(screen.getByDisplayValue('VPC-GW-M')).toBeInTheDocument() + }) + it('should render configured network features for non-Scaleway cluster without existing VPC', () => { const clusterWithFeatures: Cluster = { ...mockCluster, @@ -192,4 +263,180 @@ describe('ClusterNetworkSettings', () => { expect(screen.queryByText('Routes')).not.toBeInTheDocument() }) + + it('should show STATIC_IP as true for GCP when NAT_GATEWAY feature is missing', () => { + const gcpClusterWithoutNatGateway: Cluster = { + ...mockCluster, + cloud_provider: 'GCP', + features: [ + { + id: 'STATIC_IP', + title: 'Static IP / Nat Gateways', + value_object: { + value: true, + }, + }, + ], + } as Cluster + + mockUseCluster.mockReturnValue({ + data: gcpClusterWithoutNatGateway, + isLoading: false, + } as unknown as ReturnType) + mockUseClusterRoutingTable.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + renderWithProviders( + wrapWithReactHookForm() + ) + + // STATIC_IP is filtered from gcpDisplayFeatures so the "Configured network features" block is empty + expect(screen.queryByText('Configured network features')).not.toBeInTheDocument() + expect(screen.getByDisplayValue('true')).toBeInTheDocument() + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + expect(screen.getByDisplayValue('false')).toBeInTheDocument() + expect(screen.getByText(/may trigger a downtime of a few minutes/i)).toBeInTheDocument() + expect(screen.queryByText('Static IP count')).not.toBeInTheDocument() + }) + + it('should keep NAT_GATEWAY visible for GCP when STATIC_IP feature is missing', () => { + const gcpClusterWithoutStaticIp: Cluster = { + ...mockCluster, + cloud_provider: 'GCP', + features: [ + { + id: 'NAT_GATEWAY', + title: 'Static IP / Nat Gateways', + value_object: { + type: 'NAT_GATEWAY', + value: { + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: true, + static_ips_count: 2, + }, + }, + }, + }, + ], + } as Cluster + + mockUseCluster.mockReturnValue({ + data: gcpClusterWithoutStaticIp, + isLoading: false, + } as unknown as ReturnType) + mockUseClusterRoutingTable.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + renderWithProviders( + wrapWithReactHookForm() + ) + + expect(screen.getByText('Configured network features')).toBeInTheDocument() + expect(screen.getByDisplayValue('true')).toBeInTheDocument() + expect(screen.queryByText('Enable static egress IPs')).not.toBeInTheDocument() + }) + + it('should include NAT_GATEWAY in submit payload when absent from cluster but configured by user', async () => { + const gcpClusterWithoutNatGateway: Cluster = { + ...mockCluster, + cloud_provider: 'GCP', + features: [ + { + id: 'STATIC_IP', + title: 'Static IP / Nat Gateways', + value_object: { value: true }, + }, + ], + } as Cluster + + mockUseCluster.mockReturnValue({ + data: gcpClusterWithoutNatGateway, + isLoading: false, + } as unknown as ReturnType) + mockUseClusterRoutingTable.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + const { userEvent } = renderWithProviders( + wrapWithReactHookForm() + ) + + // toggle[0] = STATIC_IP (disabled for GCP), toggle[1] = "Enable static egress IPs" + await userEvent.click(screen.getAllByTestId('input-toggle-button')[1]) + await userEvent.click(screen.getByTestId('submit-button')) + + expect(mockEditClusterMutate).toHaveBeenCalledWith( + expect.objectContaining({ + clusterRequest: expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ id: 'STATIC_IP' }), + expect.objectContaining({ + id: 'NAT_GATEWAY', + value: expect.objectContaining({ + nat_gateway_type: expect.objectContaining({ provider: 'gcp', static_ips_enabled: true }), + }), + }), + ]), + }), + }) + ) + }) + + it('should allow editing GCP static egress settings and save', async () => { + const gcpCluster: Cluster = { + ...mockCluster, + cloud_provider: 'GCP', + features: [ + { + id: 'STATIC_IP', + title: 'Static IP / Nat Gateways', + value_object: { + value: true, + }, + }, + { + id: 'NAT_GATEWAY', + title: 'NAT Gateway', + value_object: { + type: 'NAT_GATEWAY', + value: { + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: true, + static_ips_count: 3, + }, + }, + }, + }, + ], + } as Cluster + + mockUseCluster.mockReturnValue({ + data: gcpCluster, + isLoading: false, + } as unknown as ReturnType) + mockUseClusterRoutingTable.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + const { userEvent } = renderWithProviders( + wrapWithReactHookForm() + ) + + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + expect(screen.getByDisplayValue('3')).toBeInTheDocument() + expect(screen.getByTestId('submit-button')).toBeInTheDocument() + expect(screen.queryByText('Configured network features')).not.toBeInTheDocument() + + await userEvent.click(screen.getByTestId('submit-button')) + + expect(mockEditClusterMutate).toHaveBeenCalled() + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.tsx index e06c22651dc..0a634fe31cc 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-network-settings/cluster-network-settings.tsx @@ -1,13 +1,17 @@ import { type ClusterFeatureAwsExistingVpc, type ClusterFeatureGcpExistingVpc, - type ClusterRequestFeaturesInner, + type ClusterFeatureNatGatewayParameters, + type ClusterFeatureNatGatewayTypeGcp, + type ClusterFeatureNatGatewayTypeScalewayTypeEnum, + type ClusterFeatureResponse, type ClusterRoutingTableResultsInner, } from 'qovery-typescript-axios' import { useEffect } from 'react' import { Controller, type FieldValues, FormProvider, useForm, useFormContext } from 'react-hook-form' import { match } from 'ts-pattern' import { IconEnum } from '@qovery/shared/enums' +import { type ClusterFeatureExtendedValue, type ClusterFeaturesData } from '@qovery/shared/interfaces' import { BlockContent, Button, @@ -23,17 +27,45 @@ import { useModalConfirmation, } from '@qovery/shared/ui' import { ClusterCardFeature } from '../cluster-card-feature/cluster-card-feature' +import { GcpStaticIp } from '../gcp-static-ip/gcp-static-ip' import { useClusterRoutingTable } from '../hooks/use-cluster-routing-table/use-cluster-routing-table' import { useCluster } from '../hooks/use-cluster/use-cluster' import { useEditCluster } from '../hooks/use-edit-cluster/use-edit-cluster' import { useEditRoutingTable } from '../hooks/use-edit-routing-table/use-edit-routing-table' import ScalewayStaticIp from '../scaleway-static-ip/scaleway-static-ip' +import { getGcpNatGatewaySettings } from '../utils/get-gcp-nat-gateway-settings' export interface ClusterNetworkSettingsProps { organizationId: string clusterId: string } +// Fallback used when the cluster has STATIC_IP but no NAT_GATEWAY feature yet, +// so GcpStaticIp can render sub-options in a default state. +const GCP_NAT_GATEWAY_FEATURE_FALLBACK: ClusterFeatureResponse = { + id: 'NAT_GATEWAY', + value_object: { + type: 'NAT_GATEWAY', + value: { + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: false, + static_ips_count: 2, + } as ClusterFeatureNatGatewayTypeGcp, + }, + }, +} + +const buildGcpNatGatewayValue = (settings: { + static_ips_enabled: boolean + static_ips_count: number +}): ClusterFeatureNatGatewayParameters => ({ + nat_gateway_type: { + provider: 'gcp', + ...settings, + } as ClusterFeatureNatGatewayTypeGcp, +}) + const deleteRoutes = (routes: ClusterRoutingTableResultsInner[], destination?: string) => { return [...routes]?.filter((port) => port.destination !== destination) } @@ -337,24 +369,43 @@ export function ClusterNetworkSettings({ organizationId, clusterId }: ClusterNet const { openModalConfirmation } = useModalConfirmation() const isScalewayCluster = cluster?.cloud_provider === 'SCW' + const isGcpCluster = cluster?.cloud_provider === 'GCP' - const methods = useForm({ + const methods = useForm({ mode: 'onChange', }) useEffect(() => { - if (cluster?.features && isScalewayCluster) { - const featuresData: Record = {} + if (cluster?.features && (isScalewayCluster || isGcpCluster)) { + const featuresData: Record = {} cluster.features.forEach((feature) => { if (feature.id) { - if (feature.id === 'NAT_GATEWAY' && feature.value_object?.value) { - const natGatewayValue = feature.value_object.value as unknown as { nat_gateway_type: { type: string } } - const natGatewayType = - natGatewayValue?.nat_gateway_type?.type || - (typeof natGatewayValue === 'string' ? natGatewayValue : undefined) + if (feature.id === 'NAT_GATEWAY' && isScalewayCluster) { + const valueObj = feature.value_object + let scwType: string | undefined + if (valueObj?.type === 'NAT_GATEWAY') { + // New discriminated shape + const natGatewayType = valueObj.value?.nat_gateway_type + scwType = natGatewayType && natGatewayType.provider === 'scaleway' ? natGatewayType.type : undefined + } else if (valueObj?.value) { + // Legacy shapes: plain string or object without type discriminator + const raw = valueObj.value as unknown + if (typeof raw === 'string') { + scwType = raw + } else if (raw && typeof raw === 'object' && 'nat_gateway_type' in raw) { + const legacy = raw as { nat_gateway_type?: { type?: string } } + scwType = legacy.nat_gateway_type?.type + } + } featuresData[feature.id] = { - value: Boolean(natGatewayType), - extendedValue: natGatewayType, + value: Boolean(scwType), + extendedValue: scwType, + } + } else if (feature.id === 'NAT_GATEWAY' && isGcpCluster) { + const gcpNatGatewaySettings = getGcpNatGatewaySettings(feature) + featuresData[feature.id] = { + value: Boolean(gcpNatGatewaySettings), + extendedValue: gcpNatGatewaySettings, } } else { featuresData[feature.id] = { @@ -366,23 +417,23 @@ export function ClusterNetworkSettings({ organizationId, clusterId }: ClusterNet }) methods.reset({ features: featuresData }) } - }, [cluster, isScalewayCluster, methods]) + }, [cluster, isScalewayCluster, isGcpCluster, methods]) const onSubmit = methods.handleSubmit(async (data) => { if (!cluster) return - const staticIpFeature = data['features']?.['STATIC_IP'] + const staticIpFeature = data.features?.STATIC_IP const staticIpEnabled = staticIpFeature?.value === true const features = cluster.features ?.filter((feature) => { - if (feature.id === 'NAT_GATEWAY' && isScalewayCluster && !staticIpEnabled) { + if (feature.id === 'NAT_GATEWAY' && (isScalewayCluster || isGcpCluster) && !staticIpEnabled) { return false } return true }) .map((feature) => { - const formFeature = data['features']?.[feature.id || ''] + const formFeature = data.features?.[feature.id || ''] if (feature.id === 'STATIC_IP' && formFeature) { return { @@ -395,16 +446,14 @@ export function ClusterNetworkSettings({ organizationId, clusterId }: ClusterNet } if (feature.id === 'NAT_GATEWAY' && formFeature && isScalewayCluster) { - if (formFeature.extendedValue) { - return { - ...feature, - value: { - nat_gateway_type: { - provider: 'scaleway', - type: formFeature.extendedValue, - }, - } as unknown as ClusterRequestFeaturesInner['value'], + if (formFeature.extendedValue && typeof formFeature.extendedValue === 'string') { + const natGatewayValue: ClusterFeatureNatGatewayParameters = { + nat_gateway_type: { + provider: 'scaleway', + type: formFeature.extendedValue as ClusterFeatureNatGatewayTypeScalewayTypeEnum, + }, } + return { ...feature, value: natGatewayValue } } return { ...feature, @@ -412,9 +461,31 @@ export function ClusterNetworkSettings({ organizationId, clusterId }: ClusterNet } } + if (feature.id === 'NAT_GATEWAY' && isGcpCluster) { + if (formFeature?.value && formFeature.extendedValue && typeof formFeature.extendedValue === 'object') { + return { ...feature, value: buildGcpNatGatewayValue(formFeature.extendedValue) } + } + return { ...feature, value: null } + } + return feature }) + // GCP: NAT_GATEWAY may be absent from existing cluster features (clusters created before + // this feature was introduced). If the user has configured it in the form, append it to + // the payload so the changes are not silently dropped. + const gcpNatGatewayMissing = + isGcpCluster && staticIpEnabled && !cluster.features?.some((f) => f.id === 'NAT_GATEWAY') + if (gcpNatGatewayMissing && features) { + const natFormFeature = data.features?.NAT_GATEWAY + if (natFormFeature?.extendedValue && typeof natFormFeature.extendedValue === 'object') { + features.push({ + id: 'NAT_GATEWAY', + value: buildGcpNatGatewayValue(natFormFeature.extendedValue), + }) + } + } + try { await editCluster({ organizationId, @@ -430,10 +501,20 @@ export function ClusterNetworkSettings({ organizationId, clusterId }: ClusterNet }) const featureExistingVpc = cluster?.features?.find(({ id }) => id === 'EXISTING_VPC') + const gcpStaticIpFeature = cluster?.features?.find(({ id }) => id === 'STATIC_IP') + const gcpNatGatewayFeature = cluster?.features?.find(({ id }) => id === 'NAT_GATEWAY') const featureExistingVpcValue = featureExistingVpc?.value_object const canEditRoutes = cluster?.cloud_provider === 'AWS' && cluster?.kubernetes === 'MANAGED' && !featureExistingVpcValue - const canEditFeatures = isScalewayCluster + const canEditFeatures = isScalewayCluster || isGcpCluster + const configuredFeatures = cluster?.features?.filter(({ id }) => id !== 'EXISTING_VPC' && id !== 'KARPENTER') ?? [] + const hasGcpStaticIpFeature = isGcpCluster && Boolean(gcpStaticIpFeature) + const gcpDisplayFeatures = configuredFeatures.filter(({ id }) => { + if (id === 'STATIC_IP') return false + if (id === 'NAT_GATEWAY' && hasGcpStaticIpFeature) return false + return true + }) + const displayConfiguredFeatures = isGcpCluster ? gcpDisplayFeatures : configuredFeatures const featureExistingVpcContent = match(featureExistingVpcValue) .with({ type: 'AWS_USER_PROVIDED_NETWORK' }, (f) => ( @@ -582,18 +663,37 @@ export function ClusterNetworkSettings({ organizationId, clusterId }: ClusterNet )} ) : ( - - {cluster?.features - ?.filter(({ id }) => id !== 'EXISTING_VPC' && id !== 'KARPENTER') - .map((feature) => ( - - ))} - + <> + {hasGcpStaticIpFeature && ( + + )} + {displayConfiguredFeatures.length > 0 && ( + + {displayConfiguredFeatures.map((feature) => ( + + ))} + + )} + {canEditFeatures && hasGcpStaticIpFeature && ( +
    + +
    + )} + )} )} diff --git a/libs/domains/clusters/feature/src/lib/gcp-static-ip/gcp-static-ip.spec.tsx b/libs/domains/clusters/feature/src/lib/gcp-static-ip/gcp-static-ip.spec.tsx new file mode 100644 index 00000000000..73680a9ebd9 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/gcp-static-ip/gcp-static-ip.spec.tsx @@ -0,0 +1,215 @@ +import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import { type ClusterFeatureResponse } from 'qovery-typescript-axios' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { GcpStaticIp } from './gcp-static-ip' + +const mockStaticIpFeature: ClusterFeatureResponse = { + id: 'STATIC_IP', + value: true, + value_object: { + value: true, + }, +} + +const mockNatGatewayFeature: ClusterFeatureResponse = { + id: 'NAT_GATEWAY', + value: true, + value_object: { + type: 'NAT_GATEWAY', + value: { + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: true, + static_ips_count: 2, + }, + }, + }, +} + +describe('GcpStaticIp', () => { + it('should hide GCP NAT details when static IP toggle is disabled', () => { + renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: { + features: { + STATIC_IP: { value: false }, + NAT_GATEWAY: { value: false, extendedValue: undefined }, + }, + }, + } + ) + ) + + expect(screen.queryByText('Enable static egress IPs')).not.toBeInTheDocument() + expect(screen.queryByText('Static IP count')).not.toBeInTheDocument() + }) + + it('should show default static IP count when static IP toggle is enabled', () => { + renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: { + features: { + STATIC_IP: { value: true }, + NAT_GATEWAY: { + value: true, + extendedValue: { + static_ips_enabled: true, + static_ips_count: 2, + }, + }, + }, + }, + } + ) + ) + + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + expect(screen.getByDisplayValue('2')).toBeInTheDocument() + }) + + it('should hide static IP count when static egress toggle is disabled', () => { + renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: { + features: { + STATIC_IP: { value: true }, + NAT_GATEWAY: { + value: true, + extendedValue: { + static_ips_enabled: false, + static_ips_count: 2, + }, + }, + }, + }, + } + ) + ) + + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + expect(screen.queryByText('Static IP count')).not.toBeInTheDocument() + }) + + it('should read nested nat_gateway_type settings for existing GCP clusters', () => { + renderWithProviders( + wrapWithReactHookForm( + + ) + ) + + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + expect(screen.getByDisplayValue('2')).toBeInTheDocument() + }) + + it('should disable static ip toggle when staticIpToggleDisabled is true', () => { + renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: { + features: { + STATIC_IP: { value: true }, + NAT_GATEWAY: { + value: true, + extendedValue: { + static_ips_enabled: true, + static_ips_count: 2, + }, + }, + }, + }, + } + ) + ) + + expect(screen.getAllByTestId('input-toggle')[0]).toHaveClass('opacity-50') + expect(screen.getByText('Enable static egress IPs')).toBeInTheDocument() + }) + + it('should not show NAT sub-options when natGatewayFeature is undefined', () => { + renderWithProviders( + wrapWithReactHookForm(, { + defaultValues: { features: { STATIC_IP: { value: true } } }, + }) + ) + + expect(screen.queryByText('Enable static egress IPs')).not.toBeInTheDocument() + }) + + it('should render billing badge button when is_cloud_provider_paying_feature is true', () => { + const paidFeature = { + ...mockStaticIpFeature, + is_cloud_provider_paying_feature: true, + cloud_provider_feature_documentation: 'https://cloud.google.com/nat', + } + + // disabled mode: staticIpEnabled comes from feature.value_object.value, useEffect does not run + renderWithProviders( + wrapWithReactHookForm( + + ) + ) + + // ExternalLink as="button" renders an (role=link); billing badge + documentation link + expect(screen.getAllByRole('link')).toHaveLength(2) + }) + + it('should reset static IP count to default when input is cleared', async () => { + const { userEvent } = renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: { + features: { + STATIC_IP: { value: true }, + NAT_GATEWAY: { value: true, extendedValue: { static_ips_enabled: true, static_ips_count: 5 } }, + }, + }, + } + ) + ) + + const countInput = screen.getByDisplayValue('5') + await userEvent.clear(countInput) + + // clearing triggers NaN path → resets to DEFAULT_STATIC_IPS_COUNT (2) + await waitFor(() => expect(screen.getByDisplayValue('2')).toBeInTheDocument()) + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/gcp-static-ip/gcp-static-ip.tsx b/libs/domains/clusters/feature/src/lib/gcp-static-ip/gcp-static-ip.tsx new file mode 100644 index 00000000000..bfbd48eb9e1 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/gcp-static-ip/gcp-static-ip.tsx @@ -0,0 +1,223 @@ +import { type ClusterFeatureResponse } from 'qovery-typescript-axios' +import { useEffect } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { type ClusterFeatureExtendedValue, type ClusterFeaturesData } from '@qovery/shared/interfaces' +import { BlockContent, Callout, ExternalLink, Icon, InputText, InputToggle, Tooltip } from '@qovery/shared/ui' +import { type GcpNatGatewaySettings, getGcpNatGatewaySettings } from '../utils/get-gcp-nat-gateway-settings' + +const DEFAULT_STATIC_IPS_COUNT = 2 +const GCP_NETWORK_DOCUMENTATION_URL = + 'https://www.qovery.com/docs/configuration/integrations/kubernetes/gke/managed#network' + +const isGcpNatGatewaySettings = (value: unknown): value is GcpNatGatewaySettings => + Boolean( + value && + typeof value === 'object' && + 'static_ips_enabled' in value && + typeof value.static_ips_enabled === 'boolean' && + 'static_ips_count' in value && + typeof value.static_ips_count === 'number' + ) + +const sanitizeStaticIpsCount = (value: number) => Math.max(1, Math.trunc(value)) + +const getStaticIpValueFromFeature = (feature?: ClusterFeatureResponse) => + typeof feature?.value_object?.value === 'boolean' ? feature.value_object.value : undefined + +export interface GcpStaticIpProps { + staticIpFeature?: ClusterFeatureResponse + natGatewayFeature?: ClusterFeatureResponse + disabled?: boolean + staticIpToggleDisabled?: boolean + showDowntimeWarning?: boolean + production: boolean +} + +export function GcpStaticIp({ + staticIpFeature, + natGatewayFeature, + production, + disabled = false, + staticIpToggleDisabled = false, + showDowntimeWarning = false, +}: GcpStaticIpProps) { + const { control, watch, setValue } = useFormContext() + + const isEditable = !disabled + const staticIpValueFromFeature = getStaticIpValueFromFeature(staticIpFeature) + const staticIpEnabled = staticIpFeature?.id + ? watch(`features.${staticIpFeature.id}.value`) ?? staticIpValueFromFeature ?? false + : false + const natGatewayValueFromFeature = getGcpNatGatewaySettings(natGatewayFeature) + const natGatewayEnabled = natGatewayFeature?.id + ? watch(`features.${natGatewayFeature.id}.value`) ?? Boolean(natGatewayValueFromFeature) + : false + const natGatewayExtendedValue = natGatewayFeature?.id + ? (watch(`features.${natGatewayFeature.id}.extendedValue`) as ClusterFeatureExtendedValue | undefined) + : undefined + + const natGatewaySettings = isGcpNatGatewaySettings(natGatewayExtendedValue) + ? natGatewayExtendedValue + : natGatewayValueFromFeature + const staticIpsEnabled = natGatewaySettings?.static_ips_enabled ?? false + const staticIpsCount = natGatewaySettings?.static_ips_count ?? DEFAULT_STATIC_IPS_COUNT + + const setNatGatewaySettings = (settings: GcpNatGatewaySettings) => { + if (!natGatewayFeature?.id) return + setValue(`features.${natGatewayFeature.id}.value`, true, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + setValue(`features.${natGatewayFeature.id}.extendedValue`, settings, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } + + useEffect(() => { + if (!isEditable || !natGatewayFeature?.id || !setValue) return + + if (!staticIpEnabled) { + if (natGatewayEnabled) { + setValue(`features.${natGatewayFeature.id}.value`, false) + } + if (natGatewaySettings) { + setValue(`features.${natGatewayFeature.id}.extendedValue`, undefined) + } + return + } + + if (!natGatewayEnabled) { + setValue(`features.${natGatewayFeature.id}.value`, true) + } + + if (!natGatewaySettings) { + setValue(`features.${natGatewayFeature.id}.value`, true, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + setValue( + `features.${natGatewayFeature.id}.extendedValue`, + { static_ips_enabled: false, static_ips_count: DEFAULT_STATIC_IPS_COUNT }, + { shouldDirty: true, shouldTouch: true, shouldValidate: true } + ) + } + }, [isEditable, natGatewayEnabled, natGatewayFeature?.id, natGatewaySettings, setValue, staticIpEnabled]) + + return ( + +
    +
    + ( +
    +
    + { + field.onChange(value) + if (!natGatewayFeature?.id || !setValue) return + if (!value) { + setValue(`features.${natGatewayFeature.id}.value`, false) + setValue(`features.${natGatewayFeature.id}.extendedValue`, undefined) + return + } + setNatGatewaySettings({ + static_ips_enabled: natGatewaySettings?.static_ips_enabled ?? false, + static_ips_count: natGatewaySettings?.static_ips_count ?? DEFAULT_STATIC_IPS_COUNT, + }) + }} + disabled={disabled || staticIpToggleDisabled} + title="Static IP / Nat Gateways" + description="Your cluster will use NAT Gateways for egress. You can configure static egress IPs below." + align="top" + small + /> +
    + {staticIpFeature?.is_cloud_provider_paying_feature && ( + + + + + + + )} +
    + )} + /> +
    + + {staticIpEnabled && natGatewayFeature && ( +
    + + setNatGatewaySettings({ + static_ips_enabled: value, + static_ips_count: sanitizeStaticIpsCount(staticIpsCount), + }) + } + disabled={disabled} + title="Enable static egress IPs" + description="Allocate static egress IP addresses on the NAT Gateway." + align="top" + small + /> + {staticIpsEnabled && ( + { + const parsedValue = Number.parseInt(event.currentTarget.value, 10) + setNatGatewaySettings({ + static_ips_enabled: staticIpsEnabled, + static_ips_count: Number.isNaN(parsedValue) + ? DEFAULT_STATIC_IPS_COUNT + : sanitizeStaticIpsCount(parsedValue), + }) + }} + /> + )} + {showDowntimeWarning && ( + + + + + + Changing this setting may cause downtime + + Enabling or disabling static egress IPs may trigger a downtime of a few minutes while the NAT + gateway is reconfigured. + + + + )} +
    + )} + + + Documentation link + +
    +
    + ) +} + +export default GcpStaticIp diff --git a/libs/domains/clusters/feature/src/lib/scaleway-static-ip/scaleway-static-ip.tsx b/libs/domains/clusters/feature/src/lib/scaleway-static-ip/scaleway-static-ip.tsx index ffa1591e8f9..e49504e5313 100644 --- a/libs/domains/clusters/feature/src/lib/scaleway-static-ip/scaleway-static-ip.tsx +++ b/libs/domains/clusters/feature/src/lib/scaleway-static-ip/scaleway-static-ip.tsx @@ -41,7 +41,7 @@ export function ScalewayStaticIp({ }, [staticIpEnabled, isEditable, natGatewayFeature?.id, setValue]) return ( - +
    ( -
    - { - field.onChange(value) - // When enabling Static IP, set default NAT Gateway type if not already set - if (value && natGatewayFeature?.id && !natGatewayType && setValue) { - const defaultType = SCALEWAY_NAT_GATEWAY_TYPES[0].value - setValue(`features.${natGatewayFeature.id}.extendedValue`, defaultType) - setValue(`features.${natGatewayFeature.id}.value`, true) - } - }} - disabled={disabled} - title="Static IP / Nat Gateways" - description="Your cluster will only be visible from a fixed number of public IP. On Scaleway, Nat Gateways and Elastic IPs will be setup." - align="top" - small - /> +
    +
    + { + field.onChange(value) + // When enabling Static IP, set default NAT Gateway type if not already set + if (value && natGatewayFeature?.id && !natGatewayType && setValue) { + const defaultType = SCALEWAY_NAT_GATEWAY_TYPES[0].value + setValue(`features.${natGatewayFeature.id}.extendedValue`, defaultType) + setValue(`features.${natGatewayFeature.id}.value`, true) + } + }} + disabled={disabled} + title="Static IP / Nat Gateways" + description="Your cluster will only be visible from a fixed number of public IP. On Scaleway, Nat Gateways and Elastic IPs will be setup." + align="top" + small + /> +
    {staticIpFeature?.is_cloud_provider_paying_feature && ( {natGatewayFeature && ( -
    +
    {isEditable ? ( diff --git a/libs/domains/clusters/feature/src/lib/utils/get-gcp-nat-gateway-settings.spec.ts b/libs/domains/clusters/feature/src/lib/utils/get-gcp-nat-gateway-settings.spec.ts new file mode 100644 index 00000000000..359a44f0966 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/utils/get-gcp-nat-gateway-settings.spec.ts @@ -0,0 +1,57 @@ +import { type ClusterFeatureResponse, ClusterFeatureResponseTypeEnum } from 'qovery-typescript-axios' +import { getGcpNatGatewaySettings } from './get-gcp-nat-gateway-settings' + +const makeFeature = (value: object | null): ClusterFeatureResponse => ({ + id: 'NAT_GATEWAY', + value_object: { + type: ClusterFeatureResponseTypeEnum.NAT_GATEWAY, + value, + }, +}) + +describe('getGcpNatGatewaySettings', () => { + it('should return settings when value has GCP nat_gateway_type shape', () => { + expect( + getGcpNatGatewaySettings( + makeFeature({ + nat_gateway_type: { + provider: 'gcp', + static_ips_enabled: true, + static_ips_count: 3, + }, + }) + ) + ).toEqual({ + static_ips_enabled: true, + static_ips_count: 3, + }) + }) + + it('should return undefined for Scaleway nat_gateway_type shape', () => { + expect( + getGcpNatGatewaySettings( + makeFeature({ + nat_gateway_type: { + provider: 'scaleway', + type: 'VPC-GW-S', + }, + }) + ) + ).toBeUndefined() + }) + + it('should return undefined for null, undefined, or non-NAT_GATEWAY feature', () => { + expect(getGcpNatGatewaySettings(undefined)).toBeUndefined() + expect( + getGcpNatGatewaySettings({ + id: 'STATIC_IP', + value_object: { type: ClusterFeatureResponseTypeEnum.BOOLEAN, value: true }, + }) + ).toBeUndefined() + }) + + it('should return undefined when nat_gateway_type is null or missing', () => { + expect(getGcpNatGatewaySettings(makeFeature(null))).toBeUndefined() + expect(getGcpNatGatewaySettings(makeFeature({ nat_gateway_type: null }))).toBeUndefined() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/utils/get-gcp-nat-gateway-settings.ts b/libs/domains/clusters/feature/src/lib/utils/get-gcp-nat-gateway-settings.ts new file mode 100644 index 00000000000..593bc9bcbfe --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/utils/get-gcp-nat-gateway-settings.ts @@ -0,0 +1,17 @@ +import { type ClusterFeatureNatGatewayTypeGcp, type ClusterFeatureResponse } from 'qovery-typescript-axios' + +export type GcpNatGatewaySettings = { + static_ips_enabled: boolean + static_ips_count: number +} + +export const getGcpNatGatewaySettings = (feature?: ClusterFeatureResponse): GcpNatGatewaySettings | undefined => { + const valueObj = feature?.value_object + if (valueObj?.type !== 'NAT_GATEWAY') return undefined + const natGatewayType = valueObj.value?.nat_gateway_type + if (natGatewayType && natGatewayType.provider === 'gcp') { + const { static_ips_enabled, static_ips_count } = natGatewayType as ClusterFeatureNatGatewayTypeGcp + return { static_ips_enabled, static_ips_count } + } + return undefined +} diff --git a/libs/domains/clusters/feature/src/lib/utils/has-gpu-instance.ts b/libs/domains/clusters/feature/src/lib/utils/has-gpu-instance.ts index 911c30f8fc0..56fdfb7c113 100644 --- a/libs/domains/clusters/feature/src/lib/utils/has-gpu-instance.ts +++ b/libs/domains/clusters/feature/src/lib/utils/has-gpu-instance.ts @@ -3,9 +3,12 @@ import { type Cluster } from 'qovery-typescript-axios' export const hasGpuInstance = (cluster?: Cluster) => { const clusterFeatureKarpenter = cluster?.features?.find((feature) => feature.id === 'KARPENTER') if (!clusterFeatureKarpenter) return false + const karpenterValue = clusterFeatureKarpenter.value_object?.value + return Boolean( - typeof clusterFeatureKarpenter?.value_object?.value === 'object' && - 'qovery_node_pools' in clusterFeatureKarpenter.value_object.value && - clusterFeatureKarpenter.value_object.value.qovery_node_pools.gpu_override + karpenterValue && + typeof karpenterValue === 'object' && + 'qovery_node_pools' in karpenterValue && + karpenterValue.qovery_node_pools.gpu_override ) } diff --git a/libs/shared/interfaces/src/lib/domain/cluster-creation-flow.interface.ts b/libs/shared/interfaces/src/lib/domain/cluster-creation-flow.interface.ts index 686edd943c2..c1ce7f07b56 100644 --- a/libs/shared/interfaces/src/lib/domain/cluster-creation-flow.interface.ts +++ b/libs/shared/interfaces/src/lib/domain/cluster-creation-flow.interface.ts @@ -65,6 +65,13 @@ export type Subnets = { C: string } +export type ClusterFeatureExtendedValue = + | string + | { + static_ips_enabled: boolean + static_ips_count: number + } + export type ClusterFeaturesData = { vpc_mode: 'DEFAULT' | 'EXISTING_VPC' | undefined aws_existing_vpc?: { @@ -89,7 +96,7 @@ export type ClusterFeaturesData = { id: string title: string value: boolean - extendedValue?: string + extendedValue?: ClusterFeatureExtendedValue } } } diff --git a/package.json b/package.json index 5718be9cf69..77531ac916a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@xterm/xterm": "5.5.0", "ansi-to-react": "6.1.6", "autoprefixer": "10.4.13", - "axios": "1.15.0", + "axios": "1.15.2", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -75,7 +75,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.881", + "qovery-typescript-axios": "1.1.887", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index a8c2efddc3e..95cad262a12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6320,7 +6320,7 @@ __metadata: "@xterm/xterm": 5.5.0 ansi-to-react: 6.1.6 autoprefixer: 10.4.13 - axios: 1.15.0 + axios: 1.15.2 babel-jest: 30.0.5 babel-loader: 8.3.0 chance: 1.1.10 @@ -6369,7 +6369,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.881 + qovery-typescript-axios: 1.1.887 qovery-ws-typescript-axios: 0.1.506 react: 18.3.1 react-country-flag: 3.0.2 @@ -12187,14 +12187,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.15.0": - version: 1.15.0 - resolution: "axios@npm:1.15.0" +"axios@npm:1.15.2": + version: 1.15.2 + resolution: "axios@npm:1.15.2" dependencies: follow-redirects: ^1.15.11 form-data: ^4.0.5 proxy-from-env: ^2.1.0 - checksum: 95a8455554867a083ab3772fcadba42a22ec4bb546dccc66011556d837a07e544ae006675a30a5c43453f3e37e7c0982e934cec482c06b75abead2a2c157448a + checksum: e7d208b751959c7c6936417b870d6286979e0dff17784ae230d3988e754b6322682cb136e25669b534072b6f44c08715801ab961227eb8cc075323d9fda8ad43 languageName: node linkType: hard @@ -26327,12 +26327,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.881": - version: 1.1.881 - resolution: "qovery-typescript-axios@npm:1.1.881" +"qovery-typescript-axios@npm:1.1.887": + version: 1.1.887 + resolution: "qovery-typescript-axios@npm:1.1.887" dependencies: - axios: 1.15.0 - checksum: 230ffc8a4d5d18dd650e6f0ce9a71fedd69fa21c38b738905f9969e5f65e7eb2ed5cb490bc01334208b5da7dc7193604eaa3701d6b088bf19c79670684d9d033 + axios: 1.15.2 + checksum: 24d37b51845c92dc35199dc6fca18fad9b011fe63719ba3d819fc76b681042d7b061b1ff6a31e3d6b52a11cb0e3354e06b761d725329c216067127ae3a5b6572 languageName: node linkType: hard