diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx index 05591a6842f..3468964215c 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx @@ -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' @@ -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({ mode: 'onChange', }) @@ -59,54 +45,142 @@ function ClusterCredentialsSettingsForm() { cloudProvider: clusterCloudProviderInfo?.cloud_provider, }) const { mutate: editCloudProviderInfo, isLoading: isEditCloudProviderInfoLoading } = useEditCloudProviderInfo() - const { mutateAsync: editCloudProviderInfoBeforeRedeploy } = useEditCloudProviderInfo({ + const { + mutateAsync: editCloudProviderInfoWithoutUpdateAction, + isLoading: isEditCloudProviderInfoWithoutUpdateActionLoading, + } = useEditCloudProviderInfo({ showClusterUpdateAction: false, }) + // The permissions toggle is saved without prompting for a cluster update action. + 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) => { 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:', + 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) }, + }, + }), + // Credential changes are followed by a redeploy when required; toggle and wide credential changes don't + // offer a separate cluster update action. + editCloudProviderInfo: () => editCloudProviderInfoWithoutUpdateAction(variables), + deployCluster: () => deployCluster({ organizationId, clusterId }), + }) + const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) + methods.reset( + isWideCredentialsEnabled + ? { + configured_credentials: formValues.configured_credentials, + restricted_credentials: usingRestrictivePermissions ? formValues.restricted_credentials : undefined, + using_restrictive_permissions: usingRestrictivePermissions, + } + : { credentials: formValues.credentials, using_restrictive_permissions: false } + ) + } 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) { + // Map the API credentials back into the stable configured/restricted form fields for the current mode. + methods.reset( + isWideCredentialsEnabled + ? { + configured_credentials: initialUsingRestrictivePermissions + ? clusterCloudProviderInfo.wide_permission_credentials?.id + : clusterCloudProviderInfo.credentials?.id, + restricted_credentials: initialUsingRestrictivePermissions + ? clusterCloudProviderInfo.credentials?.id + : undefined, + using_restrictive_permissions: initialUsingRestrictivePermissions, + } + : { + credentials: clusterCloudProviderInfo.credentials?.id, + using_restrictive_permissions: false, + } + ) } - }, [methods, clusterCloudProviderInfo]) + }, [methods, clusterCloudProviderInfo, initialUsingRestrictivePermissions, isWideCredentialsEnabled]) return ( @@ -115,19 +189,27 @@ function ClusterCredentialsSettingsForm() {
- - - + {isWideCredentialsEnabled ? ( + + ) : ( + + + + )}
) : ( <> - {isSetting && isEksManaged && ( - - - - - - Changing credentials triggers a cluster redeployment - - Saving new credentials will automatically redeploy the cluster to keep your services' deployments - working. - - - - )} - ) : ( - {isSetting && ( + {showSettingsInfo && ( )} - - {isSetting && ( - - - - - - - The credential change won't be applied to the mirroring registry of this cluster. Make sure to update - the credentials properly in this cluster's mirroring registry section. - - Go to mirroring registry section - - - - - )} )}
diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx new file mode 100644 index 00000000000..67d013e0728 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx @@ -0,0 +1,168 @@ +import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import { CloudProviderEnum } from 'qovery-typescript-axios' +import { useFormContext } from 'react-hook-form' +import * as cloudProvidersDomain from '@qovery/domains/cloud-providers/feature' +import { renderWithProviders, screen, within } from '@qovery/shared/util-tests' +import { ClusterRestrictivePermissionsSettings } from './cluster-restrictive-permissions-settings' + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + useModal: () => ({ + openModal: jest.fn(), + closeModal: jest.fn(), + }), +})) + +jest.mock('@tanstack/react-router', () => { + const React = jest.requireActual('react') + return { + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ + organizationId: 'org-123', + clusterId: 'cluster-123', + }), + useRouterState: () => ({ + location: { + pathname: '/', + }, + }), + useRouter: () => ({ + buildLocation: jest.fn(() => ({ href: '/' })), + }), + Link: React.forwardRef( + ( + { children, ...props }: { children?: React.ReactNode; [key: string]: unknown }, + ref: React.Ref + ) => React.createElement('a', { ref, ...props }, children) + ), + } +}) + +const useCloudProviderCredentialsMockSpy = jest.spyOn(cloudProvidersDomain, 'useCloudProviderCredentials') as jest.Mock + +const awsCredentials = [ + { id: 'static-1', name: 'static-credential', object_type: 'AWS', access_key_id: 'AKIA' }, + { id: 'role-1', name: 'role-credential', object_type: 'AWS_ROLE', role_arn: 'arn:aws:iam::1:role/qovery' }, +] + +function FormValues() { + const { watch } = useFormContext() + return ( + <> + {watch('configured_credentials')} + {watch('restricted_credentials')} + + ) +} + +const renderSettings = (defaultValues: Record) => + renderWithProviders( + wrapWithReactHookForm( + <> + + + , + { defaultValues } + ) + ) + +const isBefore = (element: HTMLElement, other: HTMLElement) => + Boolean(element.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING) + +const expectInOrder = (...elements: HTMLElement[]) => + elements.slice(1).forEach((element, index) => expect(isBefore(elements[index], element)).toBe(true)) + +describe('ClusterRestrictivePermissionsSettings', () => { + beforeEach(() => { + useCloudProviderCredentialsMockSpy.mockReturnValue({ data: awsCredentials, isLoading: false }) + }) + + it('should display the toggle above the credentials block', () => { + renderSettings({ configured_credentials: 'role-1', using_restrictive_permissions: false }) + + const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) + const block = screen.getByTestId('configured-credentials-block') + expect(within(block).queryByRole('switch')).not.toBeInTheDocument() + expect(isBefore(block, toggle)).toBe(true) + }) + + it('should display a single configured credentials block when restrictive permissions are off', () => { + renderSettings({ configured_credentials: 'role-1', using_restrictive_permissions: false }) + + expect(screen.getByRole('switch', { name: 'Use restrictive permissions' })).not.toBeChecked() + const block = screen.getByTestId('configured-credentials-block') + expect(within(block).getByText('Configured credentials')).toBeInTheDocument() + expectInOrder( + within(block).getByTestId('input-configured-credentials'), + within(block).getByText('See and edit all Cloud credentials') + ) + expect(screen.queryByTestId('restricted-credentials-block')).not.toBeInTheDocument() + expect(screen.queryByTestId('slack-notification-webhook-block')).not.toBeInTheDocument() + expect(screen.queryByTestId('mirroring-registry-credentials-warning')).not.toBeInTheDocument() + }) + + it('should keep configured credentials first and display restricted credentials when restrictive permissions are on', () => { + renderSettings({ + configured_credentials: 'role-1', + restricted_credentials: 'static-1', + using_restrictive_permissions: true, + }) + + const configuredBlock = screen.getByTestId('configured-credentials-block') + const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) + const restrictedBlock = screen.getByTestId('restricted-credentials-block') + const webhookBlock = screen.getByTestId('slack-notification-webhook-block') + expect(within(restrictedBlock).getByText('Restricted credentials')).toBeInTheDocument() + expectInOrder(configuredBlock, toggle, restrictedBlock, webhookBlock) + expect(within(configuredBlock).getByTestId('input-configured-credentials')).toBeInTheDocument() + expect(within(restrictedBlock).getByTestId('input-restricted-credentials')).toBeInTheDocument() + expect(within(configuredBlock).getByText('See and edit all Cloud credentials')).toBeInTheDocument() + expect(screen.getAllByText('See and edit all Cloud credentials')).toHaveLength(1) + expect(screen.queryByTestId('mirroring-registry-credentials-warning')).not.toBeInTheDocument() + }) + + it('should display the Slack webhook after restricted credentials when restrictive permissions are on', () => { + renderSettings({ + configured_credentials: 'role-1', + restricted_credentials: 'static-1', + using_restrictive_permissions: true, + }) + + const block = screen.getByTestId('slack-notification-webhook-block') + expect(within(block).getByText('Slack notification webhook')).toBeInTheDocument() + expect(within(block).getByLabelText('Slack webhook URL')).toHaveAttribute('type', 'password') + expect( + within(block).getByText('Receive a Slack message when Qovery requests access to the configured credentials.') + ).toBeInTheDocument() + expect( + within(screen.getByTestId('restricted-credentials-block')).getByTestId('input-restricted-credentials') + ).toBeInTheDocument() + }) + + it('should not display the Slack webhook when restrictive permissions are off', () => { + renderSettings({ configured_credentials: 'role-1', using_restrictive_permissions: false }) + + expect(screen.queryByTestId('slack-notification-webhook-block')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Slack webhook URL')).not.toBeInTheDocument() + }) + + it('should switch the layout with the toggle and keep the initial values', async () => { + const { userEvent } = renderSettings({ + configured_credentials: 'role-1', + restricted_credentials: 'static-1', + using_restrictive_permissions: true, + }) + const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) + + await userEvent.click(toggle) + expect(screen.getByText('Configured credentials')).toBeInTheDocument() + expect(screen.queryByTestId('restricted-credentials-block')).not.toBeInTheDocument() + expect(screen.getByTestId('value-configured-credentials')).toHaveTextContent('role-1') + + await userEvent.click(toggle) + expect(screen.getByText('Restricted credentials')).toBeInTheDocument() + expect(screen.getByTestId('value-configured-credentials')).toHaveTextContent('role-1') + expect(screen.getByTestId('value-restricted-credentials')).toHaveTextContent('static-1') + expect(screen.getByTestId('value-configured-credentials')).toHaveTextContent('role-1') + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx new file mode 100644 index 00000000000..4d9d1d9f06d --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx @@ -0,0 +1,80 @@ +import { type CloudProviderEnum } from 'qovery-typescript-axios' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +import { BlockContent, InputText, InputToggle } from '@qovery/shared/ui' +import { ClusterCredentialsSettings } from './cluster-credentials-settings' +import { ClusterWidePermissionCredentialsSettings } from './cluster-wide-permission-credentials-settings' + +export interface ClusterRestrictivePermissionsSettingsProps { + cloudProvider?: CloudProviderEnum +} + +export function ClusterRestrictivePermissionsSettings({ cloudProvider }: ClusterRestrictivePermissionsSettingsProps) { + const { control } = useFormContext() + // useWatch subscribes this component itself, so it stays in sync even when the parent form is reset + const isUsingRestrictivePermissions = Boolean(useWatch({ control, name: 'using_restrictive_permissions' })) + + return ( + <> + + + + + ( + + )} + /> + + {isUsingRestrictivePermissions && ( + <> + + + + + {/* UI only for now: the value isn't sent until the backend supports it */} + + ( + + )} + /> + + + )} + + ) +} + +export default ClusterRestrictivePermissionsSettings diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx new file mode 100644 index 00000000000..83e4f3bc9c6 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx @@ -0,0 +1,131 @@ +import { act } from '@testing-library/react' +import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import { CloudProviderEnum, type ClusterCredentials } from 'qovery-typescript-axios' +import { useFormContext } from 'react-hook-form' +import selectEvent from 'react-select-event' +import * as cloudProvidersDomain from '@qovery/domains/cloud-providers/feature' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { + ClusterWidePermissionCredentialsSettings, + filterWidePermissionCredentials, +} from './cluster-wide-permission-credentials-settings' + +const mockOpenModal = jest.fn() +const mockCloseModal = jest.fn() + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + useModal: () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, + }), +})) + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ + organizationId: 'org-123', + clusterId: 'cluster-123', + }), +})) + +const useCloudProviderCredentialsMockSpy = jest.spyOn(cloudProvidersDomain, 'useCloudProviderCredentials') as jest.Mock + +const awsCredentials = [ + { id: 'static-1', name: 'static-credential', object_type: 'AWS', access_key_id: 'AKIA' }, + { id: 'role-2', name: 'other-role-credential', object_type: 'AWS_ROLE', role_arn: 'arn:aws:iam::1:role/other' }, + { id: 'role-1', name: 'role-credential', object_type: 'AWS_ROLE', role_arn: 'arn:aws:iam::1:role/qovery' }, +] + +function FormState() { + const { + watch, + formState: { isValid }, + } = useFormContext() + return ( + <> + {watch('wide_permission_credentials')} + {String(isValid)} + + ) +} + +const renderSettings = (defaultValues: Record) => + renderWithProviders( + wrapWithReactHookForm( + <> + + + , + { defaultValues } + ) + ) + +describe('ClusterWidePermissionCredentialsSettings', () => { + beforeEach(() => { + jest.clearAllMocks() + useCloudProviderCredentialsMockSpy.mockReturnValue({ data: awsCredentials, isLoading: false }) + }) + + it('should only keep AWS role credentials', () => { + expect(filterWidePermissionCredentials(awsCredentials as ClusterCredentials[])).toEqual([ + awsCredentials[1], + awsCredentials[2], + ]) + }) + + it('should have loader when loading', () => { + useCloudProviderCredentialsMockSpy.mockReturnValue({ data: [], isLoading: true }) + renderSettings({ using_restrictive_permissions: true }) + + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) + + it('should display the credentials select and its hint', () => { + renderSettings({ using_restrictive_permissions: true, wide_permission_credentials: 'role-1' }) + + expect(screen.getByLabelText('Credentials')).toBeInTheDocument() + expect(screen.getByText('role-credential')).toBeInTheDocument() + expect( + screen.queryByText('When restrictive permissions are enabled, configured credentials must be an AWS role.') + ).not.toBeInTheDocument() + expect(screen.queryByText('See and edit all Cloud credentials')).not.toBeInTheDocument() + }) + + it('should only list AWS role credentials', async () => { + renderSettings({ using_restrictive_permissions: true, wide_permission_credentials: '' }) + + selectEvent.openMenu(screen.getByLabelText('Credentials')) + + expect(await screen.findByText('role-credential')).toBeInTheDocument() + expect(screen.getByText('other-role-credential')).toBeInTheDocument() + expect(screen.queryByText('static-credential')).not.toBeInTheDocument() + }) + + it.each([ + [true, 'false'], + [false, 'true'], + ])( + 'should only require a credential while restrictive permissions are used (%p)', + async (usingRestrictivePermissions, isValid) => { + renderSettings({ using_restrictive_permissions: usingRestrictivePermissions, wide_permission_credentials: '' }) + + await waitFor(() => expect(screen.getByTestId('form-valid')).toHaveTextContent(isValid)) + } + ) + + it('should only allow creating an AWS role credential and select it', async () => { + const { userEvent } = renderSettings({ using_restrictive_permissions: true, wide_permission_credentials: '' }) + + await userEvent.click(screen.getByLabelText('Credentials')) + await userEvent.click(await screen.findByText('New credential')) + + const modalProps = mockOpenModal.mock.calls[0][0].content.props + expect(modalProps.allowedTypes).toEqual(['STS']) + expect(modalProps.credential).toBeUndefined() + + act(() => modalProps.onClose({ id: 'role-new' })) + expect(screen.getByTestId('value-wide-permission-credentials')).toHaveTextContent('role-new') + expect(mockCloseModal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx new file mode 100644 index 00000000000..deef02c2039 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx @@ -0,0 +1,140 @@ +import { useParams } from '@tanstack/react-router' +import { type CloudProviderEnum, type ClusterCredentials } from 'qovery-typescript-axios' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +import { useCloudProviderCredentials } from '@qovery/domains/cloud-providers/feature' +import { Icon, InputSelect, Link, LoaderSpinner, useModal } from '@qovery/shared/ui' +import { + type ClusterCredentialAuthType, + ClusterCredentialsModal, +} from '../cluster-credentials-modal/cluster-credentials-modal' + +// Wide permission credentials must be an AWS role (assumed via STS) +const WIDE_PERMISSION_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS'] + +export interface ClusterWidePermissionCredentialsSettingsProps { + cloudProvider?: CloudProviderEnum + fieldName?: 'wide_permission_credentials' | 'configured_credentials' + isSetting?: boolean +} + +export const filterWidePermissionCredentials = (credentials: ClusterCredentials[]) => + credentials.filter((credential) => credential.object_type === 'AWS_ROLE') + +export function ClusterWidePermissionCredentialsSettings({ + cloudProvider, + fieldName = 'wide_permission_credentials', + isSetting, +}: ClusterWidePermissionCredentialsSettingsProps) { + const { organizationId = '', clusterId } = useParams({ strict: false }) + const { control, getValues } = useFormContext() + const isUsingRestrictivePermissions = Boolean(useWatch({ control, name: 'using_restrictive_permissions' })) + const { openModal, closeModal } = useModal() + + const { data: credentials = [], isLoading } = useCloudProviderCredentials({ + organizationId, + cloudProvider, + }) + const sortedCredentials = [...credentials].sort((a, b) => a.name.localeCompare(b.name)) + const widePermissionCredentials = filterWidePermissionCredentials(sortedCredentials) + const availableCredentials = ( + fieldName === 'configured_credentials' && !isUsingRestrictivePermissions + ? sortedCredentials + : widePermissionCredentials + ).map((item) => ({ + label: 'access_key_id' in item ? `${item.name} (${item.access_key_id})` : item.name, + value: item.id, + })) + + const openCredentialsModal = (onChange: (e: string | string[]) => void) => { + openModal({ + content: ( + { + response && onChange(response.id) + closeModal() + }} + cloudProvider={cloudProvider} + allowedTypes={ + fieldName === 'configured_credentials' && !isUsingRestrictivePermissions + ? undefined + : WIDE_PERMISSION_CREDENTIAL_TYPES + } + /> + ), + options: { + width: 680, + }, + }) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( + <> + { + if (!value && (fieldName === 'configured_credentials' || getValues('using_restrictive_permissions'))) { + return 'Please select a credential.' + } + if ( + fieldName === 'configured_credentials' && + isUsingRestrictivePermissions && + !widePermissionCredentials.some((credential) => credential.id === value) + ) { + return 'Select an AWS role credential for wider access.' + } + return true + }, + }} + render={({ field, fieldState: { error } }) => ( + , + onClick: () => openCredentialsModal(field.onChange), + }} + /> + )} + /> + + {isSetting && ( + <> + + See and edit all Cloud credentials + + + + )} + + ) +} + +export default ClusterWidePermissionCredentialsSettings diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts new file mode 100644 index 00000000000..705fa541981 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts @@ -0,0 +1,192 @@ +import { type ClusterCloudProviderInfo, type ClusterCredentials } from 'qovery-typescript-axios' +import { + getCloudProviderInfoRequest, + getClusterCredentialsSavePlan, + saveClusterCredentials, +} from './save-cluster-credentials' + +const credentials = [ + { id: 'static-1', name: 'static-credential', object_type: 'AWS' }, + { id: 'static-2', name: 'other-static-credential', object_type: 'AWS' }, + { id: 'role-1', name: 'role-credential', object_type: 'AWS_ROLE' }, + { id: 'role-2', name: 'other-role-credential', object_type: 'AWS_ROLE' }, +] as ClusterCredentials[] + +const cloudProviderInfo: ClusterCloudProviderInfo = { + cloud_provider: 'AWS', + region: 'eu-west-3', + credentials: { id: 'static-1', name: 'static-credential' }, + wide_permission_credentials: { id: 'role-1', name: 'role-credential' }, +} + +describe('getCloudProviderInfoRequest', () => { + it('should map configured credentials to wide and restricted credentials to credentials when enabled', () => { + expect( + getCloudProviderInfoRequest( + { configured_credentials: 'role-2', restricted_credentials: 'static-2', using_restrictive_permissions: true }, + credentials, + cloudProviderInfo + ) + ).toEqual({ + cloud_provider: 'AWS', + region: 'eu-west-3', + credentials: { id: 'static-2', name: 'other-static-credential' }, + wide_permission_credentials: { id: 'role-2', name: 'other-role-credential' }, + }) + }) + + it('should map configured credentials to credentials when restrictive permissions are off', () => { + expect( + getCloudProviderInfoRequest( + { configured_credentials: 'static-2', restricted_credentials: 'role-2', using_restrictive_permissions: false }, + credentials, + cloudProviderInfo + ) + ).toEqual({ + cloud_provider: 'AWS', + region: 'eu-west-3', + credentials: { id: 'static-2', name: 'other-static-credential' }, + }) + }) + + it('should return undefined when a selected credential is missing', () => { + expect( + getCloudProviderInfoRequest({ configured_credentials: 'unknown' }, credentials, cloudProviderInfo) + ).toBeUndefined() + expect( + getCloudProviderInfoRequest( + { configured_credentials: 'role-1', restricted_credentials: '', using_restrictive_permissions: true }, + credentials, + cloudProviderInfo + ) + ).toBeUndefined() + }) +}) + +describe('getClusterCredentialsSavePlan', () => { + const initialFormValues = { + configured_credentials: 'role-1', + restricted_credentials: 'static-1', + using_restrictive_permissions: true, + } + + it.each([ + { + case: 'restricted credentials changed', + formValues: { ...initialFormValues, restricted_credentials: 'static-2' }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, + }, + { + case: 'configured credentials become active when the toggle is turned off', + formValues: { ...initialFormValues, using_restrictive_permissions: false }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, + }, + { + case: 'configured wide credential changed only', + formValues: { ...initialFormValues, configured_credentials: 'role-2' }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, + }, + { + case: 'toggle turned on with new restricted credentials', + formValues: { + configured_credentials: 'role-2', + restricted_credentials: 'static-2', + using_restrictive_permissions: true, + }, + initialUsingRestrictivePermissions: false, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, + }, + { + case: 'nothing changed', + formValues: initialFormValues, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, + }, + ])('should plan the save when $case', ({ formValues, initialUsingRestrictivePermissions, expected }) => { + expect( + getClusterCredentialsSavePlan({ formValues, cloudProviderInfo, initialUsingRestrictivePermissions }) + ).toEqual(expected) + }) + + it('should use configured credentials and ignore the restricted value while restrictive permissions are off', () => { + expect( + getClusterCredentialsSavePlan({ + formValues: { + configured_credentials: 'role-1', + restricted_credentials: 'static-2', + using_restrictive_permissions: false, + }, + cloudProviderInfo, + initialUsingRestrictivePermissions: false, + }) + ).toEqual({ shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: true }) + }) +}) + +describe('saveClusterCredentials', () => { + const plan = { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true } + + it('should update the cluster, then the cloud provider info, then redeploy', async () => { + const calls: string[] = [] + await saveClusterCredentials({ + plan, + editCluster: async () => calls.push('editCluster'), + editCloudProviderInfo: async () => calls.push('editCloudProviderInfo'), + deployCluster: async () => calls.push('deployCluster'), + }) + + expect(calls).toEqual(['editCluster', 'editCloudProviderInfo', 'deployCluster']) + }) + + it('should not update the cloud provider info nor redeploy when the cluster update fails', async () => { + const editCloudProviderInfo = jest.fn() + const deployCluster = jest.fn() + + await expect( + saveClusterCredentials({ + plan, + editCluster: () => Promise.reject(new Error('cluster update failed')), + editCloudProviderInfo, + deployCluster, + }) + ).rejects.toThrow('cluster update failed') + + expect(editCloudProviderInfo).not.toHaveBeenCalled() + expect(deployCluster).not.toHaveBeenCalled() + }) + + it('should not redeploy when the cloud provider info update fails', async () => { + const deployCluster = jest.fn() + + await expect( + saveClusterCredentials({ + plan, + editCluster: jest.fn().mockResolvedValue(undefined), + editCloudProviderInfo: () => Promise.reject(new Error('credentials update failed')), + deployCluster, + }) + ).rejects.toThrow('credentials update failed') + + expect(deployCluster).not.toHaveBeenCalled() + }) + + it('should only run the planned steps', async () => { + const editCluster = jest.fn() + const editCloudProviderInfo = jest.fn() + const deployCluster = jest.fn() + + await saveClusterCredentials({ + plan: { shouldEditCluster: true, shouldEditCloudProviderInfo: false, shouldRedeploy: false }, + editCluster, + editCloudProviderInfo, + deployCluster, + }) + + expect(editCluster).toHaveBeenCalledTimes(1) + expect(editCloudProviderInfo).not.toHaveBeenCalled() + expect(deployCluster).not.toHaveBeenCalled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts new file mode 100644 index 00000000000..83ee15bd1c3 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts @@ -0,0 +1,100 @@ +import { + type ClusterCloudProviderInfo, + type ClusterCloudProviderInfoRequest, + type ClusterCredentials, +} from 'qovery-typescript-axios' + +export interface ClusterCredentialsFormValues { + configured_credentials?: string + restricted_credentials?: string + credentials?: string + wide_permission_credentials?: string + using_restrictive_permissions?: boolean + slack_webhook_url?: string +} + +export interface ClusterCredentialsSavePlan { + shouldEditCluster: boolean + shouldEditCloudProviderInfo: boolean + shouldRedeploy: boolean +} + +export const getCloudProviderInfoRequest = ( + formValues: ClusterCredentialsFormValues, + credentials: ClusterCredentials[], + cloudProviderInfo: ClusterCloudProviderInfo +): ClusterCloudProviderInfoRequest | undefined => { + const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) + const credentialsId = usingRestrictivePermissions + ? formValues.restricted_credentials ?? formValues.credentials + : formValues.configured_credentials ?? formValues.credentials + const restrictiveCredentials = credentials.find(({ id }) => id === credentialsId) + if (!restrictiveCredentials) return undefined + + const widePermissionCredentials = usingRestrictivePermissions + ? credentials.find(({ id }) => id === (formValues.configured_credentials ?? formValues.wide_permission_credentials)) + : undefined + if (usingRestrictivePermissions && !widePermissionCredentials) return undefined + + return { + cloud_provider: cloudProviderInfo.cloud_provider, + credentials: { + id: restrictiveCredentials.id, + name: restrictiveCredentials.name, + }, + region: cloudProviderInfo.region, + // When restrictive permissions are turned off, the backend clears the extended credentials itself + ...(widePermissionCredentials && { + wide_permission_credentials: { + id: widePermissionCredentials.id, + name: widePermissionCredentials.name, + }, + }), + } +} + +export const getClusterCredentialsSavePlan = ({ + formValues, + cloudProviderInfo, + initialUsingRestrictivePermissions, +}: { + formValues: ClusterCredentialsFormValues + cloudProviderInfo: ClusterCloudProviderInfo + initialUsingRestrictivePermissions: boolean +}): ClusterCredentialsSavePlan => { + const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) + const credentialsId = usingRestrictivePermissions + ? formValues.restricted_credentials ?? formValues.credentials + : formValues.configured_credentials ?? formValues.credentials + const hasCredentialsChanged = credentialsId !== cloudProviderInfo.credentials?.id + const hasWidePermissionCredentialsChanged = + usingRestrictivePermissions && + (formValues.configured_credentials ?? formValues.wide_permission_credentials) !== + cloudProviderInfo.wide_permission_credentials?.id + const shouldEditCluster = usingRestrictivePermissions !== initialUsingRestrictivePermissions + + return { + shouldEditCluster, + // Toggling restrictive permissions is saved on the cluster; changed credential mappings are saved with cloud provider info. + shouldEditCloudProviderInfo: hasCredentialsChanged || hasWidePermissionCredentialsChanged || !shouldEditCluster, + // Changing the API credentials field requires a redeploy; changing the configured wide credential alone does not. + shouldRedeploy: hasCredentialsChanged, + } +} + +// The cluster is updated first, the credentials are only sent once it succeeded, then the cluster is redeployed +export async function saveClusterCredentials({ + plan, + editCluster, + editCloudProviderInfo, + deployCluster, +}: { + plan: ClusterCredentialsSavePlan + editCluster: () => Promise + editCloudProviderInfo: () => Promise + deployCluster: () => Promise +}) { + if (plan.shouldEditCluster) await editCluster() + if (plan.shouldEditCloudProviderInfo) await editCloudProviderInfo() + if (plan.shouldRedeploy) await deployCluster() +} diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx new file mode 100644 index 00000000000..d7c2c51b697 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx @@ -0,0 +1,100 @@ +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { type Cluster, type ClusterRequest } from 'qovery-typescript-axios' +import { type PropsWithChildren } from 'react' +import { act, renderHook, waitFor } from '@qovery/shared/util-tests' +import { queries } from '@qovery/state/util-queries' +import { useEditCluster } from './use-edit-cluster' + +const organizationId = 'org-123' +const clusterId = 'cluster-123' + +const cluster = { + id: clusterId, + name: 'my-cluster', + region: 'eu-west-3', + cloud_provider: 'AWS', + wide_permissions: { using_restrictive_permissions: false }, +} as Cluster +const otherCluster = { ...cluster, id: 'other-cluster' } as Cluster + +const setup = (editedCluster: Cluster, options?: Parameters[0]) => { + const requestSpy = jest.spyOn(axios, 'request').mockImplementation(async (config) => { + // Keep refetches pending to observe the cache before they complete + if (config?.method?.toUpperCase() === 'GET') return new Promise(() => undefined) + return { data: editedCluster } + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const clustersQuery = queries.clusters.list({ organizationId }) + queryClient.setQueryData(clustersQuery.queryKey, [cluster, otherCluster]) + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries') + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + const { result: mutation } = renderHook(() => useEditCluster(options), { wrapper }) + const { result: list } = renderHook(() => useQuery(clustersQuery), { wrapper }) + + return { requestSpy, queryClient, invalidateSpy, mutation, list, clustersQuery } +} + +describe('useEditCluster', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('seeds the edited cluster in the list before the refetch completes', async () => { + const editedCluster = { ...cluster, wide_permissions: { using_restrictive_permissions: true } } as Cluster + const { queryClient, invalidateSpy, mutation, list, clustersQuery } = setup(editedCluster) + + await act(async () => { + await mutation.current.mutateAsync({ + organizationId, + clusterId, + clusterRequest: { ...cluster, wide_permissions: { using_restrictive_permissions: true } } as ClusterRequest, + }) + }) + + await waitFor(() => expect(list.current.data).toEqual([editedCluster, otherCluster])) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: clustersQuery.queryKey }) + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queries.clusters.cloudProviderInfo({ organizationId, clusterId }).queryKey, + }) + queryClient.clear() + }) + + it('does not refetch the cloud provider info when restrictive permissions are unchanged', async () => { + const editedCluster = { ...cluster, name: 'renamed-cluster' } as Cluster + const { queryClient, invalidateSpy, mutation } = setup(editedCluster) + + await act(async () => { + await mutation.current.mutateAsync({ + organizationId, + clusterId, + clusterRequest: { ...cluster, name: 'renamed-cluster' } as ClusterRequest, + }) + }) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: queries.clusters.cloudProviderInfo({ organizationId, clusterId }).queryKey, + }) + queryClient.clear() + }) + + it.each([ + [undefined, expect.objectContaining({ title: 'Cluster updated', labelAction: 'Update' })], + [{ showClusterUpdateAction: false }, { title: 'Cluster updated' }], + ])('notifies the success with options %p', async (options, expectedNotification) => { + const { queryClient, mutation } = setup(cluster, options) + + await act(async () => { + await mutation.current.mutateAsync({ organizationId, clusterId, clusterRequest: cluster as ClusterRequest }) + }) + + const notifyOnSuccess = queryClient.getMutationCache().getAll()[0].options.meta?.['notifyOnSuccess'] as ( + data: unknown, + variables: unknown + ) => unknown + expect(notifyOnSuccess(cluster, { organizationId, clusterId })).toEqual(expectedNotification) + queryClient.clear() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts index 0d32666c1a0..5b5a07782d2 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts @@ -1,21 +1,40 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import posthog from 'posthog-js' +import { type Cluster } from 'qovery-typescript-axios' import { mutations } from '@qovery/domains/clusters/data-access' import { queries } from '@qovery/state/util-queries' import { useDeployCluster } from '../use-deploy-cluster/use-deploy-cluster' -export function useEditCluster() { +export function useEditCluster({ silently = false, showClusterUpdateAction = true } = {}) { const queryClient = useQueryClient() const { mutateAsync: deployCluster } = useDeployCluster() return useMutation(mutations.editCluster, { - onSuccess(_, { organizationId, clusterId, clusterRequest }) { - queryClient.invalidateQueries({ - queryKey: queries.clusters.list({ organizationId }).queryKey, - }) + onSuccess(editedCluster, { organizationId, clusterId, clusterRequest }) { + const clustersQueryKey = queries.clusters.list({ organizationId }).queryKey + const previousCluster = queryClient + .getQueryData(clustersQueryKey) + ?.find((cluster) => cluster.id === clusterId) + + // Seed the list with the edited cluster so consumers don't briefly read stale values before the refetch completes + if (editedCluster) { + queryClient.setQueryData(clustersQueryKey, (clusters: Cluster[] | undefined) => + clusters?.map((cluster) => (cluster.id === editedCluster.id ? editedCluster : cluster)) + ) + } + queryClient.invalidateQueries({ queryKey: clustersQueryKey }) queryClient.invalidateQueries({ queryKey: queries.clusters.kubeconfig({ organizationId, clusterId }).queryKey, }) + // Toggling restrictive permissions changes the extended credentials on the backend side (cleared when turned off) + if ( + previousCluster?.wide_permissions?.using_restrictive_permissions !== + clusterRequest.wide_permissions?.using_restrictive_permissions + ) { + queryClient.invalidateQueries({ + queryKey: queries.clusters.cloudProviderInfo({ organizationId, clusterId }).queryKey, + }) + } if (clusterRequest.keda?.enabled) { posthog.capture('cluster-keda-enabled', { @@ -24,17 +43,23 @@ export function useEditCluster() { } }, meta: { - notifyOnSuccess(_: unknown, variables: unknown) { - const { organizationId, clusterId } = variables as Parameters[0] - return { - title: 'Cluster updated', - description: 'You must update to apply the settings', - callback() { - deployCluster({ organizationId, clusterId }) + notifyOnSuccess: silently + ? false + : (_: unknown, variables: unknown) => { + if (!showClusterUpdateAction) { + return { title: 'Cluster updated' } + } + + const { organizationId, clusterId } = variables as Parameters[0] + return { + title: 'Cluster updated', + description: 'You must update to apply the settings', + callback() { + deployCluster({ organizationId, clusterId }) + }, + labelAction: 'Update', + } }, - labelAction: 'Update', - } - }, notifyOnError: true, }, })