From 84a15589f47c124bc54d3b7fc1008e04da33377c Mon Sep 17 00:00:00 2001 From: Guillaume Dubroeucq Date: Fri, 30 Jan 2026 15:47:19 +0100 Subject: [PATCH] feat(QOV-1549): integrate port validation on helm chart --- .../domains/service-helm/feature/src/index.ts | 3 + .../get-validation-error-message.spec.ts | 83 +++++ .../get-validation-error-message.ts | 21 ++ .../get-validation-reason.spec.ts | 92 +++++ .../get-validation-reason.ts | 40 +++ .../lib/hooks/use-port-validation/index.ts | 5 + .../lib/hooks/use-port-validation/types.ts | 84 +++++ .../use-port-validation.spec.tsx | 329 ++++++++++++++++++ .../use-port-validation.tsx | 90 +++++ .../use-port-validation/validate-port.spec.ts | 164 +++++++++ .../use-port-validation/validate-port.ts | 55 +++ .../networking-setting.spec.tsx.snap | 26 +- .../networking-setting.spec.tsx | 14 + .../networking-setting/networking-setting.tsx | 32 +- .../port-validation-status.spec.tsx.snap | 90 +++++ .../src/lib/port-validation-status/index.ts | 1 + .../port-validation-status.spec.tsx | 84 +++++ .../port-validation-status.tsx | 66 ++++ .../port-validation-warning.spec.tsx.snap | 149 ++++++++ .../src/lib/port-validation-warning/index.ts | 1 + .../port-validation-warning.spec.tsx | 89 +++++ .../port-validation-warning.tsx | 73 ++++ .../page-settings-networking-feature.tsx | 1 + 23 files changed, 1582 insertions(+), 10 deletions(-) create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.spec.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.spec.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/index.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/types.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.spec.tsx create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.tsx create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.spec.ts create mode 100644 libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.ts create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-status/__snapshots__/port-validation-status.spec.tsx.snap create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-status/index.ts create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-status/port-validation-status.spec.tsx create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-status/port-validation-status.tsx create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-warning/__snapshots__/port-validation-warning.spec.tsx.snap create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-warning/index.ts create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-warning/port-validation-warning.spec.tsx create mode 100644 libs/domains/service-helm/feature/src/lib/port-validation-warning/port-validation-warning.tsx diff --git a/libs/domains/service-helm/feature/src/index.ts b/libs/domains/service-helm/feature/src/index.ts index 58af04aec8d..0a43fe3f850 100644 --- a/libs/domains/service-helm/feature/src/index.ts +++ b/libs/domains/service-helm/feature/src/index.ts @@ -9,3 +9,6 @@ export * from './lib/hooks/use-helm-repositories/use-helm-repositories' export * from './lib/hooks/use-create-helm-service/use-create-helm-service' export * from './lib/hooks/use-helm-default-values/use-helm-default-values' export * from './lib/hooks/use-kubernetes-services/use-kubernetes-services' +export * from './lib/hooks/use-port-validation' +export * from './lib/port-validation-status' +export * from './lib/port-validation-warning' diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.spec.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.spec.ts new file mode 100644 index 00000000000..82d61e88d35 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.spec.ts @@ -0,0 +1,83 @@ +import type { HelmPortRequestPortsInner } from 'qovery-typescript-axios' +import { getValidationErrorMessage } from './get-validation-error-message' + +describe('getValidationErrorMessage', () => { + const createPort = (overrides: Partial = {}): HelmPortRequestPortsInner => ({ + name: 'test-port', + service_name: 'nginx', + internal_port: 80, + external_port: 443, + protocol: 'HTTP', + ...overrides, + }) + + describe('when reason is service_not_found', () => { + it('should return message without namespace when namespace is not specified', () => { + const port = createPort({ service_name: 'my-service', namespace: undefined }) + + const result = getValidationErrorMessage(port, 'service_not_found') + + expect(result).toBe("Service 'my-service' not found") + }) + + it('should return message without namespace when namespace is empty string', () => { + const port = createPort({ service_name: 'my-service', namespace: '' }) + + const result = getValidationErrorMessage(port, 'service_not_found') + + expect(result).toBe("Service 'my-service' not found") + }) + + it('should include namespace in message when namespace is specified', () => { + const port = createPort({ service_name: 'my-service', namespace: 'production' }) + + const result = getValidationErrorMessage(port, 'service_not_found') + + expect(result).toBe("Service 'my-service' not found in namespace 'production'") + }) + }) + + describe('when reason is port_not_found', () => { + it('should return message with service name and port number', () => { + const port = createPort({ service_name: 'api', internal_port: 3000 }) + + const result = getValidationErrorMessage(port, 'port_not_found') + + expect(result).toBe("Service 'api' does not expose port 3000") + }) + + it('should not include namespace in port_not_found message', () => { + const port = createPort({ service_name: 'api', internal_port: 8080, namespace: 'default' }) + + const result = getValidationErrorMessage(port, 'port_not_found') + + expect(result).toBe("Service 'api' does not expose port 8080") + }) + }) + + describe('edge cases', () => { + it('should handle special characters in service name', () => { + const port = createPort({ service_name: 'my-service-v2.0', internal_port: 80 }) + + const result = getValidationErrorMessage(port, 'service_not_found') + + expect(result).toBe("Service 'my-service-v2.0' not found") + }) + + it('should handle port 0', () => { + const port = createPort({ service_name: 'nginx', internal_port: 0 }) + + const result = getValidationErrorMessage(port, 'port_not_found') + + expect(result).toBe("Service 'nginx' does not expose port 0") + }) + + it('should handle high port numbers', () => { + const port = createPort({ service_name: 'nginx', internal_port: 65535 }) + + const result = getValidationErrorMessage(port, 'port_not_found') + + expect(result).toBe("Service 'nginx' does not expose port 65535") + }) + }) +}) diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.ts new file mode 100644 index 00000000000..ede00b12160 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-error-message.ts @@ -0,0 +1,21 @@ +import type { HelmPortRequestPortsInner } from 'qovery-typescript-axios' + +/** + * Generates a human-readable error message for a validation failure. + * + * @param port - The port that failed validation + * @param reason - Why validation failed ('service_not_found' | 'port_not_found') + * @returns Human-readable error string + */ +export function getValidationErrorMessage( + port: HelmPortRequestPortsInner, + reason: 'service_not_found' | 'port_not_found' +): string { + if (reason === 'service_not_found') { + return port.namespace + ? `Service '${port.service_name}' not found in namespace '${port.namespace}'` + : `Service '${port.service_name}' not found` + } + + return `Service '${port.service_name}' does not expose port ${port.internal_port}` +} diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.spec.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.spec.ts new file mode 100644 index 00000000000..1fe73b65e24 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.spec.ts @@ -0,0 +1,92 @@ +import { ServiceDeploymentStatusEnum } from 'qovery-typescript-axios' +import { getValidationReason } from './get-validation-reason' + +describe('getValidationReason', () => { + describe('when service has never been deployed', () => { + it('should return SERVICE_NOT_DEPLOYED', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.NEVER_DEPLOYED, 'STOPPED', [], false) + + expect(result).toBe('SERVICE_NOT_DEPLOYED') + }) + + it('should return SERVICE_NOT_DEPLOYED regardless of other states', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.NEVER_DEPLOYED, 'RUNNING', [{}], false) + + expect(result).toBe('SERVICE_NOT_DEPLOYED') + }) + }) + + describe('when service is stopped', () => { + it('should return SERVICE_STOPPED when running state is STOPPED', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'STOPPED', [], false) + + expect(result).toBe('SERVICE_STOPPED') + }) + + it('should return SERVICE_STOPPED when K8s services is empty and not running', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'DEPLOYING', [], false) + + expect(result).toBe('SERVICE_STOPPED') + }) + }) + + describe('when there is an API error', () => { + it('should return API_ERROR when isError is true', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'RUNNING', undefined, true) + + expect(result).toBe('API_ERROR') + }) + + it('should prioritize SERVICE_NOT_DEPLOYED over API_ERROR', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.NEVER_DEPLOYED, 'RUNNING', undefined, true) + + expect(result).toBe('SERVICE_NOT_DEPLOYED') + }) + + it('should prioritize SERVICE_STOPPED over API_ERROR', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'STOPPED', undefined, true) + + expect(result).toBe('SERVICE_STOPPED') + }) + }) + + describe('when validation can proceed', () => { + it('should return null when service is deployed and running with K8s services', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'RUNNING', [{}], false) + + expect(result).toBeNull() + }) + + it('should return null when service is out of date but running', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.OUT_OF_DATE, 'RUNNING', [{}], false) + + expect(result).toBeNull() + }) + + it('should return null when K8s services array is empty but service is running', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'RUNNING', [], false) + + expect(result).toBeNull() + }) + }) + + describe('edge cases', () => { + it('should handle undefined deployment status', () => { + const result = getValidationReason(undefined, 'RUNNING', [{}], false) + + expect(result).toBeNull() + }) + + it('should handle undefined running state', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, undefined, [{}], false) + + expect(result).toBeNull() + }) + + it('should handle undefined K8s services', () => { + const result = getValidationReason(ServiceDeploymentStatusEnum.UP_TO_DATE, 'RUNNING', undefined, false) + + expect(result).toBeNull() + }) + }) +}) diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.ts new file mode 100644 index 00000000000..15081214a9c --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/get-validation-reason.ts @@ -0,0 +1,40 @@ +import { ServiceDeploymentStatusEnum } from 'qovery-typescript-axios' +import type { ValidationReason } from './types' + +/** + * Determines the reason why validation cannot be performed. + * + * @param deploymentStatus - Service deployment status enum + * @param runningState - Service running state string (e.g., 'RUNNING', 'STOPPED') + * @param kubernetesServices - K8s services from API (undefined if error) + * @param isError - Whether the K8s services query errored + * @returns ValidationReason or null if validation can proceed + */ +export function getValidationReason( + deploymentStatus: ServiceDeploymentStatusEnum | undefined, + runningState: string | undefined, + kubernetesServices: unknown[] | undefined, + isError: boolean +): ValidationReason | null { + // Check if service has never been deployed + if (deploymentStatus === ServiceDeploymentStatusEnum.NEVER_DEPLOYED) { + return 'SERVICE_NOT_DEPLOYED' + } + + // Check if service is stopped + if (runningState === 'STOPPED') { + return 'SERVICE_STOPPED' + } + + // Check for API errors + if (isError) { + return 'API_ERROR' + } + + // Check if K8s services returned empty (service might be stopped or errored) + if (kubernetesServices !== undefined && kubernetesServices.length === 0 && runningState !== 'RUNNING') { + return 'SERVICE_STOPPED' + } + + return null +} diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/index.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/index.ts new file mode 100644 index 00000000000..e4eb34064f0 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './validate-port' +export * from './get-validation-reason' +export * from './get-validation-error-message' +export * from './use-port-validation' diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/types.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/types.ts new file mode 100644 index 00000000000..6fcbce95218 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/types.ts @@ -0,0 +1,84 @@ +import type { HelmPortRequestPortsInner } from 'qovery-typescript-axios' + +/** + * Possible validation states for a port + */ +export type PortValidationStatusType = 'valid' | 'invalid' | 'unknown' | 'loading' + +/** + * Reasons why validation cannot be performed + */ +export type ValidationReason = + | 'SERVICE_NOT_DEPLOYED' // Helm service has never been deployed + | 'SERVICE_STOPPED' // Helm service is stopped + | 'API_ERROR' // Failed to fetch K8s services + | 'API_TIMEOUT' // K8s services request timed out + +/** + * Validation result for a single configured port + */ +export interface PortValidationResult { + /** The port name (unique identifier within Helm config) */ + portName: string + + /** Validation status */ + status: PortValidationStatusType + + /** Human-readable error message when status is 'invalid' */ + errorMessage?: string + + /** Original port configuration for reference */ + port: HelmPortRequestPortsInner +} + +/** + * Overall validation context for a Helm service + * Returned by the usePortValidation hook + */ +export interface PortValidationContext { + /** Whether validation can be performed */ + canValidate: boolean + + /** Reason why validation cannot be performed (when canValidate is false) */ + validationReason: ValidationReason | null + + /** Whether validation data is currently loading */ + isLoading: boolean + + /** Validation results for all configured ports */ + results: PortValidationResult[] + + /** Retry function for API errors */ + retry: () => void +} + +/** + * Props for the usePortValidation hook + */ +export interface UsePortValidationProps { + /** Helm service ID */ + helmId: string + + /** Environment ID (for deployment status lookup) */ + environmentId: string + + /** Configured ports to validate */ + ports: HelmPortRequestPortsInner[] +} + +/** + * Kubernetes service structure from API + */ +export interface KubernetesService { + metadata: { + name: string + namespace: string + } + service_spec: { + ports?: Array<{ + port: number + name?: string + protocol?: string + }> + } +} diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.spec.tsx b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.spec.tsx new file mode 100644 index 00000000000..eba3d979759 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.spec.tsx @@ -0,0 +1,329 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { type HelmPortRequestPortsInner, ServiceDeploymentStatusEnum } from 'qovery-typescript-axios' +import { type PropsWithChildren } from 'react' +import { renderHook } from '@qovery/shared/util-tests' +import type { KubernetesService } from './types' +import { usePortValidation } from './use-port-validation' + +// Mock the external hooks +const mockUseDeploymentStatus = jest.fn() +const mockUseRunningStatus = jest.fn() +const mockUseKubernetesServices = jest.fn() + +jest.mock('@qovery/domains/services/feature', () => ({ + useDeploymentStatus: (props: { environmentId?: string; serviceId?: string }) => mockUseDeploymentStatus(props), + useRunningStatus: (props: { environmentId?: string; serviceId?: string }) => mockUseRunningStatus(props), +})) + +jest.mock('../use-kubernetes-services/use-kubernetes-services', () => ({ + useKubernetesServices: (props: { helmId: string }) => mockUseKubernetesServices(props), +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: PropsWithChildren) => {children} +} + +const createPort = (overrides: Partial = {}): HelmPortRequestPortsInner => ({ + name: 'test-port', + service_name: 'nginx', + internal_port: 80, + external_port: 443, + protocol: 'HTTP', + ...overrides, +}) + +const createK8sService = ( + name: string, + namespace: string, + ports: Array<{ port: number; name?: string }> +): KubernetesService => ({ + metadata: { name, namespace }, + service_spec: { ports }, +}) + +describe('usePortValidation', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseDeploymentStatus.mockReturnValue({ + data: { service_deployment_status: ServiceDeploymentStatusEnum.UP_TO_DATE }, + isLoading: false, + }) + mockUseRunningStatus.mockReturnValue({ + data: { state: 'RUNNING' }, + isLoading: false, + }) + mockUseKubernetesServices.mockReturnValue({ + data: [createK8sService('nginx', 'default', [{ port: 80 }])], + isLoading: false, + isError: false, + refetch: jest.fn(), + }) + }) + + describe('when service is deployed and running', () => { + it('should return canValidate as true', () => { + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.canValidate).toBe(true) + expect(result.current.validationReason).toBeNull() + }) + + it('should validate ports against K8s services', () => { + const ports = [ + createPort({ name: 'port-1', service_name: 'nginx', internal_port: 80 }), + createPort({ name: 'port-2', service_name: 'missing', internal_port: 8080 }), + ] + + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports, + }), + { wrapper: createWrapper() } + ) + + expect(result.current.results).toHaveLength(2) + expect(result.current.results[0].status).toBe('valid') + expect(result.current.results[1].status).toBe('invalid') + expect(result.current.results[1].errorMessage).toBe("Service 'missing' not found") + }) + + it('should return empty results when no ports are configured', () => { + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.results).toHaveLength(0) + }) + }) + + describe('when service has never been deployed', () => { + beforeEach(() => { + mockUseDeploymentStatus.mockReturnValue({ + data: { service_deployment_status: ServiceDeploymentStatusEnum.NEVER_DEPLOYED }, + isLoading: false, + }) + }) + + it('should return canValidate as false with SERVICE_NOT_DEPLOYED reason', () => { + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.canValidate).toBe(false) + expect(result.current.validationReason).toBe('SERVICE_NOT_DEPLOYED') + }) + + it('should return unknown status for all ports', () => { + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.results[0].status).toBe('unknown') + }) + }) + + describe('when service is stopped', () => { + beforeEach(() => { + mockUseRunningStatus.mockReturnValue({ + data: { state: 'STOPPED' }, + isLoading: false, + }) + }) + + it('should return canValidate as false with SERVICE_STOPPED reason', () => { + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.canValidate).toBe(false) + expect(result.current.validationReason).toBe('SERVICE_STOPPED') + }) + }) + + describe('when K8s services API fails', () => { + beforeEach(() => { + mockUseKubernetesServices.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch: jest.fn(), + }) + }) + + it('should return canValidate as false with API_ERROR reason', () => { + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.canValidate).toBe(false) + expect(result.current.validationReason).toBe('API_ERROR') + }) + + it('should provide retry function', () => { + const mockRefetch = jest.fn() + mockUseKubernetesServices.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch: mockRefetch, + }) + + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + result.current.retry() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + + describe('loading states', () => { + it('should return isLoading true when deployment status is loading', () => { + mockUseDeploymentStatus.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.isLoading).toBe(true) + }) + + it('should return isLoading true when K8s services are loading', () => { + mockUseKubernetesServices.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: jest.fn(), + }) + + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.isLoading).toBe(true) + }) + + it('should return loading status for all ports when loading', () => { + mockUseKubernetesServices.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: jest.fn(), + }) + + const { result } = renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [createPort()], + }), + { wrapper: createWrapper() } + ) + + expect(result.current.results[0].status).toBe('loading') + }) + }) + + describe('hook parameters', () => { + it('should pass helmId to useKubernetesServices', () => { + renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [], + }), + { wrapper: createWrapper() } + ) + + expect(mockUseKubernetesServices).toHaveBeenCalledWith({ helmId: 'helm-123' }) + }) + + it('should pass environmentId and serviceId to useDeploymentStatus', () => { + renderHook( + () => + usePortValidation({ + helmId: 'helm-123', + environmentId: 'env-456', + ports: [], + }), + { wrapper: createWrapper() } + ) + + expect(mockUseDeploymentStatus).toHaveBeenCalledWith({ + environmentId: 'env-456', + serviceId: 'helm-123', + }) + }) + }) +}) diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.tsx b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.tsx new file mode 100644 index 00000000000..d8eb19d08a4 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/use-port-validation.tsx @@ -0,0 +1,90 @@ +import { useCallback, useMemo } from 'react' +import { useDeploymentStatus, useRunningStatus } from '@qovery/domains/services/feature' +import { useKubernetesServices } from '../use-kubernetes-services/use-kubernetes-services' +import { getValidationReason } from './get-validation-reason' +import type { KubernetesService, PortValidationContext, PortValidationResult, UsePortValidationProps } from './types' +import { validatePort } from './validate-port' + +/** + * Hook for validating Helm port configurations against deployed Kubernetes services. + * + * Combines deployment status, running status, and K8s services to determine + * whether validation can be performed and validates each configured port. + */ +export function usePortValidation({ helmId, environmentId, ports }: UsePortValidationProps): PortValidationContext { + const deploymentStatusQuery = useDeploymentStatus({ + environmentId, + serviceId: helmId, + }) + + const runningStatusQuery = useRunningStatus({ + environmentId, + serviceId: helmId, + }) + + const kubernetesServicesQuery = useKubernetesServices({ helmId }) + + const isLoading = deploymentStatusQuery.isLoading || runningStatusQuery.isLoading || kubernetesServicesQuery.isLoading + + const retry = useCallback(() => { + kubernetesServicesQuery.refetch() + }, [kubernetesServicesQuery]) + + const validationContext = useMemo((): PortValidationContext => { + const deploymentStatus = deploymentStatusQuery.data?.service_deployment_status + const runningState = runningStatusQuery.data?.state + const kubernetesServices = kubernetesServicesQuery.data as KubernetesService[] | undefined + const isError = kubernetesServicesQuery.isError + + // Determine if validation is possible + const validationReason = getValidationReason(deploymentStatus, runningState, kubernetesServices, isError) + + const canValidate = validationReason === null && kubernetesServices !== undefined + + // Generate validation results for each port + const results: PortValidationResult[] = ports.map((port) => { + const portName = port.name ?? `p${port.internal_port}-${port.service_name}` + + // If still loading, return loading status + if (isLoading) { + return { + portName, + status: 'loading', + port, + } + } + + // If validation cannot proceed, return unknown status + if (!canValidate || !kubernetesServices) { + return { + portName, + status: 'unknown', + port, + } + } + + // Perform actual validation + return validatePort(port, kubernetesServices) + }) + + return { + canValidate, + validationReason, + isLoading, + results, + retry, + } + }, [ + deploymentStatusQuery.data?.service_deployment_status, + runningStatusQuery.data?.state, + kubernetesServicesQuery.data, + kubernetesServicesQuery.isError, + ports, + isLoading, + retry, + ]) + + return validationContext +} + +export default usePortValidation diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.spec.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.spec.ts new file mode 100644 index 00000000000..819f98ab4fd --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.spec.ts @@ -0,0 +1,164 @@ +import type { HelmPortRequestPortsInner } from 'qovery-typescript-axios' +import type { KubernetesService } from './types' +import { validatePort } from './validate-port' + +describe('validatePort', () => { + const createPort = (overrides: Partial = {}): HelmPortRequestPortsInner => ({ + name: 'test-port', + service_name: 'nginx', + internal_port: 80, + external_port: 443, + protocol: 'HTTP', + ...overrides, + }) + + const createK8sService = ( + name: string, + namespace: string, + ports: Array<{ port: number; name?: string }> + ): KubernetesService => ({ + metadata: { name, namespace }, + service_spec: { ports }, + }) + + describe('when service exists and port is valid', () => { + it('should return valid status when service and port match', () => { + const port = createPort({ service_name: 'nginx', internal_port: 80 }) + const k8sServices = [createK8sService('nginx', 'default', [{ port: 80 }, { port: 443 }])] + + const result = validatePort(port, k8sServices) + + expect(result).toEqual({ + portName: 'test-port', + status: 'valid', + port, + }) + }) + + it('should match service by name only when namespace is not specified', () => { + const port = createPort({ service_name: 'nginx', internal_port: 8080, namespace: undefined }) + const k8sServices = [createK8sService('nginx', 'some-namespace', [{ port: 8080 }])] + + const result = validatePort(port, k8sServices) + + expect(result.status).toBe('valid') + }) + + it('should match service by name and namespace when both are specified', () => { + const port = createPort({ service_name: 'nginx', internal_port: 80, namespace: 'production' }) + const k8sServices = [ + createK8sService('nginx', 'default', [{ port: 80 }]), + createK8sService('nginx', 'production', [{ port: 80 }]), + ] + + const result = validatePort(port, k8sServices) + + expect(result.status).toBe('valid') + }) + }) + + describe('when service is not found', () => { + it('should return invalid status with error message', () => { + const port = createPort({ service_name: 'missing-service', internal_port: 80 }) + const k8sServices = [createK8sService('nginx', 'default', [{ port: 80 }])] + + const result = validatePort(port, k8sServices) + + expect(result).toEqual({ + portName: 'test-port', + status: 'invalid', + errorMessage: "Service 'missing-service' not found", + port, + }) + }) + + it('should include namespace in error message when specified', () => { + const port = createPort({ service_name: 'nginx', internal_port: 80, namespace: 'wrong-namespace' }) + const k8sServices = [createK8sService('nginx', 'default', [{ port: 80 }])] + + const result = validatePort(port, k8sServices) + + expect(result).toEqual({ + portName: 'test-port', + status: 'invalid', + errorMessage: "Service 'nginx' not found in namespace 'wrong-namespace'", + port, + }) + }) + + it('should return invalid when no k8s services exist', () => { + const port = createPort({ service_name: 'nginx', internal_port: 80 }) + const k8sServices: KubernetesService[] = [] + + const result = validatePort(port, k8sServices) + + expect(result.status).toBe('invalid') + expect(result.errorMessage).toBe("Service 'nginx' not found") + }) + }) + + describe('when port is not found on service', () => { + it('should return invalid status with error message', () => { + const port = createPort({ service_name: 'nginx', internal_port: 9999 }) + const k8sServices = [createK8sService('nginx', 'default', [{ port: 80 }, { port: 443 }])] + + const result = validatePort(port, k8sServices) + + expect(result).toEqual({ + portName: 'test-port', + status: 'invalid', + errorMessage: "Service 'nginx' does not expose port 9999", + port, + }) + }) + + it('should return invalid when service has no ports defined', () => { + const port = createPort({ service_name: 'nginx', internal_port: 80 }) + const k8sServices: KubernetesService[] = [ + { + metadata: { name: 'nginx', namespace: 'default' }, + service_spec: { ports: undefined }, + }, + ] + + const result = validatePort(port, k8sServices) + + expect(result.status).toBe('invalid') + expect(result.errorMessage).toBe("Service 'nginx' does not expose port 80") + }) + + it('should return invalid when service has empty ports array', () => { + const port = createPort({ service_name: 'nginx', internal_port: 80 }) + const k8sServices = [createK8sService('nginx', 'default', [])] + + const result = validatePort(port, k8sServices) + + expect(result.status).toBe('invalid') + expect(result.errorMessage).toBe("Service 'nginx' does not expose port 80") + }) + }) + + describe('edge cases', () => { + it('should validate multiple services and find the correct one', () => { + const port = createPort({ service_name: 'api', internal_port: 3000 }) + const k8sServices = [ + createK8sService('frontend', 'default', [{ port: 80 }]), + createK8sService('api', 'default', [{ port: 3000 }]), + createK8sService('database', 'default', [{ port: 5432 }]), + ] + + const result = validatePort(port, k8sServices) + + expect(result.status).toBe('valid') + }) + + it('should preserve the original port object in the result', () => { + const port = createPort() + const k8sServices = [createK8sService('nginx', 'default', [{ port: 80 }])] + + const result = validatePort(port, k8sServices) + + expect(result.port).toBe(port) + }) + }) +}) diff --git a/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.ts b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.ts new file mode 100644 index 00000000000..e408883a303 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/hooks/use-port-validation/validate-port.ts @@ -0,0 +1,55 @@ +import type { HelmPortRequestPortsInner } from 'qovery-typescript-axios' +import { getValidationErrorMessage } from './get-validation-error-message' +import type { KubernetesService, PortValidationResult } from './types' + +/** + * Validates a single port against the list of available Kubernetes services. + * + * Validation rules: + * 1. Service name must match a deployed K8s service + * 2. Internal port must be exposed by that service + * 3. If namespace is specified, it must match the service's namespace + * + * @param port - The configured port to validate + * @param kubernetesServices - Available K8s services from the API + * @returns PortValidationResult with status and optional error message + */ +export function validatePort( + port: HelmPortRequestPortsInner, + kubernetesServices: KubernetesService[] +): PortValidationResult { + // Find matching service by name (and namespace if specified) + const matchingService = kubernetesServices.find( + (service) => + service.metadata.name === port.service_name && (!port.namespace || service.metadata.namespace === port.namespace) + ) + + const portName = port.name ?? `p${port.internal_port}-${port.service_name}` + + if (!matchingService) { + return { + portName, + status: 'invalid', + errorMessage: getValidationErrorMessage(port, 'service_not_found'), + port, + } + } + + // Check if service has the specified port + const hasPort = matchingService.service_spec.ports?.some((p) => p.port === port.internal_port) + + if (!hasPort) { + return { + portName, + status: 'invalid', + errorMessage: getValidationErrorMessage(port, 'port_not_found'), + port, + } + } + + return { + portName, + status: 'valid', + port, + } +} diff --git a/libs/domains/service-helm/feature/src/lib/networking-setting/__snapshots__/networking-setting.spec.tsx.snap b/libs/domains/service-helm/feature/src/lib/networking-setting/__snapshots__/networking-setting.spec.tsx.snap index 275d4a13b67..2a7b8a0c58e 100644 --- a/libs/domains/service-helm/feature/src/lib/networking-setting/__snapshots__/networking-setting.spec.tsx.snap +++ b/libs/domains/service-helm/feature/src/lib/networking-setting/__snapshots__/networking-setting.spec.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`NetworkingSetting should match snapshot in empty state 1`] = ` @@ -155,11 +155,15 @@ exports[`NetworkingSetting should match snapshot with ports 1`] = `
- - My service - + + My service + +
@@ -208,11 +212,15 @@ exports[`NetworkingSetting should match snapshot with ports 1`] = `
- - My service 2 - + + My service 2 + +
diff --git a/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.spec.tsx b/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.spec.tsx index 7b565bda05a..8a1a9d59d6a 100644 --- a/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.spec.tsx +++ b/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.spec.tsx @@ -10,6 +10,20 @@ jest.mock('../hooks/use-kubernetes-services/use-kubernetes-services', () => ({ }), })) +jest.mock('../hooks/use-port-validation', () => ({ + usePortValidation: () => ({ + canValidate: false, + validationReason: null, + isLoading: false, + results: [], + retry: jest.fn(), + }), +})) + +jest.mock('../port-validation-warning/port-validation-warning', () => ({ + PortValidationWarning: () => null, +})) + describe('NetworkingSetting', () => { it('should match snapshot in empty state', () => { const { baseElement } = renderWithProviders() diff --git a/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.tsx b/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.tsx index 49ba435c73a..2fc166a2659 100644 --- a/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.tsx +++ b/libs/domains/service-helm/feature/src/lib/networking-setting/networking-setting.tsx @@ -16,10 +16,14 @@ import { useModalMultiConfirmation, } from '@qovery/shared/ui' import { isTryingToRemoveLastPublicPort } from '@qovery/shared/util-services' +import { usePortValidation } from '../hooks/use-port-validation' import { NetworkingPortSettingModal } from '../networking-port-setting-modal/networking-port-setting-modal' +import { PortValidationStatus } from '../port-validation-status/port-validation-status' +import { PortValidationWarning } from '../port-validation-warning/port-validation-warning' export interface NetworkingSettingProps extends PropsWithChildren { helmId: string + environmentId?: string ports: HelmPortRequestPortsInner[] onUpdatePorts: (ports: HelmPortRequestPortsInner[]) => void isSetting?: boolean @@ -27,6 +31,7 @@ export interface NetworkingSettingProps extends PropsWithChildren { export function NetworkingSetting({ helmId, + environmentId, ports, onUpdatePorts, isSetting = false, @@ -41,6 +46,18 @@ export function NetworkingSetting({ serviceType: 'HELM', }) + // Port validation hook - only enabled when environmentId is provided + const { + canValidate, + validationReason, + results: validationResults, + retry, + } = usePortValidation({ + helmId, + environmentId: environmentId ?? '', + ports, + }) + const onAddPort = () => openModal({ content: ( @@ -147,17 +164,30 @@ export function NetworkingSetting({
)}
+ {environmentId && !canValidate && validationReason && ( + + )} {ports.length > 0 ? ( ports.map((port) => { const { service_name, internal_port, protocol, namespace, name } = port + const validation = validationResults.find((r) => r.portName === name) return (
- {service_name} +
+ {service_name} + {environmentId && validation && ( + + )} +
Service port: {internal_port} Protocol: {protocol} diff --git a/libs/domains/service-helm/feature/src/lib/port-validation-status/__snapshots__/port-validation-status.spec.tsx.snap b/libs/domains/service-helm/feature/src/lib/port-validation-status/__snapshots__/port-validation-status.spec.tsx.snap new file mode 100644 index 00000000000..66858a367a1 --- /dev/null +++ b/libs/domains/service-helm/feature/src/lib/port-validation-status/__snapshots__/port-validation-status.spec.tsx.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`PortValidationStatus invalid status should match snapshot with error message 1`] = ` + +
+ + +