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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
type ClusterCloudProviderInfo,
type ClusterCloudProviderInfoRequest,
type ClusterCredentials,
} from 'qovery-typescript-axios'
import { useEffect } from 'react'
import { type FieldValues, FormProvider, useForm } from 'react-hook-form'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useEffect, useRef } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { useCloudProviderCredentials } from '@qovery/domains/cloud-providers/feature'
import { isClusterEksManaged } from '@qovery/domains/clusters/data-access'
import {
type ClusterCredentialsFormValues,
ClusterCredentialsSettings,
ClusterRestrictivePermissionsSettings,
getCloudProviderInfoRequest,
getClusterCredentialsSavePlan,
saveClusterCredentials,
useCluster,
useClusterCloudProviderInfo,
useDeployCluster,
useEditCloudProviderInfo,
useEditCluster,
} from '@qovery/domains/clusters/feature'
import { SettingsHeading } from '@qovery/shared/console-shared'
import { BlockContent, Button, Section, toast, useModalConfirmation } from '@qovery/shared/ui'
Expand All @@ -24,27 +26,11 @@ export const Route = createFileRoute(
component: RouteComponent,
})

const handleSubmit = (
data: FieldValues,
credentials: ClusterCredentials[],
cluster: ClusterCloudProviderInfo
): ClusterCloudProviderInfoRequest => {
const currentCredentials = credentials.filter((item) => item.id === data['credentials'])[0]

return {
cloud_provider: cluster.cloud_provider,
credentials: {
id: currentCredentials.id,
name: currentCredentials.name,
},
region: cluster.region,
}
}

