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 (
@@ -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 (
+
+ );
+}
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 | | | | | |