add eice discover flow (#32202)

This commit is contained in:
Yassine Bounekhla 2023-09-28 12:38:26 -04:00 committed by GitHub
parent c2f470fe66
commit 10a1f2d1d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2562 additions and 174 deletions

View file

@ -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,

View file

@ -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,
})
}
})

View file

@ -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,
}
}

View file

@ -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 (
<RadioCell
<RadioCell<CheckedAwsRdsDatabase>
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 }) => (
<Cell disabled={dbServerExists}>{name}</Cell>
<Cell disabledText={disabledText} disabled={dbServerExists}>
{name}
</Cell>
),
},
{
key: 'engine',
headerText: 'Engine',
render: ({ engine, dbServerExists }) => (
<Cell disabled={dbServerExists}>{engine}</Cell>
<Cell disabledText={disabledText} disabled={dbServerExists}>
{engine}
</Cell>
),
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels, dbServerExists }) => (
<Cell disabled={dbServerExists}>
<Cell disabledText={disabledText} disabled={dbServerExists}>
<Labels labels={labels} />
</Cell>
),
@ -103,7 +116,7 @@ const StatusCell = ({ item }: { item: CheckedAwsRdsDatabase }) => {
const status = getStatus(item);
return (
<Cell disabled={item.dbServerExists}>
<Cell disabledText={disabledText} disabled={item.dbServerExists}>
<Flex alignItems="center">
<StatusLight status={status} />
{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 (
<Cell width="20px" disabled={disabled}>
<Flex alignItems="center" my={2} justifyContent="center">
<input
css={`
margin: 0 ${props => 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}
/>
</Flex>
</Cell>
);
}
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 (
<Pill key={`${label.name}${label.value}${index}`} mr="1" kind="secondary">
{labelText}
</Pill>
);
});
return <Flex flexWrap="wrap">{$labels}</Flex>;
};
// 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 (
<TableCell
width={width}
title={
disabled
? 'this RDS database is already enrolled and is a part of this cluster'
: null
}
css={`
opacity: ${disabled ? '0.5' : '1'};
`}
>
{children}
</TableCell>
);
};

View file

@ -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<ResourceSpec> = {
configureResourceViews = [
{
title: 'Connect AWS Account',
component: ConnectAwsAccount,
component: AwsAccount,
eventName: DiscoverEvent.IntegrationAWSOIDCConnectEvent,
},
{

View file

@ -268,6 +268,47 @@ exports[`render with URL loc state set to "server" 1`] = `
<div
class="c10"
>
<div
class="c11"
data-testid="4"
>
<div
class="c12"
>
Guided
</div>
<div
class="c13"
>
<div
class="c14"
width="24px"
>
<img
class="c15"
height="24px"
src="file_stub"
width="23.9px"
/>
</div>
<div
class="c16"
>
<div
class="c17"
color="text.slightlyMuted"
font-size="12px"
>
Amazon Web Services (AWS)
</div>
<div
class="c18"
>
EC2 Instance
</div>
</div>
</div>
</div>
<div
class="c11"
data-testid="4"
@ -933,6 +974,47 @@ exports[`render with all access 1`] = `
</div>
</div>
</div>
<div
class="c7"
data-testid="4"
>
<div
class="c8"
>
Guided
</div>
<div
class="c9"
>
<div
class="c10"
width="24px"
>
<img
class="c11"
height="24px"
src="file_stub"
width="23.9px"
/>
</div>
<div
class="c12"
>
<div
class="c14"
color="text.slightlyMuted"
font-size="12px"
>
Amazon Web Services (AWS)
</div>
<div
class="c13"
>
EC2 Instance
</div>
</div>
</div>
</div>
<div
class="c7"
data-testid="3"
@ -3097,6 +3179,48 @@ exports[`render with no access 1`] = `
</div>
</div>
</a>
<div
class="c7"
data-testid="4"
>
<div
class="c8"
data-testid="tooltip"
>
Lacking Permissions
</div>
<div
class="c9"
>
<div
class="c10"
width="24px"
>
<img
class="c11"
height="24px"
src="file_stub"
width="23.9px"
/>
</div>
<div
class="c12"
>
<div
class="c13"
color="text.slightlyMuted"
font-size="12px"
>
Amazon Web Services (AWS)
</div>
<div
class="c14"
>
EC2 Instance
</div>
</div>
</div>
</div>
<a
class="c15 c7"
data-testid="1"
@ -4436,6 +4560,47 @@ exports[`render with partial access 1`] = `
</div>
</div>
</div>
<div
class="c7"
data-testid="4"
>
<div
class="c8"
>
Guided
</div>
<div
class="c9"
>
<div
class="c10"
width="24px"
>
<img
class="c11"
height="24px"
src="file_stub"
width="23.9px"
/>
</div>
<div
class="c12"
>
<div
class="c14"
color="text.slightlyMuted"
font-size="12px"
>
Amazon Web Services (AWS)
</div>
<div
class="c13"
>
EC2 Instance
</div>
</div>
</div>
</div>
<div
class="c7"
data-testid="3"

View file

@ -26,7 +26,12 @@ import {
DATABASES_UNGUIDED,
DATABASES_UNGUIDED_DOC,
} from './databases';
import { ResourceSpec, DatabaseLocation, DatabaseEngine } from './types';
import {
ResourceSpec,
DatabaseLocation,
DatabaseEngine,
ServerLocation,
} from './types';
import { SAML_APPLICATIONS } from './resourcesE';
const baseServerKeywords = 'server node';
@ -71,6 +76,15 @@ export const SERVERS: ResourceSpec[] = [
event: DiscoverEventResource.Server,
platform: Platform.PLATFORM_MACINTOSH,
},
{
name: 'EC2 Instance',
kind: ResourceKind.Server,
keywords:
baseServerKeywords + 'ec2 instance connect endpoint aws amazon eice',
icon: 'Aws',
event: null, // TODO rudream (ADD EVENTS FOR EICE FLOW)
nodeMeta: { location: ServerLocation.Aws },
},
];
export const APPLICATIONS: ResourceSpec[] = [
@ -155,6 +169,9 @@ export function getResourcePretitle(r: ResourceSpec) {
case ResourceKind.Desktop:
return 'Windows Desktop';
case ResourceKind.Server:
if (r.nodeMeta?.location === ServerLocation.Aws) {
return 'Amazon Web Services (AWS)';
}
return 'Server';
}

View file

@ -53,8 +53,13 @@ export enum DatabaseEngine {
Doc,
}
export enum ServerLocation {
Aws,
}
export interface ResourceSpec {
dbMeta?: { location: DatabaseLocation; engine: DatabaseEngine };
nodeMeta?: { location: ServerLocation };
name: string;
popular?: boolean;
kind: ResourceKind;

View file

@ -0,0 +1,467 @@
/**
* 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 { Info } from 'design/Alert';
import { ContextProvider } from 'teleport';
import cfg from 'teleport/config';
import { createTeleportContext } from 'teleport/mocks/contexts';
import {
DiscoverProvider,
DiscoverContextState,
NodeMeta,
} from 'teleport/Discover/useDiscover';
import {
IntegrationKind,
IntegrationStatusCode,
} from 'teleport/services/integrations';
import { CreateEc2Ice } from './CreateEc2Ice';
export default {
title: 'Teleport/Discover/Server/EC2/CreateEICE',
loaders: [mswLoader],
};
initialize();
export const ListSecurityGroupsLoading = () => <Component />;
ListSecurityGroupsLoading.parameters = {
msw: {
handlers: [
rest.post(cfg.getListSecurityGroupsUrl('test-oidc'), (req, res, ctx) =>
res(ctx.delay('infinite'))
),
],
},
};
export const ListSecurityGroupsFail = () => <Component />;
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 = () => (
<>
<Info width="1000px">To trigger this Story's state, click on "Next."</Info>
<Component />
</>
);
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 = () => (
<>
<Info width="1000px">To trigger this Story's state, click on "Next."</Info>
<Component />
</>
);
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 = () => (
<>
{' '}
<Info width="1000px">
To trigger this Story's state, click on "Next" and wait 10 seconds.
</Info>
<Component />
</>
);
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 = () => (
<>
<Info width="1000px">
To trigger this Story's state, click on "Next" and wait 10 seconds.
</Info>
<Component />
</>
);
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 (
<MemoryRouter
initialEntries={[
{ pathname: cfg.routes.discover, state: { entity: 'server' } },
]}
>
<ContextProvider ctx={ctx}>
<DiscoverProvider mockCtx={discoverCtx}>
<CreateEc2Ice />
</DiscoverProvider>
</ContextProvider>
</MemoryRouter>
);
};
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"' },
],
},
],
},
];

View file

@ -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<TableData>({
items: [],
nextToken: '',
fetchStatus: 'disabled',
});
function onSelectSecurityGroup(
sg: SecurityGroup,
e: React.ChangeEvent<HTMLInputElement>
) {
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 (
<>
<Box maxWidth="800px">
<Header>Create an EC2 Instance Connect Endpoint</Header>
<Box width="800px">
{deployEc2IceAttempt.status === 'failed' && (
<Danger>{deployEc2IceAttempt.statusText}</Danger>
)}
<Text mb={1} typography="h4">
Select AWS Security Groups to assign to the new EC2 Instance Connect
Endpoint:
</Text>
<Text mb={2}>
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.
</Text>
{fetchSecurityGroupsAttempt.status === 'failed' && (
<Danger>{fetchSecurityGroupsAttempt.statusText}</Danger>
)}
{fetchSecurityGroupsAttempt.status === 'processing' && (
<Flex width="352px" justifyContent="center" mt={3}>
<Indicator />
</Flex>
)}
{fetchSecurityGroupsAttempt.status === 'success' && (
<Box width="1000px">
<SecurityGroupPicker
items={tableData.items}
attempt={fetchSecurityGroupsAttempt}
fetchNextPage={() => fetchSecurityGroups()}
fetchStatus={tableData.fetchStatus}
onSelectSecurityGroup={onSelectSecurityGroup}
selectedSecurityGroups={selectedSecurityGroups}
/>
</Box>
)}
</Box>
<ActionButtons
onPrev={prevStep}
onProceed={() => handleOnProceed()}
disableProceed={deployEc2IceAttempt.status === 'processing'}
/>
</Box>
{showCreatingDialog && (
<CreateEc2IceDialog
nextStep={nextStep}
retry={() => deployEc2InstanceConnectEndpoint()}
/>
)}
</>
);
}

View file

@ -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<Ec2InstanceConnectEndpoint>(
() =>
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 = (
<>
<Flex mb={5} alignItems="center">
{' '}
<Icons.Warning size="large" ml={1} mr={2} color="error.main" />
<Text>
{fetchEc2IceAttempt.status === 'failed'
? fetchEc2IceAttempt.statusText
: createNodeAttempt.statusText}
</Text>
</Flex>
<Flex>
{!!retry && (
<ButtonPrimary mr={3} width="50%" onClick={retry}>
Retry
</ButtonPrimary>
)}
</Flex>
</>
);
} else {
if (ec2Ice?.state === 'create-failed') {
content = (
<>
<AnimatedProgressBar mb={1} />
<TextIcon mt={2} mb={3}>
<Icons.Warning size="large" ml={1} mr={2} color="warning.main" />
<Box
css={`
text-align: center;
`}
>
We couldn't create the EC2 Instance Connect Endpoint.
<br />
Please visit your{' '}
<Link
color="text.main"
href={ec2Ice?.dashboardLink}
target="_blank"
>
dashboard{' '}
</Link>
to troubleshoot.
<br />
We'll keep looking for the endpoint until it becomes available.
</Box>
</TextIcon>
<ButtonPrimary width="100%" disabled>
Next
</ButtonPrimary>
</>
);
} 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') && (
<Text
mb={2}
style={{ display: 'flex', textAlign: 'left', width: '100%' }}
>
<Icons.Check size="small" ml={1} mr={2} color="success" />
The EC2 Instance Connect Endpoint was successfully deployed.
</Text>
)}
<Text
mb={5}
style={{ display: 'flex', textAlign: 'left', width: '100%' }}
>
<Icons.Check size="small" ml={1} mr={2} color="success" />
The EC2 instance [{typedAgentMeta?.node.awsMetadata.instanceId}] has
been added to Teleport.
</Text>
<ButtonPrimary width="100%" onClick={() => nextStep()}>
Next
</ButtonPrimary>
</>
);
} else {
content = (
<>
<AnimatedProgressBar mb={1} />
<TextIcon
mt={2}
mb={3}
css={`
white-space: pre;
`}
>
<Icons.Clock size="medium" />
This may take a few minutes..
</TextIcon>
<ButtonPrimary width="100%" disabled>
Next
</ButtonPrimary>
</>
);
}
}
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 (
<Dialog disableEscapeKeyDown={false} open={true}>
<DialogContent
width="460px"
alignItems="center"
mb={0}
textAlign="center"
>
<Text bold caps mb={4}>
{title}
</Text>
{content}
</DialogContent>
</Dialog>
);
}
export type CreateEc2IceDialogProps = {
ec2Ice: Ec2InstanceConnectEndpoint;
fetchEc2IceAttempt: Attempt;
createNodeAttempt: Attempt;
retry: () => void;
next: () => void;
};

View file

@ -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 && (
<Danger>{attempt.statusText}</Danger>
)}
{!hasError && (
<Table
data={items}
columns={[
{
altKey: 'radio-select',
headerText: 'Select',
render: item => {
const isChecked =
item.awsMetadata.instanceId ===
selectedInstance?.awsMetadata.instanceId;
return (
<RadioCell<CheckedEc2Instance>
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 }) => (
<Cell disabledText={disabledText} disabled={ec2InstanceExists}>
{hostname}
</Cell>
),
},
{
key: 'addr',
headerText: 'Address',
render: ({ addr, ec2InstanceExists }) => (
<Cell disabledText={disabledText} disabled={ec2InstanceExists}>
{addr}
</Cell>
),
},
{
altKey: 'instanceId',
headerText: 'AWS Instance ID',
render: ({ awsMetadata, ec2InstanceExists }) => (
<Cell disabledText={disabledText} disabled={ec2InstanceExists}>
<Text
css={`
text-wrap: nowrap;
`}
>
{awsMetadata.instanceId}
</Text>
</Cell>
),
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels, ec2InstanceExists }) => (
<Cell disabledText={disabledText} disabled={ec2InstanceExists}>
<Labels labels={labels} />
</Cell>
),
},
]}
emptyText="No Results"
pagination={{ pageSize: 10 }}
customSearchMatchers={[labelMatcher]}
fetching={{ onFetchMore: fetchNextPage, fetchStatus }}
isSearchable
/>
)}
{showConfigureScript && (
<Box mt={4}>
<CommandBox
header={
<>
<Text bold>Configure your AWS IAM permissions</Text>
<Text typography="subtitle1" mb={3}>
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.
</Text>
</>
}
hasTtl={false}
>
<TextSelectCopyMulti
lines={[{ text: `bash -c "$(curl '${scriptUrl}')"` }]}
/>
</CommandBox>
</Box>
)}
</>
);
};

View file

@ -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 = () => <Component />;
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 = () => <Component />;
InstanceListLoading.parameters = {
msw: {
handlers: [
rest.post(cfg.getListEc2InstancesUrl('test-oidc'), (req, res, ctx) =>
res(ctx.delay('infinite'))
),
],
},
};
export const WithAwsPermissionsError = () => <Component />;
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 = () => <Component />;
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 (
<MemoryRouter
initialEntries={[
{ pathname: cfg.routes.discover, state: { entity: 'server' } },
]}
>
<ContextProvider ctx={ctx}>
<DiscoverProvider mockCtx={discoverCtx}>
<EnrollEc2Instance />
</DiscoverProvider>
</ContextProvider>
</MemoryRouter>
);
};
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',
},
},
];

View file

@ -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<Regions>();
const [existingEice, setExistingEice] =
useState<Ec2InstanceConnectEndpoint>();
const [selectedInstance, setSelectedInstance] =
useState<CheckedEc2Instance>();
const [tableData, setTableData] = useState<TableData>({
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<string, Node> = {};
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 (
<Box maxWidth="1000px">
<Header>Enroll an EC2 instance</Header>
<Text mt={4}>
Select the AWS Region you would like to see EC2 instances for:
</Text>
<AwsRegionSelector
onFetch={fetchEc2InstancesWithNewRegion}
onRefresh={refreshEc2Instances}
clear={clear}
disableSelector={fetchEc2InstancesAttempt.status === 'processing'}
/>
{currRegion && (
<Ec2InstanceList
attempt={fetchEc2InstancesAttempt}
items={tableData.items}
fetchStatus={tableData.fetchStatus}
selectedInstance={selectedInstance}
onSelectInstance={setSelectedInstance}
fetchNextPage={fetchNextPage}
region={currRegion}
/>
)}
{existingEice && (
<CreateEc2IceDialog
nextStep={() => nextStep(2)}
existingEice={existingEice}
/>
)}
<ActionButtons
onProceed={handleOnProceed}
disableProceed={
fetchEc2InstancesAttempt.status === 'processing' ||
fetchEc2IceAttempt.status === 'processing' ||
!selectedInstance
}
/>
</Box>
);
}

View file

@ -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<ResourceSpec> = {
kind: ResourceKind.Server,
wrapper: (component: React.ReactNode) => (
<ServerWrapper>{component}</ServerWrapper>
),
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,
},
];
},
};

View file

@ -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 (
<Cell
width={width}
title={disabled ? disabledText : null}
css={`
opacity: ${disabled ? '0.5' : '1'};
`}
>
{children}
</Cell>
);
};

View file

@ -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 (
<Pill key={`${label.name}${label.value}${index}`} mr="1" kind="secondary">
{labelText}
</Pill>
);
});
return <Flex flexWrap="wrap">{$labels}</Flex>;
};
// 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<T>(
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));
});
}
}

View file

@ -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<T>({
item,
value,
isChecked,
onChange,
disabled,
disabledText,
}: {
item: T;
value: string;
isChecked: boolean;
onChange(selectedItem: T): void;
disabled: boolean;
disabledText: string;
}) {
return (
<DisableableCell
width="20px"
disabled={disabled}
disabledText={disabledText}
>
<Flex alignItems="center" my={2} justifyContent="center">
<input
// eslint-disable-next-line react/no-unknown-property
css={`
margin: 0 ${props => 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}
/>
</Flex>
</DisableableCell>
);
}

View file

@ -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';

View file

@ -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<Integration>;
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<Option[]>([]);
@ -114,7 +135,7 @@ export function ConnectAwsAccount() {
<TextEditor
readOnly={true}
bg="levels.deep"
data={[{ content: integrationRWEAndDbCU, type: 'yaml' }]}
data={[{ content: roleTemplate, type: 'yaml' }]}
/>
</Flex>
</Box>
@ -151,7 +172,7 @@ export function ConnectAwsAccount() {
}
updateAgentMeta({
...(agentMeta as DbMeta),
...agentMeta,
integration: selectedAwsIntegration.value,
});

View file

@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ConnectAwsAccount } from './ConnectAwsAccount';
export { AwsAccount } from './AwsAccount';

View file

@ -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<CommandBoxProps>) {
export function CommandBox({
header,
children,
hasTtl = true,
}: React.PropsWithChildren<CommandBoxProps>) {
return (
<Container p={3} borderRadius={3} mb={3}>
{props.header || <Text bold>Command</Text>}
{header || <Text bold>Command</Text>}
<Box mt={3} mb={3}>
{props.children}
{children}
</Box>
This script is valid for 4 hours.
{hasTtl && `This script is valid for 4 hours.`}
</Container>
);
}

View file

@ -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<HTMLInputElement>
) => 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<ViewRulesSelection>();
function onCloseRulesDialog() {
setViewRulesSelection(null);
}
if (attempt.status === 'failed') {
return <Danger>{attempt.statusText}</Danger>;
}
return (
<>
<Table
data={items}
columns={[
{
altKey: 'checkbox-select',
headerText: 'Select',
render: item => {
const isChecked = selectedSecurityGroups.includes(item.id);
return (
<CheckboxCell
item={item}
key={item.id}
isChecked={isChecked}
onChange={onSelectSecurityGroup}
/>
);
},
},
{
key: 'name',
headerText: 'Name',
},
{
key: 'id',
headerText: 'ID',
},
{
key: 'description',
headerText: 'Description',
},
{
altKey: 'inboundRules',
headerText: 'Inbound Rules',
render: sg => {
return (
<Cell>
<Link
style={{ cursor: 'pointer' }}
onClick={() =>
setViewRulesSelection({ sg, ruleType: 'inbound' })
}
>
View ({sg.inboundRules.length})
</Link>
</Cell>
);
},
},
{
altKey: 'outboundRules',
headerText: 'Outbound Rules',
render: sg => {
return (
<Cell>
<Link
style={{ cursor: 'pointer' }}
onClick={() =>
setViewRulesSelection({ sg, ruleType: 'outbound' })
}
>
View ({sg.outboundRules.length})
</Link>
</Cell>
);
},
},
]}
emptyText="No Security Groups Found"
pagination={{ pageSize: 5 }}
fetching={{ onFetchMore: fetchNextPage, fetchStatus }}
isSearchable
/>
{viewRulesSelection && (
<SecurityGroupRulesDialog
viewRulesSelection={viewRulesSelection}
onClose={onCloseRulesDialog}
/>
)}
</>
);
};
function CheckboxCell({
item,
isChecked,
onChange,
}: {
item: SecurityGroup;
isChecked: boolean;
onChange(
selectedItem: SecurityGroup,
e: React.ChangeEvent<HTMLInputElement>
): void;
}) {
return (
<Cell width="20px">
<Flex alignItems="center" my={2} justifyContent="center">
<CheckboxInput
type="checkbox"
id={item.id}
onChange={e => {
onChange(item, e);
}}
checked={isChecked}
/>
</Flex>
</Cell>
);
}

View file

@ -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 (
<Dialog disableEscapeKeyDown={false} open={true}>
<DialogContent
width="600px"
alignItems="center"
mb={0}
textAlign="center"
>
<Text mb={4} typography="h4">
{ruleType === 'inbound' ? 'Inbound' : 'Outbound'} Rules for [{sg.name}
]
</Text>
<StyledTable
data={data}
columns={[
{
key: 'ipProtocol',
headerText: 'Type',
},
{
altKey: 'portRange',
headerText: 'Port Range',
render: ({ fromPort, toPort }) => {
// If they are the same, only show one number.
const portRange =
fromPort === toPort ? fromPort : `${fromPort} - ${toPort}`;
return <Cell>{portRange}</Cell>;
},
},
{
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 <Cell>{cidr.cidr}</Cell>;
}
return null;
},
},
{
altKey: 'description',
headerText: 'Description',
render: ({ cidrs }) => {
const cidr = cidrs[0];
if (cidr) {
return <Cell>{cidr.description}</Cell>;
}
return null;
},
},
]}
emptyText="No Rules Found"
/>
</DialogContent>
<DialogFooter
css={`
display: flex;
justify-content: flex-end;
`}
>
<ButtonSecondary mt={3} onClick={onClose}>
Close
</ButtonSecondary>
</DialogFooter>
</Dialog>
);
}
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;

View file

@ -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';

View file

@ -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';

View file

@ -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

View file

@ -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,
};

View file

@ -0,0 +1,10 @@
kind: role
spec:
allow:
rules:
- resources:
- integration
verbs:
- list
- create
- use

View file

@ -0,0 +1,17 @@
kind: role
spec:
allow:
rules:
- resources:
- integration
verbs:
- list
- create
- use
- resources:
- node
verbs:
- create
- update
- list
- read

View file

@ -191,7 +191,7 @@ export function SuccessfullyAddedIntegrationDialog({
},
}}
>
Begin RDS Enrollment
Begin AWS Resource Enrollment
</ButtonPrimary>
) : (
<Flex gap="3">

View file

@ -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?',

View file

@ -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),

View file

@ -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;
};

View file

@ -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;