diff --git a/web/packages/design/src/Toggle/Toggle.tsx b/web/packages/design/src/Toggle/Toggle.tsx index c744e3b38527a..1c70cc0caa368 100644 --- a/web/packages/design/src/Toggle/Toggle.tsx +++ b/web/packages/design/src/Toggle/Toggle.tsx @@ -41,6 +41,7 @@ export function Toggle({ onChange={onToggle} disabled={disabled} size={size} + data-testid="toggle" /> {children} diff --git a/web/packages/teleport/src/Console/DocumentNodes/__snapshots__/DocumentNodes.story.test.tsx.snap b/web/packages/teleport/src/Console/DocumentNodes/__snapshots__/DocumentNodes.story.test.tsx.snap index e8bd88d0c734d..fafede77b6658 100644 --- a/web/packages/teleport/src/Console/DocumentNodes/__snapshots__/DocumentNodes.story.test.tsx.snap +++ b/web/packages/teleport/src/Console/DocumentNodes/__snapshots__/DocumentNodes.story.test.tsx.snap @@ -478,6 +478,7 @@ exports[`render DocumentNodes 1`] = ` >
; +export const InstanceListLoading = () => { + cfg.isCloud = true; + return ; +}; InstanceListLoading.parameters = { msw: { handlers: [ diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx index 34d3f28c5e8dc..2b04302436b95 100644 --- a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx +++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx @@ -30,9 +30,9 @@ import { createTeleportContext } from 'teleport/mocks/contexts'; import { DiscoverProvider, DiscoverContextState, - NodeMeta, } from 'teleport/Discover/useDiscover'; import { + Ec2InstanceConnectEndpoint, IntegrationKind, IntegrationStatusCode, } from 'teleport/services/integrations'; @@ -46,6 +46,56 @@ export default { initialize(); +const mockedCreatedEc2Ice: Ec2InstanceConnectEndpoint = { + name: 'test-eice', + state: 'create-complete', + stateMessage: '', + dashboardLink: 'goteleport.com', + subnetId: 'test-subnetid', + vpcId: 'test', +}; + +const deployEndpointSuccess = rest.post( + cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), + (req, res, ctx) => res(ctx.json({ name: 'test-eice' })) +); + +let tick = 0; +const ec2IceEndpointWithTick = rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => { + if (tick == 1) { + tick = 0; // reset, the polling will be finished by this point. + return res( + ctx.json({ + ec2Ices: [mockedCreatedEc2Ice], + }) + ); + } + tick += 1; + return res( + ctx.json({ + ec2Ices: [{ ...mockedCreatedEc2Ice, state: 'create-in-progress' }], + }) + ); + } +); + +export const AutoDiscoverEnabled = () => ( + <> + + Devs: after clicking next, wait 10 seconds for in progress to change to + created + + + +); +AutoDiscoverEnabled.parameters = { + msw: { + handlers: [deployEndpointSuccess, ec2IceEndpointWithTick], + }, +}; + export const ListSecurityGroupsLoading = () => ; ListSecurityGroupsLoading.parameters = { @@ -133,10 +183,7 @@ CreatingInProgress.parameters = { }) ) ), - rest.post( - cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), - (req, res, ctx) => res(ctx.json({ name: 'test-eice' })) - ), + deployEndpointSuccess, ], }, }; @@ -175,10 +222,7 @@ CreatingFailed.parameters = { }) ) ), - rest.post( - cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), - (req, res, ctx) => res(ctx.json({ name: 'test-eice' })) - ), + deployEndpointSuccess, ], }, }; @@ -248,14 +292,13 @@ CreatingComplete.parameters = { }, }; -const Component = () => { +const Component = ({ autoDiscover = false }: { autoDiscover?: boolean }) => { const ctx = createTeleportContext(); const discoverCtx: DiscoverContextState = { agentMeta: { + awsRegion: 'us-east-1', resourceName: 'node-name', agentMatcherLabels: [], - db: {} as any, - selectedAwsRdsDb: {} as any, node: { kind: 'node', subKind: 'openssh-ec2-ice', @@ -286,7 +329,16 @@ const Component = () => { }, statusCode: IntegrationStatusCode.Running, }, - } as NodeMeta, + autoDiscovery: autoDiscover + ? { + config: { name: '', discoveryGroup: '', aws: [] }, + requiredVpcsAndSubnets: { + 'vpc-1': ['subnet-1'], + 'vpc-2': ['subnet-2'], + }, + } + : undefined, + }, updateAgentMeta: agentMeta => { discoverCtx.agentMeta = agentMeta; }, diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx index 9661369e4cccf..8d221177d77cf 100644 --- a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx +++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx @@ -17,16 +17,17 @@ */ import React, { useState, useEffect } from 'react'; - +import Table from 'design/DataTable'; import { Box, Indicator, Text, Flex } from 'design'; import { Warning } from 'design/Icon'; import { Danger } from 'design/Alert'; import { FetchStatus } from 'design/DataTable/types'; -import useAttempt from 'shared/hooks/useAttemptNext'; +import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; import { + AwsOidcDeployEc2InstanceConnectEndpointRequest, SecurityGroup, integrationService, } from 'teleport/services/integrations'; @@ -61,6 +62,28 @@ export function CreateEc2Ice() { fetchStatus: 'disabled', }); + const { + attempt: fetchSecurityGroupsAttempt, + setAttempt: setFetchSecurityGroupsAttempt, + } = useAttempt(''); + + const { attempt: deployEc2IceAttempt, setAttempt: setDeployEc2IceAttempt } = + useAttempt(''); + + const { emitErrorEvent, agentMeta, prevStep, nextStep, emitEvent } = + useDiscover(); + + const autoDiscoverEnabled = !!agentMeta.autoDiscovery; + + useEffect(() => { + // It has been decided for now that with auto discover, + // default security groups will be used (in the request + // this is depicted as an empty value) + if (!autoDiscoverEnabled) { + fetchSecurityGroups(); + } + }, []); + function onSelectSecurityGroup( sg: SecurityGroup, e: React.ChangeEvent @@ -74,21 +97,6 @@ export function CreateEc2Ice() { } } - useEffect(() => { - fetchSecurityGroups(); - }, []); - - const { - attempt: fetchSecurityGroupsAttempt, - setAttempt: setFetchSecurityGroupsAttempt, - } = useAttempt(''); - - const { attempt: deployEc2IceAttempt, setAttempt: setDeployEc2IceAttempt } = - useAttempt(''); - - const { emitErrorEvent, agentMeta, prevStep, nextStep, emitEvent } = - useDiscover(); - async function fetchSecurityGroups() { const integration = agentMeta.awsIntegration; @@ -117,17 +125,35 @@ export function CreateEc2Ice() { async function deployEc2InstanceConnectEndpoint() { const integration = agentMeta.awsIntegration; - setDeployEc2IceAttempt({ status: 'processing' }); - setShowCreatingDialog(true); - try { - await integrationService.deployAwsEc2InstanceConnectEndpoint( - integration.name, + let endpoints: AwsOidcDeployEc2InstanceConnectEndpointRequest[] = []; + if (autoDiscoverEnabled) { + endpoints = Object.values( + agentMeta.autoDiscovery.requiredVpcsAndSubnets + ).map(subnets => ({ + // Being in this step of the flow means + // the requiredVpcsAndSubnets will always + // be defined. + subnetId: subnets[0], + })); + } else { + endpoints = [ { - region: (agentMeta as NodeMeta).node.awsMetadata.region, subnetId: (agentMeta as NodeMeta).node.awsMetadata.subnetId, ...(selectedSecurityGroups.length && { securityGroupIds: selectedSecurityGroups, }), + }, + ]; + } + + setDeployEc2IceAttempt({ status: 'processing' }); + setShowCreatingDialog(true); + try { + await integrationService.deployAwsEc2InstanceConnectEndpoints( + integration.name, + { + region: agentMeta.awsRegion, + endpoints, } ); // Capture event for deploying EICE. @@ -137,6 +163,7 @@ export function CreateEc2Ice() { eventName: DiscoverEvent.EC2DeployEICE, } ); + setDeployEc2IceAttempt({ status: 'success' }); } catch (err) { const errMsg = getErrMessage(err); setShowCreatingDialog(false); @@ -158,51 +185,33 @@ export function CreateEc2Ice() { return ( <> -
Create an EC2 Instance Connect Endpoint
+
+ {autoDiscoverEnabled + ? 'Create EC2 Instance Connect Endpoints' + : 'Create an EC2 Instance Connect Endpoint'} +
{deployEc2IceAttempt.status === 'failed' && ( {deployEc2IceAttempt.statusText} )} - - Select AWS Security Groups to assign to the new EC2 Instance Connect - Endpoint: - - - The security groups you pick should allow outbound connectivity for - the agent to be able to dial Teleport clusters. If you don't select - any security groups, the default one for the VPC will be used. - - {fetchSecurityGroupsAttempt.status === 'failed' && ( - <> - - - {fetchSecurityGroupsAttempt.statusText} - - - Retry - - - )} - {fetchSecurityGroupsAttempt.status === 'processing' && ( - - - - )} - {fetchSecurityGroupsAttempt.status === 'success' && ( - - fetchSecurityGroups()} - fetchStatus={tableData.fetchStatus} - onSelectSecurityGroup={onSelectSecurityGroup} - selectedSecurityGroups={selectedSecurityGroups} - /> - + {autoDiscoverEnabled ? ( + + ) : ( + )} handleOnProceed()} disableProceed={deployEc2IceAttempt.status === 'processing'} /> @@ -216,3 +225,96 @@ export function CreateEc2Ice() { ); } + +function CreateEndpointsForAutoDiscover({ + requiredVpcIdsAndSubnets, +}: { + requiredVpcIdsAndSubnets: Record; +}) { + const items = Object.keys(requiredVpcIdsAndSubnets).map(key => ({ + vpcId: key, + subnetId: requiredVpcIdsAndSubnets[key][0], + })); + + return ( + + + EC2 Instance Connect Endpoints will be created for the following VPC + ID's: + + + + ); +} + +function SecurityGroups({ + fetchSecurityGroupsAttempt, + fetchSecurityGroups, + tableData, + onSelectSecurityGroup, + selectedSecurityGroups, +}: { + fetchSecurityGroupsAttempt: Attempt; + fetchSecurityGroups(): Promise; + tableData: TableData; + onSelectSecurityGroup( + sg: SecurityGroup, + e: React.ChangeEvent + ): void; + selectedSecurityGroups: string[]; +}) { + return ( + <> + + Select AWS Security Groups to assign to the new EC2 Instance Connect + Endpoint: + + + The security groups you pick should allow outbound connectivity for the + agent to be able to dial Teleport clusters. If you don't select any + security groups, the default one for the VPC will be used. + + {fetchSecurityGroupsAttempt.status === 'failed' && ( + <> + + + {fetchSecurityGroupsAttempt.statusText} + + + Retry + + + )} + {fetchSecurityGroupsAttempt.status === 'processing' && ( + + + + )} + {fetchSecurityGroupsAttempt.status === 'success' && ( + + fetchSecurityGroups()} + fetchStatus={tableData.fetchStatus} + onSelectSecurityGroup={onSelectSecurityGroup} + selectedSecurityGroups={selectedSecurityGroups} + /> + + )} + + ); +} diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx index 3c07406b652de..b3a2b31648b6b 100644 --- a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx +++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx @@ -33,13 +33,13 @@ import { getErrMessage } from 'shared/utils/errorType'; import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; import cfg from 'teleport/config'; +import useTeleport from 'teleport/useTeleport'; import { Ec2InstanceConnectEndpoint, integrationService, } from 'teleport/services/integrations'; -import NodeService from 'teleport/services/nodes'; -import { TextIcon } from 'teleport/Discover/Shared'; +import { Mark, TextIcon } from 'teleport/Discover/Shared'; import { NodeMeta, useDiscover } from 'teleport/Discover/useDiscover'; import { usePoll } from 'teleport/Discover/Shared/usePoll'; import { @@ -50,22 +50,37 @@ import { export function CreateEc2IceDialog({ nextStep, retry, - existingEice, + existingEices = null, }: { nextStep: () => void; retry?: () => void; - existingEice?: Ec2InstanceConnectEndpoint; + // Only supplied if there exists all the required ec2 + // instance connect endpoints, resulting in the user + // being able to skip the `eice deployment` step. + // Though the endpoints might all exist they may not all be + // in the `create-complete` state, which means polling + // for this endpoint is required until it becomes + // `create-complete`. + // If this field is NOT supplied, then new endpoints + // have been deployed which also needs to be polled + // until `create-complete`. + existingEices?: Ec2InstanceConnectEndpoint[]; }) { - // If the EICE already exists from the previous step and is create-complete, we don't need to do any polling for the EICE. - const [isPollingActive, setIsPollingActive] = useState( - existingEice?.state !== 'create-complete' + const { nodeService } = useTeleport(); + + // If the EICE already exists from the previous step and is + // create-complete, we don't need to do any polling for the EICE. + const [isPollingActive, setIsPollingActive] = useState(() => + existingEices + ? existingEices.some(e => e.state !== 'create-complete') + : true ); + const [mainDashboardLink, setMainDashboardLink] = useState(''); const { emitErrorEvent, updateAgentMeta, agentMeta, emitEvent } = useDiscover(); const typedAgentMeta = agentMeta as NodeMeta; - - const nodeService = new NodeService(); + const autoDiscoverEnabled = !!typedAgentMeta.autoDiscovery; const { attempt: fetchEc2IceAttempt, setAttempt: setFetchEc2IceAttempt } = useAttempt(''); @@ -74,22 +89,26 @@ export function CreateEc2IceDialog({ // When the EICE's state is 'create-complete', create the node. useEffect(() => { - if (typedAgentMeta.ec2Ice?.state === 'create-complete') { + // Auto discovery will automatically create the discovered + // nodes in the backend. + if (autoDiscoverEnabled) return; + + if (typedAgentMeta.ec2Ices?.every(e => e.state === 'create-complete')) { createNode(); } - }, [typedAgentMeta.ec2Ice]); + }, [typedAgentMeta.ec2Ices]); - let ec2Ice = usePoll( + let ec2Ices = usePoll( () => - fetchEc2InstanceConnectEndpoint().then(e => { - if (e?.state === 'create-complete') { + fetchEc2InstanceConnectEndpoints().then(endpoints => { + if (endpoints?.every(e => e.state === 'create-complete')) { setIsPollingActive(false); updateAgentMeta({ ...typedAgentMeta, - ec2Ice: e, + ec2Ices: endpoints, }); } - return e; + return endpoints; }), isPollingActive, 10000 // poll every 10 seconds @@ -97,51 +116,51 @@ export function CreateEc2IceDialog({ // If the EICE already existed from the previous step and was create-complete, we set // `ec2Ice` to it. - if (existingEice?.state === 'create-complete') { - ec2Ice = existingEice; + if (existingEices?.every(e => e.state === 'create-complete')) { + ec2Ices = existingEices; } - async function fetchEc2InstanceConnectEndpoint() { - const integration = typedAgentMeta.awsIntegration; + async function fetchEc2InstanceConnectEndpoints() { + let vpcIds: string[] = []; + if (autoDiscoverEnabled) { + const requiredVpcs = Object.keys( + typedAgentMeta.autoDiscovery.requiredVpcsAndSubnets + ); + const inprogressExistingEndpoints = + typedAgentMeta.ec2Ices + ?.filter(e => e.state === 'create-in-progress') + .map(e => e.vpcId) ?? []; + vpcIds = [...requiredVpcs, ...inprogressExistingEndpoints]; + } else { + vpcIds = [typedAgentMeta.node.awsMetadata.vpcId]; + } setFetchEc2IceAttempt({ status: 'processing' }); try { - const { endpoints: fetchedEc2Ices } = - await integrationService.fetchAwsEc2InstanceConnectEndpoints( - integration.name, - { - region: typedAgentMeta.node.awsMetadata.region, - vpcId: typedAgentMeta.node.awsMetadata.vpcId, - } - ); + const resp = await integrationService.fetchAwsEc2InstanceConnectEndpoints( + typedAgentMeta.awsIntegration.name, + { + region: typedAgentMeta.awsRegion, + vpcIds, + } + ); + setMainDashboardLink(resp.dashboardLink); setFetchEc2IceAttempt({ status: 'success' }); - const createCompleteEice = fetchedEc2Ices.find( - e => e.state === 'create-complete' + const endpoints = resp.endpoints.filter( + e => + e.state === 'create-complete' || + e.state === 'create-in-progress' || + e.state === 'create-failed' ); - if (createCompleteEice) { - return createCompleteEice; - } - const createInProgressEice = fetchedEc2Ices.find( - e => e.state === 'create-in-progress' - ); - if (createInProgressEice) { - return createInProgressEice; + if (endpoints.length > 0) { + return endpoints; } - - const createFailedEice = fetchedEc2Ices.find( - e => e.state === 'create-failed' - ); - if (createFailedEice) { - return createFailedEice; - } - } catch (err) { - const errMsg = getErrMessage(err); - setFetchEc2IceAttempt({ status: 'failed', statusText: errMsg }); - setIsPollingActive(false); - emitErrorEvent(`ec2 instance connect endpoint fetch error: ${errMsg}`); + } catch { + // eslint-disable-next-line no-empty + // Ignore any errors, as the poller will keep re-trying. } } @@ -179,6 +198,8 @@ export function CreateEc2IceDialog({ } } + const endpointsCreated = ec2Ices?.every(e => e.state === 'create-complete'); + let content: JSX.Element; if ( fetchEc2IceAttempt.status === 'failed' || @@ -205,7 +226,7 @@ export function CreateEc2IceDialog({ ); } else { - if (ec2Ice?.state === 'create-failed') { + if (ec2Ices?.some(e => e.state === 'create-failed')) { content = ( <> @@ -216,14 +237,10 @@ export function CreateEc2IceDialog({ text-align: center; `} > - We couldn't create the EC2 Instance Connect Endpoint. + We couldn't create some EC2 Instance Connect Endpoints.
Please visit your{' '} - + dashboard{' '} to troubleshoot. @@ -236,22 +253,10 @@ export function CreateEc2IceDialog({ ); - } else if ( - ec2Ice?.state === 'create-complete' && - createNodeAttempt.status === 'success' - ) { + } else if (createNodeAttempt.status === 'success' && endpointsCreated) { content = ( <> - {/* Don't show this message if the EICE had already been deployed before this step. */} - {!(existingEice?.state === 'create-complete') && ( - - - The EC2 Instance Connect Endpoint was successfully deployed. - - )} + ); + } else if (autoDiscoverEnabled && endpointsCreated) { + content = ( + <> + + + + + All endpoints required are created. The discovery service can take + a few minutes to finish auto-enrolling resources found in region{' '} + {typedAgentMeta.awsRegion}. + + + nextStep()}> + Next + + + ); } else { content = ( <> - - - This may take a few minutes.. - + + + + This may take a few minutes.. + + {!endpointsCreated && mainDashboardLink && ( + + Meanwhile, visit your{' '} + + dashboard + {' '} + to view the status of{' '} + {autoDiscoverEnabled ? 'each endpoint' : 'this endpoint'} + + )} + Next @@ -287,9 +324,9 @@ export function CreateEc2IceDialog({ } } - let title = 'Creating EC2 Instance Connect Endpoint'; + let title = 'Creating EC2 Instance Connect Endpoints'; - if (ec2Ice?.state === 'create-complete') { + if (!autoDiscoverEnabled && endpointsCreated) { if (createNodeAttempt.status === 'success') { title = 'Created Teleport Node'; } else { @@ -321,3 +358,22 @@ export type CreateEc2IceDialogProps = { retry: () => void; next: () => void; }; + +function EndpointSuccessfullyDeployed({ + existingEices, +}: { + existingEices: Ec2InstanceConnectEndpoint[]; +}) { + // Don't show this message if the EICE had already been deployed before this step. + if (!existingEices?.every(e => e.state === 'create-complete')) { + return ( + + + The EC2 Instance Connect Endpoints are successfully deployed. + + ); + } +} diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx index 728a1844fff03..6e5550c875d8f 100644 --- a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx @@ -17,9 +17,8 @@ */ import React from 'react'; -import { Box, Text } from 'design'; +import { Text } from 'design'; import Table from 'design/DataTable'; -import { Danger } from 'design/Alert'; import { FetchStatus } from 'design/DataTable/types'; import { Attempt } from 'shared/hooks/useAttemptNext'; @@ -30,11 +29,6 @@ import { labelMatcher, } from 'teleport/Discover/Shared'; -import { useDiscover } from 'teleport/Discover/useDiscover'; -import { Regions } from 'teleport/services/integrations'; -import { isIamPermError } from 'teleport/Discover/Shared/Aws/error'; -import { ConfigureIamPerms } from 'teleport/Discover/Shared/Aws/ConfigureIamPerms'; - import { CheckedEc2Instance } from './EnrollEc2Instance'; type Props = { @@ -44,7 +38,7 @@ type Props = { fetchNextPage(): void; onSelectInstance(item: CheckedEc2Instance): void; selectedInstance?: CheckedEc2Instance; - region: Regions; + wantAutoDiscover: boolean; }; export const Ec2InstanceList = ({ @@ -54,20 +48,12 @@ export const Ec2InstanceList = ({ fetchNextPage, onSelectInstance, selectedInstance, - region, + wantAutoDiscover, }: Props) => { const hasError = attempt.status === 'failed'; - const { agentMeta } = useDiscover(); - - const showConfigureScript = isIamPermError(attempt); - - const disabledText = `This EC2 instance is already enrolled and is a part of this cluster`; return ( <> - {hasError && !showConfigureScript && ( - {attempt.statusText} - )} {!hasError && (
); }, @@ -96,7 +84,7 @@ export const Ec2InstanceList = ({ altKey: 'name', headerText: 'Name', render: ({ labels, ec2InstanceExists }) => ( - + {labels.find(label => label.name === 'Name')?.value} ), @@ -105,7 +93,7 @@ export const Ec2InstanceList = ({ key: 'hostname', headerText: 'Hostname', render: ({ hostname, ec2InstanceExists }) => ( - + {hostname} ), @@ -114,7 +102,7 @@ export const Ec2InstanceList = ({ key: 'addr', headerText: 'Address', render: ({ addr, ec2InstanceExists }) => ( - + {addr} ), @@ -123,7 +111,7 @@ export const Ec2InstanceList = ({ altKey: 'instanceId', headerText: 'AWS Instance ID', render: ({ awsMetadata, ec2InstanceExists }) => ( - + ( - + ), @@ -151,15 +139,17 @@ export const Ec2InstanceList = ({ isSearchable /> )} - {showConfigureScript && ( - - - - )} ); }; + +function disabledStates(ec2InstanceExists: boolean, wantAutoDiscover: boolean) { + const disabled = wantAutoDiscover || ec2InstanceExists; + + let disabledText = `This EC2 instance is already enrolled and is a part of this cluster`; + if (wantAutoDiscover) { + disabledText = 'All eligible EC2 instances will be enrolled automatically'; + } + + return { disabled, disabledText }; +} diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx index 5f156b1ea7309..71aed7b0563d8 100644 --- a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx @@ -16,11 +16,12 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useEffect } from 'react'; import { MemoryRouter } from 'react-router'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { rest } from 'msw'; +import { Info } from 'design/Alert'; import { ContextProvider } from 'teleport'; import cfg from 'teleport/config'; @@ -30,36 +31,172 @@ import { DiscoverContextState, } from 'teleport/Discover/useDiscover'; import { + Ec2InstanceConnectEndpoint, IntegrationKind, IntegrationStatusCode, } from 'teleport/services/integrations'; import { EnrollEc2Instance } from './EnrollEc2Instance'; +const defaultIsCloud = cfg.isCloud; export default { title: 'Teleport/Discover/Server/EC2/InstanceList', loaders: [mswLoader], + decorators: [ + Story => { + useEffect(() => { + // Clean up + return () => { + cfg.isCloud = defaultIsCloud; + }; + }, []); + return ; + }, + ], }; initialize(); -export const InstanceList = () => ; +const baseHandlers = [ + rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) => + res(ctx.json({ servers: ec2InstancesResponse })) + ), + rest.get(cfg.getClusterNodesUrl('localhost'), (req, res, ctx) => + res(ctx.json({ items: [ec2InstancesResponse[2]] })) + ), + rest.post(cfg.api.discoveryConfigPath, (req, res, ctx) => res(ctx.json({}))), +]; + +let tick = 0; +const ec2IceEndpointWithTick = rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => { + if (tick == 1) { + tick = 0; // reset, the polling will be finished by this point. + return res( + ctx.json({ + ec2Ices: [mockedCreatedEc2Ice], + }) + ); + } + tick += 1; + return res( + ctx.json({ + ec2Ices: [{ ...mockedCreatedEc2Ice, state: 'create-in-progress' }], + }) + ); + } +); + +const mockedCreatedEc2Ice: Ec2InstanceConnectEndpoint = { + name: 'test-eice', + state: 'create-complete', + stateMessage: '', + dashboardLink: 'goteleport.com', + subnetId: 'test-subnetid', + vpcId: 'test', +}; + +const mockedNode = { + id: '', + siteId: '', + subKind: 'teleport', + hostname: 'hostname', + addr: '', + tunnel: false, + tags: [], + sshLogins: [], + aws: {}, +}; -InstanceList.parameters = { +export const SingleInstanceListCreated = () => ; +SingleInstanceListCreated.parameters = { msw: { handlers: [ - rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) => - res(ctx.json({ servers: ec2InstancesResponse })) + ...baseHandlers, + rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => + res( + ctx.json({ + ec2Ices: [mockedCreatedEc2Ice], + }) + ) ), - rest.get(cfg.getClusterNodesUrl('localhost'), (req, res, ctx) => - res(ctx.json({ items: [ec2InstancesResponse[2]] })) + rest.post(cfg.api.nodesPathNoParams, (req, res, ctx) => + res(ctx.json(mockedNode)) ), ], }, }; -export const InstanceListLoading = () => ; +export const SingleInstanceListForCloudPending = () => { + cfg.isCloud = true; + return ( + <> + + Devs: Select region, after clicking next, wait 10 seconds for pending + state to go into created state + + + + ); +}; +SingleInstanceListForCloudPending.parameters = { + msw: { + handlers: [ + ...baseHandlers, + ec2IceEndpointWithTick, + rest.post(cfg.api.nodesPathNoParams, (req, res, ctx) => + res(ctx.json(mockedNode)) + ), + ], + }, +}; +export const AutoDiscoverInstanceListForCloudCreated = () => { + cfg.isCloud = true; + return ; +}; +AutoDiscoverInstanceListForCloudCreated.parameters = { + msw: { + handlers: [ + ...baseHandlers, + rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => + res( + ctx.json({ + ec2Ices: [mockedCreatedEc2Ice], + }) + ) + ), + ], + }, +}; + +export const AutoDiscoverInstanceListForCloudPending = () => { + cfg.isCloud = true; + return ( + <> + + Devs: Select region, after clicking next, wait 10 seconds for pending + state to go into created state + + + + ); +}; +AutoDiscoverInstanceListForCloudPending.parameters = { + msw: { + handlers: [...baseHandlers, ec2IceEndpointWithTick], + }, +}; + +export const InstanceListLoading = () => ; InstanceListLoading.parameters = { msw: { handlers: [ @@ -75,7 +212,7 @@ export const WithAwsPermissionsError = () => ; WithAwsPermissionsError.parameters = { msw: { handlers: [ - rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) => + rest.post(cfg.api.ec2InstancesListPath, (req, res, ctx) => res( ctx.status(403), ctx.json({ message: 'StatusCode: 403, RequestID: operation error' }) @@ -97,15 +234,39 @@ WithOtherError.parameters = { }, }; -const Component = () => { +const Component = ({ + autoDiscover = false, + ec2Ices = [], +}: { + autoDiscover?: boolean; + ec2Ices?: Ec2InstanceConnectEndpoint[]; +}) => { const ctx = createTeleportContext(); const discoverCtx: DiscoverContextState = { agentMeta: { + awsRegion: 'us-east-1', resourceName: 'node-name', agentMatcherLabels: [], - db: {} as any, - selectedAwsRdsDb: {} as any, - node: {} as any, + node: { + kind: 'node', + id: 'some-id', + clusterId: 'cluster-id', + hostname: 'some-hostname', + labels: [], + addr: '', + tunnel: false, + subKind: 'teleport', + sshLogins: [], + awsMetadata: { + accountId: 'aws-account-id', + instanceId: 'instance-id', + region: 'us-east-1', + vpcId: 'instance-vpc-id', + integration: 'integration-name', + subnetId: 'subnet-id', + }, + }, + ec2Ices: ec2Ices, awsIntegration: { kind: IntegrationKind.AwsOidc, name: 'test-oidc', @@ -117,6 +278,12 @@ const Component = () => { }, statusCode: IntegrationStatusCode.Running, }, + autoDiscovery: autoDiscover + ? { + config: { name: '', discoveryGroup: '', aws: [] }, + requiredVpcsAndSubnets: {}, + } + : undefined, }, currentStep: 0, nextStep: () => null, diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx index 903fd938d00d4..920321be3e8c0 100644 --- a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx @@ -18,10 +18,17 @@ import React from 'react'; import { MemoryRouter } from 'react-router'; -import { render, screen, fireEvent } from 'design/utils/testing'; +import { + render, + screen, + fireEvent, + act, + userEvent, +} from 'design/utils/testing'; import { ContextProvider } from 'teleport'; import { + Ec2InstanceConnectEndpoint, IntegrationKind, IntegrationStatusCode, integrationService, @@ -32,19 +39,52 @@ import TeleportContext from 'teleport/teleportContext'; import { DiscoverContextState, DiscoverProvider, + NodeMeta, } from 'teleport/Discover/useDiscover'; import { FeaturesContextProvider } from 'teleport/FeaturesContext'; import { Node } from 'teleport/services/nodes'; -import { userEventService } from 'teleport/services/userEvent'; +import { + DiscoverEvent, + DiscoverEventStatus, + userEventService, +} from 'teleport/services/userEvent'; +import * as discoveryApi from 'teleport/services/discovery/discovery'; +import { DEFAULT_DISCOVERY_GROUP_NON_CLOUD } from 'teleport/services/discovery/discovery'; import { EnrollEc2Instance } from './EnrollEc2Instance'; +const defaultIsCloud = cfg.isCloud; + describe('test EnrollEc2Instance.tsx', () => { - beforeEach(() => { + afterEach(() => { + cfg.isCloud = defaultIsCloud; jest.restoreAllMocks(); }); + const selectedRegion = 'us-west-1'; + + async function selectARegion({ + waitForSelfHosted, + waitForTable, + }: { + waitForTable?: boolean; + waitForSelfHosted?: boolean; + }) { + const regionSelectorElement = screen.getByLabelText(/aws region/i); + fireEvent.focus(regionSelectorElement); + fireEvent.keyDown(regionSelectorElement, { key: 'ArrowDown', keyCode: 40 }); + fireEvent.click(screen.getByText(selectedRegion)); + + if (waitForTable) { + return await screen.findAllByText(/My EC2 Box 1/i); + } + + if (waitForSelfHosted) { + return await screen.findAllByText(/create a join token/i); + } + } + test('a cloudshell script should be shown if there is an aws permissions error', async () => { const { ctx, discoverCtx } = getMockedContexts(); @@ -57,12 +97,7 @@ describe('test EnrollEc2Instance.tsx', () => { jest.spyOn(console, 'error').mockImplementation(); renderEc2Instances(ctx, discoverCtx); - - // Selects a region - const regionSelectorElement = screen.getByLabelText(/aws region/i); - fireEvent.focus(regionSelectorElement); - fireEvent.keyDown(regionSelectorElement, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-west-1')); + await selectARegion({}); // Wait for results to be listed. await screen.findAllByText( @@ -73,7 +108,7 @@ describe('test EnrollEc2Instance.tsx', () => { expect(ctx.nodeService.fetchNodes).not.toHaveBeenCalled(); }); - test('an instance that is already enrolled should be disabled', async () => { + test('single instance, an instance that is already enrolled should be disabled', async () => { const { ctx, discoverCtx } = getMockedContexts(); jest @@ -85,14 +120,10 @@ describe('test EnrollEc2Instance.tsx', () => { .mockResolvedValue({ agents: mockFetchedNodes }); renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForSelfHosted: true }); - // Selects a region - const regionSelectorElement = screen.getByLabelText(/aws region/i); - fireEvent.focus(regionSelectorElement); - fireEvent.keyDown(regionSelectorElement, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-west-1')); - - // Wait for results to be listed. + // toggle off auto enroll, to test the table. + await userEvent.click(screen.getByText(/auto-enroll all/i)); await screen.findAllByText(/My EC2 Box 1/i); expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledTimes(1); @@ -115,7 +146,7 @@ describe('test EnrollEc2Instance.tsx', () => { expect(disabledRowElements).toHaveLength(1); }); - test('there should be no disabled rows if the fetchNodes response is empty', async () => { + test('single instance, there should be no disabled rows if the fetchNodes response is empty', async () => { const { ctx, discoverCtx } = getMockedContexts(); jest @@ -123,14 +154,10 @@ describe('test EnrollEc2Instance.tsx', () => { .mockResolvedValue({ instances: mockEc2Instances }); renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForSelfHosted: true }); - // Selects a region - const regionSelectorElement = screen.getByLabelText(/aws region/i); - fireEvent.focus(regionSelectorElement); - fireEvent.keyDown(regionSelectorElement, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-west-1')); - - // Wait for results to be listed. + // toggle off auto enroll + await userEvent.click(screen.getByText(/auto-enroll all/i)); await screen.findAllByText(/My EC2 Box 1/i); expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledTimes(1); @@ -140,12 +167,335 @@ describe('test EnrollEc2Instance.tsx', () => { expect( screen.queryAllByTitle( 'This EC2 instance is already enrolled and is a part of this cluster' - )[0] - ).toBeUndefined(); + ) + ).toHaveLength(0); + }); + + test('self-hosted, auto discover toggling', async () => { + const { ctx, discoverCtx } = getMockedContexts(); + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForSelfHosted: true }); + + // default toggler should be checked. + expect(screen.getByTestId('toggle')).toBeChecked(); + expect(screen.queryByText(/My EC2 Box 1/i)).not.toBeInTheDocument(); + expect(screen.getByText(/next/i, { selector: 'button' })).toBeEnabled(); + + // toggle off auto enroll, should render table. + await userEvent.click(screen.getByText(/auto-enroll all/i)); + expect(screen.getByTestId('toggle')).not.toBeChecked(); + expect(screen.getByText(/next/i, { selector: 'button' })).toBeDisabled(); + + await screen.findAllByText(/My EC2 Box 1/i); + + // toggle it back on. + await userEvent.click(screen.getByText(/auto-enroll all/i)); + expect(screen.getByTestId('toggle')).toBeChecked(); + }); + + test('cloud, auto discover toggling', async () => { + cfg.isCloud = true; + + const { ctx, discoverCtx } = getMockedContexts(); + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForTable: true }); + + // default toggler should be checked. + expect(screen.queryByText(/create a join token/i)).not.toBeInTheDocument(); + expect(screen.getByTestId('toggle')).toBeChecked(); + expect(screen.getByText(/next/i, { selector: 'button' })).toBeEnabled(); + + // toggle off auto enroll + await userEvent.click(screen.getByText(/auto-enroll all/i)); + await screen.findAllByText(/My EC2 Box 1/i); + expect(screen.getByTestId('toggle')).not.toBeChecked(); + expect(screen.getByText(/next/i, { selector: 'button' })).toBeDisabled(); + + // toggle it back on. + await userEvent.click(screen.getByText(/auto-enroll all/i)); + expect(screen.getByTestId('toggle')).toBeChecked(); + }); + + test('self-hosted, auto discover without existing endpoints', async () => { + const { ctx, discoverCtx } = getMockedContexts(); + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + jest + .spyOn(integrationService, 'fetchAwsEc2InstanceConnectEndpoints') + .mockResolvedValue({ endpoints: [], dashboardLink: '' }); + + const createDiscoveryConfig = jest + .spyOn(discoveryApi, 'createDiscoveryConfig') + .mockResolvedValue({ + name: 'discovery-cfg', + discoveryGroup: '', + aws: [], + }); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForSelfHosted: true }); + + await userEvent.click(screen.getByText(/next/i, { selector: 'button' })); + expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledWith( + discoverCtx.agentMeta.awsIntegration.name, + { region: selectedRegion, nextToken: '' } + ); + expect(createDiscoveryConfig.mock.calls[0][1]['discoveryGroup']).toBe( + DEFAULT_DISCOVERY_GROUP_NON_CLOUD + ); + expect(discoverCtx.nextStep).toHaveBeenCalledTimes(1); + }); + + test('self-hosted, auto discover without all existing endpoints, creates node resource', async () => { + const { ctx, discoverCtx } = getMockedContexts(); + (discoverCtx.agentMeta as NodeMeta).ec2Ices = endpoints; + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + jest + .spyOn(integrationService, 'fetchAwsEc2InstanceConnectEndpoints') + .mockResolvedValue({ endpoints, dashboardLink: '' }); + + jest.spyOn(discoveryApi, 'createDiscoveryConfig').mockResolvedValue({ + name: 'discovery-cfg', + discoveryGroup: '', + aws: [], + }); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForSelfHosted: true }); + + await userEvent.click(screen.getByText(/next/i, { selector: 'button' })); + expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledTimes(1); + expect(discoveryApi.createDiscoveryConfig).toHaveBeenCalledTimes(1); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); + expect(discoverCtx.emitEvent).toHaveBeenCalledWith( + { stepStatus: DiscoverEventStatus.Skipped }, + { + eventName: DiscoverEvent.EC2DeployEICE, + } + ); + + await screen.findByText(/created teleport node/i); + expect(ctx.nodeService.createNode).toHaveBeenCalledTimes(1); + }); + + test('cloud, auto discover with all existing created endpoints and no auto discovery config', async () => { + cfg.isCloud = true; + + let { ctx, discoverCtx } = getMockedContexts(); + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + jest + .spyOn(integrationService, 'fetchAwsEc2InstanceConnectEndpoints') + .mockResolvedValue({ + endpoints, + dashboardLink: '', + }); + + const createDiscoveryConfig = jest + .spyOn(discoveryApi, 'createDiscoveryConfig') + .mockResolvedValue({ + name: 'discovery-cfg', + discoveryGroup: '', + aws: [], + }); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForTable: true }); + + await userEvent.click(screen.getByText(/next/i, { selector: 'button' })); + expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledWith( + discoverCtx.agentMeta.awsIntegration.name, + { region: selectedRegion, nextToken: '' } + ); + expect(createDiscoveryConfig.mock.calls[0][1]['discoveryGroup']).toBe( + discoveryApi.DISCOVERY_GROUP_CLOUD + ); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); + expect(discoverCtx.emitEvent).toHaveBeenCalledWith( + { stepStatus: DiscoverEventStatus.Skipped }, + { + eventName: DiscoverEvent.EC2DeployEICE, + } + ); + }); + + test('cloud, auto discover with all existing created endpoints, with already set discovery config', async () => { + cfg.isCloud = true; + + let { ctx, discoverCtx } = getMockedContexts(true /* withAutoDiscovery */); + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + jest + .spyOn(integrationService, 'fetchAwsEc2InstanceConnectEndpoints') + .mockResolvedValue({ + endpoints: [ + { + name: 'endpoint-1', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-1', + vpcId: 'vpc-1', + }, + { + name: 'endpoint-2', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-2', + vpcId: 'vpc-2', + }, + { + name: 'endpoint-3', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-3', + vpcId: 'vpc-3', + }, + ], + dashboardLink: '', + }); + + jest.spyOn(discoveryApi, 'createDiscoveryConfig').mockResolvedValue({ + name: 'discovery-cfg', + discoveryGroup: '', + aws: [], + }); + + jest.spyOn(ctx.nodeService, 'createNode').mockResolvedValue({} as any); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForTable: true }); + + await userEvent.click(screen.getByText(/next/i, { selector: 'button' })); + expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledWith( + discoverCtx.agentMeta.awsIntegration.name, + { region: selectedRegion, nextToken: '' } + ); + expect(discoveryApi.createDiscoveryConfig).not.toHaveBeenCalled(); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); + expect(ctx.nodeService.createNode).not.toHaveBeenCalled(); + + expect(discoverCtx.emitEvent).toHaveBeenCalledWith( + { stepStatus: DiscoverEventStatus.Skipped }, + { + eventName: DiscoverEvent.EC2DeployEICE, + } + ); + + await screen.findByText(/All endpoints required are created/i); + }); + + test('cloud, with partially created endpoints, with already set discovery config', async () => { + cfg.isCloud = true; + jest.useFakeTimers(); + + const { ctx, discoverCtx } = getMockedContexts( + true /* withAutoDiscovery */ + ); + + jest + .spyOn(integrationService, 'fetchAwsEc2Instances') + .mockResolvedValue({ instances: mockEc2Instances }); + + const fetchEndpoints = jest + .spyOn(integrationService, 'fetchAwsEc2InstanceConnectEndpoints') + .mockResolvedValueOnce({ + endpoints: [ + { + name: 'endpoint-1', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-1', + vpcId: 'vpc-1', + }, + { + name: 'endpoint-2', + state: 'create-in-progress', // <-- should trigger polling + dashboardLink: '', + subnetId: 'subnet-2', + vpcId: 'vpc-2', + }, + { + name: 'endpoint-3', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-3', + vpcId: 'vpc-3', + }, + ], + dashboardLink: '', + }) + .mockResolvedValueOnce({ + endpoints: [ + { + name: 'endpoint-2', + state: 'create-complete', // <-- should stop polling + dashboardLink: '', + subnetId: 'subnet-2', + vpcId: 'vpc-2', + }, + ], + dashboardLink: '', + }); + jest.spyOn(discoveryApi, 'createDiscoveryConfig').mockResolvedValue({ + name: 'discovery-cfg', + discoveryGroup: '', + aws: [], + }); + jest.spyOn(ctx.nodeService, 'createNode').mockResolvedValue({} as any); + + renderEc2Instances(ctx, discoverCtx); + await selectARegion({ waitForTable: true }); + + // Test it's polling. + fireEvent.click(screen.getByText(/next/i, { selector: 'button' })); + await screen.findByText(/this may take a few minutes/i); + + expect(integrationService.fetchAwsEc2Instances).toHaveBeenCalledTimes(1); + expect(discoveryApi.createDiscoveryConfig).not.toHaveBeenCalled(); + expect(ctx.nodeService.createNode).not.toHaveBeenCalled(); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); + expect(discoverCtx.emitEvent).toHaveBeenCalledWith( + { stepStatus: DiscoverEventStatus.Skipped }, + { + eventName: DiscoverEvent.EC2DeployEICE, + } + ); + expect(fetchEndpoints).toHaveBeenCalledTimes(1); + fetchEndpoints.mockClear(); + + // advance timer to call the endpoint with completed state + await act(async () => jest.advanceTimersByTime(10000)); + await screen.findByText(/All endpoints required are created/i); + expect(fetchEndpoints).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); }); }); -function getMockedContexts() { +function getMockedContexts(withAutoDiscovery = false) { const ctx = createTeleportContext(); const discoverCtx: DiscoverContextState = { agentMeta: { @@ -154,7 +504,7 @@ function getMockedContexts() { agentMatcherLabels: [], db: {} as any, selectedAwsRdsDb: {} as any, - node: {} as any, + node: mockFetchedNodes[0], awsIntegration: { kind: IntegrationKind.AwsOidc, name: 'test-oidc', @@ -166,9 +516,15 @@ function getMockedContexts() { }, statusCode: IntegrationStatusCode.Running, }, + autoDiscovery: withAutoDiscovery + ? { + config: { name: '', discoveryGroup: '', aws: [] }, + requiredVpcsAndSubnets: {}, + } + : undefined, }, currentStep: 0, - nextStep: () => null, + nextStep: jest.fn(), prevStep: () => null, onSelectResource: () => null, resourceSpec: {} as any, @@ -178,11 +534,14 @@ function getMockedContexts() { setResourceSpec: () => null, updateAgentMeta: () => null, emitErrorEvent: () => null, - emitEvent: () => null, + emitEvent: jest.fn(), eventState: null, }; jest.spyOn(ctx.nodeService, 'fetchNodes').mockResolvedValue({ agents: [] }); + jest + .spyOn(ctx.nodeService, 'createNode') + .mockResolvedValue(mockFetchedNodes[0]); jest .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(undefined as never); @@ -229,9 +588,9 @@ const mockEc2Instances: Node[] = [ accountId: 'test-account', instanceId: 'instance-ec2-1', region: 'us-west-1', - vpcId: 'test', + vpcId: 'vpc-1', integration: 'test', - subnetId: 'test', + subnetId: 'subnet-1', }, }, { @@ -251,9 +610,9 @@ const mockEc2Instances: Node[] = [ accountId: 'test-account', instanceId: 'instance-ec2-2', region: 'us-west-1', - vpcId: 'test', + vpcId: 'vpc-2', integration: 'test', - subnetId: 'test', + subnetId: 'subnet-2', }, }, { @@ -273,9 +632,9 @@ const mockEc2Instances: Node[] = [ accountId: 'test-account', instanceId: 'instance-ec2-3', region: 'us-west-1', - vpcId: 'test', + vpcId: 'vpc-1', integration: 'test', - subnetId: 'test', + subnetId: 'subnet-2', }, }, { @@ -295,9 +654,9 @@ const mockEc2Instances: Node[] = [ accountId: 'test-account', instanceId: 'instance-ec2-4', region: 'us-west-1', - vpcId: 'test', + vpcId: 'vpc-2', integration: 'test', - subnetId: 'test', + subnetId: 'subnet-2', }, }, { @@ -317,9 +676,9 @@ const mockEc2Instances: Node[] = [ accountId: 'test-account', instanceId: 'instance-ec2-5', region: 'us-west-1', - vpcId: 'test', + vpcId: 'vpc-3', integration: 'test', - subnetId: 'test', + subnetId: 'subnet-3', }, }, ]; @@ -338,5 +697,37 @@ const mockFetchedNodes: Node[] = [ tunnel: false, subKind: 'openssh-ec2-ice', sshLogins: ['test'], + awsMetadata: { + instanceId: 'some-id', + accountId: '', + region: 'us-east-1', + vpcId: '', + integration: '', + subnetId: '', + }, + }, +]; + +const endpoints: Ec2InstanceConnectEndpoint[] = [ + { + name: 'endpoint-1', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-1', + vpcId: 'vpc-1', + }, + { + name: 'endpoint-2', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-2', + vpcId: 'vpc-2', + }, + { + name: 'endpoint-3', + state: 'create-complete', + dashboardLink: '', + subnetId: 'subnet-3', + vpcId: 'vpc-3', }, ]; diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx index d3d7b165a6987..11928a69b323e 100644 --- a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx @@ -17,11 +17,13 @@ */ import React, { useState } from 'react'; -import { Box, Text } from 'design'; +import { Box, Text, Toggle } from 'design'; import { FetchStatus } from 'design/DataTable/types'; import useAttempt from 'shared/hooks/useAttemptNext'; +import { Danger } from 'design/Alert'; import { getErrMessage } from 'shared/utils/errorType'; +import { ToolTipInfo } from 'shared/components/ToolTip'; import useTeleport from 'teleport/useTeleport'; import cfg from 'teleport/config'; @@ -38,12 +40,28 @@ import { DiscoverEvent, DiscoverEventStatus, } from 'teleport/services/userEvent'; +import { + DISCOVERY_GROUP_CLOUD, + DEFAULT_DISCOVERY_GROUP_NON_CLOUD, + DiscoveryConfig, + createDiscoveryConfig, +} from 'teleport/services/discovery'; +import { + getAttemptsOneOfErrorMsg, + isIamPermError, +} from 'teleport/Discover/Shared/Aws/error'; +import { ConfigureIamPerms } from 'teleport/Discover/Shared/Aws/ConfigureIamPerms'; -import { ActionButtons, Header } from '../../Shared'; +import { + ActionButtons, + Header, + SelfHostedAutoDiscoverDirections, +} from '../../Shared'; import { CreateEc2IceDialog } from '../CreateEc2Ice/CreateEc2IceDialog'; import { Ec2InstanceList } from './Ec2InstanceList'; +import { NoEc2IceRequiredDialog } from './NoEc2IceRequiredDialog'; // CheckedEc2Instance is a type to describe that an EC2 instance // has been checked to determine whether or not it is already enrolled in the cluster. @@ -55,7 +73,6 @@ type TableData = { items: CheckedEc2Instance[]; fetchStatus: FetchStatus; nextToken?: string; - currRegion?: Regions; }; const emptyTableData: TableData = { @@ -67,11 +84,11 @@ const emptyTableData: TableData = { export function EnrollEc2Instance() { const { agentMeta, emitErrorEvent, nextStep, updateAgentMeta, emitEvent } = useDiscover(); - const { nodeService } = useTeleport(); + const { nodeService, storeUser } = useTeleport(); const [currRegion, setCurrRegion] = useState(); - const [existingEice, setExistingEice] = - useState(); + const [foundAllRequiredEices, setFoundAllRequiredEices] = + useState(); const [selectedInstance, setSelectedInstance] = useState(); @@ -81,6 +98,12 @@ export function EnrollEc2Instance() { fetchStatus: 'disabled', }); + const [autoDiscoveryCfg, setAutoDiscoveryCfg] = useState(); + const [wantAutoDiscover, setWantAutoDiscover] = useState(true); + const [discoveryGroupName, setDiscoveryGroupName] = useState(() => + cfg.isCloud ? '' : DEFAULT_DISCOVERY_GROUP_NON_CLOUD + ); + const { attempt: fetchEc2InstancesAttempt, setAttempt: setFetchEc2InstancesAttempt, @@ -92,32 +115,42 @@ export function EnrollEc2Instance() { function fetchEc2InstancesWithNewRegion(region: Regions) { if (region) { setCurrRegion(region); - fetchEc2Instances({ ...emptyTableData, currRegion: region }); + fetchEc2Instances({ ...emptyTableData }, region); } } function fetchNextPage() { - fetchEc2Instances({ ...tableData }); + fetchEc2Instances({ ...tableData }, currRegion); } function refreshEc2Instances() { + setSelectedInstance(null); + setFetchEc2IceAttempt({ status: '' }); // When refreshing, start the table back at page 1. - fetchEc2Instances({ ...tableData, nextToken: '', items: [], currRegion }); + fetchEc2Instances({ ...tableData, items: [] }, currRegion); } - async function fetchEc2Instances(data: TableData) { + async function fetchEc2Instances(data: TableData, region: Regions) { const integrationName = agentMeta.awsIntegration.name; setTableData({ ...data, fetchStatus: 'loading' }); setFetchEc2InstancesAttempt({ status: 'processing' }); try { - const { instances: fetchedEc2Instances, nextToken } = - await integrationService.fetchAwsEc2Instances(integrationName, { - region: data.currRegion, - nextToken: data.nextToken, - }); - + let fetchedEc2Instances: Node[] = []; + let nextPage = ''; + // Requires list of all ec2 instances + // to formulate map of VPCs and its subnets. + do { + const { instances, nextToken } = + await integrationService.fetchAwsEc2Instances(integrationName, { + region: region, + nextToken: nextPage, + }); + + fetchedEc2Instances = [...fetchedEc2Instances, ...instances]; + nextPage = nextToken; + } while (nextPage); // Abort if there were no EC2 instances for the selected region. if (fetchedEc2Instances.length <= 0) { setFetchEc2InstancesAttempt({ status: 'success' }); @@ -170,10 +203,9 @@ export function EnrollEc2Instance() { setFetchEc2InstancesAttempt({ status: 'success' }); setTableData({ - currRegion, - nextToken, - fetchStatus: nextToken ? '' : 'disabled', - items: [...data.items, ...checkedEc2Instances], + ...data, + fetchStatus: 'disabled', + items: checkedEc2Instances, }); } catch (err) { const errMsg = getErrMessage(err); @@ -183,50 +215,202 @@ export function EnrollEc2Instance() { } } - async function fetchEc2InstanceConnectEndpoints() { + /** + * @returns + * - undefined: if there was an error from request + * - array: list of ec2 instance connect endpoints or, + * empty list if no endpoints + */ + async function fetchEc2InstanceConnectEndpointsWithErrorHandling( + vpcIds: string[] + ) { const integrationName = agentMeta.awsIntegration.name; - setFetchEc2IceAttempt({ status: 'processing' }); try { const { endpoints: fetchedEc2Ices } = await integrationService.fetchAwsEc2InstanceConnectEndpoints( integrationName, { - region: selectedInstance.awsMetadata.region, - vpcId: selectedInstance.awsMetadata.vpcId, + region: currRegion, + vpcIds, } ); - setFetchEc2IceAttempt({ status: 'success' }); return fetchedEc2Ices; } catch (err) { const errMsg = getErrMessage(err); - setFetchEc2InstancesAttempt({ status: 'failed', statusText: errMsg }); + setFetchEc2IceAttempt({ status: 'failed', statusText: errMsg }); emitErrorEvent(`ec2 instance connect endpoint fetch error: ${errMsg}`); } } function clear() { setFetchEc2InstancesAttempt({ status: '' }); + setFetchEc2IceAttempt({ status: '' }); setTableData(emptyTableData); setSelectedInstance(null); + setAutoDiscoveryCfg(null); + setFoundAllRequiredEices(null); } - function handleOnProceed() { - fetchEc2InstanceConnectEndpoints().then(ec2Ices => { - const createCompleteEice = ec2Ices.find( - e => e.state === 'create-complete' - ); - const createInProgressEice = ec2Ices.find( - e => e.state === 'create-in-progress' + /** + * @returns + * - undefined: if there was an error from request or + * - object: the created discovery config object + */ + async function createAutoDiscoveryConfigWithErrorHandling() { + // We check the agentmeta because a user could've returned + // to this step from the deploy step (clicking "back" button) + const alreadyCreatedCfg = + agentMeta?.autoDiscovery && agentMeta.awsRegion === currRegion; + + if (!autoDiscoveryCfg && !alreadyCreatedCfg) { + try { + const discoveryConfig = await createDiscoveryConfig( + storeUser.getClusterId(), + { + name: crypto.randomUUID(), + discoveryGroup: cfg.isCloud + ? DISCOVERY_GROUP_CLOUD + : discoveryGroupName, + aws: [ + { + types: ['ec2'], + regions: [currRegion], + tags: { '*': ['*'] }, + integration: agentMeta.awsIntegration.name, + }, + ], + } + ); + return discoveryConfig; + } catch (err) { + const errMsg = getErrMessage(err); + setFetchEc2IceAttempt({ status: 'failed', statusText: errMsg }); + emitErrorEvent(`failed to create discovery config: ${errMsg}`); + } + } + + if (agentMeta.autoDiscovery) { + return agentMeta.autoDiscovery.config; + } + + return autoDiscoveryCfg; + } + + /** + * Note: takes about 1 minute to go from `create-in-progress` to `create-complete` + * `create-in-progress` can be polled until it reaches `create-complete` + */ + function getCompleteOrInProgressEndpoints( + endpoints: Ec2InstanceConnectEndpoint[] + ) { + return endpoints.filter( + e => e.state === 'create-complete' || e.state === 'create-in-progress' + ); + } + + async function enableAutoDiscovery() { + // Collect unique vpcIds and its subnet for instances. + const seenVpcIdAndSubnets: Record = {}; + tableData.items.forEach(i => { + const vpcId = i.awsMetadata.vpcId; + if (!seenVpcIdAndSubnets[vpcId]) { + // Instances can have the same vpcId and be assigned + // different subnetIds, but each subnet belongs to a + // single VPC, so it does not matter which subnet we + // assign to this vpc. + seenVpcIdAndSubnets[vpcId] = i.awsMetadata.subnetId; + } + }); + + // Check if an instance connect endpoint exist for the collected vpcs. + + // instancesVpcIds can be zero if if no ec2 instances are enrolled. + const instancesVpcIds = Object.keys(seenVpcIdAndSubnets); + const gotEc2Ices = + await fetchEc2InstanceConnectEndpointsWithErrorHandling(instancesVpcIds); + if (!gotEc2Ices) { + // errored + return; + } + + const listOfExistingEndpoints = + getCompleteOrInProgressEndpoints(gotEc2Ices); + + // Determine which instance vpc needs a ec2 instance connect endpoint. + const requiredVpcsAndSubnets: Record = {}; + if (instancesVpcIds.length != gotEc2Ices.length) { + instancesVpcIds.forEach(instanceVpcId => { + const found = gotEc2Ices.some( + endpoint => endpoint.vpcId == instanceVpcId + ); + if (!found) { + requiredVpcsAndSubnets[instanceVpcId] = [ + seenVpcIdAndSubnets[instanceVpcId], + ]; + } + }); + } + + const discoveryConfig = await createAutoDiscoveryConfigWithErrorHandling(); + if (!discoveryConfig) { + // errored + return; + } + setFetchEc2IceAttempt({ status: 'success' }); + setAutoDiscoveryCfg(discoveryConfig); + updateAgentMeta({ + ...(agentMeta as NodeMeta), + ec2Ices: listOfExistingEndpoints, + autoDiscovery: { + config: discoveryConfig, + requiredVpcsAndSubnets, + }, + awsRegion: currRegion, + }); + + // Check if creating endpoints is required. + + const allRequiredEndpointsExists = + listOfExistingEndpoints.length > 0 && + Object.keys(requiredVpcsAndSubnets).length === 0; + + if (allRequiredEndpointsExists || instancesVpcIds.length === 0) { + setFoundAllRequiredEices(listOfExistingEndpoints); + emitEvent( + { stepStatus: DiscoverEventStatus.Skipped }, + { + eventName: DiscoverEvent.EC2DeployEICE, + } ); + } else { + nextStep(); + } + } + + async function handleOnProceed() { + setFetchEc2IceAttempt({ status: 'processing' }); + + if (wantAutoDiscover) { + enableAutoDiscovery(); + } else { + const ec2Ices = await fetchEc2InstanceConnectEndpointsWithErrorHandling([ + selectedInstance.awsMetadata.vpcId, + ]); + if (!ec2Ices) { + return; + } + setFetchEc2IceAttempt({ status: 'success' }); + + const existingEndpoint = getCompleteOrInProgressEndpoints(ec2Ices); // If we find existing EICE's that are either create-complete or create-in-progress, we skip the step where we create the EICE. // We first check for any EICE's that are create-complete, if we find one, the dialog will go straight to creating the node. // If we don't find any, we check if there are any that are create-in-progress, if we find one, the dialog will wait until // it's create-complete and then create the node. - if (createCompleteEice || createInProgressEice) { - setExistingEice(createCompleteEice || createInProgressEice); + if (existingEndpoint.length > 0) { + setFoundAllRequiredEices(existingEndpoint); // Since the EICE had already been deployed before the flow, emit an event for EC2DeployEICE as `Skipped`. emitEvent( { stepStatus: DiscoverEventStatus.Skipped }, @@ -237,19 +421,43 @@ export function EnrollEc2Instance() { updateAgentMeta({ ...(agentMeta as NodeMeta), node: selectedInstance, - ec2Ice: createCompleteEice || createInProgressEice, + ec2Ices: existingEndpoint, + awsRegion: currRegion, }); // If we find neither, then we go to the next step to create the EICE. } else { updateAgentMeta({ ...(agentMeta as NodeMeta), node: selectedInstance, + awsRegion: currRegion, }); nextStep(); } - }); + } } + // (Temp) + // Self hosted auto enroll is different from cloud. + // For cloud, we already run the discovery service for customer. + // For on-prem, user has to run their own discovery service. + // We hide the table for on-prem if they are wanting auto discover + // because it takes up so much space to give them instructions. + // Future work will simply provide user a script so we can show the table then. + const showTable = cfg.isCloud || !wantAutoDiscover; + + const errorMsg = getAttemptsOneOfErrorMsg( + fetchEc2InstancesAttempt, + fetchEc2IceAttempt + ); + + const hasIamPermError = + isIamPermError(fetchEc2IceAttempt) || + isIamPermError(fetchEc2InstancesAttempt); + + const showContent = !hasIamPermError && currRegion; + const showAutoEnrollToggle = + !errorMsg && fetchEc2InstancesAttempt.status === 'success'; + return (
Enroll an EC2 instance
@@ -262,29 +470,74 @@ export function EnrollEc2Instance() { clear={clear} disableSelector={fetchEc2InstancesAttempt.status === 'processing'} /> - {currRegion && ( - + {!hasIamPermError && errorMsg && {errorMsg}} + {showContent && ( + <> + {showAutoEnrollToggle && ( + + setWantAutoDiscover(b => !b)} + disabled={tableData.items.length === 0} // necessary? + > + + Auto-enroll all EC2 instances for selected region + + + Auto-enroll will automatically identify all EC2 instances from + the selected region and register them as node resources in + your infrastructure. + + + {!cfg.isCloud && wantAutoDiscover && ( + + )} + + )} + {showTable && ( + + )} + )} - {existingEice && ( + {foundAllRequiredEices?.length > 0 && ( nextStep(2)} - existingEice={existingEice} + existingEices={foundAllRequiredEices} /> )} + {foundAllRequiredEices?.length === 0 && ( + nextStep(2)} /> + )} + {hasIamPermError && ( + + + + )}
diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/NoEc2IceRequiredDialog.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/NoEc2IceRequiredDialog.tsx new file mode 100644 index 0000000000000..694ee33a6f850 --- /dev/null +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/NoEc2IceRequiredDialog.tsx @@ -0,0 +1,53 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { Text, Flex, ButtonPrimary } from 'design'; +import * as Icons from 'design/Icon'; +import Dialog, { DialogContent } from 'design/DialogConfirmation'; + +import { Mark } from 'teleport/Discover/Shared'; +import { NodeMeta, useDiscover } from 'teleport/Discover/useDiscover'; + +export function NoEc2IceRequiredDialog({ nextStep }: { nextStep: () => void }) { + const { agentMeta } = useDiscover(); + const typedAgentMeta = agentMeta as NodeMeta; + + return ( + + + + + + The discovery service can take a few minutes to finish + auto-enrolling resources found in region{' '} + {typedAgentMeta.awsRegion}. + + + nextStep()}> + Next + + + + ); +} diff --git a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx index ba0e2228bae8d..6bca0db3f84ca 100644 --- a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx @@ -17,7 +17,7 @@ */ import React, { useState, useEffect } from 'react'; -import { Box } from 'design'; +import { Box, Text } from 'design'; import { SelectCreatable, @@ -41,11 +41,14 @@ export function SetupAccess(props: State) { initSelectedOptions, getFixedOptions, getSelectableOptions, + agentMeta, ...restOfProps } = props; const [loginInputValue, setLoginInputValue] = useState(''); const [selectedLogins, setSelectedLogins] = useState([]); + const wantAutoDiscover = !!agentMeta.autoDiscovery; + useEffect(() => { if (props.attempt.status === 'success') { setSelectedLogins(initSelectedOptions('logins')); @@ -53,7 +56,14 @@ export function SetupAccess(props: State) { }, [props.attempt.status, initSelectedOptions]); function handleOnProceed() { - onProceed({ logins: selectedLogins }); + let numStepsToIncrement; + // Skip test connection since test connection currently + // only supports one resource testing and auto enrolling + // enrolls resources > 1. + if (wantAutoDiscover) { + numStepsToIncrement = 2; + } + onProceed({ logins: selectedLogins }, numStepsToIncrement); } function handleLoginKeyDown(event: React.KeyboardEvent) { @@ -83,7 +93,14 @@ export function SetupAccess(props: State) { traitDescription="users" hasTraits={hasTraits} onProceed={handleOnProceed} + wantAutoDiscover={wantAutoDiscover} > + {wantAutoDiscover && ( + + Since auto-discovery is enabled, make sure to include all OS users + that will be used to connect to the discovered EC2 instances. + + )} OS Users (props: { const result = usePoll( signal => servicesFetchFn(signal).then(res => { - if (res.agents.length) { + if (res?.agents?.length) { return res.agents[0]; } diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx index f0b4b10b1ca12..807f68849841e 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx @@ -213,13 +213,12 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, // Test that we are updating the user with the correct traits. const mockUser = getMockUser(); - const { kubeUsers, kubeGroups } = result.current.dynamicTraits; expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ ...mockUser, traits: { ...result.current.dynamicTraits, - kubeGroups: [...kubeGroups, 'dynamicKbGroup3', 'dynamicKbGroup4'], - kubeUsers: [...kubeUsers, 'dynamicKbUser3', 'dynamicKbUser4'], + kubeGroups: ['dynamicKbGroup3', 'dynamicKbGroup4'], + kubeUsers: ['dynamicKbUser3', 'dynamicKbUser4'], }, }); }); @@ -366,13 +365,12 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, // Test that we are updating the user with the correct traits. const mockUser = getMockUser(); - const { databaseUsers, databaseNames } = result.current.dynamicTraits; expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ ...mockUser, traits: { ...result.current.dynamicTraits, - databaseNames: [...databaseNames, 'banana', 'carrot'], - databaseUsers: [...databaseUsers, 'apple'], + databaseNames: ['banana', 'carrot'], + databaseUsers: ['apple'], }, }); }); @@ -447,6 +445,63 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, ...expected.logins, ]); }); + + test('node with auto discover preserves existing + new dynamic traits', async () => { + const discoverCtx = defaultDiscoverContext({ + resourceSpec: defaultResourceSpec(ResourceKind.Server), + }); + discoverCtx.agentMeta = { + ...discoverCtx.agentMeta, + ...getMeta(ResourceKind.Server), + autoDiscovery: { + config: { name: '', discoveryGroup: '', aws: [] }, + requiredVpcsAndSubnets: {}, + }, + }; + + const { result } = renderHook(() => useUserTraits(), { + wrapper: wrapperFn(discoverCtx, teleCtx), + }); + + await waitFor(() => + expect(result.current.dynamicTraits.logins).toHaveLength(2) + ); + + // Should not be setting statics. + expect(result.current.staticTraits.logins).toHaveLength(0); + + const addedTraitsOpts = { + logins: [ + { + isFixed: true, + label: 'banana', + value: 'banana', + }, + { + isFixed: false, + label: 'carrot', + value: 'carrot', + }, + ], + }; + + act(() => { + result.current.onProceed(addedTraitsOpts); + }); + + await waitFor(() => { + expect(teleCtx.userService.reloadUser).toHaveBeenCalledTimes(1); + }); + // Test that we are updating the user with the correct traits. + const mockUser = getMockUser(); + expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ + ...mockUser, + traits: { + ...result.current.dynamicTraits, + logins: ['banana', 'carrot'], + }, + }); + }); }); describe('static and dynamic traits are correctly separated and correctly creates Option objects', () => { @@ -476,7 +531,16 @@ describe('static and dynamic traits are correctly separated and correctly create }); await waitFor(() => - expect(teleCtx.userService.fetchUser).toHaveBeenCalled() + expect(result.current.dynamicTraits.logins.length).toBeGreaterThan(0) + ); + expect(teleCtx.userService.fetchUser).toHaveBeenCalled(); + expect(result.current.dynamicTraits.kubeGroups.length).toBeGreaterThan(0); + expect(result.current.dynamicTraits.kubeUsers.length).toBeGreaterThan(0); + expect(result.current.dynamicTraits.databaseNames.length).toBeGreaterThan( + 0 + ); + expect(result.current.dynamicTraits.databaseUsers.length).toBeGreaterThan( + 0 ); // Test correct making of dynamic traits. diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts index a04a612a6f3bd..3593c3a19a2e7 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts @@ -75,8 +75,13 @@ export function useUserTraits() { break; case ResourceKind.Server: - const node = (agentMeta as NodeMeta).node; - staticTraits.logins = arrayStrDiff(node.sshLogins, dynamicTraits.logins); + if (!wantAutoDiscover) { + const node = (agentMeta as NodeMeta).node; + staticTraits.logins = arrayStrDiff( + node.sshLogins, + dynamicTraits.logins + ); + } break; case ResourceKind.Database: @@ -124,9 +129,6 @@ export function useUserTraits() { switch (resourceSpec.kind) { case ResourceKind.Kubernetes: let newDynamicKubeUsers = new Set(); - if (wantAutoDiscover) { - newDynamicKubeUsers = new Set(dynamicTraits.kubeUsers); - } traitOpts.kubeUsers.forEach(o => { if (!staticTraits.kubeUsers.includes(o.value)) { newDynamicKubeUsers.add(o.value); @@ -134,9 +136,6 @@ export function useUserTraits() { }); let newDynamicKubeGroups = new Set(); - if (wantAutoDiscover) { - newDynamicKubeGroups = new Set(dynamicTraits.kubeGroups); - } traitOpts.kubeGroups.forEach(o => { if (!staticTraits.kubeGroups.includes(o.value)) { newDynamicKubeGroups.add(o.value); @@ -160,14 +159,11 @@ export function useUserTraits() { } }); - nextStep({ logins: [...newDynamicLogins] }); + nextStep({ logins: [...newDynamicLogins] }, numStepsToIncrement); break; case ResourceKind.Database: let newDynamicDbUsers = new Set(); - if (wantAutoDiscover) { - newDynamicDbUsers = new Set(dynamicTraits.databaseUsers); - } traitOpts.databaseUsers.forEach(o => { if (!staticTraits.databaseUsers.includes(o.value)) { newDynamicDbUsers.add(o.value); @@ -175,9 +171,6 @@ export function useUserTraits() { }); let newDynamicDbNames = new Set(); - if (wantAutoDiscover) { - newDynamicDbNames = new Set(dynamicTraits.databaseNames); - } traitOpts.databaseNames.forEach(o => { if (!staticTraits.databaseNames.includes(o.value)) { newDynamicDbNames.add(o.value); diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index d97ab9599db05..0345f568ae8c0 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -499,7 +499,7 @@ type BaseMeta = { // that needs to be preserved throughout the flow. export type NodeMeta = BaseMeta & { node: Node; - ec2Ice?: Ec2InstanceConnectEndpoint; + ec2Ices?: Ec2InstanceConnectEndpoint[]; }; // DbMeta describes the fields for a db resource diff --git a/web/packages/teleport/src/Nodes/__snapshots__/Nodes.story.test.tsx.snap b/web/packages/teleport/src/Nodes/__snapshots__/Nodes.story.test.tsx.snap index 62471a500f43e..bafbb9cb78d86 100644 --- a/web/packages/teleport/src/Nodes/__snapshots__/Nodes.story.test.tsx.snap +++ b/web/packages/teleport/src/Nodes/__snapshots__/Nodes.story.test.tsx.snap @@ -875,6 +875,7 @@ exports[`failed 1`] = ` >
{ return api .post(cfg.getDeployEc2InstanceConnectEndpointUrl(integrationName), req) - .then(json => ({ name: json?.name })); + .then(resp => { + return resp ?? []; + }); }, // Returns a list of VPC Security Groups using the ListSecurityGroups action of the AWS OIDC Integration. @@ -313,7 +316,7 @@ export function makeAwsDatabase(json: any): AwsRdsDatabase { function makeEc2InstanceConnectEndpoint(json: any): Ec2InstanceConnectEndpoint { json = json ?? {}; - const { name, state, stateMessage, dashboardLink, subnetId } = json; + const { name, state, stateMessage, dashboardLink, subnetId, vpcId } = json; return { name, @@ -321,6 +324,7 @@ function makeEc2InstanceConnectEndpoint(json: any): Ec2InstanceConnectEndpoint { stateMessage, dashboardLink, subnetId, + vpcId, }; } diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index eb90bed57fadc..1e9c3973cee6a 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -377,8 +377,8 @@ export type ListEc2InstancesResponse = { export type ListEc2InstanceConnectEndpointsRequest = { region: Regions; - // vpcId is the VPC to filter EC2 Instance Connect Endpoints. - vpcId: string; + // VPCIDs is a list of VPCs to filter EC2 Instance Connect Endpoints. + vpcIds: string[]; nextToken?: string; }; @@ -386,6 +386,9 @@ export type ListEc2InstanceConnectEndpointsResponse = { // endpoints is the list of EC2 Instance Connect Endpoints. endpoints: Ec2InstanceConnectEndpoint[]; nextToken?: string; + // DashboardLink is the URL for AWS Web Console that + // lists all the Endpoints for the queries VPCs. + dashboardLink: string; }; export type Ec2InstanceConnectEndpoint = { @@ -398,6 +401,8 @@ export type Ec2InstanceConnectEndpoint = { dashboardLink: string; // subnetID is the subnet used by the Endpoint. Please note that the Endpoint should be able to reach any subnet within the VPC. subnetId: string; + // VPCID is the VPC ID where the Endpoint is created. + vpcId: string; }; export type Ec2InstanceConnectEndpointState = @@ -408,17 +413,30 @@ export type Ec2InstanceConnectEndpointState = | 'delete-complete' | 'delete-failed'; +export type AwsOidcDeployEc2InstanceConnectEndpointRequest = { + // SubnetID is the subnet id for the EC2 Instance Connect Endpoint. + subnetId: string; + // SecurityGroupIDs is the list of SecurityGroups to apply to the Endpoint. + // If not specified, the Endpoint will receive the default SG for the Subnet's VPC. + securityGroupIds?: string[]; +}; + export type DeployEc2InstanceConnectEndpointRequest = { region: Regions; - // subnetID is the subnet id for the EC2 Instance Connect Endpoint. + // Endpoints is a list of endpoinst to create. + endpoints: AwsOidcDeployEc2InstanceConnectEndpointRequest[]; +}; + +export type AwsEc2InstanceConnectEndpoint = { + // Name is the EC2 Instance Connect Endpoint name. + name: string; + // SubnetID is the subnet where this endpoint was created. subnetId: string; - // securityGroupIDs is the list of SecurityGroups to apply to the Endpoint. If not specified, the Endpoint will receive the default SG for the subnet's VPC. - securityGroupIds?: string[]; }; export type DeployEc2InstanceConnectEndpointResponse = { - // name is the name of the EC2 Instance Connect Endpoint that was created. - name: string; + // Endpoints is a list of created endpoints + endpoints: AwsEc2InstanceConnectEndpoint[]; }; export type ListAwsSecurityGroupsRequest = {