diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx index c7fae9cf3a32e..48c00a6a1b231 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx @@ -249,6 +249,7 @@ const newDatabaseReq: CreateDatabaseRequest = { }; jest.useFakeTimers(); +const defaultIsCloud = cfg.isCloud; describe('registering new databases, mainly error checking', () => { const discoverCtx: DiscoverContextState = { @@ -277,6 +278,7 @@ describe('registering new databases, mainly error checking', () => { let wrapper; beforeEach(() => { + cfg.isCloud = true; jest.spyOn(api, 'get').mockResolvedValue([]); // required for fetchClusterAlerts jest @@ -313,6 +315,7 @@ describe('registering new databases, mainly error checking', () => { }); afterEach(() => { + cfg.isCloud = defaultIsCloud; jest.clearAllMocks(); }); @@ -344,6 +347,9 @@ describe('registering new databases, mainly error checking', () => { // of steps to skip. result.current.nextStep(); expect(discoverCtx.nextStep).toHaveBeenCalledWith(2); + cfg.isCloud = false; + result.current.nextStep(); + expect(discoverCtx.nextStep).toHaveBeenCalledWith(3); }); test('continue polling when poll result returns with iamPolicyStatus field set to "pending"', async () => { @@ -399,6 +405,9 @@ describe('registering new databases, mainly error checking', () => { result.current.nextStep(); // Skips both deploy service AND IAM policy step. expect(discoverCtx.nextStep).toHaveBeenCalledWith(3); + cfg.isCloud = false; + result.current.nextStep(); + expect(discoverCtx.nextStep).toHaveBeenCalledWith(4); }); test('stops polling when poll result returns with iamPolicyStatus field set to "unspecified"', async () => { @@ -467,6 +476,9 @@ describe('registering new databases, mainly error checking', () => { // number of steps to skip defined. result.current.nextStep(); expect(discoverCtx.nextStep).toHaveBeenCalledWith(); + cfg.isCloud = false; + result.current.nextStep(); + expect(discoverCtx.nextStep).toHaveBeenCalledWith(2); }); test('when failed to create db, stops flow', async () => { diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts index 840d31bdfb94b..6f23d48a33bcb 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts @@ -28,6 +28,7 @@ import { compareByString } from 'teleport/lib/util'; import { ApiError } from 'teleport/services/api/parseError'; import { DatabaseLocation } from 'teleport/Discover/SelectResource'; import { IamPolicyStatus } from 'teleport/services/databases'; +import cfg from 'teleport/config'; import { matchLabels } from '../common'; @@ -121,6 +122,7 @@ export function useCreateDatabase() { ...(agentMeta as DbMeta), resourceName: createdDb.name, awsRegion: createdDb.awsRegion, + awsVpcId: createdDb.awsVpcId, agentMatcherLabels: dbPollingResult.labels, db: dbPollingResult, serviceDeployedMethod: @@ -162,10 +164,9 @@ export function useCreateDatabase() { }); } - function fetchDatabaseServers(query: string, limit: number) { + function fetchDatabaseServers(query: string) { const request = { query, - limit, }; return ctx.databaseService.fetchDatabases(clusterId, request); } @@ -223,6 +224,7 @@ export function useCreateDatabase() { awsRegion: db.awsRegion, agentMatcherLabels: db.labels, selectedAwsRdsDb: db.awsRds, + awsVpcId: db.awsVpcId, }); setAttempt({ status: 'success' }); return; @@ -315,6 +317,11 @@ export function useCreateDatabase() { } function handleNextStep() { + if (isAws && !cfg.isCloud) { + handleNextStepForSelfHostedAwsEnrollment(); + return; + } + if (dbPollingResult) { if ( isAws && @@ -326,13 +333,28 @@ export function useCreateDatabase() { // Skips the deploy database service step. return nextStep(2); } + nextStep(); // Goes to deploy database service step. + } - const meta = agentMeta as DbMeta; - if (meta.autoDiscovery && meta.serviceDeployedMethod === 'skipped') { - // IAM policy setup is not required for auto discover. + /** + * self hosted AWS enrollment flow has one additional step + * called the Configure Discovery Service. This step is + * only required if user enabled auto discovery. + * If a user is here in "useCreateDatabase" then user did not + * opt for auto discovery (auto discovery will auto create dbs), + * so we need to skip this step here. + */ + function handleNextStepForSelfHostedAwsEnrollment() { + if (dbPollingResult) { + if (dbPollingResult.aws?.iamPolicyStatus === IamPolicyStatus.Success) { + // Skips configure discovery service, deploy db service AND + // setting up IAM policy step + return nextStep(4); + } + // Skips the configure discovery service and deploy database service step. return nextStep(3); } - nextStep(); // Goes to deploy database service step. + nextStep(2); // Skips the discovery service (goes to deploy database service step). } const access = ctx.storeUser.getDatabaseAccess(); diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx index 9f0cf8fedf75c..bc9a6bf1d47af 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx @@ -51,17 +51,20 @@ export const Init = () => { Init.parameters = { msw: { handlers: [ - http.post(cfg.getListSecurityGroupsUrl('test-integration'), () => + http.post(cfg.api.awsSecurityGroupsListPath, () => HttpResponse.json({ securityGroups: securityGroupsResponse }) ), http.post(cfg.api.awsDeployTeleportServicePath, () => HttpResponse.json({ serviceDashboardUrl: 'some-dashboard-url' }) ), + http.post(cfg.api.awsSubnetListPath, () => + HttpResponse.json({ subnets: subnetsResponse }) + ), ], }, }; -export const InitWithAutoEnroll = () => { +export const InitWithAutoDiscover = () => { return ( { ...getDbMeta(), autoDiscovery: { config: { name: '', discoveryGroup: '', aws: [] }, - requiredVpcsAndSubnets: {}, }, }} resourceSpec={getDbResourceSpec( @@ -81,10 +83,10 @@ export const InitWithAutoEnroll = () => { ); }; -InitWithAutoEnroll.parameters = { +InitWithAutoDiscover.parameters = { msw: { handlers: [ - http.post(cfg.getListSecurityGroupsUrl('test-integration'), () => + http.post(cfg.api.awsSecurityGroupsListPath, () => HttpResponse.json({ securityGroups: securityGroupsResponse }) ), http.post(cfg.getAwsRdsDbsDeployServicesUrl('test-integration'), () => @@ -92,6 +94,9 @@ InitWithAutoEnroll.parameters = { clusterDashboardUrl: 'some-cluster-dashboard-url', }) ), + http.post(cfg.api.awsSubnetListPath, () => + HttpResponse.json({ subnets: subnetsResponse }) + ), ], }, }; @@ -119,7 +124,7 @@ export const InitWithLabelsWithDeployFailure = () => { InitWithLabelsWithDeployFailure.parameters = { msw: { handlers: [ - http.post(cfg.getListSecurityGroupsUrl('test-integration'), () => + http.post(cfg.api.awsSecurityGroupsListPath, () => HttpResponse.json({ securityGroups: securityGroupsResponse }) ), http.post(cfg.api.awsDeployTeleportServicePath, () => @@ -130,6 +135,9 @@ InitWithLabelsWithDeployFailure.parameters = { { status: 500 } ) ), + http.post(cfg.api.awsSubnetListPath, () => + HttpResponse.json({ subnets: subnetsResponse }) + ), ], }, }; @@ -145,7 +153,7 @@ export const InitSecurityGroupsLoadingFailed = () => { InitSecurityGroupsLoadingFailed.parameters = { msw: { handlers: [ - http.post(cfg.getListSecurityGroupsUrl('test-integration'), () => + http.post(cfg.api.awsSecurityGroupsListPath, () => HttpResponse.json( { message: 'some error when trying to list security groups', @@ -153,6 +161,14 @@ InitSecurityGroupsLoadingFailed.parameters = { { status: 403 } ) ), + http.post(cfg.api.awsSubnetListPath, () => + HttpResponse.json( + { + error: { message: 'Whoops, error getting subnets' }, + }, + { status: 403 } + ) + ), ], }, }; @@ -168,13 +184,45 @@ export const InitSecurityGroupsLoading = () => { InitSecurityGroupsLoading.parameters = { msw: { handlers: [ - http.post(cfg.getListSecurityGroupsUrl('test-integration'), () => - delay('infinite') - ), + http.post(cfg.api.awsSecurityGroupsListPath, () => delay('infinite')), + http.post(cfg.api.awsSubnetListPath, () => delay('infinite')), ], }, }; +const subnetsResponse = [ + { + name: 'aws-something-PrivateSubnet1A', + id: 'subnet-e40cd872-74de-54e3-a081', + availability_zone: 'us-east-1c', + }, + { + name: 'aws-something-PrivateSubnet2A', + id: 'subnet-e6f9e40e-a7c7-52ab-b8e8', + availability_zone: 'us-east-1a', + }, + { + name: '', + id: 'subnet-9106bc09-ea32-5216-ae3b', + availability_zone: 'us-east-1b', + }, + { + name: '', + id: 'subnet-0ee385cf-b090-5cf7-b692', + availability_zone: 'us-east-1c', + }, + { + name: 'something-long-test-1-cluster/SubnetPublicU', + id: 'subnet-0f0b563e-629f-5921-841d', + availability_zone: 'us-east-1c', + }, + { + name: 'something-long-test-1-cluster/SubnetPrivateUS', + id: 'subnet-30c9e2f6-65ce-5422-bbc0', + availability_zone: 'us-east-1c', + }, +]; + const securityGroupsResponse = [ { name: 'security-group-1', diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx index 2d30d48045077..c9e5e61d9da88 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx @@ -18,7 +18,13 @@ import React from 'react'; import { MemoryRouter } from 'react-router'; -import { act, fireEvent, render, screen } from 'design/utils/testing'; +import { + act, + fireEvent, + render, + screen, + waitForElementToBeRemoved, +} from 'design/utils/testing'; import { ContextProvider } from 'teleport'; import { @@ -85,23 +91,55 @@ describe('test AutoDeploy.tsx', () => { jest.useFakeTimers(); beforeEach(() => { + jest.spyOn(integrationService, 'fetchAwsSubnets').mockResolvedValue({ + nextToken: '', + subnets: [ + { + name: 'subnet-name', + id: 'subnet-id', + availabilityZone: 'subnet-az', + }, + ], + }); + jest.spyOn(integrationService, 'fetchSecurityGroups').mockResolvedValue({ + nextToken: '', + securityGroups: [ + { + name: 'sg-name', + id: 'sg-id', + description: 'sg-desc', + inboundRules: [], + outboundRules: [], + }, + ], + }); + }); + + afterEach(() => { jest.restoreAllMocks(); }); - test('init: labels are rendered, command is not rendered yet', () => { + async function waitForSubnetsAndSecurityGroups() { + await screen.findByText('sg-id'); + await screen.findByText('subnet-id'); + } + + test('init: labels are rendered, command is not rendered yet', async () => { const { teleCtx, discoverCtx } = getMockedContexts(); renderAutoDeploy(teleCtx, discoverCtx); + await waitForSubnetsAndSecurityGroups(); expect(screen.getByText(/env: prod/i)).toBeInTheDocument(); expect(screen.queryByText(/copy\/paste/i)).not.toBeInTheDocument(); expect(screen.queryByText(/curl/i)).not.toBeInTheDocument(); }); - test('clicking button renders command', () => { + test('clicking button renders command', async () => { const { teleCtx, discoverCtx } = getMockedContexts(); renderAutoDeploy(teleCtx, discoverCtx); + await waitForSubnetsAndSecurityGroups(); fireEvent.click(screen.getByText(/generate command/i)); @@ -113,10 +151,11 @@ describe('test AutoDeploy.tsx', () => { ).toBeInTheDocument(); }); - test('invalid role name', () => { + test('invalid role name', async () => { const { teleCtx, discoverCtx } = getMockedContexts(); renderAutoDeploy(teleCtx, discoverCtx); + await waitForSubnetsAndSecurityGroups(); expect( screen.queryByText(/name can only contain/i) @@ -140,6 +179,23 @@ describe('test AutoDeploy.tsx', () => { const { teleCtx, discoverCtx } = getMockedContexts(); renderAutoDeploy(teleCtx, discoverCtx); + await waitForSubnetsAndSecurityGroups(); + + fireEvent.click(screen.getByText(/Deploy Teleport Service/i)); + + // select required subnet + expect( + screen.getByText(/one subnet selection is required/i) + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId(/subnet-id/i)); + + fireEvent.click(screen.getByText(/Deploy Teleport Service/i)); + + // select required sg + expect( + screen.getByText(/one security group selection is required/i) + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId(/sg-id/i)); fireEvent.click(screen.getByText(/Deploy Teleport Service/i)); diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx index 6168738019663..da37ae8bcd2a2 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx @@ -58,6 +58,7 @@ import { DeployServiceProp } from '../DeployService'; import { hasMatchingLabels, Labels } from '../../common'; import { SelectSecurityGroups } from './SelectSecurityGroups'; +import { SelectSubnetIds } from './SelectSubnetIds'; import type { Database } from 'teleport/services/databases'; @@ -71,6 +72,12 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { const [svcDeployedAwsUrl, setSvcDeployedAwsUrl] = useState(''); const [deployFinished, setDeployFinished] = useState(false); + // TODO(lisa): look into using validator.Validate() instead + // of manually validating by hand. + const [hasNoSubnets, setHasNoSubnets] = useState(false); + const [hasNoSecurityGroups, setHasNoSecurityGroups] = useState(false); + + const [selectedSubnetIds, setSelectedSubnetIds] = useState([]); const [selectedSecurityGroups, setSelectedSecurityGroups] = useState< string[] >([]); @@ -89,20 +96,38 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { } }, [labels]); + function manuallyValidateRequiredFields() { + if (selectedSubnetIds.length === 0) { + setHasNoSubnets(true); + return false; + } else { + setHasNoSubnets(false); + } + + if (selectedSecurityGroups.length === 0) { + setHasNoSecurityGroups(true); + return false; + } else { + setHasNoSecurityGroups(false); + } + + return true; // valid + } + function handleDeploy(validator) { if (!validator.validate()) { return; } + if (!manuallyValidateRequiredFields()) { + return; + } + const integrationName = dbMeta.awsIntegration.name; if (wantAutoDiscover) { setAttempt({ status: 'processing' }); - const requiredVpcsAndSubnets = - dbMeta.autoDiscovery.requiredVpcsAndSubnets; - const vpcIds = Object.keys(requiredVpcsAndSubnets); - const { awsAccountId } = splitAwsIamArn( agentMeta.awsIntegration.spec.roleArn ); @@ -111,10 +136,9 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { region: dbMeta.awsRegion, accountId: awsAccountId, taskRoleArn, - deployments: vpcIds.map(vpcId => ({ - vpcId, - subnetIds: requiredVpcsAndSubnets[vpcId], - })), + deployments: [ + { vpcId: dbMeta.awsVpcId, subnetIds: selectedSubnetIds }, + ], }) .then(url => { setAttempt({ status: 'success' }); @@ -138,7 +162,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { .deployAwsOidcService(integrationName, { deploymentMode: 'database-service', region: dbMeta.awsRegion, - subnetIds: dbMeta.selectedAwsRdsDb?.subnets, + subnetIds: selectedSubnetIds, taskRoleArn, databaseAgentMatcherLabels: labels, securityGroups: selectedSecurityGroups, @@ -216,49 +240,53 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { validator={validator} /> - {/* step two & step three - * for auto discover, these steps are disabled atm since - * user's can't supply custom label matchers and selecting - * security groups is out of scope. - */} - {!wantAutoDiscover && ( - <> - -
-

Step 2 (Optional)

- Define Matcher Labels -
- -
- {/* step three */} - -
-

Step 3 (Optional)

- Select Security Groups -
- -
- - )} + +
+

Step 2

+
+ +
-

Step {wantAutoDiscover ? 2 : 4}

+

Step 3 (Optional)

+
+ +
+ + +
+

Step 4 (Optional)

+ Define Matcher Labels +
+ +
+ + +
+

Step 5

- Deploy the Teleport Database Service. + Deploy the Teleport Database Service
- + Encountered Error: {attempt.statusText} @@ -307,6 +330,19 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { /> )} + {hasNoSubnets && selectedSubnetIds.length === 0 && ( + + + At least one subnet selection is required + + )} + {hasNoSecurityGroups && selectedSecurityGroups.length === 0 && ( + + + At least one security group selection is required + + )} + `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; `; + +const AlertIcon = () => ( + +); diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx index 142ca6a9aceda..10751d5a52671 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx @@ -18,14 +18,14 @@ import React, { useState, useEffect } from 'react'; -import { Text, Flex, Box, Indicator } from 'design'; +import { Text, Flex, Box, Indicator, ButtonSecondary, Subtitle3 } from 'design'; import * as Icons from 'design/Icon'; import { FetchStatus } from 'design/DataTable/types'; - +import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip'; import useAttempt from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; - -import { P } from 'design/Text/Text'; +import { pluralize } from 'shared/utils/text'; +import { P, P3 } from 'design/Text/Text'; import { integrationService, @@ -46,11 +46,13 @@ export const SelectSecurityGroups = ({ setSelectedSecurityGroups, dbMeta, emitErrorEvent, + disabled = false, }: { selectedSecurityGroups: string[]; setSelectedSecurityGroups: React.Dispatch>; dbMeta: DbMeta; emitErrorEvent(err: string): void; + disabled?: boolean; }) => { const [sgTableData, setSgTableData] = useState({ items: [], @@ -76,20 +78,26 @@ export const SelectSecurityGroups = ({ } } - async function fetchSecurityGroups() { + async function fetchSecurityGroups({ refresh = false } = {}) { run(() => integrationService .fetchSecurityGroups(dbMeta.awsIntegration.name, { - vpcId: dbMeta.selectedAwsRdsDb.vpcId, + vpcId: dbMeta.awsVpcId, region: dbMeta.awsRegion, nextToken: sgTableData.nextToken, }) .then(({ securityGroups, nextToken }) => { + const combinedSgs = [...sgTableData.items, ...securityGroups]; setSgTableData({ - nextToken: nextToken, + nextToken, fetchStatus: nextToken ? '' : 'disabled', - items: [...sgTableData.items, ...securityGroups], + items: refresh ? securityGroups : combinedSgs, }); + if (refresh) { + // Reset so user doesn't unintentionally keep a security group + // that no longer exists upon refresh. + setSelectedSecurityGroups([]); + } }) .catch((err: Error) => { const errMsg = getErrMessage(err); @@ -105,11 +113,30 @@ export const SelectSecurityGroups = ({ return ( <> + + Select Security Groups + + + Select security group(s) based on the following requirements: +
    +
  • + The selected security group(s) must allow all outbound traffic + (eg: 0.0.0.0/0) +
  • +
  • + A security group attached to your database(s) must allow inbound + traffic from a security group you select or from all IPs in the + subnets you selected +
  • +
+
+
+
+

Select security groups to assign to the Fargate service that will be - running the database access agent. The security groups you pick must - allow outbound connectivity to this Teleport cluster. If you don't - select any security groups, the default one for the VPC will be used. + running the Teleport Database Service. If you don't select any security + groups, the default one for the VPC will be used.

{/* TODO(bl-nero): Convert this to an alert box with embedded retry button */} {attempt.status === 'failed' && ( @@ -138,6 +165,23 @@ export const SelectSecurityGroups = ({ onSelectSecurityGroup={onSelectSecurityGroup} selectedSecurityGroups={selectedSecurityGroups} /> + + + fetchSecurityGroups({ refresh: true })} + px={2} + disabled={disabled} + > + Refresh + + + + {`${selectedSecurityGroups.length} ${pluralize(selectedSecurityGroups.length, 'security group')} selected`} + + )} diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx new file mode 100644 index 0000000000000..e118eb776324c --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx @@ -0,0 +1,185 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState, useEffect } from 'react'; + +import { + Text, + Flex, + Box, + Indicator, + ButtonSecondary, + Subtitle3, + P3, +} from 'design'; +import * as Icons from 'design/Icon'; +import { FetchStatus } from 'design/DataTable/types'; +import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip'; +import { pluralize } from 'shared/utils/text'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; + +import { SubnetIdPicker } from 'teleport/Discover/Shared/SubnetIdPicker'; +import { integrationService, Subnet } from 'teleport/services/integrations'; +import { DbMeta } from 'teleport/Discover/useDiscover'; +import useTeleport from 'teleport/useTeleport'; + +import { ButtonBlueText } from '../../../Shared'; + +type TableData = { + items: Subnet[]; + nextToken?: string; + fetchStatus: FetchStatus; +}; + +export function SelectSubnetIds({ + selectedSubnetIds, + onSelectedSubnetIds, + dbMeta, + emitErrorEvent, + disabled = false, +}: { + selectedSubnetIds: string[]; + onSelectedSubnetIds: React.Dispatch>; + dbMeta: DbMeta; + emitErrorEvent(err: string): void; + disabled?: boolean; +}) { + const ctx = useTeleport(); + const clusterId = ctx.storeUser.getClusterId(); + const [tableData, setTableData] = useState({ + items: [], + nextToken: '', + fetchStatus: 'disabled', + }); + + const { attempt, run } = useAttempt('processing'); + + function handleSelectSubnet( + subnet: Subnet, + e: React.ChangeEvent + ) { + if (e.target.checked) { + return onSelectedSubnetIds(currentSelectedGroups => [ + ...currentSelectedGroups, + subnet.id, + ]); + } else { + onSelectedSubnetIds(selectedSubnetIds.filter(id => id !== subnet.id)); + } + } + + async function fetchSubnets({ refresh = false } = {}) { + run(() => + integrationService + .fetchAwsSubnets(dbMeta.awsIntegration.name, clusterId, { + vpcId: dbMeta.awsVpcId, + region: dbMeta.awsRegion, + nextToken: tableData.nextToken, + }) + .then(({ subnets, nextToken }) => { + const combinedSubnets = [...tableData.items, ...subnets]; + setTableData({ + nextToken, + fetchStatus: nextToken ? '' : 'disabled', + items: refresh ? subnets : combinedSubnets, + }); + if (refresh) { + // Reset so user doesn't unintentionally keep a subnet + // that no longer exists upon refresh. + onSelectedSubnetIds([]); + } + }) + .catch((err: Error) => { + const errMsg = getErrMessage(err); + emitErrorEvent(`fetch subnets error: ${errMsg}`); + throw err; + }) + ); + } + + useEffect(() => { + fetchSubnets(); + }, []); + + return ( + <> + + Select Subnets + + + A subnet has an outbound internet route if it has a route to an + internet gateway or a NAT gateway in a public subnet. + + + + + + Select subnets to assign to the Fargate service that will be running the + Teleport Database Service. All of the subnets you select must have an + outbound internet route and a local route to the database subnets. + + {attempt.status === 'failed' && ( + <> + + + {attempt.statusText} + + + Retry + + + )} + {attempt.status === 'processing' && ( + + + + )} + {attempt.status === 'success' && ( + + + + + fetchSubnets({ refresh: true })} + px={2} + disabled={disabled} + > + Refresh + + + + {`${selectedSubnetIds.length} ${pluralize(selectedSubnetIds.length, 'subnet')} selected`} + + + + )} + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx new file mode 100644 index 0000000000000..6e2224731d9d8 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx @@ -0,0 +1,51 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { Box, Toggle } from 'design'; + +import { ToolTipInfo } from 'shared/components/ToolTip'; + +export function AutoDiscoverToggle({ + wantAutoDiscover, + toggleWantAutoDiscover, + disabled = false, +}: { + wantAutoDiscover: boolean; + toggleWantAutoDiscover(): void; + disabled?: boolean; +}) { + return ( + + + + Auto-enroll all databases for the selected region + + + Auto-enroll will automatically identify all RDS databases (e.g. + PostgreSQL, MySQL, Aurora) from the selected region and register them + as database resources in your infrastructure. + + + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollment.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollment.tsx new file mode 100644 index 0000000000000..a5b1440d29fad --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollment.tsx @@ -0,0 +1,278 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState, useEffect } from 'react'; +import { Box, Text, Link as ExternalLink, Flex, ButtonSecondary } from 'design'; +import { FetchStatus } from 'design/DataTable/types'; +import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; +import Alert, { OutlineInfo } from 'design/Alert/Alert'; + +import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { + AwsRdsDatabase, + Regions, + Vpc, + integrationService, +} from 'teleport/services/integrations'; +import cfg from 'teleport/config'; +import { + DISCOVERY_GROUP_CLOUD, + createDiscoveryConfig, +} from 'teleport/services/discovery'; +import useTeleport from 'teleport/useTeleport'; +import { + DiscoverEvent, + DiscoverEventStatus, +} from 'teleport/services/userEvent'; +import { CreatedDiscoveryConfigDialog } from 'teleport/Discover/Shared/ConfigureDiscoveryService'; + +import { ActionButtons } from '../../Shared'; + +import { DatabaseList } from './RdsDatabaseList'; + +type TableData = { + items: AwsRdsDatabase[]; + fetchStatus: FetchStatus; + instancesStartKey?: string; + clustersStartKey?: string; + oneOfError?: string; +}; + +const emptyTableData = (): TableData => ({ + items: [], + fetchStatus: 'disabled', + instancesStartKey: '', + clustersStartKey: '', + oneOfError: '', +}); + +export function AutoEnrollment({ + region, + vpc, + disableBtns, + onFetchAttempt, + fetchAttempt, +}: { + region: Regions; + vpc?: Vpc; + disableBtns: boolean; + fetchAttempt: Attempt; + onFetchAttempt(a: Attempt): void; + /** + * key is expected to be set to the ID of the VPC. + */ + key: string; +}) { + const hasDatabaseServiceForVpc = !!vpc?.ecsServiceDashboardURL; + + const ctx = useTeleport(); + const clusterId = ctx.storeUser.getClusterId(); + + const { agentMeta, updateAgentMeta, emitErrorEvent, nextStep, emitEvent } = + useDiscover(); + const { + attempt: createDiscoveryConfigAttempt, + setAttempt: setCreateDiscoveryConfigAttempt, + } = useAttempt(''); + + const [tableData, setTableData] = useState(); + + useEffect(() => { + if (hasDatabaseServiceForVpc) { + // No need to fetch rds's since in place of rds table + // we will render a info banner that a db service + // already exists. + return; + } + + if (vpc) { + // Start with empty table data for new vpc's. + fetchRdsDatabases(emptyTableData(), vpc); + } + }, [vpc]); + + function fetchNextPage() { + fetchRdsDatabases({ ...tableData }, vpc); + } + + async function fetchRdsDatabases(data: TableData, vpc: Vpc) { + const integrationName = agentMeta.awsIntegration.name; + + setTableData({ ...data, fetchStatus: 'loading' }); + onFetchAttempt({ status: 'processing' }); + + try { + const { + databases: fetchedDbs, + instancesNextToken, + clustersNextToken, + oneOfError, + } = await integrationService.fetchAllAwsRdsEnginesDatabases( + integrationName, + { + region: region, + instancesNextToken: data.instancesStartKey, + clustersNextToken: data.clustersStartKey, + vpcId: vpc.id, + } + ); + + // Abort if there were no rds dbs for the selected region. + if (fetchedDbs.length <= 0) { + onFetchAttempt({ status: 'success' }); + setTableData({ ...data, fetchStatus: 'disabled' }); + return; + } + + onFetchAttempt({ status: 'success' }); + setTableData({ + instancesStartKey: instancesNextToken, + clustersStartKey: clustersNextToken, + fetchStatus: instancesNextToken || clustersNextToken ? '' : 'disabled', + oneOfError, + // concat each page fetch. + items: [...data.items, ...fetchedDbs], + }); + } catch (err) { + const errMsg = getErrMessage(err); + onFetchAttempt({ status: 'failed', statusText: errMsg }); + setTableData(data); // fallback to previous data + emitErrorEvent(`database fetch error: ${errMsg}`); + } + } + + async function handleOnProceed() { + // For self-hosted, discovery config needs to be created + // on the next step since self-hosted needs to manually + // install a discovery service. + if (!cfg.isCloud) { + updateAgentMeta({ + ...(agentMeta as DbMeta), + awsVpcId: vpc.id, + awsRegion: region, + autoDiscovery: {}, + }); + nextStep(); + return; + } + + try { + setCreateDiscoveryConfigAttempt({ status: 'processing' }); + // Cloud has a discovery service automatically running so + // we have everything we need to create a + const discoveryConfig = await createDiscoveryConfig(clusterId, { + name: crypto.randomUUID(), + discoveryGroup: DISCOVERY_GROUP_CLOUD, + aws: [ + { + types: ['rds'], + regions: [region], + tags: { 'vpc-id': [vpc.id] }, + integration: agentMeta.awsIntegration.name, + }, + ], + }); + + emitEvent( + { stepStatus: DiscoverEventStatus.Success }, + { + eventName: DiscoverEvent.CreateDiscoveryConfig, + } + ); + + setCreateDiscoveryConfigAttempt({ status: 'success' }); + updateAgentMeta({ + ...(agentMeta as DbMeta), + autoDiscovery: { + config: discoveryConfig, + }, + awsVpcId: vpc.id, + awsRegion: region, + }); + } catch (err) { + const message = getErrMessage(err); + setCreateDiscoveryConfigAttempt({ + status: 'failed', + statusText: `failed to create discovery config: ${message}`, + }); + emitErrorEvent(`failed to create discovery config: ${message}`); + return; + } + } + + const selectedVpc = !!vpc; + const showTable = + selectedVpc && + !hasDatabaseServiceForVpc && + fetchAttempt.status !== 'failed'; + + return ( + <> + {hasDatabaseServiceForVpc && ( + + + There is a database service already deployed for the selected VPC, + visit its{' '} + + dashboard + {' '} + to check it out. + + + )} + {showTable && ( + <> + {tableData?.oneOfError && ( + + + {tableData.oneOfError} + fetchRdsDatabases(emptyTableData(), vpc)} + > + Retry + + + + )} + List of databases that will be auto enrolled: + + + )} + + {createDiscoveryConfigAttempt.status !== '' && ( + setCreateDiscoveryConfigAttempt({ status: '' })} + retry={handleOnProceed} + region={region} + notifyAboutDelay={false} // TODO always notify? + /> + )} + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx similarity index 63% rename from web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx rename to web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx index 73acc0f8fb0bf..cb6f6bfa8517e 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx @@ -58,8 +58,8 @@ export default { ], }; -export const InstanceList = () => ; -InstanceList.parameters = { +export const SelfHostedFlow = () => ; +SelfHostedFlow.parameters = { msw: { handlers: [ http.post(cfg.api.awsRdsDbListPath, () => @@ -76,18 +76,16 @@ InstanceList.parameters = { }) ), http.get(cfg.api.databaseServicesPath, () => HttpResponse.json({})), - http.post(cfg.api.awsRdsDbRequiredVpcsPath, () => - HttpResponse.json({ vpcMapOfSubnets: {} }) - ), + http.post(cfg.api.awsDatabaseVpcsPath, () => HttpResponse.json({ vpcs })), ], }, }; -export const InstanceListForCloud = () => { +export const CloudFlow = () => { cfg.isCloud = true; return ; }; -InstanceListForCloud.parameters = { +CloudFlow.parameters = { msw: { handlers: [ http.post(cfg.api.awsRdsDbListPath, () => @@ -105,20 +103,98 @@ InstanceListForCloud.parameters = { }) ), http.get(cfg.api.databaseServicesPath, () => HttpResponse.json({})), - http.post(cfg.api.awsRdsDbRequiredVpcsPath, () => - HttpResponse.json({ vpcMapOfSubnets: { 'vpc-1': ['subnet1'] } }) + http.post(cfg.api.awsDatabaseVpcsPath, () => HttpResponse.json({ vpcs })), + ], + }, +}; + +export const NoVpcs = () => { + return ; +}; +NoVpcs.parameters = { + msw: { + handlers: [ + http.post(cfg.api.awsRdsDbListPath, () => + HttpResponse.json({ databases: [] }) + ), + http.post( + cfg.api.awsDatabaseVpcsPath, + () => HttpResponse.json({ vpcs: [] }), + { once: true } ), + http.post(cfg.api.awsDatabaseVpcsPath, () => HttpResponse.json({ vpcs })), ], }, }; -export const InstanceListLoading = () => { - cfg.isCloud = true; +export const VpcError = () => { return ; }; -InstanceListLoading.parameters = { +VpcError.parameters = { msw: { - handlers: [http.post(cfg.api.awsRdsDbListPath, () => delay('infinite'))], + handlers: [ + http.post( + cfg.api.awsDatabaseVpcsPath, + () => + HttpResponse.json( + { + error: { message: 'Whoops, error fetching required vpcs.' }, + }, + { status: 404 } + ), + { once: true } + ), + ], + }, +}; + +export const SelectedVpcAlreadyExists = () => { + return ; +}; +SelectedVpcAlreadyExists.parameters = { + msw: { + handlers: [ + http.post(cfg.api.awsRdsDbListPath, () => + HttpResponse.json({ databases: rdsInstances }) + ), + http.get(databasesPathWithoutQuery, () => + HttpResponse.json({ items: [rdsInstances[2]] }) + ), + http.post(cfg.api.awsDatabaseVpcsPath, () => + HttpResponse.json({ + vpcs: [ + { + id: 'Click me, then toggle ON auto enroll', + ecsServiceDashboardURL: 'http://some-dashboard-url', + }, + { + id: 'vpc-1234', + }, + ], + }) + ), + ], + }, +}; + +export const LoadingVpcs = () => { + return ; +}; +LoadingVpcs.parameters = { + msw: { + handlers: [http.post(cfg.api.awsDatabaseVpcsPath, () => delay('infinite'))], + }, +}; + +export const LoadingDatabases = () => { + return ; +}; +LoadingDatabases.parameters = { + msw: { + handlers: [ + http.post(cfg.api.awsRdsDbListPath, () => delay('infinite')), + http.post(cfg.api.awsDatabaseVpcsPath, () => HttpResponse.json({ vpcs })), + ], }, }; @@ -126,10 +202,38 @@ export const WithAwsPermissionsError = () => ; WithAwsPermissionsError.parameters = { msw: { handlers: [ + http.post( + cfg.api.awsDatabaseVpcsPath, + () => + HttpResponse.json( + { + message: 'StatusCode: 403, RequestID: operation error', + }, + { status: 403 } + ), + { once: true } + ), + http.post(cfg.api.awsDatabaseVpcsPath, () => HttpResponse.json({ vpcs })), + http.post(cfg.api.awsRdsDbListPath, () => + HttpResponse.json({ databases: [] }) + ), + ], + }, +}; + +export const WithDbListError = () => ; +WithDbListError.parameters = { + msw: { + handlers: [ + http.post(cfg.api.awsDatabaseVpcsPath, () => + HttpResponse.json({ + vpcs, + }) + ), http.post(cfg.api.awsRdsDbListPath, () => HttpResponse.json( { - message: 'StatusCode: 403, RequestID: operation error', + message: 'Whoops, fetching aws databases error', }, { status: 403 } ) @@ -138,17 +242,33 @@ WithAwsPermissionsError.parameters = { }, }; -export const WithOtherError = () => ; -WithOtherError.parameters = { +export const WithOneOfDbListError = () => ; +WithOneOfDbListError.parameters = { msw: { handlers: [ + http.post(cfg.api.awsDatabaseVpcsPath, () => + HttpResponse.json({ + vpcs, + }) + ), + http.post( + cfg.api.awsRdsDbListPath, + () => HttpResponse.json({ databases: rdsInstances }), + { once: true } + ), + http.post( + cfg.api.awsRdsDbListPath, + () => + HttpResponse.json( + { + message: 'Whoops, fetching another aws databases error', + }, + { status: 403 } + ), + { once: true } + ), http.post(cfg.api.awsRdsDbListPath, () => - HttpResponse.json( - { - error: { message: 'Whoops, something went wrong.' }, - }, - { status: 404 } - ) + HttpResponse.json({ databases: rdsInstances }) ), ], }, @@ -297,3 +417,26 @@ const rdsInstances = [ }, }, ]; + +const vpcs = [ + { + name: '', + id: 'vpc-341c69a6-1bdb-5521-aad1', + }, + { + name: '', + id: 'vpc-92b8d60f-0f0e-5d31-b5b4', + }, + { + name: 'aws-controlsomething-VPC', + id: 'vpc-d36151d6-8f0e-588d-87a7', + }, + { + name: 'eksctl-bob-test-1-cluster/VPC', + id: 'vpc-fe7203d3-e959-57d4-8f87', + }, + { + name: 'Default VPC (DO NOT USE)', + id: 'vpc-57cbdb9c-0f3e-5efb-bd84', + }, +]; diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx index 68f39d10b398a..337bc83a69e36 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx @@ -28,10 +28,7 @@ import DatabaseService from 'teleport/services/databases/databases'; import * as discoveryService from 'teleport/services/discovery/discovery'; import { ComponentWrapper } from 'teleport/Discover/Fixtures/databases'; import cfg from 'teleport/config'; -import { - DISCOVERY_GROUP_CLOUD, - DEFAULT_DISCOVERY_GROUP_NON_CLOUD, -} from 'teleport/services/discovery/discovery'; +import { DISCOVERY_GROUP_CLOUD } from 'teleport/services/discovery/discovery'; import { EnrollRdsDatabase } from './EnrollRdsDatabase'; @@ -60,6 +57,15 @@ describe('test EnrollRdsDatabase.tsx', () => { jest .spyOn(DatabaseService.prototype, 'fetchDatabaseServices') .mockResolvedValue({ services: [] }); + jest.spyOn(integrationService, 'fetchAwsDatabasesVpcs').mockResolvedValue({ + nextToken: '', + vpcs: [ + { + name: 'vpc-name', + id: 'vpc-id', + }, + ], + }); }); afterEach(() => { @@ -67,6 +73,24 @@ describe('test EnrollRdsDatabase.tsx', () => { jest.restoreAllMocks(); }); + async function selectRegionAndVpc() { + // select a region + let selectEl = screen.getByLabelText(/aws region/i); + fireEvent.focus(selectEl); + fireEvent.keyDown(selectEl, { key: 'ArrowDown' }); + fireEvent.click(screen.getByText('us-east-2')); + + await screen.findByLabelText(/vpc id/i); + + // select a vpc + selectEl = screen.getByText(/select a vpc id/i); + fireEvent.focus(selectEl); + fireEvent.keyDown(selectEl, { key: 'ArrowDown' }); + fireEvent.keyDown(selectEl, { key: 'Enter' }); + + await screen.findByText(/selected region/i); + } + test('without rds database result, does not attempt to fetch db servers', async () => { jest .spyOn(integrationService, 'fetchAwsRdsDatabases') @@ -74,14 +98,7 @@ describe('test EnrollRdsDatabase.tsx', () => { render(); - // select a region from selector. - const selectEl = screen.getByLabelText(/aws region/i); - fireEvent.focus(selectEl); - fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-east-2')); - - // No results are rendered. - await screen.findByText(/no result/i); + await selectRegionAndVpc(); expect(integrationService.fetchAwsRdsDatabases).toHaveBeenCalledTimes(1); expect(DatabaseService.prototype.fetchDatabases).not.toHaveBeenCalled(); @@ -94,11 +111,7 @@ describe('test EnrollRdsDatabase.tsx', () => { render(); - // select a region from selector. - const selectEl = screen.getByLabelText(/aws region/i); - fireEvent.focus(selectEl); - fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-east-2')); + await selectRegionAndVpc(); // Rds results renders result. await screen.findByText(/rds-1/i); @@ -107,32 +120,28 @@ describe('test EnrollRdsDatabase.tsx', () => { expect(DatabaseService.prototype.fetchDatabases).toHaveBeenCalledTimes(1); }); - test('auto enroll (cloud) is on by default', async () => { - jest.spyOn(integrationService, 'fetchAwsRdsDatabases').mockResolvedValue({ - databases: mockAwsDbs, - }); + test('auto enrolling with cloud should create discovery config', async () => { jest - .spyOn(integrationService, 'fetchAwsRdsRequiredVpcs') - .mockResolvedValue({}); + .spyOn(integrationService, 'fetchAwsRdsDatabases') + .mockResolvedValue({ databases: [] }); + jest + .spyOn(integrationService, 'fetchAllAwsRdsEnginesDatabases') + .mockResolvedValue({ + databases: mockAwsDbs, + }); render(); - // select a region from selector. - const selectEl = screen.getByLabelText(/aws region/i); - fireEvent.focus(selectEl); - fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-east-2')); + await selectRegionAndVpc(); + + // Toggle on auto-enroll + act(() => screen.getByText(/auto-enroll all/i).click()); // Rds results renders result. await screen.findByText(/rds-1/i); - // Cloud uses a default discovery group name. - expect( - screen.queryByText(/define a discovery group name/i) - ).not.toBeInTheDocument(); act(() => screen.getByText('Next').click()); await screen.findByText(/Creating Auto Discovery Config/i); - expect(integrationService.fetchAwsRdsRequiredVpcs).toHaveBeenCalledTimes(1); expect(discoveryService.createDiscoveryConfig).toHaveBeenCalledTimes(1); // 2D array: @@ -146,95 +155,43 @@ describe('test EnrollRdsDatabase.tsx', () => { expect(DatabaseService.prototype.createDatabase).not.toHaveBeenCalled(); }); - test('auto enroll disabled (cloud), creates database', async () => { - jest.spyOn(integrationService, 'fetchAwsRdsDatabases').mockResolvedValue({ - databases: mockAwsDbs, - }); + test('auto enrolling with self-hosted should not create discovery config (its done on the next step)', async () => { + cfg.isCloud = false; + + jest + .spyOn(integrationService, 'fetchAwsRdsDatabases') + .mockResolvedValue({ databases: [] }); + jest + .spyOn(integrationService, 'fetchAllAwsRdsEnginesDatabases') + .mockResolvedValue({ + databases: mockAwsDbs, + }); render(); - // select a region from selector. - const selectEl = screen.getByLabelText(/aws region/i); - fireEvent.focus(selectEl); - fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-east-2')); + await selectRegionAndVpc(); - await screen.findByText(/rds-1/i); - - // disable auto enroll - expect(screen.getByText('Next')).toBeEnabled(); + // Toggle on auto-enroll act(() => screen.getByText(/auto-enroll all/i).click()); - expect(screen.getByText('Next')).toBeDisabled(); - act(() => screen.getByRole('radio').click()); + // Rds results renders result. + await screen.findByText(/rds-1/i); act(() => screen.getByText('Next').click()); - await screen.findByText(/Database "rds-1" successfully registered/i); - expect(discoveryService.createDiscoveryConfig).not.toHaveBeenCalled(); - expect( - DatabaseService.prototype.fetchDatabaseServices - ).toHaveBeenCalledTimes(1); - expect(DatabaseService.prototype.createDatabase).toHaveBeenCalledTimes(1); - }); - - test('auto enroll (self-hosted) is on by default', async () => { - cfg.isCloud = false; - jest.spyOn(integrationService, 'fetchAwsRdsDatabases').mockResolvedValue({ - databases: mockAwsDbs, - }); - jest - .spyOn(integrationService, 'fetchAwsRdsRequiredVpcs') - .mockResolvedValue({}); - - render(); - - // select a region from selector. - const selectEl = screen.getByLabelText(/aws region/i); - fireEvent.focus(selectEl); - fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-east-2')); - - // Only self-hosted need to define a discovery group name. - await screen.findByText(/define a discovery group name/i); - // There should be no talbe rendered. - expect(screen.queryByText(/rds-1/i)).not.toBeInTheDocument(); - - act(() => screen.getByText('Next').click()); - await screen.findByText(/Creating Auto Discovery Config/i); - expect(integrationService.fetchAwsRdsRequiredVpcs).toHaveBeenCalledTimes(1); - expect(discoveryService.createDiscoveryConfig).toHaveBeenCalledTimes(1); - - // 2D array: - // First array is the array of calls, we are only interested in the first. - // Second array are the parameters that this api got called with, - // we are interested in the second parameter. - expect(createDiscoveryConfig.mock.calls[0][1]['discoveryGroup']).toBe( - DEFAULT_DISCOVERY_GROUP_NON_CLOUD - ); - expect(DatabaseService.prototype.createDatabase).not.toHaveBeenCalled(); }); - test('auto enroll disabled (self-hosted), creates database', async () => { - cfg.isCloud = false; + test('auto enroll disabled, creates database', async () => { jest.spyOn(integrationService, 'fetchAwsRdsDatabases').mockResolvedValue({ databases: mockAwsDbs, }); render(); - // select a region from selector. - const selectEl = screen.getByLabelText(/aws region/i); - fireEvent.focus(selectEl); - fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.click(screen.getByText('us-east-2')); - - await screen.findByText(/define a discovery group name/i); + await selectRegionAndVpc(); - // disable auto enroll - act(() => screen.getByText(/auto-enroll all/i).click()); - expect(screen.getByText('Next')).toBeDisabled(); + await screen.findByText(/rds-1/i); act(() => screen.getByRole('radio').click()); diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx index 0347a8b7e6bc5..ab7a884d83bbc 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -17,457 +17,187 @@ */ import React, { useState } from 'react'; -import { Box, Toggle, Mark } from 'design'; -import { FetchStatus } from 'design/DataTable/types'; +import { Box, Indicator } from 'design'; import { Danger } from 'design/Alert'; -import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import useAttempt from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; - import { P } from 'design/Text/Text'; -import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { useDiscover } from 'teleport/Discover/useDiscover'; import { - AwsRdsDatabase, - RdsEngineIdentifier, Regions, + Vpc, integrationService, } from 'teleport/services/integrations'; -import { DatabaseEngine } from 'teleport/Discover/SelectResource'; import { AwsRegionSelector } from 'teleport/Discover/Shared/AwsRegionSelector'; -import { Database } from 'teleport/services/databases'; import { ConfigureIamPerms } from 'teleport/Discover/Shared/Aws/ConfigureIamPerms'; import { isIamPermError } from 'teleport/Discover/Shared/Aws/error'; -import cfg from 'teleport/config'; -import { - DISCOVERY_GROUP_CLOUD, - DEFAULT_DISCOVERY_GROUP_NON_CLOUD, - DiscoveryConfig, - createDiscoveryConfig, -} from 'teleport/services/discovery'; -import useTeleport from 'teleport/useTeleport'; import { splitAwsIamArn } from 'teleport/services/integrations/aws'; +import useTeleport from 'teleport/useTeleport'; -import { - AutoEnrollDialog, - ActionButtons, - Header, - SelfHostedAutoDiscoverDirections, -} from '../../Shared'; - -import { useCreateDatabase } from '../CreateDatabase/useCreateDatabase'; -import { CreateDatabaseDialog } from '../CreateDatabase/CreateDatabaseDialog'; - -import { DatabaseList } from './RdsDatabaseList'; - -type TableData = { - items: CheckedAwsRdsDatabase[]; - fetchStatus: FetchStatus; - startKey?: string; - currRegion?: Regions; -}; - -const emptyTableData: TableData = { - items: [], - fetchStatus: 'disabled', - startKey: '', -}; +import { Header } from '../../Shared'; -// CheckedAwsRdsDatabase is a type to describe that a -// AwsRdsDatabase has been checked (by its resource id) -// with the backend whether or not a database server already -// exists for it. -export type CheckedAwsRdsDatabase = AwsRdsDatabase & { - dbServerExists?: boolean; -}; +import { VpcOption, VpcSelector } from './VpcSelector'; +import { AutoDiscoverToggle } from './AutoDiscoverToggle'; +import { AutoEnrollment } from './AutoEnrollment'; +import { SingleEnrollment } from './SingleEnrollment'; export function EnrollRdsDatabase() { - const { - createdDb, - pollTimeout, - registerDatabase, - attempt: registerAttempt, - clearAttempt: clearRegisterAttempt, - nextStep, - fetchDatabaseServers, - } = useCreateDatabase(); - const ctx = useTeleport(); const clusterId = ctx.storeUser.getClusterId(); - const { agentMeta, resourceSpec, updateAgentMeta, emitErrorEvent } = - useDiscover(); - const { attempt: fetchDbAttempt, setAttempt: setFetchDbAttempt } = - useAttempt(''); - const { attempt: autoDiscoverAttempt, setAttempt: setAutoDiscoverAttempt } = - useAttempt(''); + const { agentMeta, emitErrorEvent } = useDiscover(); - const [tableData, setTableData] = useState({ - items: [], - startKey: '', - fetchStatus: 'disabled', - }); - const [selectedDb, setSelectedDb] = useState(); - const [wantAutoDiscover, setWantAutoDiscover] = useState(true); - const [autoDiscoveryCfg, setAutoDiscoveryCfg] = useState(); - const [requiredVpcs, setRequiredVpcs] = useState>(); - const [discoveryGroupName, setDiscoveryGroupName] = useState(() => - cfg.isCloud ? '' : DEFAULT_DISCOVERY_GROUP_NON_CLOUD - ); + // This attempt is used for both fetching vpc's and for + // fetching databases since each fetching is done at separate + // times and relies on one fetch result (vpcs) to be complete + // before performing the next fetch (databases, but only after user + // has selected a vpc). + const { attempt: fetchAttempt, setAttempt: setFetchAttempt } = useAttempt(''); - function fetchDatabasesWithNewRegion(region: Regions) { - // Clear table when fetching with new region. - fetchDatabases({ ...emptyTableData, currRegion: region }); - } + const [vpcs, setVpcs] = useState(); + const [selectedVpc, setSelectedVpc] = useState(); + const [wantAutoDiscover, setWantAutoDiscover] = useState(false); + const [selectedRegion, setSelectedRegion] = useState(); - function fetchNextPage() { - fetchDatabases({ ...tableData }); + function onNewVpc(selectedVpc: VpcOption) { + setSelectedVpc(selectedVpc); } - function refreshDatabaseList() { - setSelectedDb(null); - // When refreshing, start the table back at page 1. - fetchDatabases({ ...tableData, startKey: '', items: [] }); + function onNewRegion(region: Regions) { + setSelectedVpc(null); + setSelectedRegion(region); + fetchVpcs(region); } - async function fetchDatabases(data: TableData) { - const integrationName = agentMeta.awsIntegration.name; - - setTableData({ ...data, fetchStatus: 'loading' }); - setFetchDbAttempt({ status: 'processing' }); - + async function fetchVpcs(region: Regions) { + setFetchAttempt({ status: 'processing' }); try { - const { databases: fetchedRdsDbs, nextToken } = - await integrationService.fetchAwsRdsDatabases( - integrationName, - getRdsEngineIdentifier(resourceSpec.dbMeta?.engine), - { - region: data.currRegion, - nextToken: data.startKey, - } - ); - - // Abort if there were no rds dbs for the selected region. - if (fetchedRdsDbs.length <= 0) { - setFetchDbAttempt({ status: 'success' }); - setTableData({ ...data, fetchStatus: 'disabled' }); - return; - } - - // Check if fetched rds databases have a database - // server for it, to prevent user from enrolling - // the same db and getting an error from it. - - // Build the predicate string that will query for - // all the fetched rds dbs by its resource ids. - const resourceIds: string[] = fetchedRdsDbs.map( - d => `resource.spec.aws.rds.resource_id == "${d.resourceId}"` - ); - const query = resourceIds.join(' || '); - const { agents: fetchedDbServers } = await fetchDatabaseServers( - query, - fetchedRdsDbs.length // limit - ); - - const dbServerLookupByResourceId: Record = {}; - fetchedDbServers.forEach( - d => (dbServerLookupByResourceId[d.aws.rds.resourceId] = d) - ); - - // Check for db server matches. - const checkedRdsDbs: CheckedAwsRdsDatabase[] = fetchedRdsDbs.map(rds => { - const dbServer = dbServerLookupByResourceId[rds.resourceId]; - if (dbServer) { - return { - ...rds, - dbServerExists: true, - }; - } - return rds; - }); - - setFetchDbAttempt({ status: 'success' }); - setTableData({ - currRegion: data.currRegion, - startKey: nextToken, - fetchStatus: nextToken ? '' : 'disabled', - // concat each page fetch. - items: [...data.items, ...checkedRdsDbs], - }); + const { spec, name: integrationName } = agentMeta.awsIntegration; + const { awsAccountId } = splitAwsIamArn(spec.roleArn); + + // Get a list of every vpcs. + let fetchedVpcs: Vpc[] = []; + let nextPage = ''; + do { + const { vpcs, nextToken } = + await integrationService.fetchAwsDatabasesVpcs( + integrationName, + clusterId, + { + region: region, + accountId: awsAccountId, + nextToken: nextPage, + } + ); + + fetchedVpcs = [...fetchedVpcs, ...vpcs]; + nextPage = nextToken; + } while (nextPage); + + setVpcs(fetchedVpcs); + setFetchAttempt({ status: '' }); } catch (err) { - const errMsg = getErrMessage(err); - setFetchDbAttempt({ status: 'failed', statusText: errMsg }); - setTableData(data); // fallback to previous data - emitErrorEvent(`database fetch error: ${errMsg}`); - } - } - - function handleAndEmitRequestError( - err: Error, - cfg: { errorPrefix?: string; setAttempt?(attempt: Attempt): void } - ) { - const message = getErrMessage(err); - if (cfg.setAttempt) { - cfg.setAttempt({ + const message = getErrMessage(err); + setFetchAttempt({ status: 'failed', - statusText: `${cfg.errorPrefix}${message}`, + statusText: message, }); + emitErrorEvent(`failed to fetch vpcs: ${message}`); } - emitErrorEvent(`${cfg.errorPrefix}${message}`); } - async function enableAutoDiscovery() { - setAutoDiscoverAttempt({ status: 'processing' }); - - let requiredVpcsAndSubnets = requiredVpcs; - if (!requiredVpcsAndSubnets) { - try { - const { spec, name: integrationName } = agentMeta.awsIntegration; - const { awsAccountId } = splitAwsIamArn(spec.roleArn); - requiredVpcsAndSubnets = - await integrationService.fetchAwsRdsRequiredVpcs(integrationName, { - region: tableData.currRegion, - accountId: awsAccountId, - }); - - setRequiredVpcs(requiredVpcsAndSubnets); - } catch (err) { - handleAndEmitRequestError(err, { - errorPrefix: 'failed to collect vpc ids and its subnets: ', - setAttempt: setAutoDiscoverAttempt, - }); - return; - } - } - - // Only create a discovery config after successfully fetching - // required vpcs. This is to avoid creating a unused auto discovery - // config if user quits in the middle of things not working. - let discoveryConfig = autoDiscoveryCfg; - if (!discoveryConfig) { - try { - discoveryConfig = await createDiscoveryConfig(clusterId, { - name: crypto.randomUUID(), - discoveryGroup: cfg.isCloud - ? DISCOVERY_GROUP_CLOUD - : discoveryGroupName, - aws: [ - { - types: ['rds'], - regions: [tableData.currRegion], - tags: { '*': ['*'] }, - integration: agentMeta.awsIntegration.name, - }, - ], - }); - setAutoDiscoveryCfg(discoveryConfig); - } catch (err) { - handleAndEmitRequestError(err, { - errorPrefix: 'failed to create discovery config: ', - setAttempt: setAutoDiscoverAttempt, - }); - return; - } - } - - setAutoDiscoverAttempt({ status: 'success' }); - updateAgentMeta({ - ...(agentMeta as DbMeta), - autoDiscovery: { - config: discoveryConfig, - requiredVpcsAndSubnets, - }, - serviceDeployedMethod: - Object.keys(requiredVpcsAndSubnets).length > 0 ? undefined : 'skipped', - awsRegion: tableData.currRegion, - }); + function refreshVpcsAndDatabases() { + clear(); + fetchVpcs(selectedRegion); } + /** + * Used when user changes a region. + */ function clear() { - clearRegisterAttempt(); - - if (fetchDbAttempt.status === 'failed') { - setFetchDbAttempt({ status: '' }); - } - if (tableData.items.length > 0) { - setTableData(emptyTableData); - } - if (selectedDb) { - setSelectedDb(null); + setFetchAttempt({ status: '' }); + if (selectedVpc) { + setSelectedVpc(null); } } - function handleOnProceed() { - if (wantAutoDiscover) { - enableAutoDiscovery(); - } else { - const isNewDb = selectedDb.name !== createdDb?.name; - registerDatabase( - { - name: selectedDb.name, - protocol: selectedDb.engine, - uri: selectedDb.uri, - labels: selectedDb.labels, - awsRds: selectedDb, - awsRegion: tableData.currRegion, - }, - // Corner case where if registering db fails a user can: - // 1) change region, which will list new databases or - // 2) select a different database before re-trying. - isNewDb - ); - } - } - - let DialogComponent; - if (registerAttempt.status !== '') { - DialogComponent = ( - - ); - } else if (autoDiscoverAttempt.status !== '') { - DialogComponent = ( - setAutoDiscoverAttempt({ status: '' })} - retry={handleOnProceed} - region={tableData.currRegion} - notifyAboutDelay={ - requiredVpcs && Object.keys(requiredVpcs).length === 0 - } - /> - ); - } - - const hasIamPermError = isIamPermError(fetchDbAttempt); - const showContent = !hasIamPermError && tableData.currRegion; - const showAutoEnrollToggle = fetchDbAttempt.status === 'success'; - - // (Temp) - // Self hosted auto enroll is different from cloud. - // For cloud, we already run the discovery service for customer. - // For on-prem, user has to run their own discovery service. - // We hide the RDS table for on-prem if they are wanting auto discover - // because it takes up so much space to give them instructions. - // Future work will simply provide user a script so we can show the table then. - const showTable = cfg.isCloud || !wantAutoDiscover; + const hasIamPermError = isIamPermError(fetchAttempt); + const showVpcSelector = !hasIamPermError && !!vpcs; + const showAutoEnrollToggle = + fetchAttempt.status !== 'failed' && !!selectedVpc; + const hasVpcs = vpcs?.length > 0; + + const mainContentProps = { + vpc: selectedVpc?.value, + region: selectedRegion, + fetchAttempt, + onFetchAttempt: setFetchAttempt, + disableBtns: + fetchAttempt.status === 'processing' || + hasIamPermError || + fetchAttempt.status === 'failed', + }; return ( -
Enroll an RDS Database
- {fetchDbAttempt.status === 'failed' && !hasIamPermError && ( - {fetchDbAttempt.statusText} +
Enroll RDS Database
+ {fetchAttempt.status === 'failed' && !hasIamPermError && ( + {fetchAttempt.statusText} )} -

Select the AWS Region you would like to see databases for:

+

+ Select a AWS Region and a VPC ID you would like to see databases for: +

- {showContent && ( - <> - {showAutoEnrollToggle && ( - setWantAutoDiscover(b => !b)} - discoveryGroupName={discoveryGroupName} - setDiscoveryGroupName={setDiscoveryGroupName} - clusterPublicUrl={ctx.storeUser.state.cluster.publicURL} - /> - )} - {showTable && ( - - )} - + {!vpcs && fetchAttempt.status === 'processing' && ( + + + + )} + {showVpcSelector && hasVpcs && ( + + )} + {showVpcSelector && !hasVpcs && ( + // TODO(lisa): negative margin was required since the + // AwsRegionSelector added too much bottom margin. + // Refactor AwsRegionSelector so margins can be controlled + // outside of the component (or use flex columns with gap prop) +

+ There are no VPCs defined in the selected region. Try another region. +

)} {hasIamPermError && ( )} - {showContent && showAutoEnrollToggle && wantAutoDiscover && ( -

- Note: Auto-enroll will enroll all database engines - in this region (e.g. PostgreSQL, MySQL, Aurora). -

+ {showAutoEnrollToggle && ( + setWantAutoDiscover(b => !b)} + disabled={fetchAttempt.status === 'processing'} + /> )} - - {DialogComponent} -
- ); -} - -function getRdsEngineIdentifier(engine: DatabaseEngine): RdsEngineIdentifier { - switch (engine) { - case DatabaseEngine.MySql: - return 'mysql'; - case DatabaseEngine.Postgres: - return 'postgres'; - case DatabaseEngine.AuroraMysql: - return 'aurora-mysql'; - case DatabaseEngine.AuroraPostgres: - return 'aurora-postgres'; - } -} - -function ToggleSection({ - wantAutoDiscover, - toggleWantAutoDiscover, - discoveryGroupName, - setDiscoveryGroupName, - clusterPublicUrl, -}: { - wantAutoDiscover: boolean; - toggleWantAutoDiscover(): void; - discoveryGroupName: string; - setDiscoveryGroupName(n: string): void; - clusterPublicUrl: string; -}) { - return ( - - - - Auto-enroll all databases for selected region - - - Auto-enroll will automatically identify all RDS databases from the - selected region and register them as database resources in your - infrastructure. - - - {!cfg.isCloud && wantAutoDiscover && ( - + ) : ( + )} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx index c341eb46fc055..c5ca31114afea 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx @@ -29,13 +29,13 @@ import { labelMatcher, } from 'teleport/Discover/Shared'; -import { CheckedAwsRdsDatabase } from './EnrollRdsDatabase'; +import { CheckedAwsRdsDatabase } from './SingleEnrollment'; type Props = { items: CheckedAwsRdsDatabase[]; fetchStatus: FetchStatus; fetchNextPage(): void; - onSelectDatabase(item: CheckedAwsRdsDatabase): void; + onSelectDatabase?(item: CheckedAwsRdsDatabase): void; selectedDatabase?: CheckedAwsRdsDatabase; wantAutoDiscover: boolean; }; @@ -52,25 +52,30 @@ export const DatabaseList = ({ { - const isChecked = - item.name === selectedDatabase?.name && - item.engine === selectedDatabase?.engine; - return ( - - item={item} - key={`${item.name}${item.resourceId}`} - isChecked={isChecked} - onChange={onSelectDatabase} - value={item.name} - {...disabledStates(item, wantAutoDiscover)} - /> - ); - }, - }, + // Hide the selector when choosing to auto enroll + ...(!wantAutoDiscover + ? [ + { + altKey: 'radio-select', + headerText: 'Select', + render: item => { + const isChecked = + item.name === selectedDatabase?.name && + item.engine === selectedDatabase?.engine; + return ( + + item={item} + key={`${item.name}${item.resourceId}`} + isChecked={isChecked} + onChange={onSelectDatabase} + value={item.name} + {...disabledStates(item, wantAutoDiscover)} + /> + ); + }, + }, + ] + : []), { key: 'name', headerText: 'Name', @@ -135,13 +140,10 @@ function disabledStates( const disabled = item.status === 'failed' || item.status === 'deleting' || - wantAutoDiscover || - item.dbServerExists; + (!wantAutoDiscover && item.dbServerExists); let disabledText = `This RDS database is already enrolled and is a part of this cluster`; - if (wantAutoDiscover) { - disabledText = 'All RDS databases will be enrolled automatically'; - } else if (item.status === 'failed') { + if (item.status === 'failed') { disabledText = 'Not available, try refreshing the list'; } else if (item.status === 'deleting') { disabledText = 'Not available'; diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx new file mode 100644 index 0000000000000..69a3a45a4223b --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx @@ -0,0 +1,230 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState, useEffect } from 'react'; +import { Text } from 'design'; +import { FetchStatus } from 'design/DataTable/types'; +import { Attempt } from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; + +import { useDiscover } from 'teleport/Discover/useDiscover'; +import { + AwsRdsDatabase, + Regions, + Vpc, + integrationService, +} from 'teleport/services/integrations'; +import { Database } from 'teleport/services/databases'; +import { getRdsEngineIdentifier } from 'teleport/Discover/SelectResource/types'; + +import { ActionButtons } from '../../Shared'; + +import { useCreateDatabase } from '../CreateDatabase/useCreateDatabase'; +import { CreateDatabaseDialog } from '../CreateDatabase/CreateDatabaseDialog'; + +import { DatabaseList } from './RdsDatabaseList'; + +type TableData = { + items: CheckedAwsRdsDatabase[]; + fetchStatus: FetchStatus; + startKey?: string; +}; + +const emptyTableData = (): TableData => ({ + items: [], + fetchStatus: 'disabled', + startKey: '', +}); + +// CheckedAwsRdsDatabase is a type to describe that a +// AwsRdsDatabase has been checked (by its resource id) +// with the backend whether or not a database server already +// exists for it. +export type CheckedAwsRdsDatabase = AwsRdsDatabase & { + dbServerExists?: boolean; +}; + +export function SingleEnrollment({ + region, + vpc, + disableBtns, + onFetchAttempt, + fetchAttempt, +}: { + region: Regions; + vpc?: Vpc; + disableBtns: boolean; + fetchAttempt: Attempt; + onFetchAttempt(a: Attempt): void; + /** + * key is expected to be set to the ID of the VPC. + */ + key: string; +}) { + const { + createdDb, + pollTimeout, + registerDatabase, + attempt, + clearAttempt, // TODO + nextStep, + fetchDatabaseServers, + } = useCreateDatabase(); + + const { agentMeta, resourceSpec, emitErrorEvent } = useDiscover(); + + const [tableData, setTableData] = useState(); + const [selectedDb, setSelectedDb] = useState(); + + useEffect(() => { + if (vpc) { + // Start with empty table data for new vpc's. + fetchRdsDatabases(emptyTableData(), vpc); + } + }, [vpc]); + + function fetchNextPage() { + fetchRdsDatabases({ ...tableData }, vpc); + } + + async function fetchRdsDatabases(data: TableData, vpc: Vpc) { + const integrationName = agentMeta.awsIntegration.name; + + setTableData({ ...data, fetchStatus: 'loading' }); + onFetchAttempt({ status: 'processing' }); + + try { + const { databases: fetchedDbs, nextToken } = + await integrationService.fetchAwsRdsDatabases( + integrationName, + getRdsEngineIdentifier(resourceSpec.dbMeta?.engine), + { + region: region, + nextToken: data.startKey, + vpcId: vpc.id, + } + ); + + // Abort early if there were no rds dbs for the selected region. + if (fetchedDbs.length <= 0) { + onFetchAttempt({ status: 'success' }); + setTableData({ ...data, fetchStatus: 'disabled' }); + return; + } + + // Check if fetched rds databases have a database + // server for it, to prevent user from enrolling + // the same db and getting an error from it. + + // Build the predicate string that will query for + // all the fetched rds dbs by its resource ids. + const resourceIds: string[] = fetchedDbs.map( + d => `resource.spec.aws.rds.resource_id == "${d.resourceId}"` + ); + const query = resourceIds.join(' || '); + + const { agents: fetchedDbServers } = await fetchDatabaseServers(query); + + const dbServerLookupByResourceId: Record = {}; + fetchedDbServers.forEach( + d => (dbServerLookupByResourceId[d.aws.rds.resourceId] = d) + ); + + // Check for db server matches. + const checkedRdsDbs: CheckedAwsRdsDatabase[] = fetchedDbs.map(rds => { + const dbServer = dbServerLookupByResourceId[rds.resourceId]; + if (dbServer) { + return { + ...rds, + dbServerExists: true, + }; + } + return rds; + }); + + onFetchAttempt({ status: 'success' }); + setTableData({ + startKey: nextToken, + fetchStatus: nextToken ? '' : 'disabled', + // concat each page fetch. + items: [ + ...data.items, + ...checkedRdsDbs.sort((a, b) => a.name.localeCompare(b.name)), + ], + }); + } catch (err) { + const errMsg = getErrMessage(err); + onFetchAttempt({ status: 'failed', statusText: errMsg }); + setTableData(data); // fallback to previous data + emitErrorEvent(`database fetch error: ${errMsg}`); + } + } + + function handleOnProceed() { + const isNewDb = selectedDb.name !== createdDb?.name; + registerDatabase( + { + name: selectedDb.name, + protocol: selectedDb.engine, + uri: selectedDb.uri, + labels: selectedDb.labels, + awsRds: selectedDb, + awsRegion: region, + awsVpcId: vpc.id, + }, + // Corner case where if registering db fails a user can: + // 1) change region, which will list new databases or + // 2) select a different database before re-trying. + isNewDb + ); + } + + const showTable = !!vpc && fetchAttempt.status !== 'failed'; + + return ( + <> + {showTable && ( + <> + Select an RDS to enroll: + + + )} + + {attempt.status !== '' && ( + + )} + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/VpcSelector.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/VpcSelector.tsx new file mode 100644 index 0000000000000..792624858e17b --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/VpcSelector.tsx @@ -0,0 +1,87 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { components } from 'react-select'; +import { Box, Flex, LabelInput, Link, ButtonIcon } from 'design'; +import Select from 'shared/components/Select'; +import { NewTab } from 'design/Icon'; + +import { Regions, Vpc } from 'teleport/services/integrations'; + +export type VpcOption = { value: Vpc; label: string; link: string }; + +export function VpcSelector({ + vpcs, + selectedVpc, + onSelectedVpc, + selectedRegion, +}: { + vpcs: Vpc[]; + selectedVpc: VpcOption; + onSelectedVpc(o: VpcOption): void; + selectedRegion: Regions; +}) { + const options: VpcOption[] = vpcs?.map(vpc => { + return { + value: vpc, + label: `${vpc.id} ${vpc.name && `(${vpc.name})`}`, + link: `https://${selectedRegion}.console.aws.amazon.com/vpcconsole/home?region=${selectedRegion}#VpcDetails:VpcId=${vpc.id}`, + }; + }); + + return ( + // TODO(lisa): negative margin was required since the + // AwsRegionSelector added too much bottom margin. + // Refactor AwsRegionSelector so margins can be controlled + // outside of the component (or use flex columns with gap prop) + + + VPC ID + +
{ + const isChecked = selectedSubnets.includes(item.id); + return ( + + ); + }, + }, + { + key: 'name', + headerText: 'Name', + }, + { + key: 'id', + headerText: 'ID', + }, + { + key: 'availabilityZone', + headerText: 'Availability Zone', + }, + { + altKey: 'link-out', + render: subnet => { + return ( + + + + + + ); + }, + }, + ]} + emptyText="No Subnets Found" + pagination={{ pageSize: 5 }} + fetching={{ onFetchMore: fetchNextPage, fetchStatus }} + isSearchable + /> + ); +} + +function CheckboxCell({ + item, + isChecked, + onChange, +}: { + item: Subnet; + isChecked: boolean; + onChange(selectedItem: Subnet, e: React.ChangeEvent): void; +}) { + return ( + + + { + onChange(item, e); + }} + checked={isChecked} + data-testid={item.id} + /> + + + ); +} diff --git a/web/packages/teleport/src/Discover/Shared/AutoDiscovery/index.ts b/web/packages/teleport/src/Discover/Shared/SubnetIdPicker/index.ts similarity index 83% rename from web/packages/teleport/src/Discover/Shared/AutoDiscovery/index.ts rename to web/packages/teleport/src/Discover/Shared/SubnetIdPicker/index.ts index 387da8a33da5d..fede7765a6cfe 100644 --- a/web/packages/teleport/src/Discover/Shared/AutoDiscovery/index.ts +++ b/web/packages/teleport/src/Discover/Shared/SubnetIdPicker/index.ts @@ -16,5 +16,4 @@ * along with this program. If not, see . */ -export { AutoEnrollDialog } from './AutoEnrollDialog'; -export { SelfHostedAutoDiscoverDirections } from './SelfHostedAutoDiscoverDirections'; +export { SubnetIdPicker } from './SubnetIdPicker'; diff --git a/web/packages/teleport/src/Discover/Shared/index.ts b/web/packages/teleport/src/Discover/Shared/index.ts index 8082ff6f96b12..a8cc71627ff9c 100644 --- a/web/packages/teleport/src/Discover/Shared/index.ts +++ b/web/packages/teleport/src/Discover/Shared/index.ts @@ -42,10 +42,6 @@ export { RadioCell, StatusCell, } from './Aws'; -export { - AutoEnrollDialog, - SelfHostedAutoDiscoverDirections, -} from './AutoDiscovery'; export { StyledBox } from './StyledBox'; export type { DiscoverLabel } from './LabelsCreater'; diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index 0ad626ffa2fc9..61e493aa3b44f 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -527,10 +527,15 @@ type BaseMeta = { * on cloud platforms like AWS, Azure, etc. */ autoDiscovery?: AutoDiscovery; + /** + * If this field is defined, it means the user selected a specific vpc ID. + * Not all flows will allow a user to select a vpc ID. + */ + awsVpcId?: string; }; export type AutoDiscovery = { - config: DiscoveryConfig; + config?: DiscoveryConfig; // requiredVpcsAndSubnets is a map of required vpcs for auto discovery. // If this is empty, then a user can skip deploying db agents. // If >0, auto discovery requires deploying db agents. diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 4e10451ce5954..b4a4fe6aedbc1 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -316,12 +316,16 @@ const cfg = { '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/deploydatabaseservices', awsRdsDbRequiredVpcsPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/requireddatabasesvpcs', + awsDatabaseVpcsPath: + '/webapi/sites/:clusterId/integrations/aws-oidc/:name/databasevpcs', awsRdsDbListPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/databases', awsDeployTeleportServicePath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/deployservice', awsSecurityGroupsListPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/securitygroups', + awsSubnetListPath: + '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/subnets', awsAppAccessPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/aws-app-access', @@ -910,6 +914,13 @@ const cfg = { }); }, + getAwsDatabaseVpcsUrl(integrationName: string, clusterId: string) { + return generatePath(cfg.api.awsDatabaseVpcsPath, { + clusterId, + name: integrationName, + }); + }, + getAwsRdsDbsDeployServicesUrl(integrationName: string) { const clusterId = cfg.proxyCluster; @@ -1014,6 +1025,13 @@ const cfg = { }); }, + getAwsSubnetListUrl(integrationName: string, clusterId: string) { + return generatePath(cfg.api.awsSubnetListPath, { + clusterId, + name: integrationName, + }); + }, + getEc2InstanceConnectIAMConfigureScriptUrl( params: UrlAwsConfigureIamScriptParams ) { diff --git a/web/packages/teleport/src/services/databases/types.ts b/web/packages/teleport/src/services/databases/types.ts index 2247532da0b7d..589db3486612f 100644 --- a/web/packages/teleport/src/services/databases/types.ts +++ b/web/packages/teleport/src/services/databases/types.ts @@ -70,6 +70,7 @@ export type CreateDatabaseRequest = { labels?: ResourceLabel[]; awsRds?: AwsRdsDatabase; awsRegion?: Regions; + awsVpcId?: string; }; export type DatabaseIamPolicyResponse = { diff --git a/web/packages/teleport/src/services/integrations/fetchAllAwsRdsEnginesDatabase.test.ts b/web/packages/teleport/src/services/integrations/fetchAllAwsRdsEnginesDatabase.test.ts new file mode 100644 index 0000000000000..26e89012ebff4 --- /dev/null +++ b/web/packages/teleport/src/services/integrations/fetchAllAwsRdsEnginesDatabase.test.ts @@ -0,0 +1,154 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import api from 'teleport/services/api'; + +import { integrationService, makeAwsDatabase } from './integrations'; + +const testCases: { + name: string; + fetch: 'instance' | 'cluster' | 'both'; + err?: string; + instancesNextKey?: string; + clustersNextKey?: string; +}[] = [ + { + name: 'fetch only rds instances', + fetch: 'instance', + }, + { + name: 'fetch only clusters instances', + fetch: 'cluster', + }, + { + name: 'fetch both clusters and instances', + fetch: 'both', + instancesNextKey: 'instance-key', + clustersNextKey: 'cluster-key', + }, +]; + +test.each(testCases)('$name', async tc => { + let instances; + let clusters; + + if (tc.fetch === 'cluster') { + clusters = [ + { + protocol: 'sql', + name: 'rds-cluster', + }, + ]; + } + if (tc.fetch === 'instance') { + instances = [ + { + protocol: 'postgres', + name: 'rds-instance', + }, + ]; + } + jest + .spyOn(api, 'post') + .mockResolvedValueOnce({ + databases: instances || [], + nextToken: tc.instancesNextKey, + }) + .mockResolvedValueOnce({ + databases: clusters || [], + nextToken: tc.clustersNextKey, + }); + + const resp = await integrationService.fetchAllAwsRdsEnginesDatabases( + 'some-name', + { + region: 'us-east-1', + } + ); + + expect(resp).toStrictEqual({ + databases: [ + ...(clusters ? clusters.map(makeAwsDatabase) : []), + ...(instances ? instances.map(makeAwsDatabase) : []), + ], + instancesNextToken: tc.instancesNextKey, + clustersNextToken: tc.clustersNextKey, + }); +}); + +test('failed to fetch both clusters and instances should throw error', async () => { + jest.spyOn(api, 'post').mockRejectedValue(new Error('some error')); + + await expect( + integrationService.fetchAllAwsRdsEnginesDatabases('some-name', { + region: 'us-east-1', + }) + ).rejects.toThrow('some error'); +}); + +test('fetching instances but failed fetch clusters', async () => { + const instance = { + protocol: 'postgres', + name: 'rds-instance', + }; + jest + .spyOn(api, 'post') + .mockResolvedValueOnce({ + databases: [instance], + }) + .mockRejectedValue(new Error('some error')); + + const resp = await integrationService.fetchAllAwsRdsEnginesDatabases( + 'some-name', + { + region: 'us-east-1', + } + ); + + expect(resp).toStrictEqual({ + databases: [makeAwsDatabase(instance)], + oneOfError: 'Failed to fetch RDS clusters: Error: some error', + instancesNextToken: undefined, + }); +}); + +test('fetching clusters but failed fetch instances', async () => { + const cluster = { + protocol: 'postgres', + name: 'rds-cluster', + }; + jest + .spyOn(api, 'post') + .mockRejectedValueOnce(new Error('some error')) + .mockResolvedValue({ + databases: [cluster], + }); + + const resp = await integrationService.fetchAllAwsRdsEnginesDatabases( + 'some-name', + { + region: 'us-east-1', + } + ); + + expect(resp).toStrictEqual({ + databases: [makeAwsDatabase(cluster)], + oneOfError: 'Failed to fetch RDS instances: Error: some error', + clustersNextToken: undefined, + }); +}); diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index a884debcd6d76..476f20d925100 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -18,6 +18,7 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; +import { DatabaseEngine } from 'teleport/Discover/SelectResource'; import makeNode from '../nodes/makeNode'; import auth from '../auth/auth'; @@ -50,6 +51,12 @@ import { EnrollEksClustersRequest, ListEksClustersRequest, AwsOidcDeployDatabaseServicesRequest, + Regions, + ListAwsRdsFromAllEnginesResponse, + ListAwsSubnetsRequest, + ListAwsSubnetsResponse, + Subnet, + AwsDatabaseVpcsResponse, } from './types'; export const integrationService = { @@ -95,12 +102,114 @@ export const integrationService = { .then(resp => resp.vpcMapOfSubnets); }, + fetchAwsDatabasesVpcs( + integrationName: string, + clusterId: string, + body: { region: string; accountId: string; nextToken: string } + ): Promise { + return api + .post(cfg.getAwsDatabaseVpcsUrl(integrationName, clusterId), body) + .then(resp => { + const vpcs = resp.vpcs || []; + return { vpcs, nextToken: resp.nextToken }; + }); + }, + + /** + * Grabs a page for rds instances and rds clusters. + * Used with auto discovery to display "all" the + * rds's in a region by page. + */ + fetchAllAwsRdsEnginesDatabases( + integrationName: string, + req: { + region: Regions; + instancesNextToken?: string; + clustersNextToken?: string; + vpcId?: string; + } + ): Promise { + const makeResponse = response => { + const dbs = response?.databases ?? []; + const madeResponse: ListAwsRdsDatabaseResponse = { + databases: dbs.map(makeAwsDatabase), + nextToken: response?.nextToken, + }; + return madeResponse; + }; + + return Promise.allSettled([ + api + .post(cfg.getAwsRdsDbListUrl(integrationName), { + region: req.region, + vpcId: req.vpcId, + nextToken: req.instancesNextToken, + rdsType: 'instance', + engines: ['mysql', 'mariadb', 'postgres'], + }) + .then(makeResponse), + api + .post(cfg.getAwsRdsDbListUrl(integrationName), { + region: req.region, + vpcId: req.vpcId, + nextToken: req.clustersNextToken, + rdsType: 'cluster', + engines: ['aurora-mysql', 'aurora-postgresql'], + }) + .then(makeResponse), + ]).then(response => { + const [instances, clusters] = response; + + if (instances.status === 'rejected' && clusters.status === 'rejected') { + // Just return one error message, likely the other will be the same error. + throw new Error(instances.reason); + } + + let madeResponse: ListAwsRdsFromAllEnginesResponse = { + databases: [], + }; + + if (instances.status === 'fulfilled') { + madeResponse = { + databases: instances.value.databases, + instancesNextToken: instances.value.nextToken, + }; + } else { + madeResponse = { + ...madeResponse, + oneOfError: `Failed to fetch RDS instances: ${instances.reason}`, + }; + } + + if (clusters.status === 'fulfilled') { + madeResponse = { + ...madeResponse, + databases: [...madeResponse.databases, ...clusters.value.databases], + clustersNextToken: clusters.value.nextToken, + }; + } else { + madeResponse = { + ...madeResponse, + oneOfError: `Failed to fetch RDS clusters: ${clusters.reason}`, + }; + } + + // Sort databases by their names + madeResponse.databases = madeResponse.databases.sort((a, b) => + a.name.localeCompare(b.name) + ); + + return madeResponse; + }); + }, + fetchAwsRdsDatabases( integrationName: string, rdsEngineIdentifier: RdsEngineIdentifier, req: { - region: AwsOidcListDatabasesRequest['region']; - nextToken?: AwsOidcListDatabasesRequest['nextToken']; + region: Regions; + nextToken?: string; + vpcId?: string; } ): Promise { let body: AwsOidcListDatabasesRequest; @@ -275,6 +384,23 @@ export const integrationService = { }; }); }, + + fetchAwsSubnets( + integrationName: string, + clusterId: string, + req: ListAwsSubnetsRequest + ): Promise { + return api + .post(cfg.getAwsSubnetListUrl(integrationName, clusterId), req) + .then(json => { + const subnets = json?.subnets ?? []; + + return { + subnets: subnets.map(makeAwsSubnets), + nextToken: json?.nextToken, + }; + }); + }, }; export function makeIntegrations(json: any): Integration[] { @@ -348,3 +474,15 @@ function makeSecurityGroup(json: any): SecurityGroup { outboundRules: outboundRules ?? [], }; } + +function makeAwsSubnets(json: any): Subnet { + json = json ?? {}; + + const { name, id, availability_zone } = json; + + return { + name, + id, + availabilityZone: availability_zone, + }; +} diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 9c971917b0f40..574097f77329b 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -290,6 +290,23 @@ export type ListAwsRdsDatabaseResponse = { nextToken?: string; }; +export type ListAwsRdsFromAllEnginesResponse = { + databases: AwsRdsDatabase[]; + /** + * next page for rds instances. + */ + instancesNextToken?: string; + /** + * next page for rds clusters. + */ + clustersNextToken?: string; + /** + * set if fetching rds instances OR rds clusters + * returned an error + */ + oneOfError?: string; +}; + export type IntegrationUpdateRequest = { awsoidc: { roleArn: string; @@ -460,6 +477,36 @@ export type DeployEc2InstanceConnectEndpointResponse = { endpoints: AwsEc2InstanceConnectEndpoint[]; }; +export type Subnet = { + /** + * Subnet name. + * This is just a friendly name and should not be used for further API calls. + * It can be empty if the subnet was not given a "Name" tag. + */ + name?: string; + /** + * Subnet ID, for example "subnet-0b3ca383195ad2cc7". + * This is the value that should be used when doing further API calls. + */ + id: string; + /** + * AWS availability zone of the subnet, for example + * "us-west-1a". + */ + availabilityZone: string; +}; + +export type ListAwsSubnetsRequest = { + vpcId: string; + region: Regions; + nextToken?: string; +}; + +export type ListAwsSubnetsResponse = { + subnets: Subnet[]; + nextToken?: string; +}; + export type ListAwsSecurityGroupsRequest = { // VPCID is the VPC to filter Security Groups. vpcId: string; @@ -514,3 +561,17 @@ export type IntegrationUrlLocationState = { kind: IntegrationKind; redirectText: string; }; + +export type Vpc = { + id: string; + name?: string; + /** + * if defined, a database service is already deployed for this vpc. + */ + ecsServiceDashboardURL?: string; +}; + +export type AwsDatabaseVpcsResponse = { + vpcs: Vpc[]; + nextToken: string; +};