function ClusterCredentialsSettingsForm() {
const { organizationId = '', clusterId = '' } = useParams({ strict: false })
const isWideCredentialsFeatureFlagEnabled = Boolean(useFeatureFlagEnabled('wide-credentials'))

const methods = useForm({
const methods = useForm<ClusterCredentialsFormValues>({
mode: 'onChange',
})

Expand All @@ -59,54 +45,129 @@ function ClusterCredentialsSettingsForm() {
cloudProvider: clusterCloudProviderInfo?.cloud_provider,
})
const { mutate: editCloudProviderInfo, isLoading: isEditCloudProviderInfoLoading } = useEditCloudProviderInfo()
const { mutateAsync: editCloudProviderInfoBeforeRedeploy } = useEditCloudProviderInfo({
const {
mutateAsync: editCloudProviderInfoWithoutUpdateAction,
isLoading: isEditCloudProviderInfoWithoutUpdateActionLoading,
} = useEditCloudProviderInfo({
showClusterUpdateAction: false,
})
// Toggling restrictive permissions doesn't require a cluster update
const { mutateAsync: editCluster, isLoading: isEditClusterLoading } = useEditCluster({
showClusterUpdateAction: false,
})
const { mutateAsync: editClusterSilently, isLoading: isEditClusterSilentlyLoading } = useEditCluster({
silently: true,
})
const { mutateAsync: deployCluster } = useDeployCluster()
const { openModalConfirmation } = useModalConfirmation()

const onSubmit = methods.handleSubmit((data) => {
const findCredentials = credentials.find((credential) => credential.id === data['credentials'])
// Restrictive (dual) credentials are only available for AWS managed (EKS) clusters
const isWideCredentialsEnabled = isWideCredentialsFeatureFlagEnabled && !!cluster && isClusterEksManaged(cluster)
const initialUsingRestrictivePermissions = cluster?.wide_permissions?.using_restrictive_permissions ?? false
const isSavingRef = useRef(false)

// The cluster is required to know if the credentials change needs a redeploy
const openRedeployConfirmation = (action: () => Promise<void>) => {
if (!cluster) return

const isEksManaged = isClusterEksManaged(cluster)
openModalConfirmation({
title: 'Confirm credentials change',
description: 'Changing the credentials will trigger a cluster redeployment. To confirm, please type the name:',
warning: 'Your cluster will be redeployed automatically once the new credentials are saved.',
name: cluster.name,
action,
})
}

if (data && clusterCloudProviderInfo && findCredentials) {
const variables = {
organizationId,
clusterId,
cloudProviderInfoRequest: handleSubmit(data, credentials, clusterCloudProviderInfo),
}
const onSubmit = methods.handleSubmit(async (data) => {
// The cluster is required to know if the credentials change needs a redeploy
if (!cluster || !clusterCloudProviderInfo) return

const formValues = isWideCredentialsEnabled ? data : { ...data, using_restrictive_permissions: false }
const cloudProviderInfoRequest = getCloudProviderInfoRequest(formValues, credentials, clusterCloudProviderInfo)

if (!cloudProviderInfoRequest) {
toast('error', 'Please select a credential')
return
}

const variables = { organizationId, clusterId, cloudProviderInfoRequest }

if (!isWideCredentialsEnabled) {
// Changing the credentials of a managed EKS cluster requires a cluster redeploy, triggered once they are saved
const hasCredentialsChanged = findCredentials.id !== clusterCloudProviderInfo.credentials?.id
if (isEksManaged && hasCredentialsChanged) {
openModalConfirmation({
title: 'Confirm credentials change',
description:
'Changing the credentials will trigger a cluster redeployment. To confirm, please type the name:',
warning: 'Your cluster will be redeployed automatically once the new credentials are saved.',
name: cluster.name,
action: async () => {
await editCloudProviderInfoBeforeRedeploy(variables)
await deployCluster({ organizationId, clusterId })
},
const hasCredentialsChanged = data.credentials !== clusterCloudProviderInfo.credentials?.id
if (isClusterEksManaged(cluster) && hasCredentialsChanged) {
openRedeployConfirmation(async () => {
await editCloudProviderInfoWithoutUpdateAction(variables)
await deployCluster({ organizationId, clusterId })
})
} else {
editCloudProviderInfo(variables)
}
return
}

const plan = getClusterCredentialsSavePlan({
formValues,
cloudProviderInfo: clusterCloudProviderInfo,
initialUsingRestrictivePermissions,
})

const save = async () => {
// The cluster and its credentials are refetched one after the other: syncing the form from each refetch would
// briefly show a mix of old and new values, so the form is reset once with the saved values instead
isSavingRef.current = true
try {
await saveClusterCredentials({
plan,
// A single toast is shown when the credentials are saved too
editCluster: () =>
(plan.shouldEditCloudProviderInfo ? editClusterSilently : editCluster)({
organizationId,
clusterId,
clusterRequest: {
...cluster,
wide_permissions: { using_restrictive_permissions: Boolean(formValues.using_restrictive_permissions) },
},
}),
// Either the cluster is redeployed right after, or the change (toggle, extended credentials) doesn't require
// a cluster update: no update to offer
editCloudProviderInfo: () => editCloudProviderInfoWithoutUpdateAction(variables),
deployCluster: () => deployCluster({ organizationId, clusterId }),
})
const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions)
methods.reset({
credentials: formValues.credentials,
// Turning restrictive permissions off clears the extended credentials on the backend side
wide_permission_credentials: usingRestrictivePermissions ? formValues.wide_permission_credentials : undefined,
using_restrictive_permissions: usingRestrictivePermissions,
})
} finally {
isSavingRef.current = false
}
}

if (plan.shouldRedeploy) {
openRedeployConfirmation(save)
} else {
toast('error', 'Please select a credential')
try {
await save()
} catch (error) {
// Errors are already notified by the mutations
console.error(error)
}
}
})

useEffect(() => {
if (clusterCloudProviderInfo) {
methods.setValue('credentials', clusterCloudProviderInfo.credentials?.id)
if (clusterCloudProviderInfo && !isSavingRef.current) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we simplify the form reset here? Between the effect, isSavingRef, and the manual reset after saving, it's getting hard to follow. Could we wait for the relevant queries to refresh and reset the form once?

// Toggling restrictive permissions never clears the fields, so the user always gets back these initial values
methods.reset({
credentials: clusterCloudProviderInfo.credentials?.id,
wide_permission_credentials: clusterCloudProviderInfo.wide_permission_credentials?.id,
using_restrictive_permissions: initialUsingRestrictivePermissions,
})
}
}, [methods, clusterCloudProviderInfo])
}, [methods, clusterCloudProviderInfo, initialUsingRestrictivePermissions])

return (
<FormProvider {...methods}>
Expand All @@ -115,19 +176,31 @@ function ClusterCredentialsSettingsForm() {
<SettingsHeading title="Credentials" />
<div className="max-w-content-with-navigation-left">
<form onSubmit={onSubmit}>
<BlockContent title="Configured credentials">
<ClusterCredentialsSettings
{isWideCredentialsEnabled ? (
<ClusterRestrictivePermissionsSettings
cloudProvider={clusterCloudProviderInfo?.cloud_provider}
isSetting={true}
cluster={cluster}
/>
</BlockContent>
) : (
<BlockContent title="Configured credentials">
<ClusterCredentialsSettings
cloudProvider={clusterCloudProviderInfo?.cloud_provider}
isSetting={true}
cluster={cluster}
/>
</BlockContent>
)}
<div className="flex justify-end">
<Button
data-testid="submit-button"
type="submit"
size="lg"
loading={isEditCloudProviderInfoLoading}
loading={
isEditCloudProviderInfoLoading ||
isEditCloudProviderInfoWithoutUpdateActionLoading ||
isEditClusterLoading ||
isEditClusterSilentlyLoading
}
disabled={!methods.formState.isValid || !cluster}
>
Save
Expand Down
3 changes: 3 additions & 0 deletions libs/domains/clusters/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export * from './lib/secret-manager-modals/secret-manager-associated-external-se
export * from './lib/secret-manager-modals/secret-manager-integration-modal'
export * from './lib/cluster-general-settings/cluster-general-settings'
export * from './lib/cluster-credentials-settings/cluster-credentials-settings'
export * from './lib/cluster-credentials-settings/cluster-restrictive-permissions-settings'
export * from './lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings'
export * from './lib/cluster-credentials-settings/save-cluster-credentials'
export * from './lib/cluster-dns-provider-settings/cluster-dns-provider-settings'
export * from './lib/hooks/use-cluster-cloud-provider-info/use-cluster-cloud-provider-info'
export * from './lib/hooks/use-cluster-dns-provider/use-cluster-dns-provider'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,23 @@ describe('ClusterCredentialsModal', () => {
})
})

it('should only offer the allowed authentication types', async () => {
renderWithProviders(wrapWithReactHookForm(<ClusterCredentialsModal {...props} allowedTypes={['STS']} />))

selectEvent.openMenu(screen.getByLabelText('Authentication type'))

expect(await screen.findAllByText('Assume role via STS (preferred)')).not.toHaveLength(0)
expect(screen.queryByText('Static credentials')).not.toBeInTheDocument()
})

it('should offer both AWS authentication types by default', async () => {
renderWithProviders(wrapWithReactHookForm(<ClusterCredentialsModal {...props} />))

selectEvent.openMenu(screen.getByLabelText('Authentication type'))

expect(await screen.findByText('Static credentials')).toBeInTheDocument()
})

it('should render GCP WIF as default authentication type', () => {
props.cloudProvider = CloudProviderEnum.GCP

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type ClusterCredentialsFormValues = {
id?: string
}

type ClusterCredentialAuthType = ClusterCredentialsFormValues['type']
export type ClusterCredentialAuthType = ClusterCredentialsFormValues['type']
export type ClusterCredentialsModalCloudProvider = CloudProviderEnum | 'AWS_EKS_ANYWHERE'
const AWS_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS', 'STATIC']
const GCP_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['WIF', 'STATIC']
Expand All @@ -66,6 +66,8 @@ export interface ClusterCredentialsModalProps {
onClose: (response?: ClusterCredentials) => void
credential?: ClusterCredentials
cloudProvider?: ClusterCredentialsModalCloudProvider
// Restricts the authentication types the user can pick (e.g. extended permissions only accept an AWS role)
allowedTypes?: ClusterCredentialAuthType[]
}

export const getDefaultClusterCredentialType = ({
Expand Down Expand Up @@ -238,6 +240,7 @@ export function ClusterCredentialsModal({
onClose,
credential,
cloudProvider = 'AWS',
allowedTypes,
}: ClusterCredentialsModalProps) {
const isEksAnywhereEnabled = Boolean(useFeatureFlagEnabled('eks-anywhere'))
const { enableAlertClickOutside } = useModal()
Expand Down Expand Up @@ -304,10 +307,20 @@ export function ClusterCredentialsModal({
: 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
),
[cloudProviderLocal, credential?.object_type, inferredCredentialTypes, isEdit, isEksAnywhereEnabled, isGcpMode]
)
.filter((type) =>
type === 'EKS_ANYWHERE_VSPHERE_ROLE' || type === 'EKS_ANYWHERE_VSPHERE_STATIC' ? isEksAnywhereEnabled : true
)
.filter((type) => !allowedTypes || allowedTypes.includes(type)),
Comment thread
mzottola marked this conversation as resolved.
[
allowedTypes,
cloudProviderLocal,
credential?.object_type,
inferredCredentialTypes,
isEdit,
isEksAnywhereEnabled,
isGcpMode,
]
)

const defaultType: ClusterCredentialsFormValues['type'] = getDefaultClusterCredentialType({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,4 +247,25 @@ describe('ClusterCredentialsSettings', () => {
{ id: '2', name: 'EKS Anywhere credential', object_type: 'EKS_ANYWHERE_VSPHERE' },
])
})

it.each([
[false, 'Changing credentials triggers a cluster redeployment'],
[true, 'Changing the restrictive credentials triggers a cluster redeployment'],
])(
'should adapt the EKS credentials change warning to restrictive permissions (%p)',
(isUsingRestrictivePermissions, heading) => {
renderWithProviders(
wrapWithReactHookForm(
<ClusterCredentialsSettings
{...props}
isSetting
cluster={eksManagedCluster}
isUsingRestrictivePermissions={isUsingRestrictivePermissions}
/>
)
)

expect(screen.getByTestId('eks-credentials-change-warning')).toHaveTextContent(heading)
}
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ClusterCredentialsSettingsProps {
cloudProvider?: ClusterCredentialsModalCloudProvider
isSetting?: boolean
cluster?: Cluster
isUsingRestrictivePermissions?: boolean

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we read this from the form with useWatch instead of adding another prop? This component already uses useFormContext

}

export const filterCredentialsByCloudProvider = (
Expand All @@ -37,7 +38,12 @@ export const filterCredentialsByCloudProvider = (
return credentials.filter((credential) => credential.object_type === 'EKS_ANYWHERE_VSPHERE')
}

export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster }: ClusterCredentialsSettingsProps) {
export function ClusterCredentialsSettings({
cloudProvider,
isSetting,
cluster,
isUsingRestrictivePermissions = false,
}: ClusterCredentialsSettingsProps) {
const { organizationId = '', clusterId } = useParams({ strict: false })
const { control } = useFormContext()
const { openModal, closeModal } = useModal()
Expand Down Expand Up @@ -99,7 +105,12 @@ export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster }
<Icon iconName="triangle-exclamation" iconStyle="regular" />
</Callout.Icon>
<Callout.Text>
<Callout.TextHeading>Changing credentials triggers a cluster redeployment</Callout.TextHeading>
<Callout.TextHeading>
{/* Only the restrictive credentials trigger a redeploy, not the toggle nor the extended credentials */}
{isUsingRestrictivePermissions
? 'Changing the restrictive credentials triggers a cluster redeployment'
: 'Changing credentials triggers a cluster redeployment'}
</Callout.TextHeading>
<Callout.TextDescription>
Saving new credentials will automatically redeploy the cluster to keep your services' deployments
working.
Expand Down
Loading
Loading