From 1461846c983674f7875d0d5d6e891d750f923edf Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Tue, 7 Apr 2026 10:49:49 +0200 Subject: [PATCH 1/2] feat(eksanywhere): add cloud credential for eks anywhere --- .../src/lib/cluster-avatar/cluster-avatar.tsx | 8 +- .../cluster-credentials-modal.spec.tsx | 57 ++++ .../cluster-credentials-modal.tsx | 300 ++++++++++++++---- ...-organization-credentials-feature.spec.tsx | 51 +++ .../page-organization-credentials-feature.tsx | 115 +++++-- .../src/lib/assets/devicon/eks-anywhere.svg | 18 ++ package.json | 2 +- yarn.lock | 10 +- 8 files changed, 467 insertions(+), 94 deletions(-) create mode 100644 libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx index 820d9f5c522..1f2865d4024 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx @@ -6,14 +6,20 @@ import { Avatar, Icon } from '@qovery/shared/ui' export interface ClusterAvatarProps extends Omit, 'fallback'> { cloudProvider?: CloudProviderEnum cluster?: Cluster + customIconSrc?: string + customIconAlt?: string } export const ClusterAvatar = forwardRef, ClusterAvatarProps>(function ClusterAvatar( - { cluster, cloudProvider, ...props }, + { cluster, cloudProvider, customIconSrc, customIconAlt = 'Custom cloud provider', ...props }, ref ) { const localCloudProvider = cloudProvider ?? cluster?.cloud_provider const fallback = match({ cluster, localCloudProvider }) + .when( + () => Boolean(customIconSrc), + () => {customIconAlt} + ) .with({ cluster: { is_demo: true } }, () => (
diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx index 5766797513f..7685b219b36 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx @@ -1,4 +1,5 @@ import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import { useFeatureFlagEnabled } from 'posthog-js/react' import { CloudProviderEnum } from 'qovery-typescript-axios' import * as useCreateCloudProviderCredentialHook from '@qovery/domains/cloud-providers/feature' import * as useEditCloudProviderCredentialHook from '@qovery/domains/cloud-providers/feature' @@ -13,6 +14,10 @@ jest.mock('@qovery/domains/cloud-providers/feature', () => ({ useDeleteCloudProviderCredential: jest.fn(), })) +jest.mock('posthog-js/react', () => ({ + useFeatureFlagEnabled: jest.fn(() => true), +})) + jest.mock('../hooks/use-cluster-cloud-provider-info/use-cluster-cloud-provider-info', () => ({ useClusterCloudProviderInfo: jest.fn(), })) @@ -22,6 +27,7 @@ let props: ClusterCredentialsModalProps const mockCreateCredential = jest.fn() const mockEditCredential = jest.fn() const mockDeleteCredential = jest.fn() +const mockUseFeatureFlagEnabled = useFeatureFlagEnabled as jest.Mock describe('ClusterCredentialsModal', () => { beforeEach(() => { @@ -32,6 +38,8 @@ describe('ClusterCredentialsModal', () => { cloudProvider: CloudProviderEnum.AWS, } + mockUseFeatureFlagEnabled.mockReturnValue(true) + jest.spyOn(useCreateCloudProviderCredentialHook, 'useCreateCloudProviderCredential').mockReturnValue({ mutateAsync: mockCreateCredential, isLoading: false, @@ -100,11 +108,60 @@ describe('ClusterCredentialsModal', () => { cloudProvider: 'AWS', payload: { name: 'test-cred', + type: 'AWS_ROLE', role_arn: 'arn:aws:iam::123456789012:role/test-role', }, }) }) + it('should format EKS Anywhere vSphere role credentials correctly with handleSubmit', () => { + const data = { + name: 'test-cred', + type: 'EKS_ANYWHERE_VSPHERE_ROLE', + role_arn: 'arn:aws:iam::123456789012:role/test-role', + vsphere_user: 'administrator@vsphere.local', + vsphere_password: 'super-secret', + } + + const result = handleSubmit(data, CloudProviderEnum.AWS) + + expect(result).toEqual({ + cloudProvider: 'AWS', + payload: { + name: 'test-cred', + type: 'EKS_ANYWHERE_VSPHERE_ROLE', + role_arn: 'arn:aws:iam::123456789012:role/test-role', + vsphere_user: 'administrator@vsphere.local', + vsphere_password: 'super-secret', + }, + }) + }) + + it('should format EKS Anywhere vSphere static credentials correctly with handleSubmit', () => { + const data = { + name: 'test-cred', + type: 'EKS_ANYWHERE_VSPHERE_STATIC', + access_key_id: 'AKIA_TEST', + secret_access_key: 'secret', + vsphere_user: 'administrator@vsphere.local', + vsphere_password: 'super-secret', + } + + const result = handleSubmit(data, CloudProviderEnum.AWS) + + expect(result).toEqual({ + cloudProvider: 'AWS', + payload: { + name: 'test-cred', + type: 'EKS_ANYWHERE_VSPHERE_STATIC', + access_key_id: 'AKIA_TEST', + secret_access_key: 'secret', + vsphere_user: 'administrator@vsphere.local', + vsphere_password: 'super-secret', + }, + }) + }) + describe('Edit mode', () => { beforeEach(() => { props.credential = { diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx index 3ec9e3c4b74..d50f0cc1f81 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx @@ -1,5 +1,6 @@ +import { useFeatureFlagEnabled } from 'posthog-js/react' import { type CloudProviderEnum, type ClusterCredentials } from 'qovery-typescript-axios' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useDropzone } from 'react-dropzone' import { Controller, type FieldValues, FormProvider, useForm } from 'react-hook-form' import { P, match } from 'ts-pattern' @@ -25,10 +26,12 @@ import { CopyButton } from '@qovery/shared/ui' import { useClusterCloudProviderInfo } from '../hooks/use-cluster-cloud-provider-info/use-cluster-cloud-provider-info' type ClusterCredentialsFormValues = { - type: 'STS' | 'STATIC' + type: 'STS' | 'STATIC' | 'EKS_ANYWHERE_VSPHERE_ROLE' | 'EKS_ANYWHERE_VSPHERE_STATIC' name: string access_key_id?: string secret_access_key?: string + vsphere_user?: string + vsphere_password?: string scaleway_organization_id?: string scaleway_project_id?: string scaleway_access_key?: string @@ -42,12 +45,21 @@ type ClusterCredentialsFormValues = { id?: string } +export type ClusterCredentialAuthType = ClusterCredentialsFormValues['type'] +const AWS_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS', 'STATIC'] +const EKS_ANYWHERE_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = [ + 'EKS_ANYWHERE_VSPHERE_ROLE', + 'EKS_ANYWHERE_VSPHERE_STATIC', +] + export interface ClusterCredentialsModalProps { organizationId: string clusterId?: string onClose: (response?: ClusterCredentials) => void credential?: ClusterCredentials cloudProvider?: CloudProviderEnum + defaultCredentialType?: ClusterCredentialAuthType + allowedCredentialTypes?: ClusterCredentialAuthType[] } export const handleSubmit = (data: FieldValues, cloudProvider: CloudProviderEnum) => { @@ -57,24 +69,54 @@ export const handleSubmit = (data: FieldValues, cloudProvider: CloudProviderEnum return match(cloudProvider) .with('AWS', (cp) => { - if (data['type'] === 'STS') { - return { + return match(data['type']) + .with('STS', () => ({ cloudProvider: cp, payload: { ...currentData, + type: 'AWS_ROLE' as const, role_arn: data['role_arn'], }, - } - } - - return { - cloudProvider: cp, - payload: { - ...currentData, - access_key_id: data['access_key_id'], - secret_access_key: data['secret_access_key'], - }, - } + })) + .with('STATIC', () => ({ + cloudProvider: cp, + payload: { + ...currentData, + type: 'AWS_STATIC' as const, + access_key_id: data['access_key_id'], + secret_access_key: data['secret_access_key'], + }, + })) + .with('EKS_ANYWHERE_VSPHERE_ROLE', () => ({ + cloudProvider: cp, + payload: { + ...currentData, + type: 'EKS_ANYWHERE_VSPHERE_ROLE' as const, + role_arn: data['role_arn'], + vsphere_user: data['vsphere_user'], + vsphere_password: data['vsphere_password'], + }, + })) + .with('EKS_ANYWHERE_VSPHERE_STATIC', () => ({ + cloudProvider: cp, + payload: { + ...currentData, + type: 'EKS_ANYWHERE_VSPHERE_STATIC' as const, + access_key_id: data['access_key_id'], + secret_access_key: data['secret_access_key'], + vsphere_user: data['vsphere_user'], + vsphere_password: data['vsphere_password'], + }, + })) + .otherwise(() => ({ + cloudProvider: cp, + payload: { + ...currentData, + type: 'AWS_STATIC' as const, + access_key_id: data['access_key_id'], + secret_access_key: data['secret_access_key'], + }, + })) }) .with('SCW', (cp) => ({ cloudProvider: cp, @@ -148,7 +190,10 @@ export function ClusterCredentialsModal({ onClose, credential, cloudProvider = 'AWS', + defaultCredentialType, + allowedCredentialTypes, }: ClusterCredentialsModalProps) { + const isEksAnywhereEnabled = Boolean(useFeatureFlagEnabled('eks-anywhere')) const { enableAlertClickOutside } = useModal() const { openModalConfirmation } = useModalConfirmation() @@ -188,43 +233,76 @@ export function ClusterCredentialsModal({ }) const isEdit = !!credential + const inferredCredentialTypes = useMemo( + () => + match(credential?.object_type) + .with('EKS_ANYWHERE_VSPHERE', () => EKS_ANYWHERE_CREDENTIAL_TYPES) + .with('AWS', 'AWS_ROLE', () => AWS_CREDENTIAL_TYPES) + .otherwise(() => AWS_CREDENTIAL_TYPES), + [credential?.object_type] + ) + const awsAuthTypeOptions = useMemo( + () => + (allowedCredentialTypes && allowedCredentialTypes.length > 0 + ? allowedCredentialTypes + : isEdit + ? inferredCredentialTypes + : AWS_CREDENTIAL_TYPES + ).filter((type) => + type === 'EKS_ANYWHERE_VSPHERE_ROLE' || type === 'EKS_ANYWHERE_VSPHERE_STATIC' ? isEksAnywhereEnabled : true + ), + [allowedCredentialTypes, inferredCredentialTypes, isEdit, isEksAnywhereEnabled] + ) + + const defaultType: ClusterCredentialsFormValues['type'] = + credential?.object_type === 'EKS_ANYWHERE_VSPHERE' + ? credential.role_arn + ? 'EKS_ANYWHERE_VSPHERE_ROLE' + : 'EKS_ANYWHERE_VSPHERE_STATIC' + : !isEdit && defaultCredentialType + ? defaultCredentialType + : credential?.object_type === 'AWS_ROLE' || (!isEdit && cloudProviderLocal === 'AWS') + ? 'STS' + : 'STATIC' + const initialType = awsAuthTypeOptions.includes(defaultType) ? defaultType : awsAuthTypeOptions[0] ?? 'STS' const methods = useForm({ mode: 'onChange', - defaultValues: - credential?.object_type === 'AWS_ROLE' || (!isEdit && cloudProviderLocal === 'AWS') - ? { - type: 'STS', - name: credential?.name || '', - role_arn: credential?.role_arn || '', - } - : { - type: 'STATIC', - name: credential?.name || '', - access_key_id: match(credential) - .with({ access_key_id: P.string }, (c) => c.access_key_id) - .otherwise(() => undefined), - scaleway_organization_id: match(credential) - .with({ scaleway_organization_id: P.string }, (c) => c.scaleway_organization_id) - .otherwise(() => undefined), - scaleway_project_id: match(credential) - .with({ scaleway_project_id: P.string }, (c) => c.scaleway_project_id) - .otherwise(() => undefined), - scaleway_access_key: match(credential) - .with({ scaleway_access_key: P.string }, (c) => c.scaleway_access_key) - .otherwise(() => undefined), - scaleway_secret_key: undefined, - gcp_credentials: undefined, - azure_tenant_id: match(credential) - .with({ azure_tenant_id: P.string }, (c) => c.azure_tenant_id) - .otherwise(() => undefined), - azure_subscription_id: match(credential) - .with({ azure_subscription_id: P.string }, (c) => c.azure_subscription_id) - .otherwise(() => undefined), - azure_application_id: match(credential) - .with({ azure_application_id: P.string }, (c) => c.azure_application_id) - .otherwise(() => undefined), - }, + defaultValues: { + type: initialType, + name: credential?.name || '', + role_arn: match(credential) + .with({ role_arn: P.string }, (c) => c.role_arn) + .otherwise(() => undefined), + access_key_id: match(credential) + .with({ access_key_id: P.string }, (c) => c.access_key_id) + .otherwise(() => undefined), + vsphere_user: match(credential) + .with({ vsphere_user: P.string }, (c) => c.vsphere_user) + .otherwise(() => undefined), + vsphere_password: undefined, + secret_access_key: undefined, + scaleway_organization_id: match(credential) + .with({ scaleway_organization_id: P.string }, (c) => c.scaleway_organization_id) + .otherwise(() => undefined), + scaleway_project_id: match(credential) + .with({ scaleway_project_id: P.string }, (c) => c.scaleway_project_id) + .otherwise(() => undefined), + scaleway_access_key: match(credential) + .with({ scaleway_access_key: P.string }, (c) => c.scaleway_access_key) + .otherwise(() => undefined), + scaleway_secret_key: undefined, + gcp_credentials: undefined, + azure_tenant_id: match(credential) + .with({ azure_tenant_id: P.string }, (c) => c.azure_tenant_id) + .otherwise(() => undefined), + azure_subscription_id: match(credential) + .with({ azure_subscription_id: P.string }, (c) => c.azure_subscription_id) + .otherwise(() => undefined), + azure_application_id: match(credential) + .with({ azure_application_id: P.string }, (c) => c.azure_application_id) + .otherwise(() => undefined), + }, }) const isEditDirty = isEdit && methods.formState.isDirty @@ -323,6 +401,10 @@ export function ClusterCredentialsModal({ const watchType = methods.watch('type') const watchAzureApplicationId = methods.watch('azure_application_id') const watchAzureSubscriptionId = methods.watch('azure_subscription_id') + const isAwsStsCredential = watchType === 'STS' + const isAwsStaticCredential = watchType === 'STATIC' + const isEksAnywhereRoleCredential = watchType === 'EKS_ANYWHERE_VSPHERE_ROLE' + const isEksAnywhereStaticCredential = watchType === 'EKS_ANYWHERE_VSPHERE_STATIC' const submitLabel = isEdit ? 'Confirm' @@ -350,7 +432,7 @@ export function ClusterCredentialsModal({ description={ Follow these steps and give Qovery access to your {cloudProviderLocal} account. - {((watchType === 'STS' && cloudProviderLocal === 'AWS') || cloudProviderLocal === 'GCP') && ( + {((isAwsStsCredential && cloudProviderLocal === 'AWS') || cloudProviderLocal === 'GCP') && ( 'https://www.qovery.com/docs/getting-started/installation/aws#create-your-cluster') @@ -388,15 +470,25 @@ export function ClusterCredentialsModal({ value={field.value} label="Authentication type" error={error?.message} - options={[ - { label: 'Assume role via STS (preferred)', value: 'STS' }, - { label: 'Static credentials', value: 'STATIC' }, - ]} + options={awsAuthTypeOptions.map((type) => + match(type) + .with('STS', () => ({ label: 'Assume role via STS (preferred)', value: 'STS' })) + .with('STATIC', () => ({ label: 'Static credentials', value: 'STATIC' })) + .with('EKS_ANYWHERE_VSPHERE_ROLE', () => ({ + label: 'EKS Anywhere on vSphere role (preferred)', + value: 'EKS_ANYWHERE_VSPHERE_ROLE', + })) + .with('EKS_ANYWHERE_VSPHERE_STATIC', () => ({ + label: 'EKS Anywhere on vSphere static', + value: 'EKS_ANYWHERE_VSPHERE_STATIC', + })) + .exhaustive() + )} /> )} /> )} - {watchType === 'STATIC' && ( + {(isAwsStaticCredential || isEksAnywhereStaticCredential) && ( <> {cloudProviderLocal === 'AWS' && (
@@ -453,7 +545,7 @@ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role qovery-service-account" )} )} - {watchType === 'STS' ? ( + {isAwsStsCredential || isEksAnywhereRoleCredential ? (

1. Connect to your AWS Console

@@ -512,6 +604,53 @@ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role qovery-service-account" /> )} /> + {isEksAnywhereRoleCredential && ( + <> + ( + + )} + /> + {isEditDirty && ( + <> +
+ Confirm your vSphere password + + )} + {(!isEdit || isEditDirty) && ( + ( + + )} + /> + )} + + )}
@@ -541,7 +680,7 @@ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role qovery-service-account" /> )} /> - {cloudProviderLocal === 'AWS' && ( + {(isAwsStaticCredential || isEksAnywhereStaticCredential) && cloudProviderLocal === 'AWS' && ( <> )} + {isEksAnywhereStaticCredential && ( + <> + ( + + )} + /> + {isEditDirty && ( + <> +
+ Confirm your vSphere password + + )} + {(!isEdit || isEditDirty) && ( + ( + + )} + /> + )} + + )} )} {cloudProviderLocal === 'SCW' && ( diff --git a/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.spec.tsx index 200fac30f33..9f72f20a016 100644 --- a/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.spec.tsx +++ b/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.spec.tsx @@ -1,8 +1,15 @@ +import { useFeatureFlagEnabled } from 'posthog-js/react' import { type OrganizationCrendentialsResponseListResultsInner } from 'qovery-typescript-axios' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { PageOrganizationCredentialsFeature } from './page-organization-credentials-feature' let mockCredentials: OrganizationCrendentialsResponseListResultsInner[] = [] +const mockUseFeatureFlagEnabled = useFeatureFlagEnabled as jest.Mock + +jest.mock('posthog-js/react', () => ({ + useFeatureFlagEnabled: jest.fn(() => false), +})) + jest.mock('@qovery/domains/organizations/feature', () => { return { ...jest.requireActual('@qovery/domains/organizations/feature'), @@ -14,6 +21,11 @@ jest.mock('@qovery/domains/organizations/feature', () => { }) describe('PageOrganizationCredentialsFeature', () => { + beforeEach(() => { + mockCredentials = [] + mockUseFeatureFlagEnabled.mockReturnValue(false) + }) + it('should render', () => { const { baseElement } = renderWithProviders() expect(baseElement).toBeTruthy() @@ -130,5 +142,44 @@ describe('PageOrganizationCredentialsFeature', () => { expect(viewButton).toBeInTheDocument() }) + + it('should hide EKS Anywhere vSphere credentials when feature flag is disabled', () => { + mockCredentials = [ + { + credential: { + id: '1', + name: 'Credential EKS Anywhere', + object_type: 'EKS_ANYWHERE_VSPHERE', + vsphere_user: 'administrator@vsphere.local', + role_arn: 'arn:aws:iam::123456789012:role/test-role', + }, + clusters: [], + }, + ] + + renderWithProviders() + + expect(screen.queryByText('Credential EKS Anywhere')).not.toBeInTheDocument() + }) + + it('should display EKS Anywhere vSphere credentials when feature flag is enabled', () => { + mockUseFeatureFlagEnabled.mockReturnValue(true) + mockCredentials = [ + { + credential: { + id: '1', + name: 'Credential EKS Anywhere', + object_type: 'EKS_ANYWHERE_VSPHERE', + vsphere_user: 'administrator@vsphere.local', + role_arn: 'arn:aws:iam::123456789012:role/test-role', + }, + clusters: [], + }, + ] + + renderWithProviders() + + expect(screen.getByText('Credential EKS Anywhere')).toBeInTheDocument() + }) }) }) diff --git a/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.tsx index 6518ace7e9e..618a16b17d4 100644 --- a/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.tsx +++ b/libs/pages/settings/src/lib/feature/page-organization-credentials-feature/page-organization-credentials-feature.tsx @@ -1,10 +1,16 @@ import { useQueryClient } from '@tanstack/react-query' +import { useFeatureFlagEnabled } from 'posthog-js/react' import { CloudProviderEnum, type ClusterCredentials, type CredentialCluster } from 'qovery-typescript-axios' -import { Suspense, useMemo, useState } from 'react' +import { type ReactElement, Suspense, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' import { match } from 'ts-pattern' import { useDeleteCloudProviderCredential } from '@qovery/domains/cloud-providers/feature' -import { ClusterAvatar, ClusterCredentialsModal, CredentialsListClustersModal } from '@qovery/domains/clusters/feature' +import { + ClusterAvatar, + type ClusterCredentialAuthType, + ClusterCredentialsModal, + CredentialsListClustersModal, +} from '@qovery/domains/clusters/feature' import { useOrganizationCredentials } from '@qovery/domains/organizations/feature' import { NeedHelp } from '@qovery/shared/assistant/feature' import { BlockContent, Heading, Section, Skeleton } from '@qovery/shared/ui' @@ -12,10 +18,13 @@ import { Button, DropdownMenu, Icon, Indicator, useModal, useModalConfirmation } import { useDocumentTitle } from '@qovery/shared/util-hooks' import { queries } from '@qovery/state/util-queries' +const EKS_ANYWHERE_LOGO = '/assets/devicon/eks-anywhere.svg' + const convertToCloudProviderEnum = (cloudProvider: ClusterCredentials['object_type']): CloudProviderEnum => { return match(cloudProvider) .with('AWS', () => CloudProviderEnum.AWS) .with('AWS_ROLE', () => CloudProviderEnum.AWS) + .with('EKS_ANYWHERE_VSPHERE', () => CloudProviderEnum.AWS) .with('AZURE', () => CloudProviderEnum.AZURE) .with('SCW', () => CloudProviderEnum.SCW) .with('OTHER', () => CloudProviderEnum.ON_PREMISE) @@ -40,6 +49,8 @@ const CredentialRow = ({ credential, clusters, onEdit, onOpen, onDelete }: Crede
@@ -141,6 +152,7 @@ const CredentialRow = ({ credential, clusters, onEdit, onOpen, onDelete }: Crede const PageOrganizationCredentials = () => { const { openModal, closeModal } = useModal() const { organizationId = '' } = useParams() + const isEksAnywhereEnabled = Boolean(useFeatureFlagEnabled('eks-anywhere')) const { openModalConfirmation } = useModalConfirmation() const { mutate: deleteCloudProviderCredential } = useDeleteCloudProviderCredential() @@ -148,8 +160,15 @@ const PageOrganizationCredentials = () => { organizationId, }) const credentials = useMemo( - () => organizationCredentials.filter((item) => item.credential?.object_type !== 'OTHER'), - [organizationCredentials] + () => + organizationCredentials.filter((item) => { + const objectType = item.credential?.object_type + if (!objectType) return false + if (objectType === 'OTHER') return false + if (objectType === 'EKS_ANYWHERE_VSPHERE' && !isEksAnywhereEnabled) return false + return true + }), + [organizationCredentials, isEksAnywhereEnabled] ) const onEdit = (credential: ClusterCredentials) => { @@ -288,29 +307,67 @@ export function PageOrganizationCredentialsFeature() { useDocumentTitle('Cloud Crendentials - Organization settings') const { organizationId = '' } = useParams() const { openModal, closeModal } = useModal() + const isEksAnywhereEnabled = Boolean(useFeatureFlagEnabled('eks-anywhere')) const queryClient = useQueryClient() const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false) - const cloudProviderOptions = [ - { - label: 'AWS', - value: CloudProviderEnum.AWS, - }, - { - label: 'GCP', - value: CloudProviderEnum.GCP, - }, - { - label: 'Azure', - value: CloudProviderEnum.AZURE, - }, - { - label: 'Scaleway', - value: CloudProviderEnum.SCW, - }, - ] + type CloudProviderOption = { + key: string + label: string + cloudProvider: CloudProviderEnum + defaultCredentialType?: ClusterCredentialAuthType + allowedCredentialTypes?: ClusterCredentialAuthType[] + icon: ReactElement + } + + const cloudProviderOptions = useMemo( + () => [ + { + key: CloudProviderEnum.AWS, + label: 'AWS', + cloudProvider: CloudProviderEnum.AWS, + allowedCredentialTypes: ['STS', 'STATIC'], + icon: , + }, + { + key: CloudProviderEnum.GCP, + label: 'GCP', + cloudProvider: CloudProviderEnum.GCP, + icon: , + }, + { + key: CloudProviderEnum.AZURE, + label: 'Azure', + cloudProvider: CloudProviderEnum.AZURE, + icon: , + }, + { + key: CloudProviderEnum.SCW, + label: 'Scaleway', + cloudProvider: CloudProviderEnum.SCW, + icon: , + }, + ...(isEksAnywhereEnabled + ? [ + { + key: 'AWS_EKS_ANYWHERE', + label: 'EKS Anywhere on vSphere', + cloudProvider: CloudProviderEnum.AWS, + defaultCredentialType: 'EKS_ANYWHERE_VSPHERE_ROLE', + allowedCredentialTypes: ['EKS_ANYWHERE_VSPHERE_ROLE', 'EKS_ANYWHERE_VSPHERE_STATIC'], + icon: EKS Anywhere on vSphere, + } satisfies CloudProviderOption, + ] + : []), + ], + [isEksAnywhereEnabled] + ) - const openClusterCredentialsModal = (cloudProvider: CloudProviderEnum) => { + const openClusterCredentialsModal = ( + cloudProvider: CloudProviderEnum, + defaultCredentialType?: ClusterCredentialAuthType, + allowedCredentialTypes?: ClusterCredentialAuthType[] + ) => { openModal({ content: ( ), options: { @@ -332,9 +391,9 @@ export function PageOrganizationCredentialsFeature() { }) } - const onSelectProvider = (cloudProvider: CloudProviderEnum) => { + const onSelectProvider = (option: CloudProviderOption) => { setIsCreateMenuOpen(false) - openClusterCredentialsModal(cloudProvider) + openClusterCredentialsModal(option.cloudProvider, option.defaultCredentialType, option.allowedCredentialTypes) } return ( @@ -356,11 +415,7 @@ export function PageOrganizationCredentialsFeature() { {cloudProviderOptions.map((option) => ( - } - onClick={() => onSelectProvider(option.value)} - > + onSelectProvider(option)}> {option.label} ))} diff --git a/libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg b/libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg new file mode 100644 index 00000000000..1b35c4f1493 --- /dev/null +++ b/libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg @@ -0,0 +1,18 @@ + + + Icon-Architecture/64/Arch_Amazon-EKS-Anywhere_64 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index 03ee0e215dc..dd3ce952c1a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.260.1", - "qovery-typescript-axios": "1.1.852", + "qovery-typescript-axios": "1.1.855", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index b9c0eaaa71c..9842dd2fd20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5451,7 +5451,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.852 + qovery-typescript-axios: 1.1.855 qovery-ws-typescript-axios: 0.1.506 react: 18.3.1 react-country-flag: 3.0.2 @@ -24269,12 +24269,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.852": - version: 1.1.852 - resolution: "qovery-typescript-axios@npm:1.1.852" +"qovery-typescript-axios@npm:1.1.855": + version: 1.1.855 + resolution: "qovery-typescript-axios@npm:1.1.855" dependencies: axios: 1.12.2 - checksum: 5d2cde0c2f9c39dcd94e74aeb1f89b0b6b7755e4e61aee7c7f82bf898c05b557f13756fa56c17cd4271a8ae3f4e7df1faf590d4a495b769b988995f46e09dccc + checksum: e456e82d9b2027d4e862ceb7c720857aa52b8d36340961a6622c1478ce34126ed507d427d0abb65f17fba91a05a4cb3ff76c86ff2d5f259c8e31ebd59f599d84 languageName: node linkType: hard From 771fe7b94239092fe1aa7994d07bc85246088812 Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Tue, 7 Apr 2026 17:17:22 +0200 Subject: [PATCH 2/2] handle comments --- .../src/lib/cluster-avatar/cluster-avatar.tsx | 8 +-- .../cluster-credentials-modal.tsx | 59 +++++++++---------- .../page-organization-credentials-feature.tsx | 52 ++++++++-------- libs/shared/enums/src/lib/icon.enum.ts | 1 + .../src/lib/assets/devicon/eks-anywhere.svg | 18 ------ .../ui/src/lib/components/icon/icon.tsx | 3 + .../components/icon/icons/eks-anywhere.tsx | 20 +++++++ 7 files changed, 78 insertions(+), 83 deletions(-) delete mode 100644 libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg create mode 100644 libs/shared/ui/src/lib/components/icon/icons/eks-anywhere.tsx diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx index 1f2865d4024..820d9f5c522 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx @@ -6,20 +6,14 @@ import { Avatar, Icon } from '@qovery/shared/ui' export interface ClusterAvatarProps extends Omit, 'fallback'> { cloudProvider?: CloudProviderEnum cluster?: Cluster - customIconSrc?: string - customIconAlt?: string } export const ClusterAvatar = forwardRef, ClusterAvatarProps>(function ClusterAvatar( - { cluster, cloudProvider, customIconSrc, customIconAlt = 'Custom cloud provider', ...props }, + { cluster, cloudProvider, ...props }, ref ) { const localCloudProvider = cloudProvider ?? cluster?.cloud_provider const fallback = match({ cluster, localCloudProvider }) - .when( - () => Boolean(customIconSrc), - () => {customIconAlt} - ) .with({ cluster: { is_demo: true } }, () => (
diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx index d50f0cc1f81..4a18895d57a 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx @@ -45,7 +45,8 @@ type ClusterCredentialsFormValues = { id?: string } -export type ClusterCredentialAuthType = ClusterCredentialsFormValues['type'] +type ClusterCredentialAuthType = ClusterCredentialsFormValues['type'] +export type ClusterCredentialsModalCloudProvider = CloudProviderEnum | 'AWS_EKS_ANYWHERE' const AWS_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS', 'STATIC'] const EKS_ANYWHERE_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = [ 'EKS_ANYWHERE_VSPHERE_ROLE', @@ -57,9 +58,7 @@ export interface ClusterCredentialsModalProps { clusterId?: string onClose: (response?: ClusterCredentials) => void credential?: ClusterCredentials - cloudProvider?: CloudProviderEnum - defaultCredentialType?: ClusterCredentialAuthType - allowedCredentialTypes?: ClusterCredentialAuthType[] + cloudProvider?: ClusterCredentialsModalCloudProvider } export const handleSubmit = (data: FieldValues, cloudProvider: CloudProviderEnum) => { @@ -190,8 +189,6 @@ export function ClusterCredentialsModal({ onClose, credential, cloudProvider = 'AWS', - defaultCredentialType, - allowedCredentialTypes, }: ClusterCredentialsModalProps) { const isEksAnywhereEnabled = Boolean(useFeatureFlagEnabled('eks-anywhere')) const { enableAlertClickOutside } = useModal() @@ -204,6 +201,8 @@ export function ClusterCredentialsModal({ }) const cloudProviderLocal = cloudProviderInfo?.cloud_provider ?? cloudProvider ?? 'AWS' + const isAwsMode = cloudProviderLocal === 'AWS' || cloudProviderLocal === 'AWS_EKS_ANYWHERE' + const apiCloudProvider: CloudProviderEnum = cloudProviderLocal === 'AWS_EKS_ANYWHERE' ? 'AWS' : cloudProviderLocal const { mutateAsync: createCloudProviderCredential, isLoading: isLoadingCreate } = useCreateCloudProviderCredential() const { mutateAsync: editCloudProviderCredential, isLoading: isLoadingEdit } = useEditCloudProviderCredential() @@ -243,15 +242,15 @@ export function ClusterCredentialsModal({ ) const awsAuthTypeOptions = useMemo( () => - (allowedCredentialTypes && allowedCredentialTypes.length > 0 - ? allowedCredentialTypes - : isEdit - ? inferredCredentialTypes + (isEdit + ? inferredCredentialTypes + : cloudProviderLocal === 'AWS_EKS_ANYWHERE' + ? EKS_ANYWHERE_CREDENTIAL_TYPES : AWS_CREDENTIAL_TYPES ).filter((type) => type === 'EKS_ANYWHERE_VSPHERE_ROLE' || type === 'EKS_ANYWHERE_VSPHERE_STATIC' ? isEksAnywhereEnabled : true ), - [allowedCredentialTypes, inferredCredentialTypes, isEdit, isEksAnywhereEnabled] + [cloudProviderLocal, inferredCredentialTypes, isEdit, isEksAnywhereEnabled] ) const defaultType: ClusterCredentialsFormValues['type'] = @@ -259,11 +258,9 @@ export function ClusterCredentialsModal({ ? credential.role_arn ? 'EKS_ANYWHERE_VSPHERE_ROLE' : 'EKS_ANYWHERE_VSPHERE_STATIC' - : !isEdit && defaultCredentialType - ? defaultCredentialType - : credential?.object_type === 'AWS_ROLE' || (!isEdit && cloudProviderLocal === 'AWS') - ? 'STS' - : 'STATIC' + : credential?.object_type === 'AWS_ROLE' || (!isEdit && isAwsMode) + ? 'STS' + : 'STATIC' const initialType = awsAuthTypeOptions.includes(defaultType) ? defaultType : awsAuthTypeOptions[0] ?? 'STS' const methods = useForm({ @@ -310,7 +307,7 @@ export function ClusterCredentialsModal({ methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) const onSubmit = methods.handleSubmit(async (data) => { - const isAzureSubmitSuccessful = methods.formState.isSubmitSuccessful && cloudProviderLocal === 'AZURE' + const isAzureSubmitSuccessful = methods.formState.isSubmitSuccessful && apiCloudProvider === 'AZURE' // When Azure credential is submitted (second click on "Done"), we need to close the modal with the response so that it fills the form automatically if (isAzureSubmitSuccessful) { @@ -334,7 +331,7 @@ export function ClusterCredentialsModal({ return } - const credentials = handleSubmit(data, cloudProviderLocal) + const credentials = handleSubmit(data, apiCloudProvider) try { if (credential) { @@ -350,10 +347,10 @@ export function ClusterCredentialsModal({ ...credentials, }) - match({ cloudProviderLocal, response }) + match({ cloudProvider: apiCloudProvider, response }) .with( { - cloudProviderLocal: 'AZURE', + cloudProvider: 'AZURE', response: { azure_application_id: P.string }, }, ({ response }) => { @@ -386,7 +383,7 @@ export function ClusterCredentialsModal({ try { await deleteCloudProviderCredential({ organizationId, - cloudProvider: cloudProviderLocal, + cloudProvider: apiCloudProvider, credentialId: credential.id, }) onClose() @@ -408,17 +405,17 @@ export function ClusterCredentialsModal({ const submitLabel = isEdit ? 'Confirm' - : match({ cloudProviderLocal, watchAzureApplicationId, watchAzureSubscriptionId }) + : match({ cloudProvider: apiCloudProvider, watchAzureApplicationId, watchAzureSubscriptionId }) .with( { - cloudProviderLocal: 'AZURE', + cloudProvider: 'AZURE', watchAzureApplicationId: P.not(P.string), }, () => 'Next' ) .with( { - cloudProviderLocal: 'AZURE', + cloudProvider: 'AZURE', watchAzureApplicationId: P.string, }, () => 'Done' @@ -431,16 +428,16 @@ export function ClusterCredentialsModal({ title={`${isEdit ? `Edit` : 'Create new'} credential`} description={ - Follow these steps and give Qovery access to your {cloudProviderLocal} account. - {((isAwsStsCredential && cloudProviderLocal === 'AWS') || cloudProviderLocal === 'GCP') && ( + Follow these steps and give Qovery access to your {apiCloudProvider} account. + {((isAwsStsCredential && isAwsMode) || apiCloudProvider === 'GCP') && ( 'https://www.qovery.com/docs/getting-started/installation/aws#create-your-cluster') .with( 'GCP', () => 'https://www.qovery.com/docs/getting-started/installation/gcp#generate-installation-command' ) - .exhaustive()} + .otherwise(() => 'https://www.qovery.com/docs/getting-started/installation/aws#create-your-cluster')} size="sm" > Learn more @@ -457,7 +454,7 @@ export function ClusterCredentialsModal({ customLoader="Processing..." >
- {cloudProviderLocal === 'AWS' && ( + {isAwsMode && ( - {cloudProviderLocal === 'AWS' && ( + {isAwsMode && (

1. Create a user for Qovery

Follow the instructions available on this page

@@ -680,7 +677,7 @@ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role qovery-service-account" /> )} /> - {(isAwsStaticCredential || isEksAnywhereStaticCredential) && cloudProviderLocal === 'AWS' && ( + {(isAwsStaticCredential || isEksAnywhereStaticCredential) && isAwsMode && ( <> { return match(cloudProvider) .with('AWS', () => CloudProviderEnum.AWS) @@ -32,6 +31,14 @@ const convertToCloudProviderEnum = (cloudProvider: ClusterCredentials['object_ty .exhaustive() } +const convertToCredentialsModalCloudProvider = ( + cloudProvider: ClusterCredentials['object_type'] +): ClusterCredentialsModalCloudProvider => { + return match(cloudProvider) + .with('EKS_ANYWHERE_VSPHERE', () => 'AWS_EKS_ANYWHERE' as const) + .otherwise(() => convertToCloudProviderEnum(cloudProvider)) +} + type CredentialRowProps = { credential: ClusterCredentials clusters: CredentialCluster[] @@ -47,13 +54,15 @@ const CredentialRow = ({ credential, clusters, onEdit, onOpen, onDelete }: Crede key={credential.id} >
- + {credential.object_type === 'EKS_ANYWHERE_VSPHERE' ? ( + + ) : ( + + )}
{credential.name} @@ -180,7 +189,7 @@ const PageOrganizationCredentials = () => { closeModal() }} credential={credential} - cloudProvider={convertToCloudProviderEnum(credential.object_type)} + cloudProvider={convertToCredentialsModalCloudProvider(credential.object_type)} /> ), options: { @@ -314,9 +323,7 @@ export function PageOrganizationCredentialsFeature() { type CloudProviderOption = { key: string label: string - cloudProvider: CloudProviderEnum - defaultCredentialType?: ClusterCredentialAuthType - allowedCredentialTypes?: ClusterCredentialAuthType[] + cloudProvider: ClusterCredentialsModalCloudProvider icon: ReactElement } @@ -326,7 +333,6 @@ export function PageOrganizationCredentialsFeature() { key: CloudProviderEnum.AWS, label: 'AWS', cloudProvider: CloudProviderEnum.AWS, - allowedCredentialTypes: ['STS', 'STATIC'], icon: , }, { @@ -352,10 +358,8 @@ export function PageOrganizationCredentialsFeature() { { key: 'AWS_EKS_ANYWHERE', label: 'EKS Anywhere on vSphere', - cloudProvider: CloudProviderEnum.AWS, - defaultCredentialType: 'EKS_ANYWHERE_VSPHERE_ROLE', - allowedCredentialTypes: ['EKS_ANYWHERE_VSPHERE_ROLE', 'EKS_ANYWHERE_VSPHERE_STATIC'], - icon: EKS Anywhere on vSphere, + cloudProvider: 'AWS_EKS_ANYWHERE', + icon: , } satisfies CloudProviderOption, ] : []), @@ -363,11 +367,7 @@ export function PageOrganizationCredentialsFeature() { [isEksAnywhereEnabled] ) - const openClusterCredentialsModal = ( - cloudProvider: CloudProviderEnum, - defaultCredentialType?: ClusterCredentialAuthType, - allowedCredentialTypes?: ClusterCredentialAuthType[] - ) => { + const openClusterCredentialsModal = (cloudProvider: ClusterCredentialsModalCloudProvider) => { openModal({ content: ( ), options: { @@ -393,7 +391,7 @@ export function PageOrganizationCredentialsFeature() { const onSelectProvider = (option: CloudProviderOption) => { setIsCreateMenuOpen(false) - openClusterCredentialsModal(option.cloudProvider, option.defaultCredentialType, option.allowedCredentialTypes) + openClusterCredentialsModal(option.cloudProvider) } return ( diff --git a/libs/shared/enums/src/lib/icon.enum.ts b/libs/shared/enums/src/lib/icon.enum.ts index 21ab1f693c9..6e61ad4636e 100644 --- a/libs/shared/enums/src/lib/icon.enum.ts +++ b/libs/shared/enums/src/lib/icon.enum.ts @@ -41,6 +41,7 @@ export enum IconEnum { KUBERNETES = 'KUBERNETES', MICROSOFT = 'MICROSOFT', EKS = 'EKS', + EKS_ANYWHERE = 'EKS_ANYWHERE', ON_PREMISE = 'ON_PREMISE', ON_PREMISE_GRAY = 'ON_PREMISE_GRAY', CLOUDFORMATION = 'CLOUDFORMATION', diff --git a/libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg b/libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg deleted file mode 100644 index 1b35c4f1493..00000000000 --- a/libs/shared/ui/src/lib/assets/devicon/eks-anywhere.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EKS-Anywhere_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/libs/shared/ui/src/lib/components/icon/icon.tsx b/libs/shared/ui/src/lib/components/icon/icon.tsx index bd16bc9af6e..c8353c8e52f 100644 --- a/libs/shared/ui/src/lib/components/icon/icon.tsx +++ b/libs/shared/ui/src/lib/components/icon/icon.tsx @@ -21,6 +21,7 @@ import DOIcon from './icons/do' import DockerIcon from './icons/docker' import DopplerIcon from './icons/doppler' import EKSIcon from './icons/eks' +import EKSAnywhereIcon from './icons/eks-anywhere' import EnvironmentIcon from './icons/environment' import GCPIcon from './icons/gcp' import GcpArtifactRegistryIcon from './icons/gcp-artifact-registry' @@ -162,6 +163,8 @@ export function Icon(props: IconProps | FontAwesomeIconProps) { return case IconEnum.EKS: return + case IconEnum.EKS_ANYWHERE: + return case IconEnum.ON_PREMISE: case IconEnum.ON_PREMISE_GRAY: return diff --git a/libs/shared/ui/src/lib/components/icon/icons/eks-anywhere.tsx b/libs/shared/ui/src/lib/components/icon/icons/eks-anywhere.tsx new file mode 100644 index 00000000000..a74c378057a --- /dev/null +++ b/libs/shared/ui/src/lib/components/icon/icons/eks-anywhere.tsx @@ -0,0 +1,20 @@ +import { type IconProps } from '../icon' + +export function EKSAnywhereIcon({ ...props }: IconProps) { + return ( + + + + + + + + + + + + + ) +} + +export default EKSAnywhereIcon