diff --git a/lib/web/servers.go b/lib/web/servers.go index 082f82f3578e..2b69a146aa4c 100644 --- a/lib/web/servers.go +++ b/lib/web/servers.go @@ -339,12 +339,12 @@ type desktopIsActive struct { // createNodeRequest contains the required information to create a Node. type createNodeRequest struct { - Name string `json:"name,omitempty"` - SubKind string `json:"subKind,omitempty"` - Hostname string `json:"hostname,omitempty"` - Addr string `json:"addr,omitempty"` - Labels []ui.Label `json:"labels,omitempty"` - AWSInfo *types.AWSInfo `json:"aws,omitempty"` + Name string `json:"name,omitempty"` + SubKind string `json:"subKind,omitempty"` + Hostname string `json:"hostname,omitempty"` + Addr string `json:"addr,omitempty"` + Labels []ui.Label `json:"labels,omitempty"` + AWSInfo *ui.AWSMetadata `json:"aws,omitempty"` } func (r *createNodeRequest) checkAndSetDefaults() error { @@ -402,7 +402,14 @@ func (h *Handler) handleNodeCreate(w http.ResponseWriter, r *http.Request, p htt Hostname: req.Hostname, Addr: req.Addr, CloudMetadata: &types.CloudMetadata{ - AWS: req.AWSInfo, + AWS: &types.AWSInfo{ + AccountID: req.AWSInfo.AccountID, + InstanceID: req.AWSInfo.InstanceID, + Region: req.AWSInfo.Region, + VPCID: req.AWSInfo.VPCID, + Integration: req.AWSInfo.Integration, + SubnetID: req.AWSInfo.SubnetID, + }, }, }, labels, diff --git a/lib/web/servers_test.go b/lib/web/servers_test.go index 3fe951d4ee2b..bb90135b02b6 100644 --- a/lib/web/servers_test.go +++ b/lib/web/servers_test.go @@ -45,7 +45,7 @@ func TestCreateNode(t *testing.T) { Hostname: "myhostname", Addr: "172.31.1.1:22", Labels: []ui.Label{}, - AWSInfo: &types.AWSInfo{ + AWSInfo: &ui.AWSMetadata{ AccountID: "123456789012", InstanceID: "i-123", Region: "us-east-1", @@ -155,7 +155,14 @@ func TestCreateNode(t *testing.T) { require.NoError(t, err) require.Equal(t, node.GetName(), tt.req.Name) - require.Equal(t, node.GetCloudMetadata().AWS, tt.req.AWSInfo) + require.Equal(t, node.GetAWSInfo(), &types.AWSInfo{ + AccountID: tt.req.AWSInfo.AccountID, + InstanceID: tt.req.AWSInfo.InstanceID, + Region: tt.req.AWSInfo.Region, + VPCID: tt.req.AWSInfo.VPCID, + Integration: tt.req.AWSInfo.Integration, + SubnetID: tt.req.AWSInfo.SubnetID, + }) } }) diff --git a/lib/web/ui/server.go b/lib/web/ui/server.go index 1ebe144520f5..98c4bf0d2554 100644 --- a/lib/web/ui/server.go +++ b/lib/web/ui/server.go @@ -134,6 +134,7 @@ func MakeServer(clusterName string, server types.Server, accessChecker services. Region: awsMetadata.Region, Integration: awsMetadata.Integration, SubnetID: awsMetadata.SubnetID, + VPCID: awsMetadata.VPCID, } } diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx index bc5d698e3f0b..c8376b89005a 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx @@ -16,11 +16,16 @@ import React from 'react'; import styled from 'styled-components'; -import { Flex, Box, Label as Pill } from 'design'; -import Table, { Cell as TableCell } from 'design/DataTable'; +import { Flex, Box } from 'design'; +import Table from 'design/DataTable'; import { FetchStatus } from 'design/DataTable/types'; -import { Label } from 'teleport/types'; +import { + DisableableCell as Cell, + RadioCell, + Labels, + labelMatcher, +} from 'teleport/Discover/Shared'; import { CheckedAwsRdsDatabase } from './EnrollRdsDatabase'; @@ -32,6 +37,8 @@ type Props = { selectedDatabase?: CheckedAwsRdsDatabase; }; +const disabledText = `This RDS database is already enrolled and is a part of this cluster`; + export const DatabaseList = ({ items = [], fetchStatus = '', @@ -51,12 +58,14 @@ export const DatabaseList = ({ item.name === selectedDatabase?.name && item.engine === selectedDatabase?.engine; return ( - + disabledText={disabledText} item={item} key={`${item.name}${item.resourceId}`} isChecked={isChecked} onChange={onSelectDatabase} disabled={item.dbServerExists} + value={item.name} /> ); }, @@ -65,21 +74,25 @@ export const DatabaseList = ({ key: 'name', headerText: 'Name', render: ({ name, dbServerExists }) => ( - {name} + + {name} + ), }, { key: 'engine', headerText: 'Engine', render: ({ engine, dbServerExists }) => ( - {engine} + + {engine} + ), }, { key: 'labels', headerText: 'Labels', render: ({ labels, dbServerExists }) => ( - + ), @@ -103,7 +116,7 @@ const StatusCell = ({ item }: { item: CheckedAwsRdsDatabase }) => { const status = getStatus(item); return ( - + {item.status} @@ -112,42 +125,6 @@ const StatusCell = ({ item }: { item: CheckedAwsRdsDatabase }) => { ); }; -function RadioCell({ - item, - isChecked, - onChange, - disabled, -}: { - item: CheckedAwsRdsDatabase; - isChecked: boolean; - onChange(selectedItem: CheckedAwsRdsDatabase): void; - disabled: boolean; -}) { - return ( - - - props.theme.space[2]}px 0 0; - accent-color: ${props => props.theme.colors.brand.accent}; - cursor: pointer; - - &:disabled { - cursor: not-allowed; - } - `} - type="radio" - name={item.name} - checked={isChecked} - onChange={() => onChange(item)} - value={item.name} - disabled={disabled} - /> - - - ); -} - enum Status { Success, Warning, @@ -185,61 +162,3 @@ const StatusLight = styled(Box)` return theme.colors.grey[300]; // Unknown }}; `; - -const Labels = ({ labels }: { labels: Label[] }) => { - const $labels = labels.map((label, index) => { - const labelText = `${label.name}: ${label.value}`; - - return ( - - {labelText} - - ); - }); - - return {$labels}; -}; - -// labelMatcher allows user to client search by labels in the format -// 1) `key: value` or -// 2) `key:value` or -// 3) `key` or `value` -function labelMatcher( - targetValue: any, - searchValue: string, - propName: keyof CheckedAwsRdsDatabase & string -) { - if (propName === 'labels') { - return targetValue.some((label: Label) => { - const convertedKey = label.name.toLocaleUpperCase(); - const convertedVal = label.value.toLocaleUpperCase(); - const formattedWords = [ - `${convertedKey}:${convertedVal}`, - `${convertedKey}: ${convertedVal}`, - ]; - return formattedWords.some(w => w.includes(searchValue)); - }); - } -} - -const Cell: React.FC<{ disabled: boolean; width?: string }> = ({ - disabled, - width, - children, -}) => { - return ( - - {children} - - ); -}; diff --git a/web/packages/teleport/src/Discover/Database/index.tsx b/web/packages/teleport/src/Discover/Database/index.tsx index e9fd2655c7e6..925df96b55dd 100644 --- a/web/packages/teleport/src/Discover/Database/index.tsx +++ b/web/packages/teleport/src/Discover/Database/index.tsx @@ -16,7 +16,7 @@ import React from 'react'; -import { ResourceKind, Finished } from 'teleport/Discover/Shared'; +import { AwsAccount, ResourceKind, Finished } from 'teleport/Discover/Shared'; import { ResourceViewConfig } from 'teleport/Discover/flow'; import { DatabaseWrapper } from 'teleport/Discover/Database/DatabaseWrapper'; import { @@ -31,7 +31,6 @@ import { ManualDeploy } from 'teleport/Discover/Database/DeployService/ManualDep import { MutualTls } from 'teleport/Discover/Database/MutualTls'; import { TestConnection } from 'teleport/Discover/Database/TestConnection'; import { DiscoverEvent } from 'teleport/services/userEvent'; -import { ConnectAwsAccount } from 'teleport/Discover/Database/ConnectAwsAccount'; import { EnrollRdsDatabase } from 'teleport/Discover/Database/EnrollRdsDatabase'; import { IamPolicy } from 'teleport/Discover/Database/IamPolicy'; @@ -59,7 +58,7 @@ export const DatabaseResource: ResourceViewConfig = { configureResourceViews = [ { title: 'Connect AWS Account', - component: ConnectAwsAccount, + component: AwsAccount, eventName: DiscoverEvent.IntegrationAWSOIDCConnectEvent, }, { diff --git a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap index 085a032d4a19..ee3184f3dd37 100644 --- a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap +++ b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap @@ -268,6 +268,47 @@ exports[`render with URL loc state set to "server" 1`] = `
+
+
+ Guided +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ EC2 Instance +
+
+
+
+
+
+ Guided +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ EC2 Instance +
+
+
+
+
+
+ Lacking Permissions +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ EC2 Instance +
+
+
+
+
+
+ Guided +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ EC2 Instance +
+
+
+
; + +ListSecurityGroupsLoading.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) => + res(ctx.delay('infinite')) + ), + ], + }, +}; + +export const ListSecurityGroupsFail = () => ; + +ListSecurityGroupsFail.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) => + res( + ctx.status(403), + ctx.json({ + message: 'some error when trying to list security groups', + }) + ) + ), + ], + }, +}; + +export const DeployEiceFail = () => ( + <> + To trigger this Story's state, click on "Next." + + +); + +DeployEiceFail.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) => + res(ctx.json({ securityGroups: securityGroupsResponse })) + ), + rest.post( + cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), + (req, res, ctx) => + res( + ctx.status(403), + ctx.json({ + message: 'some error when trying to initiate the deployment', + }) + ) + ), + ], + }, +}; + +export const CreatingInProgress = () => ( + <> + To trigger this Story's state, click on "Next." + + +); + +CreatingInProgress.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) => + res(ctx.json({ securityGroups: securityGroupsResponse })) + ), + rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => + res( + ctx.json({ + ec2Ices: [ + { + name: 'test-eice', + state: 'create-in-progress', + stateMessage: '', + dashboardLink: 'goteleport.com', + subnetId: 'test-subnetid', + }, + ], + nextToken: '', + }) + ) + ), + rest.post( + cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), + (req, res, ctx) => res(ctx.json({ name: 'test-eice' })) + ), + ], + }, +}; + +export const CreatingFailed = () => ( + <> + {' '} + + To trigger this Story's state, click on "Next" and wait 10 seconds. + + + +); + +CreatingFailed.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) => + res(ctx.json({ securityGroups: securityGroupsResponse })) + ), + rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => + res( + ctx.json({ + ec2Ices: [ + { + name: 'test-eice', + state: 'create-failed', + stateMessage: '', + dashboardLink: 'goteleport.com', + subnetId: 'test-subnetid', + }, + ], + nextToken: '', + }) + ) + ), + rest.post( + cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), + (req, res, ctx) => res(ctx.json({ name: 'test-eice' })) + ), + ], + }, +}; + +export const CreatingComplete = () => ( + <> + + To trigger this Story's state, click on "Next" and wait 10 seconds. + + + +); + +CreatingComplete.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) => + res(ctx.json({ securityGroups: securityGroupsResponse })) + ), + rest.post( + cfg.getDeployEc2InstanceConnectEndpointUrl('test-oidc'), + (req, res, ctx) => res(ctx.json({ name: 'test-eice' })) + ), + rest.post( + cfg.getListEc2InstanceConnectEndpointsUrl('test-oidc'), + (req, res, ctx) => + res( + ctx.json({ + ec2Ices: [ + { + name: 'test-eice', + state: 'create-complete', + stateMessage: '', + dashboardLink: 'goteleport.com', + subnetId: 'test-subnetid', + }, + ], + nextToken: '', + }) + ) + ), + rest.post(cfg.getClusterNodesUrlNoParams('localhost'), (req, res, ctx) => + res( + ctx.delay(2000), // delay by 2 seconds + ctx.json({ + id: 'ec2-instance-1', + kind: 'node', + clusterId: 'cluster', + hostname: 'ec2-hostname-1', + labels: [{ name: 'instance', value: 'ec2-1' }], + addr: 'ec2.1.com', + tunnel: false, + subKind: 'openssh-ec2-ice', + sshLogins: ['test'], + aws: { + accountId: 'test-account', + instanceId: 'instance-ec2-1', + region: 'us-east-1', + vpcId: 'test', + integration: 'test', + subnetId: 'test', + }, + }) + ) + ), + ], + }, +}; + +const Component = () => { + const ctx = createTeleportContext(); + const discoverCtx: DiscoverContextState = { + agentMeta: { + resourceName: 'node-name', + agentMatcherLabels: [], + db: {} as any, + selectedAwsRdsDb: {} as any, + node: { + kind: 'node', + subKind: 'openssh-ec2-ice', + id: 'test-node', + hostname: 'test-node-hostname', + clusterId: 'localhost', + labels: [], + addr: 'test', + tunnel: false, + sshLogins: [], + awsMetadata: { + accountId: 'test-account', + integration: 'test-oidc', + instanceId: 'i-test', + subnetId: 'test', + vpcId: 'test-vpc', + region: 'us-east-1', + }, + }, + integration: { + kind: IntegrationKind.AwsOidc, + name: 'test-oidc', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, + }, + } as NodeMeta, + updateAgentMeta: agentMeta => { + discoverCtx.agentMeta = agentMeta; + }, + currentStep: 0, + nextStep: () => null, + prevStep: () => null, + onSelectResource: () => null, + resourceSpec: {} as any, + exitFlow: () => null, + viewConfig: null, + indexedViews: [], + setResourceSpec: () => null, + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, + }; + + cfg.proxyCluster = 'localhost'; + return ( + + + + + + + + ); +}; + +const securityGroupsResponse = [ + { + name: 'security-group-1', + id: 'sg-1', + description: 'this is security group 1', + inboundRules: [ + { + ipProtocol: 'tcp', + fromPort: '0', + toPort: '0', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '443', + toPort: '443', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '2000', + toPort: '5000', + cidrs: [ + { cidr: '192.168.1.0/24', description: 'Subnet Mask 255.255.255.0' }, + ], + }, + ], + outboundRules: [ + { + ipProtocol: 'tcp', + fromPort: '0', + toPort: '0', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '22', + toPort: '22', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '2000', + toPort: '5000', + cidrs: [ + { cidr: '10.0.0.0/16', description: 'Subnet Mask 255.255.0.0"' }, + ], + }, + ], + }, + { + name: 'security-group-2', + id: 'sg-2', + description: 'this is security group 2', + inboundRules: [ + { + ipProtocol: 'tcp', + fromPort: '0', + toPort: '0', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '443', + toPort: '443', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '2000', + toPort: '5000', + cidrs: [ + { cidr: '192.168.1.0/24', description: 'Subnet Mask 255.255.255.0' }, + ], + }, + ], + outboundRules: [ + { + ipProtocol: 'tcp', + fromPort: '0', + toPort: '0', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '22', + toPort: '22', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '2000', + toPort: '5000', + cidrs: [ + { cidr: '10.0.0.0/16', description: 'Subnet Mask 255.255.0.0"' }, + ], + }, + ], + }, + { + name: 'security-group-3', + id: 'sg-3', + description: 'this is security group 3', + inboundRules: [ + { + ipProtocol: 'tcp', + fromPort: '0', + toPort: '0', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '443', + toPort: '443', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '2000', + toPort: '5000', + cidrs: [ + { cidr: '192.168.1.0/24', description: 'Subnet Mask 255.255.255.0' }, + ], + }, + ], + outboundRules: [ + { + ipProtocol: 'tcp', + fromPort: '0', + toPort: '0', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '22', + toPort: '22', + cidrs: [{ cidr: '0.0.0.0/0', description: 'Everything' }], + }, + { + ipProtocol: 'tcp', + fromPort: '2000', + toPort: '5000', + cidrs: [ + { cidr: '10.0.0.0/16', description: 'Subnet Mask 255.255.0.0"' }, + ], + }, + ], + }, +]; diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx new file mode 100644 index 000000000000..69a0c02c8d21 --- /dev/null +++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.tsx @@ -0,0 +1,192 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; + +import { Box, Indicator, Text, Flex } from 'design'; +import { Danger } from 'design/Alert'; +import { FetchStatus } from 'design/DataTable/types'; + +import useAttempt from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; + +import { + SecurityGroup, + integrationService, +} from 'teleport/services/integrations'; +import { NodeMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { + ActionButtons, + Header, + SecurityGroupPicker, +} from 'teleport/Discover/Shared'; + +import { CreateEc2IceDialog } from './CreateEc2IceDialog'; + +type TableData = { + items: SecurityGroup[]; + nextToken?: string; + fetchStatus: FetchStatus; +}; + +export function CreateEc2Ice() { + const [showCreatingDialog, setShowCreatingDialog] = useState(false); + const [selectedSecurityGroups, setSelectedSecurityGroups] = useState< + string[] + >([]); + const [tableData, setTableData] = useState({ + items: [], + nextToken: '', + fetchStatus: 'disabled', + }); + + function onSelectSecurityGroup( + sg: SecurityGroup, + e: React.ChangeEvent + ) { + if (e.target.checked) { + return setSelectedSecurityGroups([...selectedSecurityGroups, sg.id]); + } else { + setSelectedSecurityGroups( + selectedSecurityGroups.filter(id => id !== sg.id) + ); + } + } + + useEffect(() => { + fetchSecurityGroups(); + }, []); + + const { + attempt: fetchSecurityGroupsAttempt, + setAttempt: setFetchSecurityGroupsAttempt, + } = useAttempt(''); + + const { attempt: deployEc2IceAttempt, setAttempt: setDeployEc2IceAttempt } = + useAttempt(''); + + const { emitErrorEvent, agentMeta, prevStep, nextStep } = useDiscover(); + + async function fetchSecurityGroups() { + const integration = (agentMeta as NodeMeta).integration; + + setFetchSecurityGroupsAttempt({ status: 'processing' }); + try { + const { securityGroups, nextToken } = + await integrationService.fetchSecurityGroups(integration.name, { + vpcId: (agentMeta as NodeMeta).node.awsMetadata.vpcId, + region: (agentMeta as NodeMeta).node.awsMetadata.region, + nextToken: tableData.nextToken, + }); + + setFetchSecurityGroupsAttempt({ status: 'success' }); + setTableData({ + nextToken: nextToken, + fetchStatus: nextToken ? '' : 'disabled', + items: [...tableData.items, ...securityGroups], + }); + } catch (err) { + const errMsg = getErrMessage(err); + setFetchSecurityGroupsAttempt({ status: 'failed', statusText: errMsg }); + emitErrorEvent(`fetch security groups error: ${errMsg}`); + } + } + + async function deployEc2InstanceConnectEndpoint() { + const integration = (agentMeta as NodeMeta).integration; + + setDeployEc2IceAttempt({ status: 'processing' }); + setShowCreatingDialog(true); + try { + await integrationService.deployAwsEc2InstanceConnectEndpoint( + integration.name, + { + region: (agentMeta as NodeMeta).node.awsMetadata.region, + subnetId: (agentMeta as NodeMeta).node.awsMetadata.subnetId, + ...(selectedSecurityGroups.length && { + securityGroupIds: selectedSecurityGroups, + }), + } + ); + // Capture event for deploying EICE. + // emitEvent(null); TODO rudream (ADD EVENTS FOR EICE FLOW) + } catch (err) { + const errMsg = getErrMessage(err); + setShowCreatingDialog(false); + setDeployEc2IceAttempt({ status: 'failed', statusText: errMsg }); + emitErrorEvent( + `ec2 instance connect endpoint deploying failed: ${errMsg}` + ); + } + } + + function handleOnProceed() { + deployEc2InstanceConnectEndpoint(); + } + + return ( + <> + +
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} + )} + {fetchSecurityGroupsAttempt.status === 'processing' && ( + + + + )} + {fetchSecurityGroupsAttempt.status === 'success' && ( + + fetchSecurityGroups()} + fetchStatus={tableData.fetchStatus} + onSelectSecurityGroup={onSelectSecurityGroup} + selectedSecurityGroups={selectedSecurityGroups} + /> + + )} + + handleOnProceed()} + disableProceed={deployEc2IceAttempt.status === 'processing'} + /> +
+ {showCreatingDialog && ( + deployEc2InstanceConnectEndpoint()} + /> + )} + + ); +} diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx new file mode 100644 index 000000000000..c0a06bbe1562 --- /dev/null +++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx @@ -0,0 +1,308 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { + Text, + Flex, + AnimatedProgressBar, + ButtonPrimary, + Link, + Box, +} from 'design'; +import * as Icons from 'design/Icon'; +import Dialog, { DialogContent } from 'design/DialogConfirmation'; + +import { getErrMessage } from 'shared/utils/errorType'; + +import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; + +import cfg from 'teleport/config'; + +import { + Ec2InstanceConnectEndpoint, + integrationService, +} from 'teleport/services/integrations'; +import NodeService from 'teleport/services/nodes'; +import { TextIcon } from 'teleport/Discover/Shared'; +import { NodeMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { usePoll } from 'teleport/Discover/Shared/usePoll'; + +export function CreateEc2IceDialog({ + nextStep, + retry, + existingEice, +}: { + nextStep: () => void; + retry?: () => void; + existingEice?: 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 { emitErrorEvent, updateAgentMeta, agentMeta } = useDiscover(); + const typedAgentMeta = agentMeta as NodeMeta; + + const nodeService = new NodeService(); + + const { attempt: fetchEc2IceAttempt, setAttempt: setFetchEc2IceAttempt } = + useAttempt(''); + const { attempt: createNodeAttempt, setAttempt: setCreateNodeAttempt } = + useAttempt(''); + + // When the EICE's state is 'create-complete', create the node. + useEffect(() => { + if (typedAgentMeta.ec2Ice?.state === 'create-complete') { + createNode(); + } + }, [typedAgentMeta.ec2Ice]); + + let ec2Ice = usePoll( + () => + fetchEc2InstanceConnectEndpoint().then(e => { + if (e?.state === 'create-complete') { + setIsPollingActive(false); + updateAgentMeta({ + ...typedAgentMeta, + ec2Ice: e, + }); + } + return e; + }), + isPollingActive, + 10000 // poll every 10 seconds + ); + + // 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; + } + + async function fetchEc2InstanceConnectEndpoint() { + const integration = typedAgentMeta.integration; + + setFetchEc2IceAttempt({ status: 'processing' }); + try { + const { endpoints: fetchedEc2Ices } = + await integrationService.fetchAwsEc2InstanceConnectEndpoints( + integration.name, + { + region: typedAgentMeta.node.awsMetadata.region, + vpcId: typedAgentMeta.node.awsMetadata.vpcId, + } + ); + + setFetchEc2IceAttempt({ status: 'success' }); + + const createCompleteEice = fetchedEc2Ices.find( + e => e.state === 'create-complete' + ); + if (createCompleteEice) { + return createCompleteEice; + } + + const createInProgressEice = fetchedEc2Ices.find( + e => e.state === 'create-in-progress' + ); + if (createInProgressEice) { + return createInProgressEice; + } + + 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}`); + } + } + + async function createNode() { + setCreateNodeAttempt({ status: 'processing' }); + try { + const node = await nodeService.createNode(cfg.proxyCluster, { + hostname: typedAgentMeta.node.hostname, + addr: typedAgentMeta.node.addr, + labels: typedAgentMeta.node.labels, + aws: typedAgentMeta.node.awsMetadata, + name: typedAgentMeta.node.id, + subKind: 'openssh-ec2-ice', + }); + + updateAgentMeta({ + ...typedAgentMeta, + node, + resourceName: node.id, + }); + setCreateNodeAttempt({ status: 'success' }); + } catch (err) { + const errMsg = getErrMessage(err); + setCreateNodeAttempt({ status: 'failed', statusText: errMsg }); + setIsPollingActive(false); + emitErrorEvent(`error creating teleport node: ${errMsg}`); + } + } + + let content: JSX.Element; + if ( + fetchEc2IceAttempt.status === 'failed' || + createNodeAttempt.status === 'failed' + ) { + content = ( + <> + + {' '} + + + {fetchEc2IceAttempt.status === 'failed' + ? fetchEc2IceAttempt.statusText + : createNodeAttempt.statusText} + + + + {!!retry && ( + + Retry + + )} + + + ); + } else { + if (ec2Ice?.state === 'create-failed') { + content = ( + <> + + + + + We couldn't create the EC2 Instance Connect Endpoint. +
+ Please visit your{' '} + + dashboard{' '} + + to troubleshoot. +
+ We'll keep looking for the endpoint until it becomes available. +
+
+ + Next + + + ); + } else if ( + ec2Ice?.state === 'create-complete' && + createNodeAttempt.status === 'success' + ) { + 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. + + )} + + + The EC2 instance [{typedAgentMeta?.node.awsMetadata.instanceId}] has + been added to Teleport. + + nextStep()}> + Next + + + ); + } else { + content = ( + <> + + + + This may take a few minutes.. + + + Next + + + ); + } + } + + let title = 'Creating EC2 Instance Connect Endpoint'; + + if (ec2Ice?.state === 'create-complete') { + if (createNodeAttempt.status === 'success') { + title = 'Created Teleport Node'; + } else { + title = 'Creating Teleport Node'; + } + } + + return ( + + + + {title} + + {content} + + + ); +} + +export type CreateEc2IceDialogProps = { + ec2Ice: Ec2InstanceConnectEndpoint; + fetchEc2IceAttempt: Attempt; + createNodeAttempt: Attempt; + retry: () => void; + next: () => void; +}; diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx new file mode 100644 index 000000000000..e48c74714bbb --- /dev/null +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/Ec2InstanceList.tsx @@ -0,0 +1,193 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { Box, 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'; + +import cfg from 'teleport/config'; + +import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; +import { CommandBox } from 'teleport/Discover/Shared/CommandBox'; +import { + RadioCell, + DisableableCell as Cell, + Labels, + labelMatcher, +} from 'teleport/Discover/Shared'; + +import { NodeMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { Regions } from 'teleport/services/integrations'; + +import { CheckedEc2Instance } from './EnrollEc2Instance'; + +type Props = { + attempt: Attempt; + items: CheckedEc2Instance[]; + fetchStatus: FetchStatus; + fetchNextPage(): void; + onSelectInstance(item: CheckedEc2Instance): void; + selectedInstance?: CheckedEc2Instance; + region: Regions; +}; + +export const Ec2InstanceList = ({ + attempt, + items = [], + fetchStatus = '', + fetchNextPage, + onSelectInstance, + selectedInstance, + region, +}: Props) => { + const [scriptUrl, setScriptUrl] = useState(''); + const hasError = attempt.status === 'failed'; + const { agentMeta } = useDiscover(); + + const showConfigureScript = + hasError && + attempt.statusText.includes('StatusCode: 403, RequestID:') && + attempt.statusText.includes('operation error'); + + // Regenerate the script any time the region changes. + useEffect(() => { + if (region) { + generateAutoConfigScript(); + } + }, [region]); + + function generateAutoConfigScript() { + const newScriptUrl = cfg.getEc2InstanceConnectIAMConfigureScriptUrl({ + region: region, + + // arn's are formatted as `don-care-about-this-part/role-arn`. + // We are splitting by slash and getting the last element. + awsOidcRoleArn: (agentMeta as NodeMeta).integration.spec.roleArn + .split('/') + .pop(), + }); + + setScriptUrl(newScriptUrl); + } + + const disabledText = `This EC2 instance is already enrolled and is a part of this cluster`; + + return ( + <> + {hasError && !showConfigureScript && ( + {attempt.statusText} + )} + {!hasError && ( + { + const isChecked = + item.awsMetadata.instanceId === + selectedInstance?.awsMetadata.instanceId; + return ( + + item={item} + key={item.awsMetadata.instanceId} + isChecked={isChecked} + onChange={onSelectInstance} + disabled={item.ec2InstanceExists} + value={item.awsMetadata.instanceId} + disabledText={disabledText} + /> + ); + }, + }, + { + key: 'hostname', + headerText: 'Hostname', + render: ({ hostname, ec2InstanceExists }) => ( + + {hostname} + + ), + }, + { + key: 'addr', + headerText: 'Address', + render: ({ addr, ec2InstanceExists }) => ( + + {addr} + + ), + }, + { + altKey: 'instanceId', + headerText: 'AWS Instance ID', + render: ({ awsMetadata, ec2InstanceExists }) => ( + + + {awsMetadata.instanceId} + + + ), + }, + { + key: 'labels', + headerText: 'Labels', + render: ({ labels, ec2InstanceExists }) => ( + + + + ), + }, + ]} + emptyText="No Results" + pagination={{ pageSize: 10 }} + customSearchMatchers={[labelMatcher]} + fetching={{ onFetchMore: fetchNextPage, fetchStatus }} + isSearchable + /> + )} + {showConfigureScript && ( + + + Configure your AWS IAM permissions + + We were unable to list your EC2 instances. Run the command + below on your AWS CloudShell to configure your IAM + permissions. Then press the refresh button above. + + + } + hasTtl={false} + > + + + + )} + + ); +}; diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx new file mode 100644 index 000000000000..9059b0aba22e --- /dev/null +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx @@ -0,0 +1,225 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { MemoryRouter } from 'react-router'; + +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { rest } from 'msw'; + +import { ContextProvider } from 'teleport'; +import cfg from 'teleport/config'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { + DiscoverProvider, + DiscoverContextState, +} from 'teleport/Discover/useDiscover'; +import { + IntegrationKind, + IntegrationStatusCode, +} from 'teleport/services/integrations'; + +import { EnrollEc2Instance } from './EnrollEc2Instance'; + +export default { + title: 'Teleport/Discover/Server/EC2/InstanceList', + loaders: [mswLoader], +}; + +initialize(); + +export const InstanceList = () => ; + +InstanceList.parameters = { + msw: { + handlers: [ + 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]] })) + ), + ], + }, +}; + +export const InstanceListLoading = () => ; + +InstanceListLoading.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) => + res(ctx.delay('infinite')) + ), + ], + }, +}; + +export const WithAwsPermissionsError = () => ; + +WithAwsPermissionsError.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) => + res( + ctx.status(403), + ctx.json({ message: 'StatusCode: 403, RequestID: operation error' }) + ) + ), + ], + }, +}; + +export const WithOtherError = () => ; + +WithOtherError.parameters = { + msw: { + handlers: [ + rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) => + res(ctx.status(404)) + ), + ], + }, +}; + +const Component = () => { + const ctx = createTeleportContext(); + const discoverCtx: DiscoverContextState = { + agentMeta: { + resourceName: 'node-name', + agentMatcherLabels: [], + db: {} as any, + selectedAwsRdsDb: {} as any, + node: {} as any, + integration: { + kind: IntegrationKind.AwsOidc, + name: 'test-oidc', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, + }, + }, + currentStep: 0, + nextStep: () => null, + prevStep: () => null, + onSelectResource: () => null, + resourceSpec: {} as any, + exitFlow: () => null, + viewConfig: null, + indexedViews: [], + setResourceSpec: () => null, + updateAgentMeta: () => null, + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, + }; + + cfg.proxyCluster = 'localhost'; + return ( + + + + + + + + ); +}; + +const ec2InstancesResponse = [ + { + id: 'ec2-instance-1', + kind: 'node', + clusterId: 'cluster', + hostname: 'ec2-hostname-1', + tags: [{ name: 'instance', value: 'ec2-1' }], + addr: 'ec2.1.com', + tunnel: false, + subKind: 'openssh-ec2-ice', + sshLogins: ['test'], + aws: { + accountId: 'test-account', + instanceId: 'instance-ec2-1', + region: 'us-west-1', + vpcId: 'test', + integration: 'test', + subnetId: 'test', + }, + }, + { + id: 'ec2-instance-2', + kind: 'node', + clusterId: 'cluster', + hostname: 'ec2-hostname-2', + tags: [{ name: 'instance', value: 'ec2-2' }], + addr: 'ec2.2.com', + tunnel: false, + subKind: 'openssh-ec2-ice', + sshLogins: ['test'], + aws: { + accountId: 'test-account', + instanceId: 'instance-ec2-2', + region: 'us-west-1', + vpcId: 'test', + integration: 'test', + subnetId: 'test', + }, + }, + { + id: 'ec2-instance-3', + kind: 'node', + clusterId: 'cluster', + hostname: 'ec2-hostname-3', + tags: [{ name: 'instance', value: 'ec2-3' }], + addr: 'ec2.3.com', + tunnel: false, + subKind: 'openssh-ec2-ice', + sshLogins: ['test'], + aws: { + accountId: 'test-account', + instanceId: 'instance-ec2-3', + region: 'us-west-1', + vpcId: 'test', + integration: 'test', + subnetId: 'test', + }, + }, + { + id: 'ec2-instance-4', + kind: 'node', + clusterId: 'cluster', + hostname: 'ec2-hostname-4', + tags: [{ name: 'instance', value: 'ec2-4' }], + addr: 'ec2.4.com', + tunnel: false, + subKind: 'openssh-ec2-ice', + sshLogins: ['test'], + aws: { + accountId: 'test-account', + instanceId: 'instance-ec2-4', + region: 'us-west-1', + vpcId: 'test', + integration: 'test', + subnetId: 'test', + }, + }, +]; diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx new file mode 100644 index 000000000000..40a78376b7cf --- /dev/null +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.tsx @@ -0,0 +1,272 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Box, Text } from 'design'; +import { FetchStatus } from 'design/DataTable/types'; +import useAttempt from 'shared/hooks/useAttemptNext'; + +import { getErrMessage } from 'shared/utils/errorType'; + +import cfg from 'teleport/config'; +import { NodeMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { + Ec2InstanceConnectEndpoint, + Regions, + integrationService, +} from 'teleport/services/integrations'; +import { AwsRegionSelector } from 'teleport/Discover/Shared/AwsRegionSelector'; +import NodeService, { Node } from 'teleport/services/nodes'; + +import { ActionButtons, Header } from '../../Shared'; + +import { CreateEc2IceDialog } from '../CreateEc2Ice/CreateEc2IceDialog'; + +import { Ec2InstanceList } from './Ec2InstanceList'; + +// 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. +export type CheckedEc2Instance = Node & { + ec2InstanceExists?: boolean; +}; + +type TableData = { + items: CheckedEc2Instance[]; + fetchStatus: FetchStatus; + nextToken?: string; + currRegion?: Regions; +}; + +const emptyTableData: TableData = { + items: [], + fetchStatus: 'disabled', + nextToken: '', +}; + +export function EnrollEc2Instance() { + const { agentMeta, emitErrorEvent, nextStep, updateAgentMeta } = + useDiscover(); + const nodeService = new NodeService(); + + const [currRegion, setCurrRegion] = useState(); + const [existingEice, setExistingEice] = + useState(); + const [selectedInstance, setSelectedInstance] = + useState(); + + const [tableData, setTableData] = useState({ + items: [], + nextToken: '', + fetchStatus: 'disabled', + }); + + const { + attempt: fetchEc2InstancesAttempt, + setAttempt: setFetchEc2InstancesAttempt, + } = useAttempt(''); + + const { attempt: fetchEc2IceAttempt, setAttempt: setFetchEc2IceAttempt } = + useAttempt(''); + + function fetchEc2InstancesWithNewRegion(region: Regions) { + if (region) { + setCurrRegion(region); + fetchEc2Instances({ ...emptyTableData, currRegion: region }); + } + } + + function fetchNextPage() { + fetchEc2Instances({ ...tableData }); + } + + function refreshEc2Instances() { + // When refreshing, start the table back at page 1. + fetchEc2Instances({ ...tableData, nextToken: '', items: [], currRegion }); + } + + async function fetchEc2Instances(data: TableData) { + const integrationName = (agentMeta as NodeMeta).integration.name; + + setTableData({ ...data, fetchStatus: 'loading' }); + setFetchEc2InstancesAttempt({ status: 'processing' }); + + try { + const { instances: fetchedEc2Instances, nextToken } = + await integrationService.fetchAwsEc2Instances(integrationName, { + region: data.currRegion, + nextToken: data.nextToken, + }); + + // Abort if there were no EC2 instances for the selected region. + if (fetchedEc2Instances.length <= 0) { + setFetchEc2InstancesAttempt({ status: 'success' }); + setTableData({ ...data, fetchStatus: 'disabled' }); + return; + } + + // Check if fetched EC2 instances are already in the cluster + // so that they can be disabled in the table. + + // Builds the predicate string that will query for + // all the fetched EC2 instances by searching by the AWS instance ID label. + const instanceIdPredicateQueries: string[] = fetchedEc2Instances.map( + d => + `labels["teleport.dev/instance-id"] == "${d.awsMetadata.instanceId}"` + ); + const fullPredicateQuery = instanceIdPredicateQueries.join(' || '); + const { agents: fetchedNodes } = await nodeService.fetchNodes( + cfg.proxyCluster, + { + query: fullPredicateQuery, + limit: fetchedEc2Instances.length, + } + ); + + const ec2InstancesLookupByInstanceId: Record = {}; + fetchedNodes.forEach( + d => (ec2InstancesLookupByInstanceId[d.awsMetadata.instanceId] = d) + ); + + // Check for already existing EC2 instances. + const checkedEc2Instances: CheckedEc2Instance[] = fetchedEc2Instances.map( + ec2 => { + const instance = + ec2InstancesLookupByInstanceId[ec2.awsMetadata.instanceId]; + if (instance) { + return { + ...ec2, + ec2InstanceExists: true, + }; + } + return ec2; + } + ); + + setFetchEc2InstancesAttempt({ status: 'success' }); + setTableData({ + currRegion, + nextToken, + fetchStatus: nextToken ? '' : 'disabled', + items: [...data.items, ...checkedEc2Instances], + }); + } catch (err) { + const errMsg = getErrMessage(err); + setTableData(data); + setFetchEc2InstancesAttempt({ status: 'failed', statusText: errMsg }); + emitErrorEvent(`ec2 instance fetch error: ${errMsg}`); + } + } + + async function fetchEc2InstanceConnectEndpoints() { + const integrationName = (agentMeta as NodeMeta).integration.name; + + setFetchEc2IceAttempt({ status: 'processing' }); + try { + const { endpoints: fetchedEc2Ices } = + await integrationService.fetchAwsEc2InstanceConnectEndpoints( + integrationName, + { + region: selectedInstance.awsMetadata.region, + vpcId: selectedInstance.awsMetadata.vpcId, + } + ); + setFetchEc2IceAttempt({ status: 'success' }); + return fetchedEc2Ices; + } catch (err) { + const errMsg = getErrMessage(err); + setFetchEc2InstancesAttempt({ status: 'failed', statusText: errMsg }); + emitErrorEvent(`ec2 instance connect endpoint fetch error: ${errMsg}`); + } + } + + function clear() { + setFetchEc2InstancesAttempt({ status: '' }); + setTableData(emptyTableData); + setSelectedInstance(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' + ); + + // 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); + updateAgentMeta({ + ...(agentMeta as NodeMeta), + node: selectedInstance, + ec2Ice: createCompleteEice || createInProgressEice, + }); + // If we find neither, then we go to the next step to create the EICE. + } else { + updateAgentMeta({ + ...(agentMeta as NodeMeta), + node: selectedInstance, + }); + nextStep(); + } + }); + } + + return ( + +
Enroll an EC2 instance
+ + Select the AWS Region you would like to see EC2 instances for: + + + {currRegion && ( + + )} + {existingEice && ( + nextStep(2)} + existingEice={existingEice} + /> + )} + +
+ ); +} diff --git a/web/packages/teleport/src/Discover/Server/index.tsx b/web/packages/teleport/src/Discover/Server/index.tsx index dacdaf6c3035..33cef4c1db64 100644 --- a/web/packages/teleport/src/Discover/Server/index.tsx +++ b/web/packages/teleport/src/Discover/Server/index.tsx @@ -20,38 +20,85 @@ import { ResourceViewConfig } from 'teleport/Discover/flow'; import { DownloadScript } from 'teleport/Discover/Server/DownloadScript'; import { SetupAccess } from 'teleport/Discover/Server/SetupAccess'; import { TestConnection } from 'teleport/Discover/Server/TestConnection'; -import { ResourceKind, Finished } from 'teleport/Discover/Shared'; +import { AwsAccount, ResourceKind, Finished } from 'teleport/Discover/Shared'; import { DiscoverEvent } from 'teleport/services/userEvent'; +import { ResourceSpec, ServerLocation } from '../SelectResource'; + +import { EnrollEc2Instance } from './EnrollEc2Instance/EnrollEc2Instance'; +import { CreateEc2Ice } from './CreateEc2Ice/CreateEc2Ice'; + import { ServerWrapper } from './ServerWrapper'; -export const ServerResource: ResourceViewConfig = { +export const ServerResource: ResourceViewConfig = { kind: ResourceKind.Server, wrapper: (component: React.ReactNode) => ( {component} ), - views: [ - { - title: 'Configure Resource', - component: DownloadScript, - eventName: DiscoverEvent.DeployService, - }, - { - title: 'Set Up Access', - component: SetupAccess, - eventName: DiscoverEvent.PrincipalsConfigure, - }, - { - title: 'Test Connection', - component: TestConnection, - eventName: DiscoverEvent.TestConnection, - manuallyEmitSuccessEvent: true, - }, - { - title: 'Finished', - component: Finished, - hide: true, - eventName: DiscoverEvent.Completed, - }, - ], + shouldPrompt(currentStep, resourceSpec) { + if (resourceSpec?.nodeMeta?.location === ServerLocation.Aws) { + // Allow user to bypass prompting on this step (Connect AWS Connect) + // on exit because users might need to change route to setup an + // integration. + if (currentStep === 0) { + return false; + } + } + return true; + }, + + views(resource) { + let configureResourceViews; + if (resource && resource.nodeMeta?.location === ServerLocation.Aws) { + configureResourceViews = [ + { + title: 'Connect AWS Account', + component: AwsAccount, + eventName: DiscoverEvent.IntegrationAWSOIDCConnectEvent, + }, + { + title: 'Enroll EC2 Instance', + component: EnrollEc2Instance, + // eventName: null, TODO rudream (ADD EVENTS FOR EICE FLOW) + }, + { + title: 'Create EC2 Instance Connect Endpoint', + component: CreateEc2Ice, + // eventName: null, TODO rudream (ADD EVENTS FOR EICE FLOW) + }, + ]; + } else { + configureResourceViews = [ + { + title: 'Configure Resource', + component: DownloadScript, + eventName: DiscoverEvent.DeployService, + }, + ]; + } + + return [ + { + title: 'Configure Resource', + views: configureResourceViews, + }, + { + title: 'Set Up Access', + component: SetupAccess, + eventName: DiscoverEvent.PrincipalsConfigure, + }, + { + title: 'Test Connection', + component: TestConnection, + eventName: DiscoverEvent.TestConnection, + manuallyEmitSuccessEvent: true, + }, + { + title: 'Finished', + component: Finished, + hide: true, + eventName: DiscoverEvent.Completed, + }, + ]; + }, }; diff --git a/web/packages/teleport/src/Discover/Shared/Aws/DisableableCell.tsx b/web/packages/teleport/src/Discover/Shared/Aws/DisableableCell.tsx new file mode 100644 index 000000000000..f9ea2cc88de6 --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/Aws/DisableableCell.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Cell } from 'design/DataTable'; + +export const DisableableCell: React.FC<{ + disabledText: string; + disabled: boolean; + width?: string; +}> = ({ disabledText, disabled, width, children }) => { + return ( + + {children} + + ); +}; diff --git a/web/packages/teleport/src/Discover/Shared/Aws/Labels.tsx b/web/packages/teleport/src/Discover/Shared/Aws/Labels.tsx new file mode 100644 index 000000000000..e7d8944f087c --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/Aws/Labels.tsx @@ -0,0 +1,57 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Flex, Label as Pill } from 'design'; + +import { Label } from 'teleport/types'; + +export const Labels = ({ labels }: { labels: Label[] }) => { + const $labels = labels.map((label, index) => { + const labelText = `${label.name}: ${label.value}`; + + return ( + + {labelText} + + ); + }); + + return {$labels}; +}; + +// labelMatcher allows user to client search by labels in the format +// 1) `key: value` or +// 2) `key:value` or +// 3) `key` or `value` +export function labelMatcher( + targetValue: any, + searchValue: string, + propName: keyof T & string +) { + if (propName === 'labels') { + return targetValue.some((label: Label) => { + const convertedKey = label.name.toLocaleUpperCase(); + const convertedVal = label.value.toLocaleUpperCase(); + const formattedWords = [ + `${convertedKey}:${convertedVal}`, + `${convertedKey}: ${convertedVal}`, + ]; + return formattedWords.some(w => w.includes(searchValue)); + }); + } +} diff --git a/web/packages/teleport/src/Discover/Shared/Aws/RadioCell.tsx b/web/packages/teleport/src/Discover/Shared/Aws/RadioCell.tsx new file mode 100644 index 000000000000..9c82fa03c789 --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/Aws/RadioCell.tsx @@ -0,0 +1,66 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Flex } from 'design'; + +import { DisableableCell } from './DisableableCell'; + +export function RadioCell({ + item, + value, + isChecked, + onChange, + disabled, + disabledText, +}: { + item: T; + value: string; + isChecked: boolean; + onChange(selectedItem: T): void; + disabled: boolean; + disabledText: string; +}) { + return ( + + + props.theme.space[2]}px 0 0; + accent-color: ${props => props.theme.colors.brand.accent}; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + } + `} + type="radio" + name={value} + checked={isChecked} + onChange={() => onChange(item)} + value={value} + disabled={disabled} + /> + + + ); +} diff --git a/web/packages/teleport/src/Discover/Shared/Aws/index.ts b/web/packages/teleport/src/Discover/Shared/Aws/index.ts new file mode 100644 index 000000000000..350e89f993f7 --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/Aws/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DisableableCell } from './DisableableCell'; +export { Labels, labelMatcher } from './Labels'; +export { RadioCell } from './RadioCell'; diff --git a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx b/web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx similarity index 83% rename from web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx rename to web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx index 18b5cfe69b65..388c52cc3a0b 100644 --- a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx +++ b/web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx @@ -38,20 +38,25 @@ import { IntegrationKind, integrationService, } from 'teleport/services/integrations'; -import { integrationRWEAndDbCU } from 'teleport/Discover/yamlTemplates'; +import { + integrationRWE, + integrationRWEAndNodeRWE, + integrationRWEAndDbCU, +} from 'teleport/Discover/yamlTemplates'; import useTeleport from 'teleport/useTeleport'; -import { ActionButtons, HeaderSubtitle, Header } from '../../Shared'; - import { - DbMeta, - DiscoverUrlLocationState, - useDiscover, -} from '../../useDiscover'; + ActionButtons, + HeaderSubtitle, + Header, + ResourceKind, +} from '../../Shared'; + +import { DiscoverUrlLocationState, useDiscover } from '../../useDiscover'; type Option = BaseOption; -export function ConnectAwsAccount() { +export function AwsAccount() { const { storeUser } = useTeleport(); const { prevStep, @@ -61,16 +66,32 @@ export function ConnectAwsAccount() { eventState, resourceSpec, currentStep, + viewConfig, } = useDiscover(); const integrationAccess = storeUser.getIntegrationsAccess(); - const databaseAccess = storeUser.getDatabaseAccess(); - const hasAccess = - integrationAccess.create && - integrationAccess.list && - // Required access after integrating: - integrationAccess.use && // required to list AWS RDS db's - databaseAccess.create; // required to enroll AWS RDS db + + let roleTemplate = integrationRWE; + let hasAccess = + integrationAccess.create && integrationAccess.list && integrationAccess.use; + + // Ensure required permissions based on which flow this is in. + if (viewConfig.kind === ResourceKind.Database) { + roleTemplate = integrationRWEAndDbCU; + const databaseAccess = storeUser.getDatabaseAccess(); + hasAccess = hasAccess && databaseAccess.create; // required to enroll AWS RDS db + } + if (viewConfig.kind === ResourceKind.Server) { + roleTemplate = integrationRWEAndNodeRWE; + const nodesAccess = storeUser.getNodeAccess(); + hasAccess = + hasAccess && + nodesAccess.create && + nodesAccess.edit && + nodesAccess.list && + nodesAccess.read; // Needed for TestConnection flow + } + const { attempt, run } = useAttempt(hasAccess ? 'processing' : ''); const [awsIntegrations, setAwsIntegrations] = useState([]); @@ -114,7 +135,7 @@ export function ConnectAwsAccount() { @@ -151,7 +172,7 @@ export function ConnectAwsAccount() { } updateAgentMeta({ - ...(agentMeta as DbMeta), + ...agentMeta, integration: selectedAwsIntegration.value, }); diff --git a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/index.ts b/web/packages/teleport/src/Discover/Shared/AwsAccount/index.ts similarity index 91% rename from web/packages/teleport/src/Discover/Database/ConnectAwsAccount/index.ts rename to web/packages/teleport/src/Discover/Shared/AwsAccount/index.ts index 238cb305ffe6..9b270603e1a5 100644 --- a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/index.ts +++ b/web/packages/teleport/src/Discover/Shared/AwsAccount/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ConnectAwsAccount } from './ConnectAwsAccount'; +export { AwsAccount } from './AwsAccount'; diff --git a/web/packages/teleport/src/Discover/Shared/CommandBox.tsx b/web/packages/teleport/src/Discover/Shared/CommandBox.tsx index ccb486d46bd7..e6094847351d 100644 --- a/web/packages/teleport/src/Discover/Shared/CommandBox.tsx +++ b/web/packages/teleport/src/Discover/Shared/CommandBox.tsx @@ -28,16 +28,23 @@ const Container = styled(Box)` interface CommandBoxProps { header?: React.ReactNode; + // hasTtl when true means that the command has an expiry TTL, otherwise the command + // is valid forever. + hasTtl?: boolean; } -export function CommandBox(props: React.PropsWithChildren) { +export function CommandBox({ + header, + children, + hasTtl = true, +}: React.PropsWithChildren) { return ( - {props.header || Command} + {header || Command} - {props.children} + {children} - This script is valid for 4 hours. + {hasTtl && `This script is valid for 4 hours.`} ); } diff --git a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx new file mode 100644 index 000000000000..afd556f1cbcb --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx @@ -0,0 +1,177 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; + +import { Flex, Link } from 'design'; +import Table, { Cell } from 'design/DataTable'; +import { Danger } from 'design/Alert'; +import { CheckboxInput } from 'design/Checkbox'; +import { FetchStatus } from 'design/DataTable/types'; + +import { Attempt } from 'shared/hooks/useAttemptNext'; + +import { SecurityGroup } from 'teleport/services/integrations'; + +import { SecurityGroupRulesDialog } from './SecurityGroupRulesDialog'; + +type Props = { + attempt: Attempt; + items: SecurityGroup[]; + fetchStatus: FetchStatus; + fetchNextPage(): void; + onSelectSecurityGroup: ( + sg: SecurityGroup, + e: React.ChangeEvent + ) => void; + selectedSecurityGroups: string[]; +}; + +export type ViewRulesSelection = { + sg: SecurityGroup; + ruleType: 'inbound' | 'outbound'; +}; + +export const SecurityGroupPicker = ({ + attempt, + items = [], + fetchStatus = '', + fetchNextPage, + onSelectSecurityGroup, + selectedSecurityGroups, +}: Props) => { + const [viewRulesSelection, setViewRulesSelection] = + useState(); + + function onCloseRulesDialog() { + setViewRulesSelection(null); + } + + if (attempt.status === 'failed') { + return {attempt.statusText}; + } + + return ( + <> +
{ + const isChecked = selectedSecurityGroups.includes(item.id); + return ( + + ); + }, + }, + { + key: 'name', + headerText: 'Name', + }, + { + key: 'id', + headerText: 'ID', + }, + { + key: 'description', + headerText: 'Description', + }, + { + altKey: 'inboundRules', + headerText: 'Inbound Rules', + render: sg => { + return ( + + + setViewRulesSelection({ sg, ruleType: 'inbound' }) + } + > + View ({sg.inboundRules.length}) + + + ); + }, + }, + { + altKey: 'outboundRules', + headerText: 'Outbound Rules', + render: sg => { + return ( + + + setViewRulesSelection({ sg, ruleType: 'outbound' }) + } + > + View ({sg.outboundRules.length}) + + + ); + }, + }, + ]} + emptyText="No Security Groups Found" + pagination={{ pageSize: 5 }} + fetching={{ onFetchMore: fetchNextPage, fetchStatus }} + isSearchable + /> + {viewRulesSelection && ( + + )} + + ); +}; + +function CheckboxCell({ + item, + isChecked, + onChange, +}: { + item: SecurityGroup; + isChecked: boolean; + onChange( + selectedItem: SecurityGroup, + e: React.ChangeEvent + ): void; +}) { + return ( + + + { + onChange(item, e); + }} + checked={isChecked} + /> + + + ); +} diff --git a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupRulesDialog.tsx b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupRulesDialog.tsx new file mode 100644 index 000000000000..fec012ad9238 --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupRulesDialog.tsx @@ -0,0 +1,120 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { Text, ButtonSecondary } from 'design'; +import Table, { Cell } from 'design/DataTable'; +import Dialog, { DialogContent, DialogFooter } from 'design/DialogConfirmation'; + +import { ViewRulesSelection } from './SecurityGroupPicker'; + +export function SecurityGroupRulesDialog({ + viewRulesSelection, + onClose, +}: { + viewRulesSelection: ViewRulesSelection; + onClose: () => void; +}) { + const { ruleType, sg } = viewRulesSelection; + const data = ruleType === 'inbound' ? sg.inboundRules : sg.outboundRules; + + return ( + + + + {ruleType === 'inbound' ? 'Inbound' : 'Outbound'} Rules for [{sg.name} + ] + + { + // If they are the same, only show one number. + const portRange = + fromPort === toPort ? fromPort : `${fromPort} - ${toPort}`; + return {portRange}; + }, + }, + { + altKey: 'source', + headerText: 'Source', + render: ({ cidrs }) => { + // The AWS API returns an array, however it appears it's not actually possible to have multiple CIDR's for a single rule. + // As a fallback we just display the first one. + const cidr = cidrs[0]; + if (cidr) { + return {cidr.cidr}; + } + return null; + }, + }, + { + altKey: 'description', + headerText: 'Description', + render: ({ cidrs }) => { + const cidr = cidrs[0]; + if (cidr) { + return {cidr.description}; + } + return null; + }, + }, + ]} + emptyText="No Rules Found" + /> + + + + Close + + + + ); +} + +const StyledTable = styled(Table)` + & > tbody > tr > td { + vertical-align: middle; + text-align: left; + } + + & > thead > tr > th { + background: ${props => props.theme.colors.spotBackground[1]}; + } + + border-radius: 8px; + box-shadow: ${props => props.theme.boxShadow[0]}; + overflow: hidden; +` as typeof Table; diff --git a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/index.ts b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/index.ts new file mode 100644 index 000000000000..224265064ebe --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { SecurityGroupPicker } from './SecurityGroupPicker'; +export type { ViewRulesSelection } from './SecurityGroupPicker'; diff --git a/web/packages/teleport/src/Discover/Shared/index.ts b/web/packages/teleport/src/Discover/Shared/index.ts index 7db05d57e0e1..5213901784ae 100644 --- a/web/packages/teleport/src/Discover/Shared/index.ts +++ b/web/packages/teleport/src/Discover/Shared/index.ts @@ -30,5 +30,9 @@ export { } from './ConnectionDiagnostic'; export { useShowHint } from './useShowHint'; export { StepBox } from './StepBox'; +export { SecurityGroupPicker } from './SecurityGroupPicker'; +export type { ViewRulesSelection } from './SecurityGroupPicker'; +export { AwsAccount } from './AwsAccount'; +export { DisableableCell, Labels, labelMatcher, RadioCell } from './Aws'; export type { DiscoverLabel } from './LabelsCreater'; diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index b02ce44cd6e3..0167de946c28 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -46,6 +46,7 @@ import type { ResourceLabel } from 'teleport/services/agents'; import type { ResourceSpec } from './SelectResource'; import type { AwsRdsDatabase, + Ec2InstanceConnectEndpoint, Integration, } from 'teleport/services/integrations'; @@ -465,6 +466,8 @@ type BaseMeta = { // that needs to be preserved throughout the flow. export type NodeMeta = BaseMeta & { node: Node; + integration?: Integration; + ec2Ice?: Ec2InstanceConnectEndpoint; }; // DbMeta describes the fields for a db resource diff --git a/web/packages/teleport/src/Discover/yamlTemplates/index.ts b/web/packages/teleport/src/Discover/yamlTemplates/index.ts index 1f65acf5795c..9d208ef0d890 100644 --- a/web/packages/teleport/src/Discover/yamlTemplates/index.ts +++ b/web/packages/teleport/src/Discover/yamlTemplates/index.ts @@ -22,7 +22,9 @@ import kubeAccessRO from './kubeAccessRO.yaml?raw'; import dbAccessRW from './dbAccessRW.yaml?raw'; import dbAccessRO from './dbAccessRO.yaml?raw'; import dbCU from './dbCU.yaml?raw'; +import integrationRWE from './integrationRWE.yaml?raw'; import integrationRWEAndDbCU from './integrationRWEAndDbCU.yaml?raw'; +import integrationRWEAndNodeRWE from './integrationRWEAndNodeRWE.yaml?raw'; export { nodeAccessRO, @@ -33,5 +35,7 @@ export { dbAccessRO, dbAccessRW, dbCU, + integrationRWE, integrationRWEAndDbCU, + integrationRWEAndNodeRWE, }; diff --git a/web/packages/teleport/src/Discover/yamlTemplates/integrationRWE.yaml b/web/packages/teleport/src/Discover/yamlTemplates/integrationRWE.yaml new file mode 100644 index 000000000000..7c99f58c6665 --- /dev/null +++ b/web/packages/teleport/src/Discover/yamlTemplates/integrationRWE.yaml @@ -0,0 +1,10 @@ +kind: role +spec: + allow: + rules: + - resources: + - integration + verbs: + - list + - create + - use \ No newline at end of file diff --git a/web/packages/teleport/src/Discover/yamlTemplates/integrationRWEAndNodeRWE.yaml b/web/packages/teleport/src/Discover/yamlTemplates/integrationRWEAndNodeRWE.yaml new file mode 100644 index 000000000000..376923730e64 --- /dev/null +++ b/web/packages/teleport/src/Discover/yamlTemplates/integrationRWEAndNodeRWE.yaml @@ -0,0 +1,17 @@ +kind: role +spec: + allow: + rules: + - resources: + - integration + verbs: + - list + - create + - use + - resources: + - node + verbs: + - create + - update + - list + - read diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx index d2756cdabf4a..f5cecd62361b 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx @@ -191,7 +191,7 @@ export function SuccessfullyAddedIntegrationDialog({ }, }} > - Begin RDS Enrollment + Begin AWS Resource Enrollment ) : ( diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index e0418b143cc2..b4623812a8ff 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -252,7 +252,7 @@ const cfg = { ec2InstanceConnectIAMConfigureScriptPath: '/v1/webapi/scripts/integrations/configure/eice-iam.sh?awsRegion=:region&role=:awsOidcRoleArn', ec2InstanceConnectDeployPath: - '/v1/webapi/sites/:site/integrations/aws-oidc/:name/deployec2ice', + '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/deployec2ice', userGroupsListPath: '/v1/webapi/sites/:clusterId/user-groups?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?', diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 0557dec20296..65a8e8949312 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -159,7 +159,7 @@ export const integrationService = { return api .post(cfg.getListEc2InstanceConnectEndpointsUrl(integrationName), req) .then(json => { - const endpoints = json?.ec2InstanceConnectEndpoints ?? []; + const endpoints = json?.ec2Ices ?? []; return { endpoints: endpoints.map(makeEc2InstanceConnectEndpoint), diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 6c637d4e2767..21215bcb0d60 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -281,13 +281,7 @@ export type ListEc2InstanceConnectEndpointsResponse = { export type Ec2InstanceConnectEndpoint = { name: string; // state is the current state of the EC2 Instance Connect Endpoint. - state: - | 'create-in-progress' - | 'create-complete' - | 'create-failed' - | 'delete-in-progress' - | 'delete-complete' - | 'delete-failed'; + state: Ec2InstanceConnectEndpointState; // stateMessage is an optional message describing the state of the EICE, such as an error message. stateMessage?: string; // dashboardLink is a URL to AWS Console where the user can see the EC2 Instance Connect Endpoint. @@ -296,6 +290,14 @@ export type Ec2InstanceConnectEndpoint = { subnetId: string; }; +export type Ec2InstanceConnectEndpointState = + | 'create-in-progress' + | 'create-complete' + | 'create-failed' + | 'delete-in-progress' + | 'delete-complete' + | 'delete-failed'; + export type DeployEc2InstanceConnectEndpointRequest = { region: Regions; // subnetID is the subnet id for the EC2 Instance Connect Endpoint. @@ -312,6 +314,7 @@ export type DeployEc2InstanceConnectEndpointResponse = { export type ListAwsSecurityGroupsRequest = { // VPCID is the VPC to filter Security Groups. vpcId: string; + region: Regions; nextToken?: string; }; diff --git a/web/packages/teleport/src/services/nodes/types.ts b/web/packages/teleport/src/services/nodes/types.ts index 254c528b1c9a..a531a4c95d7a 100644 --- a/web/packages/teleport/src/services/nodes/types.ts +++ b/web/packages/teleport/src/services/nodes/types.ts @@ -15,6 +15,8 @@ limitations under the License. */ import { ResourceLabel } from 'teleport/services/agents'; +import { Regions } from '../integrations'; + export interface Node { kind: 'node'; id: string; @@ -36,7 +38,7 @@ export interface BashCommand { export type AwsMetadata = { accountId: string; instanceId: string; - region: string; + region: Regions; vpcId: string; integration: string; subnetId: string;