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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libs/domains/service-helm/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
@@ -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> = {}): 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")
})
})
})
Original file line number Diff line number Diff line change
@@ -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}`
}
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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
}>
}
}
Loading